Source code for hofmann.model.composition

"""Site composition value type for partial / mixed occupancy."""

from __future__ import annotations

import math
from collections.abc import Iterator, Mapping
from dataclasses import dataclass
from types import MappingProxyType

_OCCUPANCY_TOLERANCE = 1e-9


[docs] @dataclass(frozen=True, eq=False) class Composition(Mapping[str, float]): """Species-to-occupancy mapping for a (possibly mixed) site. A frozen, hashable ``Mapping[str, float]`` describing how a site is occupied: a single species at full occupancy (a "pure" site, also expressible as a plain ``str``), a weighted mix of species (a "mixed" site), or any of the above with an implicit vacancy fraction (``1 - sum(occupancies)``). Validated at construction: - All occupancy values must be finite and lie in ``[0, 1]``. Zero values are dropped before further validation; negative or non-finite values raise. - The occupancy sum must not exceed ``1.0`` (within a tolerance of ``1e-9``). Any deficit is interpreted as a vacancy fraction. - Keys must be non-empty strings. Iteration order is canonical: descending occupancy, with alphabetical tiebreak. This ordering determines wedge layout in the renderer, so visual reproducibility is guaranteed across runs. Args: occupancies: A mapping of species labels to occupancy fractions. Raises: ValueError: If any value is non-finite or outside ``[0, 1]``; if the sum exceeds 1.0; if the resulting mapping is empty (all values zero, or the input was empty). TypeError: If any key is not a string. """ occupancies: Mapping[str, float] def __post_init__(self) -> None: raw = dict(self.occupancies) for key in raw: if not isinstance(key, str): raise TypeError( f"Composition keys must be strings, got " f"{type(key).__name__}: {key!r}" ) if not key.strip(): raise ValueError( "Composition keys must be non-empty, non-whitespace species labels" ) cleaned: dict[str, float] = {} for key, value in raw.items(): v = float(value) if not math.isfinite(v): raise ValueError( f"occupancy for {key!r} must be finite, got {v}" ) if v < 0: raise ValueError( f"occupancy for {key!r} must be non-negative, got {v}" ) if v > 1.0 + _OCCUPANCY_TOLERANCE: raise ValueError( f"occupancy for {key!r} must be in (0, 1], got {v}" ) if v == 0.0: continue cleaned[key] = v if not cleaned: raise ValueError("Composition must not be empty") total = sum(cleaned.values()) if total > 1.0 + _OCCUPANCY_TOLERANCE: raise ValueError( f"Composition occupancies sum to {total}, must be <= 1.0" ) # Canonical order: descending occupancy, then alphabetical. ordered = dict(sorted( cleaned.items(), key=lambda kv: (-kv[1], kv[0]), )) object.__setattr__(self, "occupancies", MappingProxyType(ordered)) def __getitem__(self, key: str) -> float: return self.occupancies[key] def __iter__(self) -> Iterator[str]: return iter(self.occupancies) def __len__(self) -> int: return len(self.occupancies) def __eq__(self, other: object) -> bool: if not isinstance(other, Composition): return NotImplemented return dict(self.occupancies) == dict(other.occupancies) def __hash__(self) -> int: return hash(tuple(self.occupancies.items())) @property def species(self) -> frozenset[str]: """Set of constituent species (vacancy excluded).""" return frozenset(self.occupancies.keys()) @property def dominant_species(self) -> str: """Species with the highest occupancy. Tiebreak: alphabetical.""" # Iteration order is already canonical (descending occupancy, # then alphabetical), so the first key is the dominant species. return next(iter(self.occupancies)) @property def vacancy(self) -> float: """Vacancy fraction: ``1 - sum(occupancies)``, clamped to [0, 1).""" deficit = 1.0 - sum(self.occupancies.values()) return max(0.0, deficit)