"""
Backend wrapping cell parameters (a, b, c, alpha, beta, gamma).
"""
import fractions
from typing import Any
from httk.core import SurdScalar, SurdVector
from httk.core.vectors import exactmath
from ._vector_guards import is_params6, to_fracvector
from .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({}, ())
av = SurdVector.create(a)._as_scalar()
bv = SurdVector.create(b)._as_scalar()
cv = SurdVector.create(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.create(rows)
[docs]
class CellParams(CellBackend):
"""
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.vectors.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 no orientation, so a cell → parameters → cell
round-trip reproduces lengths, angles, and volume, but not the original orientation.
``unwrap`` returns the original raw object.
"""
_raw: Any
_params: tuple[fractions.Fraction, ...]
_basis_cache: SurdVector | None
# Cannot type annotate __new__ as `Self | None` for some reason
def __new__(cls, obj: Any, **hints: Any) -> Any:
if hints and hints.get("kind", "params") != "params":
return None
if not is_params6(obj):
return None
return super().__new__(cls)
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:
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 SurdVector.one()
@property
[docs]
def unscaled_basis(self) -> SurdVector:
return self.basis
@property
[docs]
def params(self) -> tuple[fractions.Fraction, ...]:
"""The stored ``(a, b, c, alpha, beta, gamma)`` as exact Fractions (angles in degrees)."""
return self._params
[docs]
def unwrap(self) -> Any:
return self._raw