Source code for httk.atomistic.structure_comparison
"""Comparing structures as crystals rather than as lists of atoms.
``Structure.__eq__`` compares the site list element by element, which is the right default
for a data object but is stricter than crystallographic identity: the same crystal written
with its atoms in a different order, or with a site represented by a different lattice
translate, is a different ``Structure`` and the same crystal.
That distinction matters for the asymmetric-unit round trip.
``Structure -> ASUStructure -> Structure`` reproduces the crystal exactly — the coordinates
are exact rationals throughout, with no numerical drift — but site ordering and the choice
of lattice representative follow the canonical expansion order rather than the original
input. :func:`same_crystal` is the predicate that guarantee is stated in.
"""
from collections import Counter
from typing import Any
from ._periodic_wrap import wrap_periodic
from .structure_like import StructureLike
__all__ = ["same_crystal"]
[docs]
def same_crystal(first: StructureLike, second: StructureLike) -> bool:
"""Whether two structures describe the same crystal.
True when they have the same cell and the same *multiset* of occupied sites, comparing
each site by its species and its reduced coordinate wrapped into ``[0, 1)``. Site order
is ignored, and so is which lattice translate of a site was written down.
The comparison is exact, not approximate: reduced coordinates are exact rationals and
cell bases are exact, so two structures that differ by any amount at all compare
unequal. There is deliberately no tolerance parameter — a tolerant comparison belongs
with the recognition step that snaps a measured structure onto an idealised one, not
here.
Periodicity takes part in two ways. Cells must agree on it, so a slab is never the same
crystal as the bulk with the same lattice vectors. And only the periodic directions are
wrapped, since along the others there is no lattice translate to be indifferent about —
an atom at ``1.05`` really is somewhere else than one at ``0.05``. The comparison stays
exact in the frame as written: two descriptions of the same slab that differ in their
non-periodic frame vector compare unequal, because reconciling them would need an origin
convention this function deliberately does not have.
Accepts anything structure-like on either side, so a
:class:`~httk.atomistic.Structure` may be compared directly against an
:class:`~httk.atomistic.ASUStructure` without expanding it by hand.
"""
from .unitcell_structure_view import UnitcellStructureView
left = UnitcellStructureView(first)
right = UnitcellStructureView(second)
if left.cell.periodicity != right.cell.periodicity:
return False
if left.cell.basis != right.cell.basis:
return False
return _site_multiset(left) == _site_multiset(right)
def _site_multiset(structure: Any) -> Counter[tuple[Any, ...]]:
"""Occupied sites as a multiset of ``(species, wrapped coordinate)``.
A multiset rather than a set, so that a repeated entry counts. Collapsing duplicates
would make a structure with a doubled atom compare equal to a correct one, which is
exactly the sort of expansion bug this predicate exists to catch.
"""
by_name = {item.name: item for item in structure.species}
wrapped = wrap_periodic(structure.sites.reduced_coords, structure.cell.periodicity)
return Counter(
(by_name[name], tuple(coordinate))
for name, coordinate in zip(structure.species_at_sites, wrapped.to_fractions())
)