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

"""
The Simple structure representation for httk-atomistic.
"""

import datetime
import fractions
from collections.abc import Sequence
from typing import TYPE_CHECKING, ClassVar, cast

from httk.core import SurdVector, VectorLike

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.crystalaxis import CrystalAxisSiteMoments
from httk.atomistic.models.moments.like import SiteMomentsLike
from httk.atomistic.models.moments.view_base import SiteMomentsViewBase
from httk.atomistic.models.sites.like import SitesLike
from httk.atomistic.models.sites.sites import Sites
from httk.atomistic.models.sites.view import SitesView
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,
    StructureSymmetry,
    _semantic_value,
    initialize_semantics,
)

if TYPE_CHECKING:
    from httk.atomistic.composition import Assembly, ChemicalComposition
    from httk.atomistic.models.structure.numeric_view import NumericUnitcellStructureView
    from httk.atomistic.symmetry.standardization import ConventionalCellResult


def _norm_cell(cell: CellLike) -> Cell:
    return cell if isinstance(cell, Cell) else CellView(cell)


def _norm_sites(sites: SitesLike) -> Sites:
    return sites if isinstance(sites, Sites) else SitesView(sites)


def _norm_species(species: Sequence[SpeciesLike]) -> tuple[Species, ...]:
    return tuple(s if isinstance(s, Species) else SpeciesView(s) for s in species)


def _norm_species_at_sites(species_at_sites: Sequence[object]) -> tuple[str, ...]:
    return tuple(str(name) for name in species_at_sites)


def _norm_site_moments(value: SiteMomentsLike | None) -> SiteMomentsBackend | None:
    if value is None:
        return None
    if isinstance(value, SiteMomentsBackend):
        return value
    if isinstance(value, SiteMomentsViewBase):
        return cast(SiteMomentsBackend, value)
    raise TypeError(
        "UnitcellStructure site_moments must be a SiteMoments class or view; a bare array is "
        "frame-ambiguous, so construct CartesianSiteMoments, CrystalAxisSiteMoments, or "
        "CollinearSiteMoments explicitly"
    )


def _check_site_moments(value: SiteMomentsBackend | None, sites: Sites, cell: Cell) -> None:
    if value is None:
        return
    if len(value) != len(sites):
        raise ValueError("UnitcellStructure site_moments must have the same length as sites")
    if isinstance(value, CrystalAxisSiteMoments) and value.cell != cell:
        raise ValueError("UnitcellStructure site_moments has an incoherent crystal-axis frame")


def _infer_species(species_at_sites: Sequence[SpeciesLike]) -> tuple[tuple[Species, ...], tuple[str, ...]]:
    """Build the distinct species table and site names from convenient values."""
    distinct: list[Species] = []
    by_name: dict[str, Species] = {}
    names: list[str] = []
    for source in species_at_sites:
        value = source if isinstance(source, Species) else SpeciesView(source)
        existing = by_name.get(value.name)
        if existing is None:
            by_name[value.name] = value
            distinct.append(value)
        elif existing != value:
            raise ValueError(
                f"UnitcellStructure species_at_sites gives conflicting definitions for species {value.name!r}"
            )
        names.append(value.name)
    return tuple(distinct), tuple(names)


def _check_species_names(species: Sequence[Species]) -> None:
    names = [s.name for s in species]
    if len(names) != len(set(names)):
        raise ValueError("UnitcellStructure species names must be unique")


def _check_species_at_sites(species_at_sites: Sequence[str], species: Sequence[Species]) -> None:
    known = {s.name for s in species}
    for name in species_at_sites:
        if name not in known:
            raise ValueError(f"UnitcellStructure species_at_sites references unknown species name: {name!r}")


def _check_sites_length(sites: Sites, species_at_sites: Sequence[str]) -> None:
    if len(species_at_sites) != len(sites):
        raise ValueError("UnitcellStructure species_at_sites must have the same length as sites")


[docs] class UnitcellStructure(StructureSemanticsMixin, StructureBackend): """Represent a crystal structure in the Unitcell representation. A UnitcellStructure holds a ``cell`` (a ``Cell`` of 3x3 cell vectors), ``sites`` (a ``Sites`` of Nx3 reduced coordinates), a list of ``species`` (each a ``Species``), and a length-N ``species_at_sites`` giving the species name occupying each site. Inputs are normalized on construction through the component families: the cell, sites, and each species are passed through their ``*Like`` unions, and every ``species_at_sites`` name must match one of the (uniquely named) species. When ``species`` is omitted, ``species_at_sites`` may itself contain species-like values; the distinct species table is then inferred in first-occurrence order. The numeric model is exact and split by purpose. The fractional frame — reduced coordinates and symmetry — is rational and lives in ``sites`` as a :class:`~httk.core.FracVector`. The Cartesian frame — where radicals such as the hexagonal ``sqrt(3)`` appear — is exact in the squarefree-radical field: ``cell.basis`` is a :class:`~httk.core.SurdVector` and :meth:`cartesian_sites` returns the exact Cartesian positions. Pure magnitudes (bond-length comparisons) stay rational-exact via ``cell.metric()``. Floats appear only at the presentation and JSON boundaries. :param cell: The unit-cell geometry. :param sites: The reduced coordinates of the sites. :param species: The distinct species definitions. Omit this to infer them from ``species_at_sites``. :param species_at_sites: The species name occupying each site; this value is required. :param site_moments: Optional magnetic moments aligned with the sites. :param molecular: Whether the structure describes a molecular unit cell. :param assemblies: Optional correlations among sites. :param symmetry: Optional symmetry metadata. :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 cell content; it is not derived from the species, and an explicit zero remains distinct from an unstated charge. """ _cell: Cell _sites: Sites _species: tuple[Species, ...] _species_at_sites: tuple[str, ...] _site_moments: SiteMomentsBackend | None _charge: fractions.Fraction | None
[docs] kind: ClassVar[str] = "unitcell"
def __init__( self, cell: CellLike, sites: SitesLike, species: Sequence[SpeciesLike] | None = None, species_at_sites: Sequence[SpeciesLike] | None = None, *, site_moments: SiteMomentsLike | None = None, molecular: bool = False, assemblies: Sequence["Assembly"] | None = None, symmetry: StructureSymmetry | None = None, chemical_composition: "ChemicalComposition | None" = 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, ) -> None: if species_at_sites is None: raise TypeError("UnitcellStructure species_at_sites is required") norm_cell = _norm_cell(cell) norm_sites = _norm_sites(sites) if species is None: norm_species, norm_species_at_sites = _infer_species(species_at_sites) else: norm_species = _norm_species(species) norm_species_at_sites = _norm_species_at_sites(species_at_sites) _check_sites_length(norm_sites, norm_species_at_sites) _check_species_names(norm_species) _check_species_at_sites(norm_species_at_sites, norm_species) norm_site_moments = _norm_site_moments(site_moments) _check_site_moments(norm_site_moments, norm_sites, norm_cell) self._cell = norm_cell self._sites = norm_sites self._species = norm_species self._species_at_sites = norm_species_at_sites self._site_moments = norm_site_moments self._charge = None if charge is None else fractions.Fraction(charge) initialize_semantics( self, nsites=len(norm_sites), molecular=molecular, assemblies=None if assemblies is None else tuple(assemblies), symmetry=symmetry, 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, ) @property
[docs] def cell(self) -> Cell: """Expose the cell geometry. :return: The cell in the structure's exact representation. """ return self._cell
@property
[docs] def sites(self) -> Sites: """Expose the reduced site coordinates. :return: The sites in the structure's exact representation. """ return self._sites
@property
[docs] def species(self) -> tuple[Species, ...]: """Expose the distinct species. :return: The species referenced by the structure. """ return self._species
@property
[docs] def species_at_sites(self) -> tuple[str, ...]: """Expose the species name occupying each site. :return: Site species names in site order. """ return self._species_at_sites
@property
[docs] def site_moments(self) -> SiteMomentsBackend | None: """Expose optional per-site magnetic moments in ``sites`` order. :return: Site moments, or ``None`` when they are unstated. """ return self._site_moments
@property
[docs] def charge(self) -> fractions.Fraction | None: """Expose the explicitly assigned exact charge of the cell. :return: The assigned charge, or ``None`` when it is unstated. """ return self._charge
@property
[docs] def coordinate_precision(self) -> fractions.Fraction | None: """Expose the precision recorded for the reduced coordinates. Read through from :attr:`sites`. Dimensionless — see :meth:`cartesian_precision` for the corresponding length. :return: The fractional precision, or ``None`` when it is unknown. """ return self._sites.precision
@property
[docs] def basis_precision(self) -> fractions.Fraction | None: """Expose the precision recorded for the cell basis. Read through from :attr:`cell`. :return: The absolute precision, or ``None`` when it is unknown. """ return self._cell.precision
@property
[docs] def periodicity(self) -> tuple[bool, bool, bool]: """Expose which cell directions are periodic. Read through from :attr:`cell`, where the full account lives. ``(True, True, True)`` for an ordinary crystal, which is what a structure built without saying otherwise is. :return: The periodicity flags for the cell directions. """ return self._cell.periodicity
@property
[docs] def site_coordinate_span(self) -> str: """Expose the coordinate span asserted by this representation. :return: ``unit_cell`` or ``molecular_unit_cell``. """ molecular = _semantic_value(self, "molecular", False, "_molecular") return "molecular_unit_cell" if molecular else "unit_cell"
@property
[docs] def molecular(self) -> bool: """Expose whether this structure describes a molecular unit cell. :return: Whether molecular semantics are enabled. """ return bool(_semantic_value(self, "molecular", False, "_molecular"))
@property
[docs] def symmetry(self) -> StructureSymmetry | None: """Expose the optional symmetry metadata. :return: The symmetry metadata, or ``None`` when it is absent. """ return _semantic_value(self, "symmetry", private_name="_symmetry")
[docs] def cartesian_precision(self) -> fractions.Fraction | None: """The coordinate precision as a length, or ``None`` if it is unknown. This is the number a real tolerance wants — an interatomic matching tolerance or an spglib ``symprec`` is a distance, and a fractional precision is not. A coordinate good to ``1e-4`` of a cell edge means something quite different in a 3 Å cell and a 30 Å one. Computed as the fractional precision times the *longest* cell edge, which is the conservative choice: it is the largest displacement that fractional uncertainty can produce along any axis. The cell's own precision is folded in as well, since a cell stated to ``1e-3`` cannot place an atom better than that however many digits the coordinates carry. :return: The conservative Cartesian precision, or ``None`` when the coordinate precision is unknown. """ fractional = self._sites.precision if fractional is None: return None longest = max(length.to_float() for length in self._cell.lengths) cartesian = fractional * fractions.Fraction(str(longest)) basis = self._cell.precision return cartesian if basis is None or basis < cartesian else basis
[docs] def cartesian_sites(self) -> SurdVector: """Compute the exact Cartesian site positions. Under the row-vector convention this is ``reduced_coords * cell.basis`` (each Cartesian position is the sum over lattice vectors ``sum_k reduced[k] * basis[k]``). The reduced coordinates are rational (a ``FracVector``), the cell basis carries the radicals (a ``SurdVector``), so the product is exact in the surd field — the hexagonal ``sqrt(3)`` survives into the Cartesian positions. :return: The Cartesian positions in the exact surd representation. """ return SurdVector(self._sites.reduced_coords) * self._cell.basis
[docs] def numeric(self) -> "NumericUnitcellStructureView": """Create a plain-numpy presentation of this structure. :return: The numpy-backed structure view. :raises ImportError: If numpy is unavailable. """ from httk.atomistic.models.structure.numeric_view import NumericUnitcellStructureView return NumericUnitcellStructureView(self)
[docs] def supercell( self, transformation: VectorLike, *, max_sites: int | None = 100_000, ) -> "SupercellResult": """Build an exact supercell from an integer transformation. :param transformation: The lattice transformation to apply. :param max_sites: The maximum permitted number of sites, or ``None`` for no limit. :return: The generated supercell and transformation metadata. """ from httk.atomistic.supercell import build_supercell return build_supercell(self, transformation, max_sites=max_sites)
[docs] def orthogonal_supercell( self, multiplier: int | None = None, *, tolerance: fractions.Fraction | str | float | None = None, max_multiplier: int | None = None, search_radius: int = 1, max_sites: int | None = 100_000, ) -> "SupercellResult": """Build a deterministically selected orthogonal supercell. :param multiplier: The requested volume multiplier, or ``None`` to search. :param tolerance: The geometric tolerance used during the search. :param max_multiplier: The largest multiplier considered when searching. :param search_radius: The integer search radius for candidate transformations. :param max_sites: The maximum permitted number of sites, or ``None`` for no limit. :return: The generated supercell and transformation metadata. """ from httk.atomistic.supercell import orthogonal_supercell return orthogonal_supercell( self, multiplier, tolerance=tolerance, max_multiplier=max_multiplier, search_radius=search_radius, max_sites=max_sites, )
[docs] def cubic_supercell( self, multiplier: int | None = None, *, tolerance: fractions.Fraction | str | float | None = None, max_multiplier: int | None = None, search_radius: int = 1, max_sites: int | None = 100_000, ) -> "SupercellResult": """Build a deterministically selected cubic supercell. :param multiplier: The requested volume multiplier, or ``None`` to search. :param tolerance: The geometric tolerance used during the search. :param max_multiplier: The largest multiplier considered when searching. :param search_radius: The integer search radius for candidate transformations. :param max_sites: The maximum permitted number of sites, or ``None`` for no limit. :return: The generated supercell and transformation metadata. """ from httk.atomistic.supercell import cubic_supercell return cubic_supercell( self, multiplier, tolerance=tolerance, max_multiplier=max_multiplier, search_radius=search_radius, max_sites=max_sites, )
[docs] def conventional_cell( self, *, tolerance: float | None = None, limit_denominator: int | None = None, ) -> "ConventionalCellResult": """Express this structure in its conventional standard-setting cell. :param tolerance: The tolerance used when standardizing the structure. :param limit_denominator: The denominator limit used for rationalizing measured coordinates. :return: The standardized structure and its transformation metadata. """ from httk.atomistic.symmetry.standardization import conventional_cell return conventional_cell( self, tolerance=tolerance, limit_denominator=limit_denominator, )
def __eq__(self, other: object) -> bool: """Equality of geometry and structural assertions, including precision and moments. Moments are content and participate in equality; their own stated precision remains excluded by the SiteMoments equality contract. """ if not isinstance(other, UnitcellStructure): return NotImplemented return ( self._cell == other._cell and self.basis_precision == other.basis_precision and self._sites == other._sites and self.coordinate_precision == other.coordinate_precision and self._species == other._species and self._species_at_sites == other._species_at_sites and self._site_moments == other._site_moments and self.charge == other.charge and self.molecular == other.molecular and self.assemblies == other.assemblies and self.symmetry == other.symmetry 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: n = len(self._species_at_sites) if n <= 12: return ( f"UnitcellStructure(cell={self._cell!r}, sites={self._sites!r}, " f"species={self._species!r}, species_at_sites={self._species_at_sites!r})" ) # Large structure: abbreviate the bulky arguments and mark the elision with a trailing ... return ( f"UnitcellStructure(cell={self._cell!r}, sites=Sites(... {n} sites ...), " f"species=(... {len(self._species)} species ...), ...)" ) def __str__(self) -> str: names = sorted(set(self._species_at_sites)) formula = " ".join( f"{name}{self._species_at_sites.count(name)}" if self._species_at_sites.count(name) > 1 else name for name in names ) return ( f"UnitcellStructure({formula}: {len(self._species_at_sites)} sites, volume={float(self._cell.volume):.4g})" )
if TYPE_CHECKING: from httk.atomistic.supercell import SupercellResult