"""The one home of httk's protopattern and protostructure label notation.
An httk label encodes the information content of an unsuffixed AFLOW-style prototype
label: a space group, its occupied Wyckoff letters, and the partition of those
occupations into species classes. The grammar is::
ANON_PEARSON_ITNUMBER_GROUP(_GROUP)* # protopattern label
ANON_PEARSON_ITNUMBER_GROUP(_GROUP)*:NAME(-NAME)* # protostructure label
A ``GROUP`` is the concatenation of one species class's Wyckoff letters, sorted
alphabetically, a letter occupied ``k >= 2`` times prefixed by the integer ``k``
(``2e``); count ``1`` is omitted. ``ANON`` is the anonymous formula built from
:func:`~httk.atomistic.models.formula.notation.anonymous_symbol` in group order with
per-group summed conventional multiplicities reduced by their overall gcd.
httk labels are deliberately NOT AFLOW labels. AFLOW orders classes by element symbol
(alphabetically) so its unsuffixed prefix still depends on the chemistry; httk orders
classes by their Wyckoff letters, so the unsuffixed prefix is element-agnostic and a
protostructure label is exactly the protopattern label of the erased pattern followed
by ``:`` and the class species names. :func:`render_aflow_label` renders the AFLOW-style
variant for interoperability; it has no parser here.
"Canonicality" terminology: any faithful render of an object is *the* protopattern or
protostructure label; the *canonical* protopattern or protostructure label is the one
obtained from a normalizer-canonical object (for example one derived via
``canonical_asu``). The renderer performs no affine-normalizer pass.
ponytail: the label is canonical only up to the fixed Wyckoff-letter naming of the IT
standard setting; two normalizer-equivalent occupation sets can still render to distinct
labels. The upgrade path is to vendor the per-Hall affine-normalizer coset data in
``httk.atomistic.data``, induce the Wyckoff-letter permutations it generates, and emit the
lexicographic-min label over that orbit. Not needed until labels must match across
normalizer-equivalent settings.
"""
import re
from collections.abc import Mapping, Sequence
from functools import reduce
from math import gcd
from typing import TYPE_CHECKING
from httk.atomistic.elements import SYMBOLS
from httk.atomistic.models.formula.notation import anonymous_symbol
from httk.atomistic.symmetry.spacegroup import Spacegroup
if TYPE_CHECKING:
from httk.atomistic.models.protopattern.protopattern import Protopattern
from httk.atomistic.models.protostructure.protostructure import Protostructure
# The 27th Wyckoff letter used by a handful of high-multiplicity settings (group 47's
# eightfold orbit). It renders as 'A' and parses back from it; positionally a group token
# never collides with the leading anonymous formula.
_SPECIAL_LETTER = "α"
_SPECIAL_RENDER = "A"
_CRYSTAL_SYSTEM_LETTER = {
"triclinic": "a",
"monoclinic": "m",
"orthorhombic": "o",
"tetragonal": "t",
"trigonal": "h",
"hexagonal": "h",
"cubic": "c",
}
_GROUP_TOKEN = re.compile(r"(\d*)([a-zA-Z])")
_ELEMENTS = frozenset(SYMBOLS)
[docs]
def pearson_symbol(spacegroup: Spacegroup, nsites_conventional: int) -> str:
"""Return the Pearson symbol for a setting and its conventional-cell site count.
The crystal-system letter follows the space group's ``crystal_system``; the centring
letter follows its ``centring_type`` with the base-centred variants
``A``, ``B``, ``C``, and ``S`` folded to ``C``; the count is
``nsites_conventional``, except a rhombohedral ``R`` setting (tabulated on hexagonal
axes) divides it by three. Calcite (167 with 30 conventional sites) yields ``hR10``.
:param spacegroup: The standard-setting space group.
:param nsites_conventional: The number of sites in the standard conventional cell.
:return: The Pearson symbol, such as ``"cF8"``.
:raises ValueError: If a rhombohedral count is not divisible by three.
"""
system_letter = _CRYSTAL_SYSTEM_LETTER[spacegroup.crystal_system]
centring = spacegroup.centring_type
centring_letter = "C" if centring in ("A", "B", "C", "S") else centring
count = nsites_conventional
if centring == "R":
if count % 3 != 0:
raise ValueError(
f"rhombohedral setting {spacegroup.setting} has {count} conventional sites, not a multiple of three"
)
count //= 3
return f"{system_letter}{centring_letter}{count}"
def _render_letter(letter: str) -> str:
"""Return the display form of a Wyckoff letter (the special 27th letter as ``A``)."""
return _SPECIAL_RENDER if letter == _SPECIAL_LETTER else letter
def _render_group(letters: Sequence[str]) -> str:
"""Render one class's Wyckoff letters, sorted, with counts for repeats."""
ordered = sorted(letters)
parts: list[str] = []
index = 0
while index < len(ordered):
letter = ordered[index]
run = 1
while index + run < len(ordered) and ordered[index + run] == letter:
run += 1
parts.append((str(run) if run >= 2 else "") + _render_letter(letter))
index += run
return "".join(parts)
def _classes(
spacegroup: Spacegroup,
occupations: Sequence[tuple[str, str]],
) -> dict[str, tuple[tuple[str, ...], int]]:
"""Group ``(wyckoff, class-key)`` occupations into ``key -> (letters, conv mult)``.
:param spacegroup: The standard-setting space group.
:param occupations: The occupied ``(wyckoff, class-key)`` pairs.
:return: For each class key its sorted Wyckoff letters and summed conventional
multiplicity.
:raises ValueError: If any Wyckoff letter is absent from the setting.
"""
letters: dict[str, list[str]] = {}
multiplicity: dict[str, int] = {}
for wyckoff, key in occupations:
try:
position = spacegroup.wyckoff_position(wyckoff)
except KeyError as exc:
raise ValueError(str(exc)) from exc
letters.setdefault(key, []).append(wyckoff)
multiplicity[key] = multiplicity.get(key, 0) + position.multiplicity
return {key: (tuple(sorted(values)), multiplicity[key]) for key, values in letters.items()}
def _assemble(
spacegroup: Spacegroup,
ordered_letters: Sequence[tuple[str, ...]],
ordered_multiplicity: Sequence[int],
) -> str:
"""Build the unsuffixed label from ordered per-class letters and multiplicities."""
reduced = _reduce_counts(ordered_multiplicity)
anon = "".join(anonymous_symbol(index) + (str(count) if count != 1 else "") for index, count in enumerate(reduced))
pearson = pearson_symbol(spacegroup, sum(ordered_multiplicity))
groups = "_".join(_render_group(letters) for letters in ordered_letters)
return f"{anon}_{pearson}_{spacegroup.it_number}_{groups}"
def _reduce_counts(counts: Sequence[int]) -> tuple[int, ...]:
"""Divide integer counts by their overall greatest common divisor."""
common = reduce(gcd, counts)
return tuple(count // common for count in counts)
[docs]
def canonical_label_map(class_letters: Mapping[str, tuple[str, ...]]) -> dict[str, str]:
"""Map input class keys to consecutive anonymous class labels in group order.
Classes are ordered lexicographically by their sorted Wyckoff-letter sequence
(including repetitions); the input key breaks ties deterministically. Two classes
with identical letter sequences are interchangeable, so either tie order renders the
identical label.
:param class_letters: For each input class key its sorted Wyckoff letters.
:return: A mapping from each input class key to its canonical anonymous label.
"""
ordered = sorted(class_letters, key=lambda key: (class_letters[key], key))
return {key: anonymous_symbol(index) for index, key in enumerate(ordered)}
[docs]
def render_protopattern_label(spacegroup: Spacegroup, occupations: Sequence[tuple[str, str]]) -> str:
"""Render the protopattern label of a space group and its class-partitioned Wyckoff letters.
:param spacegroup: The standard-setting space group.
:param occupations: The occupied ``(wyckoff, class-key)`` pairs; the class key names
the anonymous species class an occupation belongs to.
:return: The protopattern label text.
:raises ValueError: If any Wyckoff letter is absent from the setting.
"""
classes = _classes(spacegroup, occupations)
ordered_keys = sorted(classes, key=lambda key: (classes[key][0], key))
return _assemble(
spacegroup,
[classes[key][0] for key in ordered_keys],
[classes[key][1] for key in ordered_keys],
)
[docs]
def render_protostructure_label(spacegroup: Spacegroup, occupations: Sequence[tuple[str, str]]) -> str:
"""Render the httk protostructure label of a space group and its named occupations.
Classes are ordered by their sorted Wyckoff letters, ties broken by species name. The
unsuffixed prefix equals the protopattern label of the erased pattern; the suffix lists
the class species names in group order.
:param spacegroup: The standard-setting space group.
:param occupations: The occupied ``(wyckoff, species-name)`` pairs.
:return: The protostructure label text.
:raises ValueError: If any Wyckoff letter is absent from the setting.
"""
classes = _classes(spacegroup, occupations)
ordered_keys = sorted(classes, key=lambda key: (classes[key][0], key))
prefix = _assemble(
spacegroup,
[classes[key][0] for key in ordered_keys],
[classes[key][1] for key in ordered_keys],
)
return prefix + ":" + "-".join(ordered_keys)
[docs]
def render_aflow_label(spacegroup: Spacegroup, occupations: Sequence[tuple[str, str]]) -> str:
"""Render the AFLOW-style label of a space group and its named occupations.
Unlike the httk label, classes are ordered by species name alphabetically and the
anonymous symbols are reassigned in that order, so the unsuffixed prefix depends on
the chemistry. Provided for interoperability only; there is no parser for this form.
:param spacegroup: The standard-setting space group.
:param occupations: The occupied ``(wyckoff, species-name)`` pairs.
:return: The AFLOW-style label text.
:raises ValueError: If any Wyckoff letter is absent from the setting.
"""
classes = _classes(spacegroup, occupations)
ordered_keys = sorted(classes)
prefix = _assemble(
spacegroup,
[classes[key][0] for key in ordered_keys],
[classes[key][1] for key in ordered_keys],
)
return prefix + ":" + "-".join(ordered_keys)
def _split_label(text: str) -> tuple[str, list[str], list[str] | None]:
"""Split a label into its main text, ``_``-separated fields, and optional name suffix."""
if not isinstance(text, str) or not text:
raise ValueError("label must be a non-empty string")
main, sep, suffix = text.partition(":")
names = suffix.split("-") if sep else None
fields = main.split("_")
if len(fields) < 4:
raise ValueError("label must be ANON_PEARSON_ITNUMBER_GROUP(_GROUP)*")
return main, fields, names
def _parse_it_number(field: str) -> int:
"""Parse and range-check the International Tables number field."""
if not field.isdigit():
raise ValueError(f"label International Tables field {field!r} is not a number")
it_number = int(field)
if not 1 <= it_number <= 230:
raise ValueError(f"label International Tables number {it_number} is out of range [1, 230]")
return it_number
def _parse_group(token: str) -> list[str]:
"""Parse one group token into its Wyckoff-letter multiset."""
letters: list[str] = []
position = 0
while position < len(token):
match = _GROUP_TOKEN.match(token, position)
if match is None or match.start() != position:
raise ValueError(f"invalid group token {token!r}")
digits, char = match.groups()
if char.isupper() and char != _SPECIAL_RENDER:
raise ValueError(f"invalid Wyckoff letter {char!r} in group token {token!r}")
count = int(digits) if digits else 1
if digits and count < 2:
raise ValueError("explicit letter counts must be at least 2; explicit 1 is invalid")
letters.extend([_SPECIAL_LETTER if char == _SPECIAL_RENDER else char] * count)
position = match.end()
return letters
[docs]
def parse_protopattern_label(text: str) -> "Protopattern":
"""Parse a strictly canonical protopattern label into a protopattern.
Every Wyckoff letter must exist in the resolved standard setting, and the Pearson
symbol, reduced anonymous counts, and group ordering must all match their recomputed
canonical values; any deviation is rejected. This canonical-string-only stance mirrors
:func:`~httk.atomistic.models.formula.notation.parse_anonymous_formula`.
:param text: The protopattern label to parse.
:return: The parsed :class:`~httk.atomistic.models.protopattern.protopattern.Protopattern`.
:raises ValueError: If ``text`` is not a canonical protopattern label.
"""
from httk.atomistic.models.protopattern.protopattern import Protopattern
main, fields, names = _split_label(text)
if names is not None:
raise ValueError("a protopattern label carries no ':' species suffix")
it_number = _parse_it_number(fields[2])
occupations: list[tuple[str, str]] = []
for index, token in enumerate(fields[3:]):
for letter in _parse_group(token):
occupations.append((letter, anonymous_symbol(index)))
value = Protopattern(it_number, occupations)
if render_protopattern_label(value.spacegroup, [(o.wyckoff, o.label) for o in value.occupations]) != main:
raise ValueError(f"{text!r} is not a canonical protopattern label")
return value
[docs]
def parse_protostructure_label(text: str) -> "Protostructure":
"""Parse a strictly canonical httk protostructure label into a protostructure.
The unsuffixed part is validated as for a protopattern label; each ``:`` name must be a
known element symbol and becomes ``Species(name, (name,), (1,))``. Non-canonical labels
are rejected.
:param text: The protostructure label to parse.
:return: The parsed :class:`~httk.atomistic.models.protostructure.protostructure.Protostructure`.
:raises ValueError: If ``text`` is not a canonical protostructure label.
"""
from httk.atomistic.models.protostructure.protostructure import Protostructure
from httk.atomistic.models.species.species import Species
_, fields, names = _split_label(text)
if names is None:
raise ValueError("a protostructure label needs a ':' species suffix")
it_number = _parse_it_number(fields[2])
groups = fields[3:]
if len(names) != len(groups):
raise ValueError(f"label has {len(groups)} class(es) but {len(names)} species name(s)")
occupations: list[tuple[str, object]] = []
for name, token in zip(names, groups):
if name not in _ELEMENTS:
raise ValueError(f"protostructure label name {name!r} is not a known element symbol")
species = Species(name, (name,), (1,))
for letter in _parse_group(token):
occupations.append((letter, species))
value = Protostructure(it_number, occupations)
if render_protostructure_label(value.spacegroup, [(o.wyckoff, o.species.name) for o in value.occupations]) != text:
raise ValueError(f"{text!r} is not a canonical protostructure label")
return value
[docs]
def try_parse_protopattern(text: str) -> "Protopattern | None":
"""Return the parsed protopattern, or ``None`` when *text* is not a canonical one.
:param text: The label text to test.
:return: The parsed protopattern, or ``None`` for a non-label string.
"""
if not isinstance(text, str) or ":" in text:
return None
try:
return parse_protopattern_label(text)
except ValueError:
return None
[docs]
def try_parse_protostructure(text: str) -> "Protostructure | None":
"""Return the parsed protostructure, or ``None`` when *text* is not a canonical one.
:param text: The label text to test.
:return: The parsed protostructure, or ``None`` for a non-label string.
"""
if not isinstance(text, str) or ":" not in text:
return None
try:
return parse_protostructure_label(text)
except ValueError:
return None