Source code for httk.atomistic.models.structure.asu

"""A crystal structure held as its asymmetric unit.

An :class:`ASUStructure` records only the symmetry-distinct sites — the asymmetric unit —
plus the space group needed to regenerate the rest. Where a :class:`~httk.atomistic.UnitcellStructure`
lists every atom in the cell, this lists one representative per orbit as a Wyckoff letter
and the values of that position's free parameters.

**Any setting, including non-standard ones.** Wyckoff data is recorded directly against
the tabulated setting it arrived in. No change of basis is done merely to store or expand
it. A setting that appears in no table remains representable by recording the Wyckoff data
against the standard setting together with an exact transform into the structure's own
coordinates.

**Expansion is exact and needs no tolerance.** Reduced coordinates, symmetry operations,
Wyckoff parameters, and the setting transform are all exact rationals, and the vendored
orbits are complete and pre-deduplicated. So generating the full cell is affine arithmetic
over the rationals with an exact equality test at the end — no coordinate grid, no
snapping, no neighbour search. Tolerance enters this class only where a *measured*
structure is first recognized as symmetric, never in expansion.
"""

import datetime
import fractions
from collections.abc import Sequence
from dataclasses import dataclass
from functools import cached_property
from typing import Any, ClassVar, Self

from httk.core import FracVector, SurdVector

from httk.atomistic import data
from httk.atomistic.composition import Assembly
from httk.atomistic.models._vector_guards import to_precision
from httk.atomistic.models.cell.cell import Cell
from httk.atomistic.models.cell.like import CellLike
from httk.atomistic.models.cell.view import CellView
from httk.atomistic.models.moments.backend import SiteMomentsBackend
from httk.atomistic.models.moments.cartesian import CartesianSiteMoments
from httk.atomistic.models.moments.collinear import CollinearSiteMoments
from httk.atomistic.models.moments.crystalaxis import CrystalAxisSiteMoments
from httk.atomistic.models.sites.sites import Sites
from httk.atomistic.models.species.like import SpeciesLike
from httk.atomistic.models.species.species import Species
from httk.atomistic.models.species.view import SpeciesView
from httk.atomistic.models.structure.backend import StructureBackend
from httk.atomistic.models.structure.semantics import StructureSemanticsMixin, initialize_semantics
from httk.atomistic.symmetry._periodicity_guard import require_full_periodicity
from httk.atomistic.symmetry.setting_transform import SettingTransform
from httk.atomistic.symmetry.spacegroup import Spacegroup, wyckoff_letter_map

__all__ = ["ASUStructure", "FundamentalDomainStructure", "WyckoffSite"]


@dataclass(frozen=True)
[docs] class WyckoffSite: """Represent one symmetry-distinct site. ``wyckoff`` is a bare letter (``"e"``, not ``"4e"``) naming a position of the structure's stored setting, and ``free_params`` holds one exact value per degree of freedom of that position — none at all for a fixed position such as an inversion centre. ``species`` names one of the owning structure's species. Moment data uses verbatim-copy semantics: every expanded orbit image carries the same moment. This is physically meaningful only when the site symmetry preserves that moment; magnetic structures that break it must be represented as a unit cell (or via ``SymopsStructure``, coming later). Partial occupancy needs nothing special here: it lives in the referenced :class:`~httk.atomistic.Species`, which already carries a composition. :param wyckoff: The Wyckoff letter in the structure's stored setting. :param free_params: The free values for the Wyckoff position. :param species: The name of the owning structure's species. :param representative: An optional retained representative coordinate. :param moment: An optional moment assigned to the site. """
[docs] wyckoff: str
[docs] free_params: FracVector
[docs] species: str
[docs] representative: FracVector | None = None
[docs] moment: SiteMomentsBackend | None = None
def __post_init__(self) -> None: object.__setattr__(self, "wyckoff", str(self.wyckoff)) object.__setattr__(self, "species", str(self.species)) object.__setattr__(self, "free_params", FracVector(self.free_params)) if self.representative is not None: representative = FracVector(self.representative) if representative.dim != (3,): raise ValueError("WyckoffSite representative must be a three-dimensional coordinate") object.__setattr__(self, "representative", representative) if self.moment is not None: if not isinstance(self.moment, SiteMomentsBackend): raise TypeError("WyckoffSite moment must be a SiteMomentsBackend") if len(self.moment) != 1: raise ValueError("WyckoffSite moment must contain exactly one site moment") def __repr__(self) -> str: values = ", ".join(str(value) for value in self.free_params.to_fractions()) if self.free_count else "" return f"WyckoffSite({self.species!r} at {self.wyckoff}{f'({values})' if values else ''})" @property
[docs] def free_count(self) -> int: """How many free parameters this site carries.""" return 0 if self.free_params.dim in ((), (0,)) else self.free_params.dim[0]
def _representative_matches_coordinates( representative: FracVector, candidates: Sequence[FracVector | Sequence[fractions.Fraction]], coordinate_precision: fractions.Fraction | None, ) -> bool: stated = representative.normalize().to_fractions() tolerance = (coordinate_precision or fractions.Fraction()) * 3 for candidate in candidates: expected = candidate.to_fractions() if isinstance(candidate, FracVector) else candidate differences = [] for left, right in zip(stated, expected): difference = abs(left - right) % 1 differences.append(min(difference, 1 - difference)) if all(value <= tolerance for value in differences): return True return False @dataclass(frozen=True, init=False) class _ValidatedASUProof: spacegroup: Spacegroup transform: SettingTransform wyckoff_sites: tuple[WyckoffSite, ...] expansion: tuple[FracVector, tuple[str, ...], tuple[int, ...]] coordinate_precision: fractions.Fraction | None @classmethod def _issue_from_cif_deduplication( cls, spacegroup: Spacegroup, transform: SettingTransform, wyckoff_sites: Sequence[WyckoffSite], expansion: tuple[FracVector, tuple[str, ...], tuple[int, ...]], coordinate_precision: Any, ) -> Self: sites = tuple(wyckoff_sites) precision = to_precision(coordinate_precision) coordinates, species_at_sites, counts = expansion if not isinstance(coordinates, FracVector) or len(coordinates.dim) != 2 or coordinates.dim[1] != 3: raise ValueError("validated CIF expansion coordinates must have shape (N, 3)") if len(counts) != len(sites) or any(not isinstance(count, int) or count <= 0 for count in counts): raise ValueError("validated CIF expansion counts must be positive and match the Wyckoff sites") if sum(counts) != coordinates.dim[0]: raise ValueError("validated CIF expansion counts must cover every coordinate") expected_species = tuple(site.species for site, count in zip(sites, counts) for _ in range(count)) if tuple(species_at_sites) != expected_species: raise ValueError("validated CIF expansion species do not match the Wyckoff sites") rows = coordinates.to_fractions() offset = 0 for site, count in zip(sites, counts): candidates = rows[offset : offset + count] if site.representative is not None and not _representative_matches_coordinates( site.representative, candidates, precision ): raise ValueError( f"representative coordinate for Wyckoff site {site.wyckoff!r} does not match its orbit" ) offset += count proof = object.__new__(cls) object.__setattr__(proof, "spacegroup", spacegroup) object.__setattr__(proof, "transform", transform) object.__setattr__(proof, "wyckoff_sites", sites) object.__setattr__(proof, "expansion", expansion) object.__setattr__(proof, "coordinate_precision", precision) return proof
[docs] class FundamentalDomainStructure(StructureSemanticsMixin, StructureBackend): """Represent a crystal structure by one exact site per symmetry orbit. Holds the cell in the structure's own setting, the space-group setting that names its Wyckoff data, an optional transform from that setting to the structure's own, one :class:`WyckoffSite` per symmetry-distinct site, and the species they name. On first expansion, a site whose orbit contributes no new points raises ``ValueError`` because it duplicates an earlier site's orbit. :param cell: The cell in the structure's own setting. :param spacegroup: The setting that names the stored Wyckoff data. :param wyckoff_sites: The symmetry-distinct site definitions. :param species: The species referenced by the site definitions. :param transform: The change of basis from the stored setting to the structure's setting. :param coordinate_precision: The precision recorded for the reduced coordinates. :param molecular: Whether the structure describes molecular entities. :param assemblies: Optional correlations among domain sites. :param chemical_composition: Optional chemical composition metadata. :param chemical_formula_descriptive: Optional descriptive chemical formula. :param chemical_formula_hill: Optional Hill chemical formula. :param optimization_type: Optional optimization provenance. :param immutable_id: Optional immutable source identifier. :param last_modified: Optional source modification timestamp. :param charge: An explicitly assigned charge for the expanded cell content; it is not derived from the species. :param _validated_proof: Internal proof that the supplied CIF expansion already validates the representatives. """ _cell: Cell _spacegroup: Spacegroup _transform: SettingTransform _wyckoff_sites: tuple[WyckoffSite, ...] _species: tuple[Species, ...] _coordinate_precision: fractions.Fraction | None _charge: fractions.Fraction | None _precomputed_expansion: tuple[FracVector, tuple[str, ...], tuple[int, ...]]
[docs] kind: ClassVar[str] = "asu"
def __init__( self, cell: CellLike, spacegroup: Spacegroup | int, wyckoff_sites: Sequence[WyckoffSite], species: Sequence[SpeciesLike], transform: SettingTransform | None = None, coordinate_precision: Any = None, *, molecular: bool = False, assemblies: Sequence[Any] | None = None, chemical_composition: Any = None, chemical_formula_descriptive: str | None = None, chemical_formula_hill: str | None = None, optimization_type: str | None = None, immutable_id: str | None = None, last_modified: datetime.datetime | None = None, charge: fractions.Fraction | int | str | None = None, _validated_proof: _ValidatedASUProof | None = None, ) -> None: self._cell = cell if isinstance(cell, Cell) else CellView(cell) require_full_periodicity(self._cell, "ASUStructure") self._spacegroup = spacegroup if isinstance(spacegroup, Spacegroup) else Spacegroup.standard(spacegroup) self._transform = SettingTransform.identity() if transform is None else transform if not self._spacegroup.is_standard_setting and not self._transform.is_identity(): raise ValueError( "a nonidentity ASUStructure transform is only valid when the stored Wyckoff " "setting is the IT standard setting" ) self._coordinate_precision = to_precision(coordinate_precision) self._charge = None if charge is None else fractions.Fraction(charge) self._wyckoff_sites = tuple(wyckoff_sites) self._species = tuple(item if isinstance(item, Species) else SpeciesView(item) for item in species) if _validated_proof is not None: if self._wyckoff_sites != _validated_proof.wyckoff_sites: raise ValueError("validated ASU proof does not match the supplied Wyckoff sites") if ( self._spacegroup != _validated_proof.spacegroup or self._transform != _validated_proof.transform or self._coordinate_precision != _validated_proof.coordinate_precision ): raise ValueError("validated ASU proof does not match the supplied structure context") moments = tuple(site.moment for site in self._wyckoff_sites) if any(value is None for value in moments) and any(value is not None for value in moments): raise ValueError("state moments for all sites or none") stated_moments = tuple(value for value in moments if value is not None) if stated_moments: kind = getattr(stated_moments[0], "kind", None) if any(getattr(value, "kind", None) != kind for value in stated_moments[1:]): raise ValueError("ASUStructure site moments must all have the same kind") for value in stated_moments: if isinstance(value, CrystalAxisSiteMoments) and value.cell != self._cell: raise ValueError("ASUStructure site moments have incoherent crystal-axis frames") names = [item.name for item in self._species] if len(names) != len(set(names)): raise ValueError("ASUStructure species names must be unique") known = set(names) for site in self._wyckoff_sites: if site.species not in known: raise ValueError(f"ASUStructure site references unknown species name: {site.species!r}") position = self._spacegroup.wyckoff_position(site.wyckoff) if site.free_count != position.free_count: raise ValueError( f"Wyckoff position {position.multiplicity}{position.letter} of " f"{self._spacegroup.setting} takes {position.free_count} free parameter(s), " f"but the site supplies {site.free_count}" ) if ( _validated_proof is None and site.representative is not None and not self._representative_matches_orbit(site) ): raise ValueError( f"representative coordinate for Wyckoff site {site.wyckoff!r} does not match its orbit" ) initialize_semantics( self, nsites=len(self._wyckoff_sites), molecular=molecular, assemblies=None if assemblies is None else tuple(assemblies), symmetry=None, chemical_composition=chemical_composition, chemical_formula_descriptive=chemical_formula_descriptive, chemical_formula_hill=chemical_formula_hill, optimization_type=optimization_type, immutable_id=immutable_id, last_modified=last_modified, ) if _validated_proof is not None: self._precomputed_expansion = _validated_proof.expansion @classmethod def _from_validated_proof( cls, cell: CellLike, spacegroup: Spacegroup | int, proof: _ValidatedASUProof, species: Sequence[SpeciesLike], transform: SettingTransform | None, coordinate_precision: Any, ) -> Self: return cls( cell, spacegroup, proof.wyckoff_sites, species, transform, coordinate_precision, _validated_proof=proof, ) # --- accessors --- @property
[docs] def cell(self) -> Cell: """Expose the cell in the structure's own setting.""" return self._cell
@property
[docs] def spacegroup(self) -> Spacegroup: """Expose the setting that names the stored Wyckoff data.""" return self._spacegroup
@property
[docs] def transform(self) -> SettingTransform: """Expose the transform from the stored setting to the structure's setting.""" return self._transform
@property
[docs] def transform_from_standard(self) -> SettingTransform: """Return the exact transform from the IT standard setting to this structure.""" return self._transform if self._spacegroup.is_standard_setting else self._spacegroup.transform_from_standard
def _standard_wyckoff_sites(self) -> tuple[Spacegroup, tuple[WyckoffSite, ...]]: """Return equivalent Wyckoff data in the IT standard setting, on demand.""" standard = self._spacegroup.standard_setting() if self._spacegroup.is_standard_setting: return standard, self._wyckoff_sites transform = self._spacegroup.transform_from_standard sites = [] for site in self._wyckoff_sites: local = self._spacegroup.wyckoff_position(site.wyckoff).representative.coordinate(site.free_params) identified = standard.identify_wyckoff(transform.to_standard(local).normalize()) if identified is None: raise ValueError(f"cannot express Wyckoff site {site.wyckoff!r} in {standard.setting}") position, parameters = identified representative = ( None if site.representative is None else transform.to_standard(site.representative).normalize() ) sites.append(WyckoffSite(position.letter, parameters, site.species, representative, site.moment)) return standard, tuple(sites) @property
[docs] def wyckoff_sites(self) -> tuple[WyckoffSite, ...]: """Expose the symmetry-distinct sites.""" return self._wyckoff_sites
@property
[docs] def domain_sites(self) -> tuple[WyckoffSite, ...]: """Expose the directly stored fundamental-domain sites.""" return self._wyckoff_sites
@property
[docs] def species(self) -> tuple[Species, ...]: """Expose the species referenced by the sites.""" return self._species
@property
[docs] def coordinate_precision(self) -> fractions.Fraction | None: """Expose the recorded precision of the reduced coordinates. Fractional, and expressed in **this structure's own setting** — the frame the data arrived in — so it needs no transforming on the way to the expanded sites. Recording it here is what lets an asymmetric unit say how good the data behind it was, rather than leaving that to be guessed again downstream. It is provenance, never an operating parameter: expansion remains exact and uses no tolerance at all. :return: The fractional precision, or ``None`` when it is unknown. """ return self._coordinate_precision
@property
[docs] def asu(self) -> "FundamentalDomainStructure": """Expose this structure as its own fundamental domain.""" return self
@property
[docs] def periodicity(self) -> tuple[bool, bool, bool]: """Expose the cell's periodic directions.""" return self._cell.periodicity
@property
[docs] def molecular(self) -> bool: """Expose whether molecular semantics are enabled.""" return self._molecular
@property
[docs] def domain_species_at_sites(self) -> tuple[str, ...]: """Expose species names for the directly represented domain sites.""" return tuple(site.species for site in self._wyckoff_sites)
def _representatives_for_site(self, site: WyckoffSite) -> tuple[FracVector, ...]: position = self._spacegroup.wyckoff_position(site.wyckoff) if self._transform.is_identity(): return tuple(point.normalize() for point in position.coordinates(site.free_params)) values: list[FracVector] = [] for standard_point in position.coordinates(site.free_params): own_point = self._transform.to_setting(standard_point) values.extend((own_point + coset).normalize() for coset in self._transform.lattice_cosets()) return tuple(values) def _representative_matches_orbit(self, site: WyckoffSite) -> bool: assert site.representative is not None return _representative_matches_coordinates( site.representative, self._representatives_for_site(site), self._coordinate_precision ) def _representative_sites(self) -> Sites: """Compute the exact representative positions retained by this representation.""" coordinates = [ site.representative.normalize() if site.representative is not None else self._representatives_for_site(site)[0] for site in self._wyckoff_sites ] return Sites(FracVector([list(value.to_fractions()) for value in coordinates]), self._coordinate_precision)
[docs] def cartesian_sites(self) -> SurdVector: """Compute the exact Cartesian positions of the represented sites. :return: The Cartesian representative positions in the exact surd representation. """ from httk.core import SurdVector return SurdVector(self._representative_sites().reduced_coords) * self._cell.basis
@property
[docs] def fractional_site_positions(self) -> list[list[float]]: """Expose representative positions as floating-point coordinates.""" return self._representative_sites().reduced_coords.to_floats()
@property
[docs] def nsites(self) -> int: """Expose the number of directly represented sites.""" return len(self._wyckoff_sites)
@property
[docs] def site_coordinate_span(self) -> str: """Expose the fundamental-domain coordinate span.""" return "molecular_fundamental_domain" if self._molecular else "fundamental_domain"
@property
[docs] def space_group_it_number(self) -> int: """Expose the space group's International Tables number.""" return self._spacegroup.it_number
@property
[docs] def space_group_symbol_hall(self) -> str | None: """Expose the Hall symbol for the active setting.""" setting = self.setting() return None if setting is None else setting.hall_symbol
@property
[docs] def space_group_symbol_hermann_mauguin(self) -> str | None: """Expose the Hermann–Mauguin symbol for the active setting.""" setting = self.setting() return None if setting is None else setting.hermann_mauguin
@property
[docs] def space_group_symbol_hermann_mauguin_extended(self) -> str | None: """Expose the extended Hermann–Mauguin symbol for the active setting.""" setting = self.setting() if setting is None: return None value = setting.record.get("hm_extended") return None if not value else " ".join(part.strip() for part in str(value).split("\n") if part.strip())
@property
[docs] def space_group_symmetry_operations_xyz(self) -> tuple[str, ...]: """Expose the active setting's symmetry operations in ``xyz`` notation.""" setting = self.setting() operations = ( tuple(self._transform.symop_to_setting(value) for value in self._spacegroup.symmetry_operations) if setting is None else setting.symmetry_operations ) return tuple(operation.wrapped().to_xyz() for operation in operations)
@property
[docs] def wyckoff_positions(self) -> tuple[str, ...] | None: """Expose Wyckoff positions in the active setting.""" setting = self.setting() if setting is None: return None letters = ( {position.letter: position.letter for position in setting.wyckoff} if setting == self._spacegroup else wyckoff_letter_map(self._spacegroup, setting) ) return tuple(setting.wyckoff_position(letters[site.wyckoff]).letter for site in self._wyckoff_sites)
@property
[docs] def is_standard_setting(self) -> bool: """Expose whether the structure uses its space group's standard setting.""" return self._spacegroup.is_standard_setting and self._transform.is_identity()
[docs] def setting(self) -> Spacegroup | None: """The tabulated setting this structure is written in, or ``None`` if untabulated. A structure in an arbitrary setting is perfectly representable but has no tabulated name; that is the point of storing the transform rather than a setting label. A transform looked up from the tables remembers which setting it came from, but one that was constructed directly does not, so an equal transform is also matched against the group's tabulated settings. An identity transform means the stored tabulated setting is already the structure's own setting. :return: The matching tabulated setting, or ``None`` when untabulated. """ if self._transform.is_identity(): return self._spacegroup hall_entry = self._transform.hall_entry if hall_entry is not None: return Spacegroup.from_hall_entry(hall_entry) for record in data.spacegroup_settings(): if record["it_number"] != self._spacegroup.it_number: continue candidate = Spacegroup(record) if candidate.transform_from_standard == self._transform: return candidate return None
# --- expansion --- def _expanded_offsets(self) -> tuple[tuple[int, ...], tuple[int, ...]]: counts = self.multiplicities() offsets: list[int] = [] offset = 0 for count in counts: offsets.append(offset) offset += count return counts, tuple(offsets) def _expanded_assemblies(self) -> tuple[Assembly, ...] | None: assemblies = self._assemblies if assemblies is None or not assemblies: return assemblies counts, offsets = self._expanded_offsets() expanded: list[Assembly] = [] for assembly in assemblies: groups: list[tuple[int, ...]] = [] for group in assembly.sites_in_groups: if any(counts[index] != 1 for index in group): raise ValueError( "symmetry-reduced expansion cannot map assembly correlations " "when a correlated domain site has multiple unit-cell images" ) groups.append(tuple(offsets[index] for index in group)) expanded.append( Assembly( tuple(groups), assembly.group_probabilities, assembly.group_probabilities_precision, ) ) return tuple(expanded) def _validate_expansion_semantics(self) -> None: self._expanded_assemblies() if not self.molecular: return counts = self.multiplicities() if any(count != 1 for count in counts) or any(site.representative is None for site in self.wyckoff_sites): raise ValueError( "symmetry-reduced molecular expansion requires one retained representative " "for every one-to-one domain site" ) @cached_property def _expansion(self) -> tuple[FracVector, tuple[str, ...], tuple[int, ...]]: """The full cell: coordinates, the species at each, and the per-site counts. Computed once. Sites generated by *different* asymmetric-unit sites are checked against each other too, not only within an orbit, so two sites that name the same point cannot silently produce a doubled atom. Coincident listings with different species, or redundant listings in a strict stored ASU, raise ``ValueError``. """ precomputed = getattr(self, "_precomputed_expansion", None) if precomputed is not None: object.__delattr__(self, "_precomputed_expansion") return precomputed transform = self._transform cosets = transform.lattice_cosets() identity = transform.is_identity() coordinates: list[tuple[fractions.Fraction, ...]] = [] species_at_sites: list[str] = [] counts: list[int] = [] species_by_name = {species.name: species for species in self._species} seen: dict[tuple[fractions.Fraction, ...], tuple[str, WyckoffSite]] = {} for site in self._wyckoff_sites: position = self._spacegroup.wyckoff_position(site.wyckoff) # The tabulated orbit is complete and already deduplicated, so the group's # operations never need to be applied one by one here. generated: list[tuple[fractions.Fraction, ...]] = [] for stored_point in position.coordinates(site.free_params): own_points = ( (stored_point,) if identity else tuple(transform.to_setting(stored_point) + x for x in cosets) ) for own_point in own_points: key = tuple(own_point.normalize().to_fractions()) previous = seen.get(key) if previous is not None: previous_name, previous_site = previous if species_by_name[previous_name] != species_by_name[site.species]: raise ValueError( f"{site!r} coincides with {previous_site!r} at {key} but has a different species" ) if previous_site is not site: raise ValueError( f"{site!r} has an orbit that contributes no new sites; it duplicates an earlier " "site's orbit, so this is not a valid fundamental domain" ) continue seen[key] = (site.species, site) generated.append(key) # Deterministic order, so an expansion is reproducible run to run. counts.append(len(generated)) for key in sorted(generated): coordinates.append(key) species_at_sites.append(site.species) if not coordinates: return FracVector(()), (), tuple(counts) return FracVector([list(point) for point in coordinates]), tuple(species_at_sites), tuple(counts)
[docs] def expand_sites(self) -> Sites: """Every site of the unit cell, as exact reduced coordinates in this structure's setting. The orbit of each asymmetric-unit site is generated directly from its stored setting's table, wrapped into ``[0, 1)``, and deduplicated by exact equality. Only an untabulated setting uses the stored transform. Deduplication then also handles a transform that shrinks the cell; the opposite case, a transform onto a larger cell, is covered by :meth:`~httk.atomistic.SettingTransform.lattice_cosets`. :return: All unit-cell sites in the structure's exact setting. """ return Sites(self._expansion[0], self._coordinate_precision)
[docs] def expand_species_at_sites(self) -> tuple[str, ...]: """Expose the species names produced by :meth:`expand_sites`. :return: Species names in expanded site order. """ return self._expansion[1]
[docs] def expand_site_moments(self) -> SiteMomentsBackend | None: """Expand one exact moment for every represented site. :return: Expanded site moments, or ``None`` when moments are unstated. """ moments = tuple(site.moment for site in self._wyckoff_sites) if not moments or moments[0] is None: return None self._validate_expansion_semantics() counts = (1,) * len(moments) if self.molecular else self.multiplicities() first = moments[0] if isinstance(first, CollinearSiteMoments): values = [ site.moment.collinear_moments.to_fractions()[0] # type: ignore[union-attr] for site, count in zip(self._wyckoff_sites, counts) for _ in range(count) ] return CollinearSiteMoments(values) if isinstance(first, CrystalAxisSiteMoments): rows = [ [ site.moment.crystalaxis_moments._element((0, column)) # type: ignore[union-attr] for column in range(3) ] for site, count in zip(self._wyckoff_sites, counts) for _ in range(count) ] return CrystalAxisSiteMoments(SurdVector._from_scalar_grid(rows, (len(rows), 3)), self._cell) if isinstance(first, CartesianSiteMoments): rows = [ [ site.moment.cartesian_moments._element((0, column)) # type: ignore[union-attr] for column in range(3) ] for site, count in zip(self._wyckoff_sites, counts) for _ in range(count) ] return CartesianSiteMoments(SurdVector._from_scalar_grid(rows, (len(rows), 3))) raise TypeError(f"unsupported SiteMomentsBackend kind: {getattr(first, 'kind', None)!r}")
[docs] def multiplicities(self) -> tuple[int, ...]: """How many cell sites each asymmetric-unit site generates, in order. Usually the Wyckoff position's tabulated multiplicity, but not always: a setting transform that changes the cell volume changes the count too, by a factor of three for the rhombohedral-axes settings. :return: The number of expanded sites generated by each domain site. """ return self._expansion[2]
@property
[docs] def sites(self) -> Sites: """Expose representative or expanded sites according to the semantics.""" self._validate_expansion_semantics() return self._representative_sites() if self.molecular else self.expand_sites()
@property
[docs] def species_at_sites(self) -> tuple[str, ...]: """Expose representative or expanded species names according to the semantics.""" self._validate_expansion_semantics() return self.domain_species_at_sites if self.molecular else self.expand_species_at_sites()
@property
[docs] def site_moments(self) -> SiteMomentsBackend | None: """Expose representative or expanded site moments.""" return self.expand_site_moments()
@property
[docs] def charge(self) -> fractions.Fraction | None: """Expose the explicitly assigned exact charge of the expanded cell. :return: The assigned charge, or ``None`` when it is unstated. """ return self._charge
@property
[docs] def assemblies(self) -> tuple[Assembly, ...] | None: """Expose correlations among the domain sites.""" return self._assemblies
# --- identity --- def __eq__(self, other: object) -> bool: if not isinstance(other, FundamentalDomainStructure) or ( type(self) is not type(other) and not (isinstance(self, ASUStructure) and isinstance(other, ASUStructure)) ): return NotImplemented return ( self._cell == other._cell and self._cell.precision == other._cell.precision and self._spacegroup == other._spacegroup and self._transform == other._transform and self._wyckoff_sites == other._wyckoff_sites and self._species == other._species and self._coordinate_precision == other._coordinate_precision and self._charge == other._charge and self._molecular == other._molecular and self._assemblies == other._assemblies and self._chemical_composition == other._chemical_composition and self._chemical_formula_descriptive == other._chemical_formula_descriptive and self._chemical_formula_hill == other._chemical_formula_hill and self._optimization_type == other._optimization_type ) def __repr__(self) -> str: setting = self.setting() where = ( "standard setting" if self.is_standard_setting else f"setting {setting.setting if setting else '(untabulated)'}" ) return ( f"{type(self).__name__}({self._spacegroup.hermann_mauguin!r}, {len(self._wyckoff_sites)} site(s), {where})" )
[docs] class ASUStructure(FundamentalDomainStructure): """Assert that a fundamental domain is a true asymmetric unit.""" @property
[docs] def site_coordinate_span(self) -> str: """Expose the asymmetric-unit coordinate span.""" return "molecular_asymmetric_unit" if self._molecular else "asymmetric_unit"