"""Lazy, exact OPTIMADE-backed structure representation.
The transport spelling of an OPTIMADE property is deliberately never part of
the conversion contract. The accompanying ``/info/structures`` snapshot maps
each spelling to a property-definition IRI; only that IRI selects a local
meaning.
"""
import datetime
import re
from collections.abc import Mapping
from dataclasses import dataclass
from decimal import Decimal
from fractions import Fraction
from functools import cached_property
from types import MappingProxyType, SimpleNamespace
from typing import Any, ClassVar, Self, cast
from urllib.parse import urlsplit
from httk.core import (
MISSING as _MISSING,
)
from httk.core import (
EntryTypeDefinition,
SurdVector,
combined_precision,
decimal_precision,
load_entry_type_definition,
)
from httk.core.optimade import (
IncompleteOptimadeResourceError,
OptimadeResource,
decode_optimade_value,
optimade_document_root,
)
from httk.core.storage import stored_property
from httk.atomistic._composition_values import normalization
from httk.atomistic.composition import Assembly, project_composition, validate_assemblies
from httk.atomistic.entries.precision import precision_definitions
from httk.atomistic.models.cell.cell import Cell
from httk.atomistic.models.formula.composition import Composition
from httk.atomistic.models.formula.formula_view import ChemicalFormulaView
from httk.atomistic.models.formula.notation import anonymous_symbol
from httk.atomistic.models.moments.cartesian import CartesianSiteMoments
from httk.atomistic.models.sites.sites import Sites
from httk.atomistic.models.species.species import Species
from httk.atomistic.models.structure.backend import StructureBackend
from httk.atomistic.models.structure.semantics import (
_ELEMENTS,
_FORMULA_TOKEN,
_OPTIMIZATION_TYPES,
_SYMOP_COORDINATE,
StructureSymmetry,
validate_descriptive_formula,
validate_hill_formula,
)
from httk.atomistic.symmetry.spacegroup import Spacegroup
_STRUCTURES_DEFINITION_ID = "https://schemas.optimade.org/defs/v1.3/entrytypes/optimade/structures"
_COORDINATE_SPANS = frozenset(
{
"fundamental_domain",
"asymmetric_unit",
"molecular_fundamental_domain",
"molecular_asymmetric_unit",
"unit_cell",
"molecular_unit_cell",
"molecular_entities",
"other",
}
)
_UNIT_CELL_SPANS = frozenset({"unit_cell", "molecular_unit_cell"})
_STRUCTURE_FEATURES = frozenset({"assemblies", "disorder", "implicit_atoms", "site_attachments", "_httk_magnetism"})
_ANONYMOUS_TOKEN = re.compile(r"([A-Z][a-z]*)([1-9][0-9]*)?")
_WYCKOFF = frozenset("abcdefghijklmnopqrstuvwxyzα")
def _is_definition_iri(value: object) -> bool:
"""Return whether *value* is a minimally well-formed absolute IRI."""
if not isinstance(value, str) or not value or value != value.strip():
return False
try:
return bool(urlsplit(value).scheme)
except ValueError:
return False
@dataclass(frozen=True, init=False)
[docs]
class OptimadeStructure(StructureBackend):
"""Represent an OPTIMADE structure resource as a lazy structure backend.
Construction merely retains the resource. The canonical structure quartet
is decoded one component at a time, so an incomplete remote resource is
still storable, inspectable, and round-trippable.
OPTIMADE dictionaries are presented through exact local values and converted to
floats only at presentation boundaries. Species dictionaries may retain the
``_httk_charges``, ``_httk_spins``, and ``_httk_labels`` extensions.
:param obj: The OPTIMADE resource to retain.
:param \\*\\*hints: Backend-selection hints, including optional ``resource`` or ``kind``.
"""
[docs]
resource: OptimadeResource
[docs]
kind: ClassVar[str] = "optimade"
[docs]
entry_type_definition_id: ClassVar[str] = _STRUCTURES_DEFINITION_ID
@classmethod
def _backend_adopt(cls, obj: Any = None, **hints: Any) -> Self | None:
r"""Adopt an OPTIMADE resource.
:param obj: The source object to adopt.
:param \**hints: Backend-selection hints, including an optional resource.
:return: An initialized backend, or ``None`` when this backend declines ``obj``.
"""
resource = hints.get("resource", obj)
if not isinstance(resource, OptimadeResource):
return None
if hints and hints.get("kind", cls.kind) != cls.kind:
return None
return cls(obj, **hints)
def __init__(self, obj: OptimadeResource | None = None, **hints: Any) -> None:
resource = hints.get("resource", obj)
if not isinstance(resource, OptimadeResource):
raise TypeError("OptimadeStructure requires an OptimadeResource")
if hints and hints.get("kind", self.kind) != self.kind:
raise TypeError("OptimadeStructure kind must be 'optimade'")
object.__setattr__(self, "resource", resource)
[docs]
def unwrap(self) -> OptimadeResource:
"""Return the exact authoritative source resource by identity.
:return: The original OPTIMADE resource.
"""
return self.resource
@property
[docs]
def raw(self) -> Mapping[str, object]:
"""Expose the immutable JSON API resource envelope.
:return: The decoded resource envelope, including source spelling and extensions.
"""
return cast(Mapping[str, object], self.resource.unwrap())
@cached_property
def _local_schema(self) -> EntryTypeDefinition:
return load_entry_type_definition(self.entry_type_definition_id)
@cached_property
def _remote_names_by_definition_id(self) -> Mapping[str, str]:
root = optimade_document_root(self.resource.schema.info_document)
data = root.get("data")
if not isinstance(data, Mapping):
raise IncompleteOptimadeResourceError("OPTIMADE structure schema has no object 'data' member")
properties = data.get("properties")
if not isinstance(properties, Mapping):
raise IncompleteOptimadeResourceError("OPTIMADE structure schema has no object 'data.properties' member")
names: dict[str, str] = {}
for remote_name, document in properties.items():
if not isinstance(remote_name, str) or not isinstance(document, Mapping):
continue
definition_id = document.get("$id")
if not _is_definition_iri(definition_id):
# A remote label is not a semantic identity. Invalid or absent
# IDs are unknown rather than inferred from their spelling.
continue
definition_id = cast(str, definition_id)
previous = names.get(definition_id)
if previous is not None and previous != remote_name:
raise IncompleteOptimadeResourceError(
f"OPTIMADE structure schema assigns {definition_id!r} to both {previous!r} and {remote_name!r}"
)
names[definition_id] = remote_name
return MappingProxyType(names)
def _value(self, property_name: str, *, component: str, optional: bool = False) -> object:
definition = self._local_schema.properties[property_name]
definition_id = definition.definition_id
remote_name = self._remote_names_by_definition_id.get(definition_id)
if remote_name is None:
if optional:
return _MISSING
raise IncompleteOptimadeResourceError(
f"OPTIMADE {component} requires semantic property {property_name!r} "
f"({definition_id}), but the schema does not identify it"
)
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
if optional and attributes is None:
return _MISSING
raise IncompleteOptimadeResourceError(f"OPTIMADE {component} requires an object 'attributes' member")
value = attributes.get(remote_name, _MISSING)
if value is _MISSING or value is None:
if optional:
return _MISSING
state = "null" if value is None else "missing"
raise IncompleteOptimadeResourceError(
f"OPTIMADE {component} has {state} semantic property {property_name!r}"
)
return value
def _raw_optional(self, property_name: str) -> object:
"""Return a supplied semantic value, preserving missing/null separately."""
definition = self._local_schema.properties[property_name]
remote_name = self._remote_names_by_definition_id.get(definition.definition_id)
if remote_name is None:
return _MISSING
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
return _MISSING
return attributes.get(remote_name, _MISSING)
def _raw_site_moments(self) -> object:
# TODO: resolve _httk_site_moments through its future property-definition helper.
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
return _MISSING
return attributes.get("_httk_site_moments", _MISSING)
def _decoded_optional(self, property_name: str) -> object | None:
"""Decode one optional source property without borrowing its transport name."""
raw = self._raw_optional(property_name)
if raw is _MISSING or raw is None:
return None
definition = self._local_schema.properties[property_name]
try:
return decode_optimade_value(definition, raw)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} is invalid: {exc}"
) from exc
@staticmethod
def _number(value: object, *, property_name: str) -> Fraction:
if not isinstance(value, Decimal | int) or isinstance(value, bool):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} must contain only JSON numbers"
)
return Fraction(value)
@classmethod
def _numeric_matrix(
cls,
value: object,
*,
property_name: str,
rows: int | None = None,
allow_null_rows: bool = False,
) -> tuple[tuple[Fraction, Fraction, Fraction] | None, ...]:
if not isinstance(value, tuple | list) or (rows is not None and len(value) != rows):
expected = f"exactly {rows}" if rows is not None else "any number of"
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} must contain {expected} three-vectors"
)
result: list[tuple[Fraction, Fraction, Fraction] | None] = []
for row in value:
if allow_null_rows and row is None:
result.append(None)
continue
if not isinstance(row, tuple | list) or len(row) != 3:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} must contain three-vectors"
)
if any(item is None for item in row):
if allow_null_rows and all(item is None for item in row):
result.append(None)
continue
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} has a partly null vector"
)
converted = tuple(cls._number(item, property_name=property_name) for item in row)
result.append(cast(tuple[Fraction, Fraction, Fraction], converted))
return tuple(result)
def _require_unit_cell_projection(self, component: str) -> None:
span = self.site_coordinate_span
if span not in _UNIT_CELL_SPANS:
description = f": {self.site_coordinate_span_description}" if self.site_coordinate_span_description else ""
raise IncompleteOptimadeResourceError(
f"OPTIMADE {component} cannot project site_coordinate_span={span!r} as a native unit cell{description}"
)
def _portable_value(self, property_name: str) -> object | None:
"""Decode an optional portable-query field by its exact definition IRI."""
definition = self._local_schema.properties[property_name]
remote_name = self._remote_names_by_definition_id.get(definition.definition_id)
if remote_name is None:
return None
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
return None
raw = attributes.get(remote_name, _MISSING)
if raw is _MISSING or raw is None:
return None
try:
return decode_optimade_value(definition, raw)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE portable property {property_name!r} is invalid: {exc}"
) from exc
@cached_property
def _composition_from_sites(self) -> Composition | None:
"""Project a complete supplied unit-cell site model without requiring a view."""
raw_species = self._raw_optional("species")
raw_names = self._raw_optional("species_at_sites")
raw_span = self._raw_optional("site_coordinate_span")
if (
not isinstance(raw_species, tuple | list)
or not isinstance(raw_names, tuple | list)
or (raw_span not in (_MISSING, None) and raw_span not in _UNIT_CELL_SPANS)
):
return None
if not all(isinstance(name, str) for name in raw_names):
return None
proxy = SimpleNamespace(
species=self._decoded_species,
species_at_sites=tuple(raw_names),
assemblies=self.assemblies,
chemical_composition=None,
)
try:
return project_composition(proxy)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(f"OPTIMADE supplied site composition is invalid: {exc}") from exc
@cached_property
[docs]
def composition(self) -> Composition:
"""Project the source-backed composition, retaining implicit or source-only ratios.
:return: The projected composition.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If source composition fields are inconsistent.
"""
site_result = self._composition_from_sites
features = self.structure_features or ()
if site_result is not None and "implicit_atoms" not in features:
return site_result
elements = self.elements
ratios = self.elements_ratios
if elements is None or ratios is None or len(elements) != len(ratios):
if site_result is not None:
return site_result
raise IncompleteOptimadeResourceError(
"OPTIMADE composition requires consistent 'elements' and 'elements_ratios'"
)
raw_ratios = self._raw_optional("elements_ratios")
raw_values = raw_ratios if isinstance(raw_ratios, tuple | list) else ()
uncertainties = tuple(
(
element,
decimal_precision(raw_values[index])
if index < len(raw_values) and isinstance(raw_values[index], Decimal)
else None,
)
for index, element in enumerate(elements)
)
exact = all(width is None for _, width in uncertainties)
normalized, status, _, _ = normalization(ratios, tuple(width for _, width in uncertainties))
return Composition(
tuple(zip(elements, ratios)),
uncertainties,
True,
exact,
normalized,
status,
)
@property
def _precision(self, name: str, *, component: str) -> Decimal | int | object:
definition_id = precision_definitions()[name].definition_id
remote_name = self._remote_names_by_definition_id.get(definition_id)
if remote_name is None:
return _MISSING
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
return _MISSING
value = attributes.get(remote_name, _MISSING)
if value is _MISSING or value is None:
return _MISSING
if not isinstance(value, Decimal | int) or isinstance(value, bool):
raise IncompleteOptimadeResourceError(
f"OPTIMADE {component} has invalid semantic property {name!r}: expected a JSON number"
)
return value
@staticmethod
def _decimal_precision(value: object) -> Fraction | None:
"""Return the coarsest precision stated by Decimal leaves in *value*.
JSON integers are exact protocol values here, not decimal measurement
spellings. Only :class:`Decimal` leaves retain a source decimal token
(including its trailing zeros), so only they contribute a fallback.
"""
values: list[Fraction] = []
def visit(item: object) -> None:
if isinstance(item, Decimal):
precision = decimal_precision(item)
if precision is not None:
values.append(precision)
elif isinstance(item, Mapping):
for nested in item.values():
visit(nested)
elif isinstance(item, tuple | list):
for nested in item:
visit(nested)
visit(value)
return combined_precision(values)
def _fractional_cartesian_precision(self, cartesian: object) -> Fraction | None:
"""Convert Cartesian decimal-token precision to a conservative reduced bound.
As for POSCAR Cartesian coordinates, divide by the shortest cell edge:
it produces the largest fractional uncertainty and is conservative for
the coordinate frame. The existing atomistic boundary uses the same
deterministic float rendering for non-rational edge lengths.
"""
absolute = self._decimal_precision(cartesian)
if absolute is None:
return None
shortest = min(length.to_float() for length in self.cell.lengths)
if shortest <= 0:
return None
return absolute / Fraction(str(shortest)).limit_denominator(10**12)
@stored_property
[docs]
def id(self) -> str:
"""Expose the JSON API resource identifier without inferring it from a remote label.
:return: The resource identifier.
"""
return self.resource.id
@stored_property
[docs]
def type(self) -> str:
"""Expose the JSON API resource type identifier without inferring it from a remote label.
:return: The resource type.
"""
return self.resource.type
@stored_property
[docs]
def immutable_id(self) -> str | None:
"""Expose the portable immutable source identifier.
:return: The identifier, or ``None`` when absent.
"""
return cast(str | None, self._portable_value("immutable_id"))
@stored_property
[docs]
def last_modified(self) -> datetime.datetime | None:
"""Expose the portable source modification timestamp.
:return: The timestamp, or ``None`` when absent.
"""
return cast(datetime.datetime | None, self._portable_value("last_modified"))
@stored_property
[docs]
def elements(self) -> tuple[str, ...] | None:
"""Expose the validated portable element symbols.
:return: Alphabetically ordered element symbols, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If related source composition fields disagree.
"""
value = self._portable_value("elements")
if value is None:
return None
if (
not isinstance(value, tuple)
or any(not isinstance(element, str) or element not in _ELEMENTS for element in value)
or tuple(sorted(set(value))) != value
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'elements' must contain unique real element symbols in alphabetical order"
)
ratios = self._portable_value("elements_ratios")
if ratios is not None and (not isinstance(ratios, tuple) or len(ratios) != len(value)):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic properties 'elements' and 'elements_ratios' have different lengths"
)
nelements = self._portable_value("nelements")
if nelements is not None and nelements != len(value):
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'nelements' disagrees with 'elements'")
projected = self._composition_from_sites
features = self._portable_value("structure_features")
if (
projected is not None
and projected.complete
and (not isinstance(features, tuple) or "implicit_atoms" not in features)
and projected.elements != value
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'elements' disagrees with the supplied site composition"
)
return value
@stored_property
[docs]
def nelements(self) -> int | None:
"""Expose the validated portable element count.
:return: The element count, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If related source composition fields disagree.
"""
value = self._portable_value("nelements")
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'nelements' must be non-negative")
elements = self._portable_value("elements")
ratios = self._portable_value("elements_ratios")
if isinstance(elements, tuple) and len(elements) != value:
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'nelements' disagrees with 'elements'")
if isinstance(ratios, tuple) and len(ratios) != value:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'nelements' disagrees with 'elements_ratios'"
)
return value
@stored_property
[docs]
def elements_ratios(self) -> tuple[Fraction, ...] | None:
"""Expose exact portable element ratios.
:return: Non-negative ratios summing to one, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the ratios are invalid or inconsistent.
"""
value = self._portable_value("elements_ratios")
if value is None:
return None
if not isinstance(value, tuple):
raise IncompleteOptimadeResourceError("OPTIMADE portable property 'elements_ratios' is not a list")
try:
ratios = tuple(Fraction(ratio) for ratio in value)
except (TypeError, ValueError, ZeroDivisionError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE portable property 'elements_ratios' is invalid: {exc}"
) from exc
width = Fraction()
for ratio in value:
width += decimal_precision(ratio) or Fraction()
if any(ratio < 0 for ratio in ratios) or abs(sum(ratios, Fraction()) - 1) > width:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'elements_ratios' must contain non-negative values summing to one"
)
elements = self._portable_value("elements")
nelements = self._portable_value("nelements")
if isinstance(elements, tuple) and len(elements) != len(ratios):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic properties 'elements_ratios' and 'elements' have different lengths"
)
if isinstance(nelements, int) and nelements != len(ratios):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'elements_ratios' disagrees with 'nelements'"
)
projected = self._composition_from_sites
features = self._portable_value("structure_features")
if projected is not None and (not isinstance(features, tuple) or "implicit_atoms" not in features):
expected = projected.elements_ratios
if len(expected) == len(ratios) and any(abs(left - right) > width for left, right in zip(expected, ratios)):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'elements_ratios' disagrees with the supplied site composition"
)
return ratios
@staticmethod
def _formula_tokens(value: str, property_name: str) -> tuple[tuple[str, int], ...]:
if not value:
raise IncompleteOptimadeResourceError(f"OPTIMADE semantic property {property_name!r} is empty")
position = 0
result: list[tuple[str, int]] = []
while position < len(value):
match = _FORMULA_TOKEN.match(value, position)
if match is None or match.group(1) not in _ELEMENTS:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} has invalid chemical-formula syntax"
)
result.append((match.group(1), int(match.group(2) or 1)))
position = match.end()
return tuple(result)
def _formula(self, property_name: str) -> str | None:
value = self._portable_value(property_name)
if value is None:
return None
if not isinstance(value, str):
raise IncompleteOptimadeResourceError(f"OPTIMADE semantic property {property_name!r} must be a string")
if property_name == "chemical_formula_anonymous":
if not value or "".join(match.group(0) for match in _ANONYMOUS_TOKEN.finditer(value)) != value:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_anonymous' has invalid syntax"
)
anonymous_matches = tuple(_ANONYMOUS_TOKEN.finditer(value))
labels = tuple(match.group(1) for match in anonymous_matches)
coefficients = tuple(int(match.group(2) or 1) for match in anonymous_matches)
if labels != tuple(anonymous_symbol(index) for index in range(len(labels))) or coefficients != tuple(
sorted(coefficients, reverse=True)
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_anonymous' has invalid symbol or coefficient order"
)
ratios = self.elements_ratios
if ratios is not None:
total = sum(coefficients)
formula_ratios = sorted((Fraction(item, total) for item in coefficients), reverse=True)
stated_ratios = sorted(ratios, reverse=True)
width = self._elements_ratio_width()
if len(formula_ratios) != len(stated_ratios) or any(
abs(left - right) > width for left, right in zip(formula_ratios, stated_ratios)
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_anonymous' disagrees with 'elements_ratios'"
)
projected = self._composition_from_sites
features = self._portable_value("structure_features")
if (
projected is not None
and (not isinstance(features, tuple) or "implicit_atoms" not in features)
and projected.chemical_formula_anonymous is not None
and value != projected.chemical_formula_anonymous
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_anonymous' disagrees with the supplied site composition"
)
return value
tokens = self._formula_tokens(value, property_name)
if len({element for element, _ in tokens}) != len(tokens):
raise IncompleteOptimadeResourceError(f"OPTIMADE semantic property {property_name!r} repeats an element")
if property_name == "chemical_formula_hill":
try:
validate_hill_formula(value, None)
except ValueError as exc:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_hill' is not in Hill order"
) from exc
if property_name == "chemical_formula_reduced" and tuple(element for element, _ in tokens) != tuple(
sorted(element for element, _ in tokens)
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_reduced' is not in alphabetical order"
)
declared_elements = self.elements
if declared_elements is not None and set(declared_elements) != {element for element, _ in tokens}:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} disagrees with 'elements'"
)
declared_ratios = self.elements_ratios
if declared_elements is not None and declared_ratios is not None:
counts = dict(tokens)
total = sum(counts.values())
named_formula_ratios = tuple(Fraction(counts[element], total) for element in declared_elements)
width = self._elements_ratio_width()
if any(abs(left - right) > width for left, right in zip(named_formula_ratios, declared_ratios)):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} disagrees with 'elements_ratios'"
)
projected = self._composition_from_sites
features = self._portable_value("structure_features")
if projected is not None and (not isinstance(features, tuple) or "implicit_atoms" not in features):
expected_formula = (
projected.chemical_formula_anonymous
if property_name == "chemical_formula_anonymous"
else projected.chemical_formula_reduced
)
if property_name in {"chemical_formula_reduced", "chemical_formula_anonymous"} and (
expected_formula is not None and value != expected_formula
):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} disagrees with the supplied site composition"
)
if property_name == "chemical_formula_hill":
try:
validate_hill_formula(value, projected)
except ValueError as exc:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'chemical_formula_hill' disagrees with the supplied site composition"
) from exc
return value
def _elements_ratio_width(self) -> Fraction:
raw = self._raw_optional("elements_ratios")
if not isinstance(raw, tuple | list):
return Fraction()
return sum((decimal_precision(value) or Fraction() for value in raw), Fraction())
@stored_property
@stored_property
@stored_property
@stored_property
@stored_property
[docs]
def dimension_types(self) -> tuple[int, ...] | None:
"""Expose portable periodicity flags.
:return: Three ``0``/``1`` flags, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the flags are invalid or inconsistent.
"""
value = self._portable_value("dimension_types")
if value is None:
return None
if (
not isinstance(value, tuple)
or len(value) != 3
or any(not isinstance(item, int) or isinstance(item, bool) or item not in (0, 1) for item in value)
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'dimension_types' must contain exactly three 0/1 integers"
)
stated = self._portable_value("nperiodic_dimensions")
if stated is not None and stated != sum(value):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'nperiodic_dimensions' disagrees with 'dimension_types'"
)
return cast(tuple[int, int, int], value)
@stored_property
[docs]
def nperiodic_dimensions(self) -> int | None:
"""Expose the portable periodic-dimension count.
:return: The count from zero through three, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the count is invalid or inconsistent.
"""
value = self._portable_value("nperiodic_dimensions")
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool) or not 0 <= value <= 3:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'nperiodic_dimensions' must be an integer in [0, 3]"
)
dimensions = self._portable_value("dimension_types")
if isinstance(dimensions, tuple) and value != sum(cast(tuple[int, ...], dimensions)):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'nperiodic_dimensions' disagrees with 'dimension_types'"
)
return value
@stored_property
[docs]
def nsites(self) -> int | None:
"""Expose the portable site count.
:return: The non-negative site count, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If related arrays disagree with the count.
"""
value = self._portable_value("nsites")
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'nsites' must be non-negative")
for property_name in ("species_at_sites", "cartesian_site_positions", "fractional_site_positions"):
supplied = self._raw_optional(property_name)
if (
supplied is not _MISSING
and supplied is not None
and (not isinstance(supplied, tuple | list) or len(supplied) != value)
):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'nsites' disagrees with {property_name!r}"
)
return value
@stored_property
[docs]
def structure_features(self) -> tuple[str, ...] | None:
"""Expose validated OPTIMADE structure-feature flags.
:return: Canonically ordered feature flags, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If flags are invalid or inconsistent.
"""
value = self._portable_value("structure_features")
if value is None:
return None
if (
not isinstance(value, tuple)
or any(not isinstance(item, str) or item not in _STRUCTURE_FEATURES for item in value)
or tuple(sorted(set(value))) != value
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'structure_features' must be a unique alphabetical list of standard flags"
)
raw_species = self._raw_optional("species")
raw_assemblies = self._raw_optional("assemblies")
derived: set[str] = set()
if raw_assemblies is not _MISSING and raw_assemblies is not None:
derived.add("assemblies")
if isinstance(raw_species, tuple | list):
for species in self._decoded_species:
if len(species.chemical_symbols) > 1 or species.concentration != (Fraction(1),):
derived.add("disorder")
if species.attached is not None:
derived.add("site_attachments")
for flag in ("assemblies", "disorder", "site_attachments"):
if (flag in value) != (flag in derived):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'structure_features' has an inconsistent {flag!r} flag"
)
has_magnetism = self._raw_site_moments() not in (_MISSING, None)
if ("_httk_magnetism" in value) != has_magnetism:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'structure_features' has an inconsistent '_httk_magnetism' flag"
)
return value
@cached_property
[docs]
def lattice_vectors(self) -> tuple[tuple[Fraction, Fraction, Fraction] | None, ...] | None:
"""Expose exact lattice vectors from the OPTIMADE source.
:return: Three vectors, with ``None`` for non-periodic directions, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If vectors conflict with periodicity.
"""
value = self._decoded_optional("lattice_vectors")
if value is None:
return None
vectors = self._numeric_matrix(value, property_name="lattice_vectors", rows=3, allow_null_rows=True)
dimensions = self.dimension_types
if dimensions is not None:
for index, vector in enumerate(vectors):
if vector is None and dimensions[index] != 0:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'lattice_vectors' nulls a periodic lattice vector"
)
return vectors
@cached_property
def _coordinate_arrays(
self,
) -> tuple[
tuple[tuple[Fraction, Fraction, Fraction], ...] | None,
tuple[tuple[Fraction, Fraction, Fraction], ...] | None,
]:
raw_fractional = self._decoded_optional("fractional_site_positions")
raw_cartesian = self._decoded_optional("cartesian_site_positions")
fractional = (
None
if raw_fractional is None
else cast(
tuple[tuple[Fraction, Fraction, Fraction], ...],
self._numeric_matrix(raw_fractional, property_name="fractional_site_positions"),
)
)
cartesian = (
None
if raw_cartesian is None
else cast(
tuple[tuple[Fraction, Fraction, Fraction], ...],
self._numeric_matrix(raw_cartesian, property_name="cartesian_site_positions"),
)
)
if fractional is not None and cartesian is not None:
if len(fractional) != len(cartesian):
raise IncompleteOptimadeResourceError(
"OPTIMADE fractional and Cartesian site-position arrays have different lengths"
)
lattice = self.lattice_vectors
if lattice is not None and all(vector is not None for vector in lattice):
expected = SurdVector(fractional) * SurdVector(lattice)
actual = SurdVector(cartesian)
difference = (expected - actual).to_fractions_approx(Fraction(1, 10**24))
cartesian_width = self._decimal_precision(self._raw_optional("cartesian_site_positions")) or Fraction()
fractional_width = (
self._decimal_precision(self._raw_optional("fractional_site_positions")) or Fraction()
)
lattice_width = self._decimal_precision(self._raw_optional("lattice_vectors")) or Fraction()
scale = max(abs(value) for row in cast(tuple[tuple[Fraction, ...], ...], lattice) for value in row)
tolerance = cartesian_width + 3 * (fractional_width * scale + lattice_width)
if any(abs(value) > tolerance for row in difference for value in row):
raise IncompleteOptimadeResourceError(
"OPTIMADE fractional_site_positions and cartesian_site_positions disagree with lattice_vectors"
)
expected_nsites = self._portable_value("nsites")
species_names = self._raw_optional("species_at_sites")
for name, positions in (
("fractional_site_positions", fractional),
("cartesian_site_positions", cartesian),
):
if positions is None:
continue
if isinstance(expected_nsites, int) and len(positions) != expected_nsites:
raise IncompleteOptimadeResourceError(f"OPTIMADE semantic property {name!r} disagrees with 'nsites'")
if isinstance(species_names, tuple | list) and len(positions) != len(species_names):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {name!r} disagrees with 'species_at_sites'"
)
return fractional, cartesian
@cached_property
[docs]
def fractional_site_positions(self) -> tuple[tuple[Fraction, Fraction, Fraction], ...] | None:
"""Expose exact fractional site positions.
:return: Fractional positions, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If supplied coordinate arrays disagree.
"""
return self._coordinate_arrays[0]
@cached_property
[docs]
def cartesian_site_positions(self) -> tuple[tuple[Fraction, Fraction, Fraction], ...] | None:
"""Expose exact Cartesian site positions.
:return: Cartesian positions, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If supplied coordinate arrays disagree.
"""
return self._coordinate_arrays[1]
@stored_property
[docs]
def site_coordinate_span(self) -> str:
"""Expose the source coordinate span.
:return: The OPTIMADE coordinate-span value, defaulting to ``"unit_cell"``.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the span is invalid or lacks required symmetry.
"""
value = self._decoded_optional("site_coordinate_span")
if value is None:
return "unit_cell"
if not isinstance(value, str) or value not in _COORDINATE_SPANS:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'site_coordinate_span' has an unrecognized representation"
)
if (
value
in {
"fundamental_domain",
"asymmetric_unit",
"molecular_fundamental_domain",
"molecular_asymmetric_unit",
}
and not self.space_group_symmetry_operations_xyz
):
raise IncompleteOptimadeResourceError(
f"OPTIMADE site_coordinate_span={value!r} requires space-group symmetry operations"
)
return value
@stored_property
[docs]
def site_coordinate_span_description(self) -> str | None:
"""Expose the description for an ``"other"`` coordinate span.
:return: The span description, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If a description is invalid or used for another span.
"""
value = self._decoded_optional("site_coordinate_span_description")
if value is None:
return None
if not isinstance(value, str) or not value:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'site_coordinate_span_description' must be a non-empty string"
)
if self.site_coordinate_span != "other":
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'site_coordinate_span_description' is only valid for span 'other'"
)
return value
@stored_property
[docs]
def molecular(self) -> bool:
"""Expose whether the native unit-cell projection carries molecular placement.
:return: Whether the coordinate span is ``"molecular_unit_cell"``.
"""
return self.site_coordinate_span == "molecular_unit_cell"
@stored_property
[docs]
def coordinate_precision(self) -> Fraction | None:
"""Expose the source precision for reduced coordinates.
:return: The fractional precision, or ``None`` when unavailable.
"""
value = self._precision("_httk_coordinate_precision", component="sites")
if value is not _MISSING:
return Fraction(cast(Decimal | int, value))
fractional = self._raw_optional("fractional_site_positions")
return None if fractional is _MISSING else self._decimal_precision(fractional)
@stored_property
[docs]
def basis_precision(self) -> Fraction | None:
"""Expose the source precision for lattice vectors.
:return: The basis precision, or ``None`` when unavailable.
"""
value = self._precision("_httk_basis_precision", component="cell")
if value is not _MISSING:
return Fraction(cast(Decimal | int, value))
lattice = self._raw_optional("lattice_vectors")
return None if lattice is _MISSING else self._decimal_precision(lattice)
@property
[docs]
def site_moments(self) -> CartesianSiteMoments | None:
"""Expose source Cartesian site moments.
:return: Cartesian moments, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If moment rows are invalid or cannot be aligned to sites.
"""
raw = self._raw_site_moments()
if raw is _MISSING or raw is None:
return None
raw_sites = self._raw_optional("species_at_sites")
nsites = len(raw_sites) if isinstance(raw_sites, tuple | list) else self.nsites
if not isinstance(nsites, int):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property '_httk_site_moments' cannot validate its site count"
)
rows = self._numeric_matrix(raw, property_name="_httk_site_moments", rows=nsites)
return CartesianSiteMoments(rows)
@cached_property
[docs]
def symmetry(self) -> StructureSymmetry:
"""Build typed source symmetry metadata for the common unit-cell view layer.
:return: Validated symmetry metadata.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If supplied symmetry fields are inconsistent.
"""
try:
return StructureSymmetry(
space_group_it_number=self._space_group_it_number_value(),
space_group_symbol_hall=self._symmetry_string_value("space_group_symbol_hall"),
space_group_symbol_hermann_mauguin=self._symmetry_string_value("space_group_symbol_hermann_mauguin"),
space_group_symbol_hermann_mauguin_extended=self._symmetry_string_value(
"space_group_symbol_hermann_mauguin_extended"
),
space_group_symmetry_operations_xyz=self._space_group_operations_value(),
wyckoff_positions=self._wyckoff_positions_value(),
)
except ValueError as exc:
message = str(exc)
if message == "supplied space-group number and symbols are inconsistent":
message = "OPTIMADE supplied space-group number and symbols are mutually inconsistent"
elif message == "supplied space-group operations disagree with its number or symbols":
message = "OPTIMADE supplied space-group operations disagree with its number or symbols"
elif message == "supplied Wyckoff positions disagree with the space-group setting":
message = "OPTIMADE semantic property 'wyckoff_positions' disagrees with the supplied space group"
elif message.startswith("space-group symmetry operations "):
message = f"OPTIMADE supplied space-group operations do not form a valid group: {message}"
raise IncompleteOptimadeResourceError(message) from exc
@stored_property
[docs]
def optimization_type(self) -> str | None:
"""Expose the source optimization provenance.
:return: The normalized optimization type, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the source value is not a string.
"""
value = self._decoded_optional("optimization_type")
if value is None:
return None
if not isinstance(value, str):
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'optimization_type' must be a string")
if value in _OPTIMIZATION_TYPES:
return value
# The specification tells clients to interpret an unrecognized string as
# "other". Keep the source spelling available through ``raw``.
return "other"
@cached_property
[docs]
def assemblies(self) -> tuple[Assembly, ...] | None:
"""Expose validated source site assemblies.
:return: Assemblies, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If assemblies are invalid or inconsistent.
"""
raw = self._raw_optional("assemblies")
if raw is _MISSING or raw is None:
return None
if not isinstance(raw, tuple | list) or not all(isinstance(value, Mapping) for value in raw):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'assemblies' must be a list of dictionaries"
)
values: list[Assembly] = []
try:
for item in raw:
item = cast(Mapping[str, object], item)
if set(item) != {"sites_in_groups", "group_probabilities"} and any(
not str(name).startswith("_") for name in set(item) - {"sites_in_groups", "group_probabilities"}
):
raise ValueError("assembly dictionaries contain an unknown non-namespaced key")
groups = item.get("sites_in_groups")
probabilities = item.get("group_probabilities")
if not isinstance(groups, tuple | list) or not isinstance(probabilities, tuple | list):
raise ValueError("assembly dictionaries require sites_in_groups and group_probabilities lists")
values.append(Assembly(tuple(tuple(group) for group in groups), tuple(probabilities)))
nsites = self._portable_value("nsites")
if nsites is None:
species_names = self._raw_optional("species_at_sites")
nsites = len(species_names) if isinstance(species_names, tuple | list) else None
result = validate_assemblies(values, cast(int | None, nsites))
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(f"OPTIMADE semantic property 'assemblies' is invalid: {exc}") from exc
features = self._portable_value("structure_features")
if isinstance(features, tuple) and "assemblies" not in features:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'assemblies' is present without its structure_features flag"
)
return result
@cached_property
def _declared_spacegroup_candidates(self) -> tuple[Spacegroup, ...]:
return tuple(Spacegroup(record) for record in self.symmetry.matched_settings)
def _declared_periodic_dimensions(self) -> int | None:
periodic = self._portable_value("nperiodic_dimensions")
if isinstance(periodic, int) and not isinstance(periodic, bool):
return periodic
dimensions = self._portable_value("dimension_types")
if isinstance(dimensions, tuple) and len(dimensions) == 3 and all(value in (0, 1) for value in dimensions):
return sum(cast(tuple[int, int, int], dimensions))
return None
def _symmetry_string_value(self, property_name: str) -> str | None:
value = self._decoded_optional(property_name)
if value is None:
return None
if not isinstance(value, str) or not value:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} must be a non-empty string"
)
periodic = self._declared_periodic_dimensions()
if isinstance(periodic, int) and periodic != 3:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property {property_name!r} must be null unless nperiodic_dimensions is 3"
)
return value
def _symmetry_string(self, property_name: str) -> str | None:
value = self._symmetry_string_value(property_name)
_ = self.symmetry
return value
def _space_group_it_number_value(self) -> int | None:
value = self._decoded_optional("space_group_it_number")
if value is None:
return None
if not isinstance(value, int) or isinstance(value, bool) or not 1 <= value <= 230:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'space_group_it_number' must be an integer in [1, 230]"
)
periodic = self._declared_periodic_dimensions()
if isinstance(periodic, int) and periodic != 3:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'space_group_it_number' must be null unless nperiodic_dimensions is 3"
)
return value
def _space_group_operations_value(self) -> tuple[str, ...] | None:
value = self._decoded_optional("space_group_symmetry_operations_xyz")
if value is None:
return None
if (
not isinstance(value, tuple)
or not value
or any(
not isinstance(operation, str)
or len(operation.split(",")) != 3
or not all(_SYMOP_COORDINATE.fullmatch(part) for part in operation.split(","))
for operation in value
)
):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'space_group_symmetry_operations_xyz' must contain three-coordinate strings"
)
normalized = tuple(operation.replace(" ", "") for operation in value)
if "x,y,z" not in normalized:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'space_group_symmetry_operations_xyz' lacks the identity operation"
)
periodic = self._declared_periodic_dimensions()
if periodic == 0:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'space_group_symmetry_operations_xyz' must be null for a nonperiodic structure"
)
return value
def _wyckoff_positions_value(self) -> tuple[str, ...] | None:
value = self._decoded_optional("wyckoff_positions")
if value is None:
return None
if not isinstance(value, tuple) or any(item not in _WYCKOFF for item in value):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'wyckoff_positions' must contain single valid Wyckoff letters"
)
for property_name in ("species_at_sites", "fractional_site_positions", "cartesian_site_positions"):
supplied = self._raw_optional(property_name)
if isinstance(supplied, tuple | list) and len(supplied) != len(value):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'wyckoff_positions' disagrees with {property_name!r}"
)
nsites = self._portable_value("nsites")
if isinstance(nsites, int) and nsites != len(value):
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'wyckoff_positions' disagrees with 'nsites'"
)
return value
@stored_property
[docs]
def space_group_symbol_hall(self) -> str | None:
"""Expose the source Hall space-group symbol.
:return: The symbol, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the symbol conflicts with source symmetry.
"""
return self._symmetry_string("space_group_symbol_hall")
@stored_property
[docs]
def space_group_symbol_hermann_mauguin(self) -> str | None:
"""Expose the source short Hermann–Mauguin symbol.
:return: The symbol, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the symbol conflicts with source symmetry.
"""
return self._symmetry_string("space_group_symbol_hermann_mauguin")
@stored_property
[docs]
def space_group_symbol_hermann_mauguin_extended(self) -> str | None:
"""Expose the source extended Hermann–Mauguin symbol.
:return: The symbol, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the symbol conflicts with source symmetry.
"""
return self._symmetry_string("space_group_symbol_hermann_mauguin_extended")
@stored_property
[docs]
def space_group_it_number(self) -> int | None:
"""Expose the source International Tables space-group number.
:return: The number, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the number conflicts with source symmetry.
"""
value = self._space_group_it_number_value()
_ = self.symmetry
return value
@stored_property
[docs]
def space_group_symmetry_operations_xyz(self) -> tuple[str, ...] | None:
"""Expose the declared raw ``xyz`` symmetry-operation strings.
:return: The source operation strings, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If operations are invalid or inconsistent.
"""
value = self._space_group_operations_value()
_ = self.symmetry
return value
@stored_property
[docs]
def wyckoff_positions(self) -> tuple[str, ...] | None:
"""Expose source Wyckoff letters aligned with the represented sites.
:return: Wyckoff letters, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If letters are invalid or misaligned.
"""
value = self._wyckoff_positions_value()
_ = self.symmetry
return value
@cached_property
def _cell(self) -> Cell:
self._require_unit_cell_projection("cell")
lattice = self._value("lattice_vectors", component="cell")
dimensions = self.dimension_types
if dimensions is None:
raise IncompleteOptimadeResourceError("OPTIMADE cell has missing or null 'dimension_types'")
vectors = self.lattice_vectors
if vectors is None or any(vector is None for vector in vectors):
raise IncompleteOptimadeResourceError(
"OPTIMADE cell cannot project null non-periodic lattice vectors into the native coordinate frame"
)
precision = self._precision("_httk_basis_precision", component="cell")
if precision is _MISSING:
precision = self._decimal_precision(lattice)
try:
return Cell(
cast(Any, lattice),
precision=None if precision is _MISSING else precision,
periodicity=tuple(bool(value) for value in dimensions),
)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE cell has invalid semantic property 'lattice_vectors': {exc}"
) from exc
@property
[docs]
def cell(self) -> Cell:
"""Expose the projected exact cell.
:return: The native cell projection.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the source cannot project a unit cell.
"""
return self._cell
@cached_property
def _sites(self) -> Sites:
self._require_unit_cell_projection("sites")
fractional, cartesian = self._coordinate_arrays
precision = self._precision("_httk_coordinate_precision", component="sites")
if fractional is not None:
if precision is _MISSING:
precision = self._decimal_precision(self._raw_optional("fractional_site_positions"))
try:
return Sites(cast(Any, fractional), precision=None if precision is _MISSING else precision)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE sites has invalid semantic property 'fractional_site_positions': {exc}"
) from exc
if cartesian is None:
raise IncompleteOptimadeResourceError(
"OPTIMADE sites requires 'fractional_site_positions' or 'cartesian_site_positions'"
)
if precision is _MISSING:
precision = self._fractional_cartesian_precision(self._raw_optional("cartesian_site_positions"))
try:
reduced = SurdVector(cast(Any, cartesian)) * self.cell.basis.inv()
return Sites(reduced, precision=None if precision is _MISSING else precision)
except (TypeError, ValueError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE sites has invalid semantic property 'cartesian_site_positions': {exc}"
) from exc
@property
[docs]
def sites(self) -> Sites:
"""Expose the projected exact sites.
:return: The native site projection.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the source cannot project site coordinates.
"""
return self._sites
@cached_property
def _decoded_species(self) -> tuple[Species, ...]:
raw_species = self._value("species", component="species")
if not isinstance(raw_species, tuple | list):
raise IncompleteOptimadeResourceError(
"OPTIMADE species has invalid semantic property 'species': expected a JSON array"
)
if not all(isinstance(value, Mapping) for value in raw_species):
raise IncompleteOptimadeResourceError(
"OPTIMADE species has invalid semantic property 'species': expected objects"
)
try:
values = tuple(Species.from_object(dict(value)) for value in raw_species)
except (TypeError, ValueError, KeyError) as exc:
raise IncompleteOptimadeResourceError(
f"OPTIMADE species has invalid semantic property 'species': {exc}"
) from exc
names = tuple(value.name for value in values)
if len(set(names)) != len(names):
raise IncompleteOptimadeResourceError("OPTIMADE semantic property 'species' has duplicate names")
raw_names = self._raw_optional("species_at_sites")
if isinstance(raw_names, tuple | list):
unknown = sorted(set(raw_names) - set(names))
if unknown:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'species_at_sites' references undefined species {unknown!r}"
)
return values
@property
[docs]
def species(self) -> tuple[Species, ...]:
"""Expose decoded species definitions.
:return: Distinct species definitions.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If source species dictionaries are invalid.
"""
return self._decoded_species
@cached_property
def _species_at_sites(self) -> tuple[str, ...]:
raw_names = self._value("species_at_sites", component="species_at_sites")
if not isinstance(raw_names, tuple | list) or not all(isinstance(name, str) for name in raw_names):
raise IncompleteOptimadeResourceError(
"OPTIMADE species_at_sites has invalid semantic property 'species_at_sites': expected strings"
)
names = tuple(raw_names)
for property_name in ("fractional_site_positions", "cartesian_site_positions"):
positions = self._raw_optional(property_name)
if isinstance(positions, tuple | list) and len(names) != len(positions):
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'species_at_sites' disagrees with {property_name!r}"
)
stated_nsites = self._portable_value("nsites")
if isinstance(stated_nsites, int) and len(names) != stated_nsites:
raise IncompleteOptimadeResourceError(
"OPTIMADE semantic property 'species_at_sites' disagrees with 'nsites'"
)
defined = {value.name for value in self._decoded_species}
unknown = sorted(set(names) - defined)
if unknown:
raise IncompleteOptimadeResourceError(
f"OPTIMADE semantic property 'species_at_sites' references undefined species {unknown!r}"
)
return names
@property
[docs]
def species_at_sites(self) -> tuple[str, ...]:
"""Expose decoded species names for each site.
:return: Site species names in site order.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If names do not align with source sites.
"""
return self._species_at_sites
@property
[docs]
def charge(self) -> Fraction | None:
"""Expose the private exact charge extension.
:return: The assigned charge, or ``None`` when absent.
:raises httk.core.optimade.entries.IncompleteOptimadeResourceError: If the source charge is not numeric.
"""
attributes = self.raw.get("attributes")
if not isinstance(attributes, Mapping):
return None
value = attributes.get("_httk_charge")
if value is None:
return None
try:
return Fraction(str(value))
except (TypeError, ValueError, ZeroDivisionError) as exc:
raise IncompleteOptimadeResourceError("OPTIMADE '_httk_charge' must be a number or null") from exc