Source code for httk.atomistic.setting_transform
"""The change of basis between a space-group setting and the IT standard setting.
A crystal structure can be written in any setting of its space group — a different choice
of axes, a different origin, or something that appears in no table at all. httk represents
such a structure by holding its Wyckoff data in the International Tables **standard**
setting and carrying a :class:`SettingTransform` that says how to get from there to the
setting the structure is actually in. That pairing is what lets an arbitrary,
non-tabulated setting be represented losslessly.
**Direction.** A :class:`SettingTransform` maps *standard* coordinates into the
structure's *own* setting::
f_own = f_std * M.T() + v
with the reverse ``f_std = (f_own - v) * inv(M).T()`` and cell basis rows transforming as
``B_own = inv(M).T() * B_std``. Applying it backwards produces a structurally valid but
systematically wrong crystal.
**Never solve for a transform.** A transform is *stored*, never re-derived by searching
for one that maps one group onto another. Such a search is massively underdetermined — for
SG 15 alone there are 192 valid pairs with integer entries in ``{-1,0,1}`` and translations
on a 1/24 grid, and the true solution set is the full affine-normalizer coset, which is
infinite. Two structures with the same group, the same Wyckoff letters and the same free
parameters but different transforms are *different crystals*, so picking an arbitrary
member of that family would silently produce the wrong structure.
"""
import fractions
from typing import Any, Self
from httk.core import FracVector, SurdVector
from . import data
from ._lattice import finite_translation_cosets
from .affine_operation import AffineOperation
__all__ = ["SettingTransform"]
[docs]
class SettingTransform:
"""An exact rational change of basis from the IT standard setting to another setting.
Wraps an :class:`~httk.atomistic.AffineOperation` and gives it the standard-to-own
reading described in the module docstring, plus the cell-basis and symmetry-operation
transformations that follow from it.
"""
_operation: AffineOperation
_hall_entry: str | None
_cosets_cache: tuple[FracVector, ...] | None
def __init__(self, matrix: Any, vector: Any = (0, 0, 0), *, hall_entry: str | None = None) -> None:
self._operation = AffineOperation(matrix, vector)
if self._operation.determinant() == 0:
raise ValueError("a setting transform must be invertible, but its matrix is singular")
self._hall_entry = hall_entry
self._cosets_cache = None
# --- constructors ---
@classmethod
[docs]
def identity(cls) -> Self:
"""The transform of a structure already in its IT standard setting."""
return cls(FracVector.eye((3, 3)), (0, 0, 0))
@classmethod
[docs]
def for_hall_entry(cls, hall_entry: str) -> Self:
"""The tabulated transform for one of the 527 known settings.
``hall_entry`` is the normalized Hall symbol of the setting, which names it
unambiguously — symbol, axes, and origin choice together.
"""
record = data.setting_transform(hall_entry)
affine = record["affine_transformation"]
return cls(affine["matrix"], affine["vector"], hall_entry=hall_entry)
# --- accessors ---
@property
[docs]
def operation(self) -> AffineOperation:
"""The underlying affine map, standard to own setting."""
return self._operation
@property
[docs]
def matrix(self) -> FracVector:
"""The 3x3 rotation part ``M``."""
return self._operation.matrix
@property
@property
[docs]
def hall_entry(self) -> str | None:
"""The Hall entry this transform was looked up for, if it came from the tables."""
return self._hall_entry
[docs]
def determinant(self) -> fractions.Fraction:
"""``det M``: the ratio of the own cell's volume to the standard cell's, inverted.
``1`` for 520 of the 527 tabulated settings. The exceptions are the seven
rhombohedral-axes settings (IT numbers 146, 148, 155, 160, 161, 166, 167) where it
is ``3``, because the standard hexagonal cell holds three primitive rhombohedral
cells. A caller-supplied transform may have any non-zero value.
"""
return self._operation.determinant()
[docs]
def is_identity(self) -> bool:
"""Whether the structure is already in its IT standard setting."""
return self._operation.is_identity()
# --- coordinates ---
[docs]
def to_setting(self, coords: Any) -> FracVector:
"""Map standard-setting reduced coordinates into this setting. Not wrapped."""
return self._operation.apply(coords)
[docs]
def to_standard(self, coords: Any) -> FracVector:
"""Map this setting's reduced coordinates into the standard setting. Not wrapped."""
return self._operation.inverse().apply(coords)
# --- symmetry operations ---
[docs]
def symop_to_setting(self, operation: AffineOperation) -> AffineOperation:
"""A symmetry operation written in the standard setting, rewritten in this one."""
return operation.conjugated_by(self._operation)
[docs]
def symop_to_standard(self, operation: AffineOperation) -> AffineOperation:
"""A symmetry operation written in this setting, rewritten in the standard one."""
return operation.conjugated_by(self._operation.inverse())
# --- cell basis ---
[docs]
def basis_to_setting(self, basis: Any) -> SurdVector:
"""Map a standard-setting cell basis (lattice vectors as rows) into this setting.
Follows from coordinate invariance: if ``f_own = f_std * M.T()`` then
``B_own = inv(M).T() * B_std``, so that ``f * B`` is the same Cartesian point
either way. The transform is rational, so an exact basis stays exact — a
hexagonal cell keeps its ``sqrt(3)``.
"""
return SurdVector.create(self.matrix.T().inv()) * SurdVector.create(basis)
[docs]
def basis_to_standard(self, basis: Any) -> SurdVector:
"""Map this setting's cell basis into the standard setting."""
return SurdVector.create(self.matrix.T()) * SurdVector.create(basis)
# --- lattice change ---
[docs]
def lattice_cosets(self) -> tuple[FracVector, ...]:
"""Translations of this setting's cell that are standard-lattice translations.
Expanding an orbit generates points from the standard setting's symmetry
operations, which carry the standard lattice's periodicity. When this setting's
cell is *larger* than the standard one, that is not enough: points related by a
standard lattice translation are genuinely distinct sites here, and the missing
ones are recovered by also applying each translation returned by this method.
The result is the finite subgroup of ``(Q/Z)^3`` generated by the columns of ``M``
reduced modulo 1, always including the zero translation. It is trivial (just zero)
whenever ``M`` is an integer matrix, which covers **all 527** tabulated settings —
including the seven with ``det M == 3``, where this setting's cell is *smaller* and
the surplus points collapse under wrapping instead. So this only ever does work for
a caller-supplied transform to a supercell setting.
"""
if self._cosets_cache is None:
self._cosets_cache = self._compute_cosets()
return self._cosets_cache
def _compute_cosets(self) -> tuple[FracVector, ...]:
columns = [self.matrix.T()[index].normalize() for index in range(3)]
return finite_translation_cosets(columns)
# --- algebra ---
[docs]
def inverse(self) -> "SettingTransform":
"""The transform in the opposite direction, from this setting to the standard one."""
inverted = self._operation.inverse()
return SettingTransform(inverted.matrix, inverted.vector)
def __eq__(self, other: object) -> bool:
if not isinstance(other, SettingTransform):
return NotImplemented
return self._operation == other._operation
def __hash__(self) -> int:
return hash(self._operation)
def __repr__(self) -> str:
if self.is_identity():
return "SettingTransform.identity()"
if self._hall_entry is not None:
return f"SettingTransform.for_hall_entry({self._hall_entry!r})"
return f"SettingTransform({self._operation.to_xyz()!r})"