"""
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