Source code for httk.atomistic.models.cell.params

"""
Backend wrapping cell parameters (a, b, c, alpha, beta, gamma).
"""

import fractions
from typing import Any, Self

from httk.core import SurdScalar, SurdVector, exactmath

from httk.atomistic.models._vector_guards import is_params6, to_fracvector
from httk.atomistic.models.cell.backend import CellBackend

# Deterministic precision for the rational fallback when an angle is not a special angle
# (a multiple of 15 or 36 degrees) and so has no exact cosine in the surd field.
_PARAMS_PREC = exactmath.default_accuracy


def _rcos(deg: fractions.Fraction) -> fractions.Fraction:
    """A deterministic rational cosine of ``deg`` degrees at ``_PARAMS_PREC``."""
    return fractions.Fraction(exactmath.cos(deg, degrees=True, prec=_PARAMS_PREC, limit=False))


def _rsin(deg: fractions.Fraction) -> fractions.Fraction:
    """A deterministic rational sine of ``deg`` degrees at ``_PARAMS_PREC``."""
    return fractions.Fraction(exactmath.sin(deg, degrees=True, prec=_PARAMS_PREC, limit=False))


def _params_to_basis(params: tuple[fractions.Fraction, ...]) -> SurdVector:
    """
    Build the standard-orientation cell basis from ``(a, b, c, alpha, beta, gamma)``.

    First cell vector along x, second in the xy-plane. When all of ``cos(alpha)``, ``cos(beta)``,
    ``cos(gamma)``, ``sin(gamma)`` are exact in the surd field (Niven angles) AND the resulting
    ``cz**2`` is rational, the basis is exact (radicals intact). Otherwise it falls back
    **completely** to a deterministic rational matrix at ``_PARAMS_PREC`` — never mixing exact
    and approximate entries.
    """
    a, b, c, alpha, beta, gamma = params
    cos_a = SurdScalar.cos_degrees(alpha)
    cos_b = SurdScalar.cos_degrees(beta)
    cos_g = SurdScalar.cos_degrees(gamma)
    sin_g = SurdScalar.sin_degrees(gamma)
    if cos_a is not None and cos_b is not None and cos_g is not None and sin_g is not None:
        cy = ((cos_a - cos_b * cos_g) / sin_g)._as_scalar()
        cz_sq = (SurdVector.one() - cos_b * cos_b - cy * cy)._as_scalar()
        if cz_sq.is_rational:
            zero = SurdScalar._of({}, ())
            av = SurdVector(a)._as_scalar()
            bv = SurdVector(b)._as_scalar()
            cv = SurdVector(c)._as_scalar()
            cz = (cv * SurdVector.sqrt_of(cz_sq._rational_fraction()))._as_scalar()
            grid = [
                [av, zero, zero],
                [(bv * cos_g)._as_scalar(), (bv * sin_g)._as_scalar(), zero],
                [(cv * cos_b)._as_scalar(), (cv * cy)._as_scalar(), cz],
            ]
            return SurdVector._from_scalar_grid(grid, (3, 3))
    # Fully deterministic rational fallback (a non-Niven angle, or an irrational cz**2).
    ca, cb, cg = _rcos(alpha), _rcos(beta), _rcos(gamma)
    sg = _rsin(gamma)
    cy_r = (ca - cb * cg) / sg
    cz_sq_r = max(fractions.Fraction(0), fractions.Fraction(1) - cb * cb - cy_r * cy_r)
    cz_r = c * fractions.Fraction(exactmath.sqrt(cz_sq_r, prec=_PARAMS_PREC, limit=False))
    rows = [
        [a, fractions.Fraction(0), fractions.Fraction(0)],
        [b * cg, b * sg, fractions.Fraction(0)],
        [c * cb, c * cy_r, cz_r],
    ]
    return SurdVector(rows)


[docs] class CellParams(CellBackend): r""" Backend for a cell backed by cell parameters ``(a, b, c, alpha, beta, gamma)``. The native representation is a flat length-6 vector-like of the cell-vector lengths ``a``/``b``/``c`` and the angles ``alpha``/``beta``/``gamma`` in degrees, stored as exact :class:`~fractions.Fraction` values (parsed via :func:`~httk.core.exactmath.any_to_fraction`). The exact ``basis`` is derived lazily and cached using the standard crystallographic orientation convention (first cell vector along x, second in the xy-plane); for the common Niven angles it is exact (radicals intact). Since parameters carry no separate length factor, ``scale`` is the exact ``1`` and ``unscaled_basis == basis``. Parameters carry neither orientation nor periodicity, so a cell → parameters → cell round-trip reproduces lengths and angles, and reproduces volume only for a fully periodic source. Reconstruction uses the fully periodic default and therefore discards any non-3D periodicity as well as the original orientation. ``unwrap`` returns the original raw object. :param obj: The six cell parameters in crystallographic order. :param \**hints: Backend-selection hints. """ _raw: Any _params: tuple[fractions.Fraction, ...] _basis_cache: SurdVector | None @classmethod def _backend_adopt(cls, obj: Any, **hints: Any) -> Self | None: r"""Adopt six cell parameters. :param obj: The source object to adopt. :param \**hints: Backend-selection hints. :return: An initialized backend, or ``None`` when this backend declines ``obj``. """ if hints and hints.get("kind", "params") != "params": return None if not is_params6(obj): return None return cls(obj, **hints) def __init__(self, obj: Any, **hints: Any) -> None: params = tuple(to_fracvector(obj).to_fractions()) a, b, c, alpha, beta, gamma = params if a <= 0 or b <= 0 or c <= 0: raise ValueError("Cell parameter lengths a, b, c must be positive") if not all(0 < angle < 180 for angle in (alpha, beta, gamma)): raise ValueError("Cell parameter angles alpha, beta, gamma must be strictly between 0 and 180 degrees") if not self._describes_valid_cell(alpha, beta, gamma): raise ValueError("Cell parameter angles do not describe a valid (non-degenerate) cell") self._raw = obj self._params = params self._basis_cache = None @staticmethod def _describes_valid_cell(alpha: fractions.Fraction, beta: fractions.Fraction, gamma: fractions.Fraction) -> bool: """Exact positivity of ``1 - cos^2 a - cos^2 b - cos^2 g + 2 cos a cos b cos g``.""" cos_a = SurdScalar.cos_degrees(alpha) cos_b = SurdScalar.cos_degrees(beta) cos_g = SurdScalar.cos_degrees(gamma) if cos_a is not None and cos_b is not None and cos_g is not None: factor = ( SurdVector.one() - cos_a * cos_a - cos_b * cos_b - cos_g * cos_g + 2 * cos_a * cos_b * cos_g )._as_scalar() return factor.sign() > 0 ca, cb, cg = _rcos(alpha), _rcos(beta), _rcos(gamma) return (1 - ca * ca - cb * cb - cg * cg + 2 * ca * cb * cg) > 0 @property
[docs] def basis(self) -> SurdVector: """Return the basis derived from the stored parameters. :return: The standard-orientation cell vectors. """ if self._basis_cache is None: self._basis_cache = _params_to_basis(self._params) return self._basis_cache
@property
[docs] def scale(self) -> SurdScalar: """Return the unit scale factor. :return: The factor applied to ``unscaled_basis``. """ return SurdVector.one()
@property
[docs] def unscaled_basis(self) -> SurdVector: """Return the parameter-derived basis before scaling. :return: The cell vectors. """ return self.basis
@property
[docs] def lengths(self) -> tuple[SurdScalar, ...]: """Return the natively stored cell-vector lengths. :return: The exact stored ``a``, ``b``, and ``c`` lengths. """ return tuple(SurdVector(value)._as_scalar() for value in self._params[:3])
@property
[docs] def angles(self) -> tuple[fractions.Fraction, ...]: """Return the natively stored cell angles in degrees. :return: The exact stored ``alpha``, ``beta``, and ``gamma`` angles. """ return self._params[3:]
@property
[docs] def params(self) -> tuple[fractions.Fraction, ...]: """The stored ``(a, b, c, alpha, beta, gamma)`` in degrees. :return: The exact cell parameters. """ return self._params
[docs] def unwrap(self) -> Any: """Return the original parameter object. :return: The raw parameter representation. """ return self._raw