Source code for httk.atomistic.symmetry.paths

"""Exact symmetry-preserving structure alignment and interpolation."""

import itertools
import math
from collections.abc import Callable
from dataclasses import dataclass
from fractions import Fraction
from functools import cache

from httk.core import FracVector, SurdVector, register_citation

from httk.atomistic import data
from httk.atomistic.models.cell.cell import Cell
from httk.atomistic.models.species.species import Species
from httk.atomistic.models.structure.asu import ASUStructure, FundamentalDomainStructure, WyckoffSite
from httk.atomistic.symmetry._nearest_image import _NearestImageMetric
from httk.atomistic.symmetry._periodicity_guard import require_full_periodicity
from httk.atomistic.symmetry._standardization_common import (
    _matrix_column_sum_factor,
    _matrix_row_sum_factor,
    _scaled_precision,
)
from httk.atomistic.symmetry.affine_operation import AffineOperation
from httk.atomistic.symmetry.lift import (
    _apply_normalizer_operation,
    _demote_sites,
    _discrete_normalizer_translations,
    _translation_normal_form,
    _wrapped,
    rerepresent,
)
from httk.atomistic.symmetry.lift import (
    _canonical_sites as _orbit_canonical_sites,
)
from httk.atomistic.symmetry.lift import (
    _site_key as _orbit_site_key,
)
from httk.atomistic.symmetry.setting_transform import SettingTransform
from httk.atomistic.symmetry.spacegroup import Spacegroup
from httk.atomistic.symmetry.subgroups import _standard_input, subgroup_closure

__all__ = [
    "CommonSubgroupResult",
    "StructurePath",
    "canonicalize_full",
    "common_subgroup_representation",
    "interpolate_structures",
    "list_representations",
    "represent_like",
    "structure_delta",
]

_MAX_PAIRING_PERMUTATIONS = 40_320

# When neither input's own space group is a common subgroup, structure_delta scans at most
# this many further common subgroups (descending symmetry) for a coincidence before returning
# the least travel it saw. Bounds the search so a large subgroup lattice cannot make it hang.
_STRUCTURE_DELTA_SUBGROUP_LIMIT = 32


@cache
def _register_subgroup_matching_citation() -> None:
    """Register the subgroup-matching thesis citation, once per process."""
    register_citation(
        applies_to=(
            "The structure-matching and symmetry-path features (represent_like, "
            "common_subgroup_representation, structure_delta, interpolate_structures) build on Edvard "
            "Valentin's subgroup-matching work for httk v1"
        ),
        references={
            "authors": ({"name": "Edvard Valentin"},),
            "title": "Connecting Crystal Structures by Symmetry via Subgroup Matching",
            "school": "Linköping University",
            "year": "2024",
            "url": "https://urn.kb.se/resolve?urn=urn:nbn:se:liu:diva-207867",
            "bib_type": "mastersthesis",
            "note": (
                "Master's thesis examiner: Rickard Armiento; "
                "supervisor: Florian Trybel; "
                "co-supervisor: Abhijith S Parackal; "
                "urn:nbn:se:liu:diva-207867"
            ),
        },
    )


class NoCommonRepresentation(ValueError):
    """No common subgroup could represent both structures.

    This is the one documented "incompatible classes / no common subgroup representation
    succeeded" outcome of :func:`structure_delta` and
    :func:`common_subgroup_representation`; callers that treat that case as "not similar"
    catch this specific subclass and let any other ``ValueError`` propagate.
    """


@dataclass(frozen=True, slots=True)
[docs] class CommonSubgroupResult: """Two aligned structures in their highest common subgroup. :param first: The first input represented in the common subgroup's standard setting. :param second: The second input represented and aligned to ``first``. :param spacegroup: The selected highest common subgroup in standard setting. """
[docs] first: ASUStructure
[docs] second: ASUStructure
[docs] spacegroup: Spacegroup
@dataclass(frozen=True, slots=True)
[docs] class StructurePath: """A finite exact interpolation path between two aligned asymmetric units. :param frames: The endpoint-inclusive asymmetric-unit frames. :param spacegroup: The shared space group and setting of all frames. :param start: The first frame. :param end: The last frame. """
[docs] frames: tuple[ASUStructure, ...]
[docs] spacegroup: Spacegroup
[docs] start: ASUStructure
[docs] end: ASUStructure
@dataclass(frozen=True, slots=True) class _Alignment: structure: ASUStructure pairs: tuple[tuple[int, int], ...] def _validate(structure: ASUStructure, operation: str) -> None: require_full_periodicity(structure.cell, operation) if any(site.moment is not None for site in structure.wyckoff_sites): raise ValueError(f"{operation} does not support structures with site moments") if structure.assemblies is not None: raise ValueError(f"{operation} does not support structures with assemblies") if structure.molecular: raise ValueError(f"{operation} does not support molecular structures") def _exact_asu( structure: ASUStructure | FundamentalDomainStructure, operation: str, ) -> ASUStructure: """Promote a clean fundamental domain to the exact ASU path representation.""" if isinstance(structure, ASUStructure): return structure if not isinstance(structure, FundamentalDomainStructure): raise TypeError( f"{operation} requires ASUStructure or FundamentalDomainStructure, got {type(structure).__name__}" ) # This forces the fundamental domain's exact expansion proof before copying its # declared representation. No symmetry recognition or coordinate snapping occurs. structure.expand_sites() return ASUStructure( structure.cell, structure.spacegroup, structure.wyckoff_sites, structure.species, transform=structure.transform, coordinate_precision=structure.coordinate_precision, molecular=structure.molecular, assemblies=structure.assemblies, charge=structure.charge, ) def _species_by_name(structure: ASUStructure) -> dict[str, Species]: return {species.name: species for species in structure.species} def _species_signature(structure: ASUStructure) -> tuple[tuple[str, Species], ...]: return tuple( sorted(((species.name, species) for species in structure.species), key=lambda item: (item[0], repr(item[1]))) ) def _signature(structure: ASUStructure) -> tuple[tuple[Species, str, int], ...]: species = _species_by_name(structure) entries = [ ( species[site.species], site.wyckoff, structure.spacegroup.wyckoff_position(site.wyckoff).multiplicity, ) for site in structure.wyckoff_sites ] return tuple(sorted(entries, key=lambda item: (item[0].name, item[1], item[2], repr(item[0])))) def _site_key(site: WyckoffSite) -> tuple[str, str, tuple[Fraction, ...]]: return site.species, site.wyckoff, tuple(Fraction(value) for value in site.free_params.to_fractions()) def _canonical_sites(sites: tuple[WyckoffSite, ...]) -> tuple[tuple[str, str, tuple[Fraction, ...]], ...]: return tuple(sorted(_site_key(site) for site in sites)) def _classes(structure: ASUStructure) -> dict[tuple[Species, str], tuple[int, ...]]: species = _species_by_name(structure) result: dict[tuple[Species, str], list[int]] = {} for index, site in enumerate(structure.wyckoff_sites): result.setdefault((species[site.species], site.wyckoff), []).append(index) return {key: tuple(value) for key, value in result.items()} def _dot(left: tuple[float, float, float], right: tuple[float, float, float]) -> float: """Return a three-dimensional dot product at the float metric boundary.""" return math.fsum(left_value * right_value for left_value, right_value in zip(left, right, strict=True)) def _basis_rows(basis: SurdVector) -> tuple[tuple[float, float, float], ...]: """Convert an exact three-dimensional basis at the metric boundary.""" values = tuple(tuple(float(value) for value in row) for row in basis.to_floats()) if len(values) != 3 or any(len(row) != 3 for row in values): raise ValueError("structure_delta requires a three-dimensional cell") return tuple((row[0], row[1], row[2]) for row in values) def _mean_endpoint_gram( first: tuple[tuple[float, float, float], ...], second: tuple[tuple[float, float, float], ...] ) -> tuple[tuple[float, float, float], ...]: """Return the symmetric arithmetic mean of two endpoint cell metrics.""" gram = tuple( tuple((_dot(first[row], first[column]) + _dot(second[row], second[column])) / 2.0 for column in range(3)) for row in range(3) ) if not all(math.isfinite(value) for row in gram for value in row): raise ValueError("structure_delta requires a finite cell basis") return tuple((row[0], row[1], row[2]) for row in gram) def _cholesky_basis(gram: tuple[tuple[float, float, float], ...]) -> tuple[tuple[float, float, float], ...]: """Return lower ``L`` with ``L L.T == gram`` for a positive 3D metric.""" first_diagonal = gram[0][0] if first_diagonal <= 0.0: raise ValueError("structure_delta requires a non-singular cell basis") lower00 = math.sqrt(first_diagonal) lower10 = gram[1][0] / lower00 lower20 = gram[2][0] / lower00 second_diagonal = gram[1][1] - lower10**2 if second_diagonal <= 0.0: raise ValueError("structure_delta requires a non-singular cell basis") lower11 = math.sqrt(second_diagonal) lower21 = (gram[2][1] - lower20 * lower10) / lower11 third_diagonal = gram[2][2] - lower20**2 - lower21**2 if third_diagonal <= 0.0: raise ValueError("structure_delta requires a non-singular cell basis") lower22 = math.sqrt(third_diagonal) return ( (lower00, 0.0, 0.0), (lower10, lower11, 0.0), (lower20, lower21, lower22), ) def _row_matrix_product( row: tuple[float, float, float], matrix: tuple[tuple[float, float, float], ...] ) -> tuple[float, float, float]: """Return a three-dimensional row-vector matrix product.""" return ( math.fsum(row[index] * matrix[index][0] for index in range(3)), math.fsum(row[index] * matrix[index][1] for index in range(3)), math.fsum(row[index] * matrix[index][2] for index in range(3)), ) def _solve_cholesky( lower: tuple[tuple[float, float, float], ...], right: tuple[float, float, float] ) -> tuple[float, float, float]: """Solve ``(lower lower.T) x = right`` for a three-dimensional vector.""" first = right[0] / lower[0][0] second = (right[1] - lower[1][0] * first) / lower[1][1] third = (right[2] - lower[2][0] * first - lower[2][1] * second) / lower[2][2] result_third = third / lower[2][2] result_second = (second - lower[2][1] * result_third) / lower[1][1] return ( (first - lower[1][0] * result_second - lower[2][0] * result_third) / lower[0][0], result_second, result_third, ) def _point_travel(first: FracVector, first_cell: Cell, second: FracVector, second_cell: Cell) -> float: """Return symmetric physical travel between two periodic endpoint positions. Endpoint coordinates are first converted with their *own* exact cells, retaining the physical displacement caused by a lattice deformation. For one shared integer image ``n``, its squared travel is the arithmetic mean ``(||d + n B1||^2 + ||d + n B2||^2) / 2``, where ``d`` is the Cartesian endpoint displacement and ``B1``/``B2`` are the endpoint bases. This symmetric endpoint-cell convention is invariant under reversal and simultaneous rigid rotation, and reduces to the ordinary minimum image for equal cells. The finite closest-vector search completes this quadratic square in the mean endpoint metric, while the raw Cartesian displacement retains the contribution from lattice deformation. """ first_rows = _basis_rows(SurdVector(first_cell.basis)) second_rows = _basis_rows(SurdVector(second_cell.basis)) first_cartesian = SurdVector(first) * first_cell.basis second_cartesian = SurdVector(second) * second_cell.basis displacement_values = tuple(float(value) for value in (first_cartesian - second_cartesian).to_floats()) displacement = displacement_values[0], displacement_values[1], displacement_values[2] gram = _mean_endpoint_gram(first_rows, second_rows) lower = _cholesky_basis(gram) linear = tuple( _dot( displacement, ( (first_rows[index][0] + second_rows[index][0]) / 2.0, (first_rows[index][1] + second_rows[index][1]) / 2.0, (first_rows[index][2] + second_rows[index][2]) / 2.0, ), ) for index in range(3) ) center = _solve_cholesky(lower, (-linear[0], -linear[1], -linear[2])) nearest_squared = _NearestImageMetric(lower, (True, True, True)).distance(_row_matrix_product(center, lower)) ** 2 baseline = _dot(displacement, displacement) - _dot(center, _row_matrix_product(center, gram)) squared = baseline + nearest_squared if squared < 0.0: roundoff = 1e-12 * max(1.0, abs(baseline), nearest_squared) if squared >= -roundoff: squared = 0.0 else: raise ValueError("structure_delta produced a negative travel squared") return math.sqrt(squared) def _orbit_travel( first: ASUStructure, first_site: WyckoffSite, second: ASUStructure, second_site: WyckoffSite, ) -> float: """Return the branch-wise Cartesian travel for one compatible pair of Wyckoff orbits.""" first_coordinates = first.spacegroup.wyckoff_position(first_site.wyckoff).coordinates(first_site.free_params) second_coordinates = second.spacegroup.wyckoff_position(second_site.wyckoff).coordinates(second_site.free_params) if len(first_coordinates) != len(second_coordinates): raise ValueError("structures have incompatible Wyckoff orbit multiplicities") costs = tuple( tuple( _point_travel(FracVector(left), first.cell, FracVector(right), second.cell) for right in second_coordinates ) for left in first_coordinates ) return _minimum_assignment_cost(costs) def _minimum_assignment(costs: tuple[tuple[float, ...], ...]) -> tuple[float, tuple[int, ...]]: """Return the deterministic minimum one-to-one cost for a square matrix. The complete atom sets of two Wyckoff orbits do not necessarily retain the same branch order after a cell re-expression. The Hungarian algorithm gives the required minimum physical pairing without the factorial site-orbit search used above. """ count = len(costs) if count == 0: return 0.0, () if any(len(row) != count for row in costs): raise ValueError("Wyckoff orbit cost matrix must be square") # 1-indexed implementation of the shortest-augmenting-path Hungarian algorithm. potential_left = [0.0] * (count + 1) potential_right = [0.0] * (count + 1) matched_left = [0] * (count + 1) predecessor = [0] * (count + 1) for left in range(1, count + 1): matched_left[0] = left right0 = 0 minimum = [math.inf] * (count + 1) used = [False] * (count + 1) while True: used[right0] = True left0 = matched_left[right0] delta = math.inf right1 = 0 for right in range(1, count + 1): if used[right]: continue reduced = costs[left0 - 1][right - 1] - potential_left[left0] - potential_right[right] if reduced < minimum[right]: minimum[right] = reduced predecessor[right] = right0 if minimum[right] < delta: delta = minimum[right] right1 = right for right in range(count + 1): if used[right]: potential_left[matched_left[right]] += delta potential_right[right] -= delta else: minimum[right] -= delta right0 = right1 if matched_left[right0] == 0: break while True: right1 = predecessor[right0] matched_left[right0] = matched_left[right1] right0 = right1 if right0 == 0: break assignment = [0] * count for right in range(1, count + 1): assignment[matched_left[right] - 1] = right - 1 return math.fsum(costs[left][right] for left, right in enumerate(assignment)), tuple(assignment) def _minimum_assignment_cost(costs: tuple[tuple[float, ...], ...]) -> float: """Return the deterministic minimum one-to-one cost for a square matrix.""" return _minimum_assignment(costs)[0] def _pair_score(candidate: ASUStructure, reference: ASUStructure) -> tuple[Fraction, tuple[tuple[int, int], ...]]: """Pair compatible orbits by wrapped fractional free-parameter distance.""" candidate_classes = _classes(candidate) reference_classes = _classes(reference) if candidate_classes.keys() != reference_classes.keys(): raise ValueError("structures have incompatible site classes") score = Fraction(0) pairs: list[tuple[int, int]] = [] for key in sorted(reference_classes, key=lambda item: (item[0].name, item[1], repr(item[0]))): reference_indices = reference_classes[key] candidate_indices = candidate_classes[key] if len(reference_indices) != len(candidate_indices): raise ValueError("structures have incompatible site classes") if len(reference_indices) > 1 and len(reference_indices) > _MAX_PAIRING_PERMUTATIONS: raise ValueError(f"pairing permutation bound exceeded for {key!r}; maximum is {_MAX_PAIRING_PERMUTATIONS}") best: tuple[Fraction, tuple[tuple[Fraction, ...], ...], tuple[int, ...]] | None = None for permutation in itertools.permutations(candidate_indices): distance = Fraction(0) parameter_key: list[tuple[Fraction, ...]] = [] for reference_index, candidate_index in zip(reference_indices, permutation, strict=True): reference_params = reference.wyckoff_sites[reference_index].free_params.to_fractions() candidate_params = candidate.wyckoff_sites[candidate_index].free_params.to_fractions() distance += sum( ( _wrapped(Fraction(right) - Fraction(left)) ** 2 for left, right in zip(reference_params, candidate_params) ), Fraction(0), ) parameter_key.append(tuple(Fraction(value) for value in candidate_params)) choice = (distance, tuple(parameter_key), tuple(permutation)) if best is None or choice < best: best = choice assert best is not None score += best[0] pairs.extend(zip(reference_indices, best[2], strict=True)) return score, tuple(pairs) def _pair_travel_score(candidate: ASUStructure, reference: ASUStructure) -> tuple[float, tuple[tuple[int, int], ...]]: """Pair compatible Wyckoff orbits by their total physical Cartesian travel.""" candidate_classes = _classes(candidate) reference_classes = _classes(reference) if candidate_classes.keys() != reference_classes.keys(): raise ValueError("structures have incompatible site classes") score = 0.0 pairs: list[tuple[int, int]] = [] for key in sorted(reference_classes, key=lambda item: (item[0].name, item[1], repr(item[0]))): reference_indices = reference_classes[key] candidate_indices = candidate_classes[key] if len(reference_indices) != len(candidate_indices): raise ValueError("structures have incompatible site classes") costs = tuple( tuple( _orbit_travel( reference, reference.wyckoff_sites[reference_index], candidate, candidate.wyckoff_sites[candidate_index], ) for candidate_index in candidate_indices ) for reference_index in reference_indices ) distance, assignment = _minimum_assignment(costs) score += distance pairs.extend( (reference_index, candidate_indices[candidate_offset]) for reference_index, candidate_offset in zip(reference_indices, assignment, strict=True) ) return score, tuple(pairs) def _reference_setting(candidate: ASUStructure, reference: ASUStructure) -> ASUStructure: transform = reference.transform_from_standard basis_matrix = transform.matrix.T().inv() cell = Cell( transform.basis_to_setting(candidate.cell.basis), precision=_scaled_precision(candidate.cell.precision, _matrix_row_sum_factor(basis_matrix)), periodicity=candidate.cell.periodicity, ) spacegroup = reference.spacegroup sites = candidate.wyckoff_sites residual = reference.transform if not spacegroup.is_standard_setting: mapped = [] for site in sites: point = candidate.spacegroup.wyckoff_position(site.wyckoff).representative.coordinate(site.free_params) identified = spacegroup.identify_wyckoff(transform.to_setting(point).normalize()) if identified is None: raise ValueError(f"cannot express Wyckoff site {site.wyckoff!r} in {spacegroup.setting}") position, parameters = identified mapped.append(WyckoffSite(position.letter, parameters, site.species)) sites = tuple(mapped) residual = SettingTransform.identity() return ASUStructure( cell, spacegroup, sites, candidate.species, transform=residual, coordinate_precision=_scaled_precision( candidate.coordinate_precision, _matrix_column_sum_factor(transform.matrix.T()), ), charge=candidate.charge, ) def _normalizer_image(structure: ASUStructure, operation: AffineOperation) -> ASUStructure | None: return _apply_normalizer_operation(structure, operation) def _aligned( end: ASUStructure, reference: ASUStructure, *, tolerance: float | None, pair_score: Callable[[ASUStructure, ASUStructure], tuple[object, tuple[tuple[int, int], ...]]] = _pair_score, ) -> _Alignment: _validate(end, "represent_like") _validate(reference, "represent_like") reference_standard = _standard_input(reference) represented = rerepresent(end, reference_standard.spacegroup, tolerance=tolerance) represented = _standard_input(represented) reference_signature = _signature(reference_standard) represented_signature = _signature(represented) if ( _species_signature(represented) != _species_signature(reference_standard) or represented_signature != reference_signature ): raise ValueError( f"structures are not representable alike: signatures {represented_signature!r} and {reference_signature!r}" ) candidates: dict[tuple[tuple[str, str, tuple[Fraction, ...]], ...], ASUStructure] = { _canonical_sites(represented.wyckoff_sites): represented } try: record = data.affine_normalizer_coset_record(represented.spacegroup.hall_entry) except KeyError: record = {} for coset in record.get("affine_normalizer_cosets", ()): if represented.spacegroup.crystal_system not in coset["compatible_systems"]: continue image = _normalizer_image(represented, AffineOperation.from_record(coset)) if image is not None: candidates.setdefault(_canonical_sites(image.wyckoff_sites), image) best: ( tuple[object, tuple[tuple[str, str, tuple[Fraction, ...]], ...], ASUStructure, tuple[tuple[int, int], ...]] | None ) = None for candidate in candidates.values(): try: score, pairs = pair_score(candidate, reference_standard) except ValueError: continue choice = (score, _canonical_sites(candidate.wyckoff_sites), candidate, pairs) if best is None or choice[:2] < best[:2]: best = choice assert best is not None aligned = _reference_setting(best[2], reference) return _Alignment(aligned, best[3])
[docs] def represent_like( structure: ASUStructure, reference: ASUStructure, *, tolerance: float | None = None, ) -> ASUStructure: """Represent a structure in a reference's group and setting. The input is first sent through :func:`~httk.atomistic.symmetry.lift.rerepresent`, then equivalent affine-normalizer coset images of that one descent realization are scored against the reference. This is deliberately bounded: tabulated variants of alternate multi-hop descent paths are not enumerated because :func:`~httk.atomistic.symmetry.lift.rerepresent` exposes only its deterministic selected realization. Site pairing is brute force and capped at 40,320 permutations per class; larger classes require a future assignment solver. :param structure: The structure to represent. :param reference: The structure supplying the group, setting, and alignment target. :param tolerance: Cartesian tolerance passed to upward rerepresentation. :return: The input represented in the reference's group and setting. :raises ValueError: If the groups are unrelated, signatures differ, or the input is unsupported by the exact symmetry machinery. """ _register_subgroup_matching_citation() return _aligned(structure, reference, tolerance=tolerance).structure
def _representation_gram(structure: ASUStructure) -> tuple[object, ...]: metric = structure.cell.metric() return tuple(metric._element((row, column)) for row in range(3) for column in range(3)) def _representation_orbit(structure: ASUStructure) -> tuple[ASUStructure, ...]: """Return every distinct representation reachable by the group's discrete affine normalizer. The images are the tabulated affine-normalizer cosets crossed with the runtime discrete Euclidean-normalizer translations -- exactly the crossing :func:`~httk.atomistic.symmetry.lift`'s normal form minimizes over, but enumerated instead of reduced to the least. Each image is put in its continuous-normalizer translation-normal form, made right-handed where inversion re-describes the group (and dropped as the enantiomorph where it does not, for a Sohncke group), stored at its orbit-canonical Wyckoff representatives, then deduplicated by exact orbit-canonical site key and cell gram and sorted by that key. This is the full set of representations modulo the continuous normalizer, for the discrete-normalizer freedom; representations differing by an untabulated conventional-cell re-choice (the A.5 recell-class freedom) are not generated. """ structure = _demote_sites(structure) identity = FracVector.eye((3, 3)) inversion = AffineOperation(FracVector(((-1, 0, 0), (0, -1, 0), (0, 0, -1))), (0, 0, 0)) operations = [AffineOperation.identity()] try: record = data.affine_normalizer_coset_record(structure.spacegroup.hall_entry) except KeyError: record = None if record is not None: system = structure.spacegroup.crystal_system operations.extend( AffineOperation.from_record(coset) for coset in record.get("affine_normalizer_cosets", ()) if system in coset["compatible_systems"] ) translations = _discrete_normalizer_translations(structure.spacegroup) images: dict[tuple[object, ...], ASUStructure] = {} for operation in operations: image = _apply_normalizer_operation(structure, operation) if image is None: continue for translation in translations: shifted = ( image if not any(translation) else _apply_normalizer_operation(image, AffineOperation(identity, FracVector(translation))) ) if shifted is None: continue reduced = _translation_normal_form(shifted) if reduced.cell.basis.det().sign() < 0: flipped = _apply_normalizer_operation(reduced, inversion) if flipped is None: # Inversion does not re-describe an enantiomorphic (Sohncke) group in its own # setting, so a left-handed image is the enantiomorph -- a different crystal, not # another representation of this one. Drop it rather than emit a mirror twin. continue reduced = _translation_normal_form(flipped) reduced = _orbit_canonical_sites(reduced) images.setdefault((_orbit_site_key(reduced), _representation_gram(reduced)), reduced) return tuple(images[key] for key in sorted(images))
[docs] def list_representations( structure: ASUStructure, target: Spacegroup | int, *, tolerance: float | None = None, ) -> tuple[ASUStructure, ...]: """Return every distinct representation of one crystal in a target group's standard setting. The crystal is first expressed once in ``target`` by :func:`~httk.atomistic.symmetry.lift.rerepresent` -- an exact descent for a subgroup target, a round-trip-gated lift for a supergroup target, itself for the same group -- and the full discrete affine-normalizer orbit of that one realization is then enumerated. Every representation is returned in its continuous-translation normal form (otherwise a polar or triclinic target would have infinitely many), deduplicated by exact orbit-canonical site key and cell gram, and sorted by that key. **Scope.** When ``target`` is the crystal's *own* full symmetry group, two representations differ only by an element of that group's affine normalizer, so this one orbit is the complete set -- modulo the continuous normalizer and limited only by the bounded tabulated coset table. For a PROPER-SUBGROUP (or supergroup) target only the normalizer orbit of the single :func:`~httk.atomistic.symmetry.lift.rerepresent` embedding is returned; inequivalent embeddings reachable by *other* descent chains -- the same crystal at the same cell size but a genuinely different site placement -- are deliberately out of scope and are NOT returned, because enumerating every chain is combinatorially explosive for deep targets (many tabulated chains), so a single canonical embedding is chosen. Representations needing an untabulated conventional-cell re-choice (the A.5 recell-class freedom) are likewise not generated. A supercell description is the same crystal in a larger cell; it too is not enumerated -- the exclusion there is "not a distinct representation at the same cell size", not "not the same crystal". This honors the explicit ``target`` exactly and never flips an enantiomorphic group; normalizing an enantiomorphic pair to its lower-numbered member is the closed-target canonicalizers' (:func:`~httk.atomistic.canonicalize`, :func:`~httk.atomistic.canonical_asu`) job. :param structure: The crystal, as an asymmetric-unit structure. :param target: The target space group or IT number. :param tolerance: Cartesian acceptance tolerance passed to any upward lift; ``None`` derives it. :return: The distinct representations in ``target``'s standard setting, sorted by canonical key. :raises ValueError: If ``target`` is unrelated to the crystal's group, or the input is unsupported by the exact symmetry machinery. """ _validate(structure, "list_representations") standardized = _standard_input(structure) target_group = (target if isinstance(target, Spacegroup) else Spacegroup.standard(target)).standard_setting() base = _standard_input(rerepresent(standardized, target_group, tolerance=tolerance)) return _representation_orbit(base)
[docs] def canonicalize_full( structure: ASUStructure, target: Spacegroup | int, *, tolerance: float | None = None, ) -> ASUStructure: """Return the canonically least representation of a crystal in a target group's standard setting. The least element, by exact orbit-canonical site key then cell gram, of :func:`list_representations`. On the crystal's own group this is a normalizer-canonical form: it selects the same representative the upward search's normal form does, over the same discrete normalizer crossing and modulo the continuous quotient. It is idempotent -- re-running it on its own result in the same target returns that result. This honors the explicit ``target`` exactly and never flips an enantiomorphic group; normalizing an enantiomorphic pair to its lower-numbered member is the closed-target canonicalizers' (:func:`~httk.atomistic.canonicalize`, :func:`~httk.atomistic.canonical_asu`) job. :param structure: The crystal, as an asymmetric-unit structure. :param target: The target space group or IT number. :param tolerance: Cartesian acceptance tolerance passed to any upward lift; ``None`` derives it. :return: The canonically least representation in ``target``'s standard setting. :raises ValueError: If ``target`` is unrelated, or the input is unsupported. """ return list_representations(structure, target, tolerance=tolerance)[0]
[docs] def common_subgroup_representation( first: ASUStructure, second: ASUStructure, *, tolerance: float | None = None, ) -> CommonSubgroupResult: """Represent two structures in their highest common subgroup. Common subgroups are ordered by descending symmetry-operation count and then descending International Tables number. The first group for which both exact descents succeed is selected; the second structure is then aligned to the first by :func:`~httk.atomistic.symmetry.paths.represent_like`. :param first: The first structure. :param second: The second structure. :param tolerance: Cartesian tolerance passed to upward rerepresentation. :return: The two aligned structures and their selected common subgroup. :raises ValueError: If no common subgroup can represent both structures. """ _register_subgroup_matching_citation() _validate(first, "common_subgroup_representation") _validate(second, "common_subgroup_representation") common = set(subgroup_closure(first.spacegroup, include_self=True)) & set( subgroup_closure(second.spacegroup, include_self=True) ) ordered = sorted( common, key=lambda number: (-len(Spacegroup.standard(number).symmetry_operations), -number), ) for number in ordered: target = Spacegroup.standard(number) try: first_child = _standard_input(rerepresent(first, target, tolerance=tolerance)) second_child = _standard_input(rerepresent(second, target, tolerance=tolerance)) second_aligned = represent_like(second_child, first_child, tolerance=tolerance) except ValueError: continue return CommonSubgroupResult(first_child, second_aligned, target) raise NoCommonRepresentation("no common subgroup representation succeeded")
[docs] def structure_delta( first: ASUStructure | FundamentalDomainStructure, second: ASUStructure | FundamentalDomainStructure, *, tolerance: float | None = None, ) -> float: """Return the total Cartesian atom travel between two compatible structures. Each exact asymmetric-unit or fundamental-domain input is first canonicalized in its declared space group, without symmetry recognition. The canonical structures are then expressed in every compatible common Bärnighausen subgroup, in the same descending-symmetry order used by :func:`~httk.atomistic.symmetry.paths.common_subgroup_representation`, and the second is aligned through the bounded affine-normalizer search used by :func:`~httk.atomistic.symmetry.paths.represent_like`. Compatible full :class:`~httk.atomistic.Species` and Wyckoff-letter classes are paired one-to-one, choosing the minimum total physical travel within each class. Every member of each paired Wyckoff orbit contributes its shortest Cartesian distance to the total; the first and second positions are converted with their respective endpoint cells, so a lattice deformation contributes even when fractional coordinates do not change. Periodic endpoint images are selected by a finite closest-vector search in the arithmetic mean endpoint metric. This remains correct for skew cells and is symmetric when the endpoints are interchanged. The return value is a finite non-negative ``float`` in the units of the cells' bases (ångström for ordinary crystallographic structures). Both directed bounded normalizer alignments are considered for each common subgroup, and their least travel is used. This makes the metric symmetric without pretending to enumerate every possible Bärnighausen embedding. The subgroup and normalizer searches are deliberately bounded: only the deterministic subgroup embedding exposed by :func:`~httk.atomistic.symmetry.lift.rerepresent` and its tabulated normalizer images are considered. The recognized space group of either input, when it is a common subgroup, is the most faithful shared representation and is evaluated first; the travel of the first such group that yields a finite delta is returned (so two same-group structures are aligned in their own group, not a same-order sibling). Only when neither input's own group is common does the search descend the remaining common subgroups in descending-symmetry order, evaluating at most ``_STRUCTURE_DELTA_SUBGROUP_LIMIT`` of them and returning at an exact coincidence or the least travel seen within that bound. Each subgroup considers both directed bounded normalizer alignments and every tabulated normalizer image, scoring each by its exact per-orbit Cartesian travel and keeping the minimum, which makes the metric symmetric. Atom and orbit assignment uses a deterministic Hungarian minimum-cost matching, so repeated Wyckoff classes do not require a factorial permutation search. Charges do not enter this geometrical metric. :param first: The first fully periodic, non-molecular asymmetric-unit or fundamental-domain structure. :param second: The second fully periodic, non-molecular asymmetric-unit or fundamental-domain structure. :param tolerance: Cartesian tolerance passed only to any required upward rerepresentation. :return: Total atom travel in the endpoint cells' length units. :raises ValueError: If the structures are unsupported, cannot be represented in a common subgroup, have incompatible species/Wyckoff classes, or yield a non-finite travel. """ _register_subgroup_matching_citation() first = _exact_asu(first, "structure_delta") second = _exact_asu(second, "structure_delta") _validate(first, "structure_delta") _validate(second, "structure_delta") if first == second: return 0.0 first_canonical = canonicalize_full(first, first.spacegroup, tolerance=tolerance) second_canonical = canonicalize_full(second, second.spacegroup, tolerance=tolerance) common = set(subgroup_closure(first_canonical.spacegroup, include_self=True)) & set( subgroup_closure(second_canonical.spacegroup, include_self=True) ) def _descending(numbers: set[int]) -> list[int]: return sorted(numbers, key=lambda number: (-len(Spacegroup.standard(number).symmetry_operations), -number)) def _subgroup_travel(number: int) -> float | None: target = Spacegroup.standard(number) try: first_child = _standard_input(rerepresent(first_canonical, target, tolerance=tolerance)) second_child = _standard_input(rerepresent(second_canonical, target, tolerance=tolerance)) except ValueError: return None directed: list[float] = [] for reference, candidate in ((first_child, second_child), (second_child, first_child)): try: alignment = _aligned(candidate, reference, tolerance=tolerance, pair_score=_pair_travel_score) except ValueError: continue delta = math.fsum( _orbit_travel( reference, reference.wyckoff_sites[reference_index], alignment.structure, alignment.structure.wyckoff_sites[candidate_index], ) for reference_index, candidate_index in alignment.pairs ) if not math.isfinite(delta): raise ValueError("structure_delta produced a non-finite travel") directed.append(delta) return min(directed) if directed else None # The recognized space group of either input is the most faithful shared representation: # try those first and return the first finite travel, so two same-group structures are # aligned in their own group instead of descending to a same-order sibling. recognized = {first_canonical.spacegroup.it_number, second_canonical.spacegroup.it_number} & common for number in _descending(recognized): delta = _subgroup_travel(number) if delta is not None: return delta # Otherwise scan a bounded number of the remaining subgroups in descending symmetry, # returning at an exact coincidence or the least travel seen within the bound. best: float | None = None for number in _descending(common - recognized)[:_STRUCTURE_DELTA_SUBGROUP_LIMIT]: delta = _subgroup_travel(number) if delta is None: continue if delta == 0.0: return 0.0 if best is None or delta < best: best = delta if best is None: raise NoCommonRepresentation("no common subgroup representation succeeded") return best
[docs] def interpolate_structures( start: ASUStructure, end: ASUStructure, *, steps: int, tolerance: float | None = None, ) -> StructurePath: """Build an exact symmetry-preserving linear interpolation. Free parameters follow the wrapped shortest rational displacement and cell bases are linearly interpolated in the shared setting. Every intermediate frame is expanded so a collision with an already occupied orbit is reported with its step index. Frames carry the start structure's setting transform, while their Wyckoff parameters remain standard- setting values. :param start: The first endpoint. :param end: The second endpoint. :param steps: Number of endpoint-inclusive frames, at least two. :param tolerance: Cartesian tolerance passed to upward rerepresentation. :return: The exact interpolation path. :raises ValueError: If endpoints cannot be aligned, charges differ, or an intermediate frame is invalid. """ _register_subgroup_matching_citation() if steps < 2: raise ValueError("interpolate_structures requires steps >= 2") _validate(start, "interpolate_structures") _validate(end, "interpolate_structures") start_standard = rerepresent(start, start.spacegroup, tolerance=tolerance) alignment = _aligned(end, start_standard, tolerance=tolerance) end_aligned = alignment.structure if set(start_standard.species) != set(end_aligned.species): raise ValueError("interpolation requires identical species definitions at both endpoints") if start_standard.charge != end_aligned.charge: raise ValueError("interpolation requires equal charges or both charges to be None") pairs = alignment.pairs frames: list[ASUStructure] = [] last = steps - 1 for index in range(steps): if index == 0: frames.append(start_standard) continue if index == last: frames.append(end_aligned) continue weight = Fraction(index, last) sites: list[WyckoffSite] = [] for start_index, end_index in pairs: left = start_standard.wyckoff_sites[start_index] right = end_aligned.wyckoff_sites[end_index] parameters = [ Fraction(left_value) + weight * _wrapped(Fraction(right_value) - Fraction(left_value)) for left_value, right_value in zip( left.free_params.to_fractions(), right.free_params.to_fractions(), strict=True ) ] sites.append(WyckoffSite(left.wyckoff, FracVector(parameters), left.species)) basis = (SurdVector(start_standard.cell.basis) * (1 - weight)) + (SurdVector(end_aligned.cell.basis) * weight) try: frame = ASUStructure( Cell(basis, periodicity=start_standard.cell.periodicity), start_standard.spacegroup, sites, start_standard.species, transform=start_standard.transform, coordinate_precision=start_standard.coordinate_precision, charge=start_standard.charge, ) frame.expand_sites() except ValueError as error: raise ValueError(f"interpolation step {index}: {error}") from error frames.append(frame) return StructurePath(tuple(frames), start_standard.spacegroup, start_standard, end_aligned)