"""The immutable canonical elemental composition value."""
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
from fractions import Fraction
from types import MappingProxyType
from typing import Any, cast
from httk.atomistic._composition_values import as_fraction, as_precision
from httk.atomistic.elements import SYMBOLS
from httk.atomistic.models.formula.backend import ChemicalFormulaBackend
from httk.atomistic.models.formula.diagnostics import CompositionDiagnostic
from httk.atomistic.models.formula.notation import (
reduced_coefficients,
render_anonymous,
render_reduced,
)
_ELEMENTS = frozenset(SYMBOLS)
@dataclass(frozen=True, init=False, eq=False)
[docs]
class Composition(ChemicalFormulaBackend):
"""Store an immutable projected composition and its formula diagnostics.
:param amounts: The projected elemental amounts in symbol order.
:param uncertainties: The corresponding amount precisions, if known.
:param complete: Whether the projection contains no unknown elemental content.
:param exact: Whether all projected amounts are exact.
:param normalized: Whether all contributing probabilities and concentrations normalize.
:param normalization_status: The combined normalization status.
:param diagnostics: The non-fatal issues found during projection.
"""
[docs]
amounts: tuple[tuple[str, Fraction], ...] = () # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
uncertainties: tuple[tuple[str, Fraction | None], ...] = () # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
complete: bool = True # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
exact: bool = True # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
normalized: bool = True # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
normalization_status: str = "exact" # pyright: ignore[reportIncompatibleMethodOverride]
[docs]
diagnostics: tuple[CompositionDiagnostic, ...] = () # pyright: ignore[reportIncompatibleMethodOverride]
def __init__(
self,
amounts: Mapping[str, Any] | Iterable[tuple[str, Any]],
uncertainties: Mapping[str, Any] | Iterable[tuple[str, Any]] | None = None,
complete: bool = True,
exact: bool | None = None,
normalized: bool = True,
normalization_status: str | None = None,
diagnostics: Iterable[CompositionDiagnostic] = (),
) -> None:
if isinstance(amounts, Mapping):
raw_amounts = dict(cast(Mapping[str, Any], amounts))
else:
amount_pairs = list(amounts)
seen_amount_labels: set[str] = set()
for element, _ in amount_pairs:
if element in seen_amount_labels:
raise ValueError(f"Composition amount pairs contain duplicate label {element!r}")
seen_amount_labels.add(element)
raw_amounts = dict(amount_pairs)
converted: dict[str, Fraction] = {}
inferred: dict[str, Fraction | None] = {}
for element, value in raw_amounts.items():
if element not in _ELEMENTS:
raise ValueError(f"Composition amount is not a real element: {element!r}")
central, width = as_fraction(value, field=f"Composition amount for {element}")
if central <= 0:
raise ValueError("Composition amounts must be positive")
converted[element] = central
inferred[element] = width
ordered = tuple((element, converted[element]) for element in sorted(converted))
if uncertainties is None:
ordered_uncertainties = tuple((element, inferred[element]) for element, _ in ordered)
else:
if isinstance(uncertainties, Mapping):
supplied = dict(cast(Mapping[str, Any], uncertainties))
else:
uncertainty_pairs = list(uncertainties)
seen_uncertainty_labels: set[str] = set()
for element, _ in uncertainty_pairs:
if element in seen_uncertainty_labels:
raise ValueError(f"Composition uncertainty pairs contain duplicate label {element!r}")
seen_uncertainty_labels.add(element)
supplied = dict(uncertainty_pairs)
if set(supplied) != set(converted):
raise ValueError("Composition uncertainty keys must match amount keys")
ordered_uncertainties = tuple(
(element, as_precision(supplied[element], field=f"Composition uncertainty for {element}"))
for element, _ in ordered
)
derived_exact = all(width is None for _, width in ordered_uncertainties)
stated_exact = derived_exact if exact is None else exact
stated_status = (
("exact" if stated_exact and normalized else "within_precision" if normalized else "outside_precision")
if normalization_status is None
else normalization_status
)
object.__setattr__(self, "amounts", ordered)
object.__setattr__(self, "uncertainties", ordered_uncertainties)
object.__setattr__(self, "complete", complete)
object.__setattr__(self, "exact", stated_exact)
object.__setattr__(self, "normalized", normalized)
object.__setattr__(self, "normalization_status", stated_status)
object.__setattr__(self, "diagnostics", tuple(diagnostics))
@property
[docs]
def amount_mapping(self) -> Mapping[str, Fraction]:
"""Return the projected amounts as a read-only mapping."""
return MappingProxyType(dict(self.amounts))
@property
[docs]
def uncertainty_mapping(self) -> Mapping[str, Fraction | None]:
"""Return the projected amount precisions as a read-only mapping."""
return MappingProxyType(dict(self.uncertainties))
@property
[docs]
def elements(self) -> tuple[str, ...]:
"""Return the projected element symbols in amount order."""
return tuple(element for element, _ in self.amounts)
@property
[docs]
def nelements(self) -> int:
"""Return the number of projected elements."""
return len(self.amounts)
@property
[docs]
def elements_ratios(self) -> tuple[Fraction, ...]:
"""Return the projected amounts normalized by their total."""
total = sum((amount for _, amount in self.amounts), Fraction())
return () if not total else tuple(amount / total for _, amount in self.amounts)
def _formula_coefficients(self) -> tuple[tuple[str, int], ...] | None:
if not self.complete or not self.amounts:
return None
central = reduced_coefficients(self.elements_ratios)
return (
None if central is None else tuple((element, amount) for (element, _), amount in zip(self.amounts, central))
)
@property
@property
def __repr__(self) -> str:
parts = [f"amounts={self.amounts!r}"]
if any(width is not None for _, width in self.uncertainties):
parts.append(f"uncertainties={self.uncertainties!r}")
if not self.complete:
parts.append(f"complete={self.complete!r}")
if not self.exact:
parts.append(f"exact={self.exact!r}")
if not self.normalized:
parts.append(f"normalized={self.normalized!r}")
if self.normalization_status != "exact":
parts.append(f"normalization_status={self.normalization_status!r}")
if self.diagnostics:
parts.append(f"diagnostics={self.diagnostics!r}")
return f"Composition({', '.join(parts)})"
def __eq__(self, other: object) -> bool:
if not isinstance(other, Composition):
return NotImplemented
return self._identity_tuple() == other._identity_tuple()
def __hash__(self) -> int:
return hash(self._identity_tuple())
def _identity_tuple(self) -> tuple[object, ...]:
return (
self.amounts,
self.uncertainties,
self.complete,
self.exact,
self.normalized,
self.normalization_status,
self.diagnostics,
)