"""The symmetry properties an asymmetric-unit structure can serve over OPTIMADE.
Two families, kept apart on purpose:
**Standard OPTIMADE properties.** ``space_group_it_number``, the three space-group
symbols, ``space_group_symmetry_operations_xyz``, ``wyckoff_positions``,
``fractional_site_positions``, and ``site_coordinate_span`` are all part of the OPTIMADE
standard and are already described in the vendored ``optimade_defs/structures.json``. They
were simply never served.
**Provider-specific properties**, for the things OPTIMADE does not standardise — chiefly
*which setting* a structure is written in, and the change of basis to the standard one.
Rather than inventing descriptions, these reuse the published property definitions from
`schemas.httk.org <https://schemas.httk.org>`_, vendored verbatim in ``httk_defs/``. The served *name* carries the ``_httk_`` prefix that OPTIMADE requires
of a database-specific property, but the definition keeps its own ``$id``, so a client
following the link reaches the authoritative published schema instead of a local
paraphrase.
Everything here serves ``null`` for a plain :class:`~httk.atomistic.Structure`, which
carries no symmetry — with the sole exception of ``fractional_site_positions``, which is
just the reduced coordinates and is always available.
"""
from functools import cache
from typing import Any
from httk.core import PropertyDefinition, unwrap
from .asu_structure import ASUStructure
from .httk_definitions import load_httk_definitions
from .spacegroup import wyckoff_letter_map
__all__ = [
"SETTING_PROPERTY_KEYS",
"SYMMETRY_PROPERTY_KEYS",
"setting_definitions",
"symmetry_properties",
]
#: Standard OPTIMADE symmetry properties, served from an ASU when there is one. Already
#: described by the vendored ``structures.json``, so they need no definition of their own.
[docs]
SYMMETRY_PROPERTY_KEYS: tuple[str, ...] = (
"space_group_it_number",
"space_group_symbol_hall",
"space_group_symbol_hermann_mauguin",
"space_group_symbol_hermann_mauguin_extended",
"space_group_symmetry_operations_xyz",
"wyckoff_positions",
"fractional_site_positions",
"site_coordinate_span",
"site_coordinate_span_description",
)
#: Provider-specific properties describing *which setting* a structure is written in,
#: mapped to the vendored definition file that describes each. The served name carries the
#: ``_httk_`` prefix; the definition keeps its ``schemas.httk.org`` identity.
[docs]
SETTING_PROPERTY_KEYS: dict[str, str] = {
"_httk_setting_it_nc": "setting_it_nc",
"_httk_hall_entry": "hall_entry",
"_httk_is_reference_setting": "is_reference_setting",
"_httk_crystal_system": "crystal_system",
"_httk_centring_type": "centring_type",
"_httk_setting_transform": "affine_transformation",
}
@cache
[docs]
def setting_definitions() -> dict[str, PropertyDefinition]:
"""The vendored setting definitions, keyed by their served name.
Loaded verbatim, so each keeps the ``$id`` under ``schemas.httk.org`` that makes it
resolvable. Cached, since the documents are immutable.
"""
return load_httk_definitions(SETTING_PROPERTY_KEYS)
[docs]
def symmetry_properties(structure: Any) -> dict[str, Any]:
"""Every symmetry property for one structure, standard and provider-specific alike.
A structure that is not an :class:`~httk.atomistic.ASUStructure` carries no symmetry, so
everything but ``fractional_site_positions`` serves null. That is the honest answer:
inferring a space group here would mean running a symmetry search behind the caller's
back, with a tolerance nobody chose, on every record served.
"""
values: dict[str, Any] = {name: None for name in SYMMETRY_PROPERTY_KEYS}
values.update({name: None for name in SETTING_PROPERTY_KEYS})
if structure is None:
return values
values["fractional_site_positions"] = structure.sites.reduced_coords.to_floats()
# ``unit_cell`` promises that translating the given sites by the lattice vectors
# reconstructs the whole periodic system, which holds as long as *something* is
# periodic. With nothing periodic there is no periodic system to reconstruct, and none
# of the ``molecular_*`` values fit either: each of them additionally requires bonded
# atoms to be placed at a bond distance, which httk does not know. ``other`` is the
# honest remainder.
values["site_coordinate_span"] = "unit_cell" if structure.cell.nperiodic_dimensions else "other"
# A structure built from an asymmetric unit unwraps back to it, so the symmetry is
# recovered rather than re-derived; anything else genuinely has none to report.
asu = unwrap(structure)
if not isinstance(asu, ASUStructure):
return values
setting = asu.setting()
values["space_group_it_number"] = asu.spacegroup.it_number
if setting is None:
# The structure is in a setting that appears in no table. Its space-group *number*
# is still meaningful, but the symbols and the Wyckoff letters name a setting, and
# this one has no name — so reporting the standard setting's would misdescribe it.
values["_httk_setting_transform"] = _transform_record(asu)
values["_httk_is_reference_setting"] = False
values["_httk_crystal_system"] = asu.spacegroup.crystal_system
return values
values["space_group_symbol_hall"] = setting.hall_symbol
values["space_group_symbol_hermann_mauguin"] = setting.hermann_mauguin
values["space_group_symbol_hermann_mauguin_extended"] = _extended_symbol(setting)
values["space_group_symmetry_operations_xyz"] = [
operation.wrapped().to_xyz() for operation in setting.symmetry_operations
]
values["wyckoff_positions"] = _wyckoff_symbols(asu, setting)
values["_httk_setting_it_nc"] = setting.setting
values["_httk_hall_entry"] = setting.hall_entry
values["_httk_is_reference_setting"] = setting.is_standard_setting
values["_httk_crystal_system"] = setting.crystal_system
values["_httk_centring_type"] = setting.centring_type
values["_httk_setting_transform"] = _transform_record(asu)
return values
def _extended_symbol(setting: Any) -> str | None:
"""The extended Hermann-Mauguin symbol as a single line.
International Tables prints it over several lines, listing the additional symmetry
elements beneath the generators, and the tables keep that layout — so the continuation
lines are folded onto one, since this property is a symbol string.
Deliberately *not* the "universal" cctbx symbol, which is otherwise the more informative
string: it appends the origin choice (``P n n n :2 (a+1/4,b+1/4,c+1/4)``), and this
property states that the symbol SHOULD NOT be amended to convey that. The extended
symbol is already written in the setting's own axes (``A 1 1 2/a`` for ``15:c1``), so
the axis choice is conveyed without a change-of-basis suffix.
"""
extended = setting.record.get("hm_extended")
if not extended:
return None
return " ".join(part.strip() for part in str(extended).split("\n") if part.strip())
def _wyckoff_symbols(asu: ASUStructure, setting: Any) -> list[str]:
"""One Wyckoff symbol per expanded site, in the order the sites are served.
The symbols name positions of the setting the structure is *written in*, as OPTIMADE
requires, not of the standard setting the ASU stores them against. Those differ in two
ways that both bite silently: the letters themselves are permuted for setting ``224:1``
(standard ``j`` is that setting's ``i`` and vice versa), and the multiplicity changes
for the rhombohedral settings, where a 3a of the standard hexagonal cell is a 1a of the
smaller rhombohedral one.
"""
letters = wyckoff_letter_map(asu.spacegroup, setting)
symbols: list[str] = []
for site, count in zip(asu.asu_sites, asu.multiplicities()):
position = setting.wyckoff_position(letters[site.wyckoff])
symbols.extend([f"{position.multiplicity}{position.letter}"] * count)
return symbols
def _transform_record(asu: ASUStructure) -> dict[str, Any]:
"""The change of basis from the IT standard setting into this structure's own.
Rendered as exact rational strings, matching how the vendored symmetry tables write
them, so the value survives a JSON round trip without becoming a float.
"""
transform = asu.transform
return {
"matrix": [[str(value) for value in row] for row in transform.matrix.to_fractions()],
"vector": [str(value) for value in transform.vector.to_fractions()],
"xyz": transform.operation.to_xyz(),
}