Source code for httk.atomistic.data

"""Provide lazy access to five vendored crystallographic symmetry datasets.

All five datasets ship as canonical upstream ``.json.gz`` artifacts, copied verbatim,
read through :class:`~httk.core.DatasetLoader`, and licensed CC BY 4.0 (see the adjacent
``LICENSE`` and ``README.md``). The canonical per-concern split happened upstream.

``symmetry_basics.json.gz``
    One record per space-group **setting** — 527 of them, of which 230 are flagged
    ``is_reference_setting`` (the International Tables standard setting for their IT
    number). Each record is self-contained *in its own setting*: its symmetry operations,
    its Wyckoff table, and its asymmetric-unit region are all expressed in that setting's
    coordinates. So SG 15 Wyckoff letter ``e`` reads ``0,y,1/4`` in the reference setting
    ``15:b1`` but ``1/4,0,z`` in ``15:c1``.

``spacegroup_setting_transforms.json.gz``
    The change-of-basis operation taking each setting to its IT standard setting, keyed on
    Hall entry, covering all 527 settings. See :func:`setting_transform` for the direction
    convention, which is easy to get backwards.

``baernighausen_std.json.gz``
    Per-IT-number Bärnighausen subgroup transformations.

``continuous_euclidean_normalizer_std.json.gz``
    Per-IT-number continuous-normalizer bases.

``affine_normalizer_cosets.json.gz``
    Affine-normalizer cosets for all 527 Hall entries.

Nothing here is read at import time. The first lookup parses the whole document: about
0.4 s and 12 MB resident for ``symmetry_basics``, with the other datasets much smaller.
Upstream also publishes ``.sqlar`` twins for lazy access to large datasets; they are
readable by the same :class:`~httk.core.DatasetLoader`.

Two field-choice traps worth stating once, because both fail silently:

* Use ``symops`` and ``orbit``, not ``symops_mod_centering`` and ``orbit_mod_centering``.
  The former are the full sets with centering translations folded in, so
  ``len(orbit) == multiplicity`` holds; the ``_mod_centering`` variants are the factored
  forms and mixing the two in a set comparison misreports every centred group.
* ``orbit[0]`` follows the record's ``first_orbit``, which differs from
  ``first_orbit_ita`` in 180 of the 3440 Wyckoff entries. Both describe the same orbit,
  but only the latter matches what International Tables prints.
"""

import atexit
from collections.abc import Mapping, Sequence
from contextlib import ExitStack
from functools import cache
from importlib.resources import as_file, files
from pathlib import Path
from typing import Any

from httk.core import DatasetLoader, register_citation

__all__ = [
    "affine_normalizer_coset_record",
    "isomorphic_subgroup_record",
    "point_groups",
    "setting_transform",
    "spacegroup_setting",
    "spacegroup_setting_by_symop_key",
    "spacegroup_settings",
    "spacegroup_subgroup_record",
    "spglib_default_spacegroup_setting",
    "standard_setting_it_numbers",
    "standard_spacegroup_setting",
]

_RESOURCES = ExitStack()
atexit.register(_RESOURCES.close)


@cache
def _resource_path(name: str) -> Path:
    """A real filesystem path for a packaged data file.

    ``importlib.resources.files`` yields a plain ``Path`` for an ordinary installation,
    but only a ``Traversable`` when the package is loaded from a zip, so this goes through
    ``as_file`` rather than assuming. The extraction that implies for a zipped install is
    kept alive for the life of the process by the module-level ``ExitStack``.
    """
    return _RESOURCES.enter_context(as_file(files(__package__).joinpath(name)))


@cache
def _basics() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader("httk.atomistic.symmetry_basics", _resource_path("symmetry_basics.json.gz"))


@cache
def _transforms() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader(
        "httk.atomistic.spacegroup_setting_transforms",
        _resource_path("spacegroup_setting_transforms.json.gz"),
    )


@cache
def _baernighausen_std() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader(
        "httk.atomistic.baernighausen_std",
        _resource_path("baernighausen_std.json.gz"),
    )


@cache
def _continuous_euclidean_normalizer_std() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader(
        "httk.atomistic.continuous_euclidean_normalizer_std",
        _resource_path("continuous_euclidean_normalizer_std.json.gz"),
    )


@cache
def _cosets() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader(
        "httk.atomistic.affine_normalizer_cosets",
        _resource_path("affine_normalizer_cosets.json.gz"),
    )


@cache
def _isomorphic_subgroups_std() -> DatasetLoader:
    register_citation(
        applies_to="Vendored crystallographic symmetry datasets (CC BY 4.0)",
        references={
            "title": "Basic symmetry information generated by httk data-generators",
            "authors": ({"name": "Rickard Armiento"},),
            "url": "https://github.com/httk/data-generator",
            "note": "Version 0.1.0, CC BY 4.0",
            "bib_type": "misc",
        },
    )
    return DatasetLoader(
        "httk.atomistic.isomorphic_subgroups_std",
        _resource_path("isomorphic_subgroups_std.json.gz"),
    )


def _lookup_index(loader: DatasetLoader, dataset: str, name: str) -> Mapping[str, int]:
    """A named lookup index from a dataset, as a name-to-position mapping.

    ``DatasetLoader.index`` is ``None`` for a document that is not in the structured JSON-LD
    form. That cannot happen for the files vendored here, but it is worth failing with a
    sentence that names the cause rather than an ``AttributeError`` on ``None`` if a data
    refresh ever changes the shape.
    """
    index = loader.index
    if index is None:
        raise RuntimeError(f"vendored dataset {dataset!r} has no lookup indices; its file shape changed")
    return getattr(index, name)


[docs] def spacegroup_settings() -> Sequence[Mapping[str, Any]]: """Return every tabulated space-group setting, one record each. The symmetry-basics dataset is loaded lazily on the first lookup. :return: All tabulated space-group setting records. """ return _basics().data.spacegroups
[docs] def point_groups() -> Sequence[Mapping[str, Any]]: """Return the crystallographic point groups with their operations and character tables. The symmetry-basics dataset is loaded lazily on the first lookup. :return: All tabulated point-group records. """ return _basics().data.pointgroups
[docs] def spacegroup_setting( *, hall_entry: str | None = None, setting_it_nc: str | None = None, hm_entry: str | None = None, ) -> Mapping[str, Any]: """The setting record identified by exactly one of the given keys. ``hall_entry`` is the normalized Hall symbol (``"-c_2yc"``), ``setting_it_nc`` the IT number with coordinate-system code (``"15:c1"``), and ``hm_entry`` the Hermann-Mauguin entry name (``"C 1 2/c 1"``). A Hall entry names a setting unambiguously — symbol, axes and origin — which is why it is the key the transform dataset uses. Raises :class:`KeyError` if the key is unknown, and :class:`TypeError` unless exactly one key is given. :param hall_entry: The normalized Hall symbol identifying the setting. :param setting_it_nc: The IT number and coordinate-system code identifying the setting. :param hm_entry: The Hermann-Mauguin entry name identifying the setting. :return: The matching space-group setting record. :raises KeyError: If the selected key is unknown. :raises TypeError: If zero or multiple keys are supplied. """ given = { "hall_entry": hall_entry, "setting_it_nc": setting_it_nc, "hm_entry": hm_entry, } supplied = {name: value for name, value in given.items() if value is not None} if len(supplied) != 1: raise TypeError(f"spacegroup_setting() takes exactly one of {', '.join(given)}; got {len(supplied)}") key, value = next(iter(supplied.items())) index = _lookup_index(_basics(), "symmetry_basics", f"index_{key}_to_spacegroups") try: position = index[value] except KeyError: raise KeyError(f"no space-group setting with {key}={value!r}") from None return _basics().data.spacegroups[position]
[docs] def spacegroup_setting_by_symop_key(key: str) -> Mapping[str, Any]: """Return the setting indexed by a canonical complete-operation-set key. :param key: The v1 key from :func:`httk.atomistic.symmetry.symop_key.symop_key_v1`. :return: The matching space-group setting record. :raises KeyError: If the operations key is not tabulated. """ try: position = _symop_key_to_spacegroups()[key] except KeyError: raise KeyError(f"no space-group setting with symop key {key!r}") from None return _basics().data.spacegroups[position]
@cache def _symop_key_to_spacegroups() -> Mapping[str, int]: """The vendored operations-key index, or a lazy compatibility reconstruction.""" index = _basics().index vendored = None if index is None else getattr(index, "index_symop_key_to_spacegroups", None) if vendored is not None: return vendored from httk.atomistic.symmetry.symop_key import symop_key_v1 return {symop_key_v1(record["symops"]): position for position, record in enumerate(spacegroup_settings())}
[docs] def standard_spacegroup_setting(it_number: int) -> Mapping[str, Any]: """The IT standard (reference) setting for a space-group number, ``1 <= it_number <= 230``. This is the setting flagged ``is_reference_setting`` and is the one :func:`setting_transform` transforms to. Note it is **not** always spglib's default setting: the two differ for the 24 space groups with two origin choices (48, 50, 59, 68, 70, 85, 86, 88, 125, 126, 129, 130, 133, 134, 137, 138, 141, 142, 201, 203, 222, 224, 227, 228) and agree for the other 206. Any interoperation with spglib must go through an explicit transform rather than assuming the two coincide. :param it_number: The International Tables space-group number. :return: The reference setting record for the number. :raises KeyError: If no reference setting has the requested number. """ index = _lookup_index(_basics(), "symmetry_basics", "index_it_number_to_std_spacegroups") try: position = index[str(int(it_number))] except KeyError: raise KeyError(f"no space group with IT number {it_number!r}") from None return _basics().data.spacegroups[position]
[docs] def spglib_default_spacegroup_setting(it_number: int) -> Mapping[str, Any]: """The setting spglib treats as its default for a space-group number. This differs from :func:`standard_spacegroup_setting` for the 24 space groups with two origin choices and coincides with it for the other 206, which is exactly why any code that hands coordinates to or takes them from spglib must transform explicitly rather than assume the two agree — the failure mode is a structure displaced by a fraction of a cell that still passes a symmetry check. :param it_number: The International Tables space-group number. :return: The setting record selected by spglib for the number. :raises KeyError: If spglib has no setting for the requested number. """ index = _lookup_index(_basics(), "symmetry_basics", "index_it_number_to_spglib_default_spacegroups") try: position = index[str(int(it_number))] except KeyError: raise KeyError(f"no space group with IT number {it_number!r}") from None return _basics().data.spacegroups[position]
[docs] def standard_setting_it_numbers() -> list[int]: """Return the IT numbers that have a tabulated standard setting. :return: The available International Tables space-group numbers in ascending order. """ return sorted(int(key) for key in _lookup_index(_basics(), "symmetry_basics", "index_it_number_to_std_spacegroups"))
[docs] def setting_transform(hall_entry: str) -> Mapping[str, Any]: """The change-of-basis operation between a setting and its IT standard setting. The returned record's ``affine_transformation`` holds an exact rational ``matrix`` ``M`` and ``vector`` ``v``. **Direction matters and the field name is misleading**: despite deriving from a table called ``hall_to_it_std_transform``, the pair maps standard-setting coordinates *into* this setting, as column vectors:: x_own = M @ x_std + v Under httk's row-vector convention that is ``f_own = f_std @ M.T + v``, with the reverse ``f_std = (f_own - v) @ inv(M).T`` and cell basis rows transforming as ``B_own = inv(M).T @ B_std``. Applying it backwards yields a structurally valid but systematically wrong crystal. ``M`` is unimodular for 520 of the 527 settings. The exceptions are the seven rhombohedral-axes settings (IT numbers 146, 148, 155, 160, 161, 166, 167), where ``det M == 3`` because the standard hexagonal cell has three times the volume of the rhombohedral one — and correspondingly ``inv(M)`` has thirds, so nothing may assume the reverse transform is integral. :param hall_entry: The normalized Hall symbol identifying the setting. :return: The setting-transform record mapping standard-setting coordinates into the named setting. :raises KeyError: If no transform is tabulated for ``hall_entry``. """ index = _lookup_index( _transforms(), "spacegroup_setting_transforms", "index_hall_entry_to_spacegroup_setting_transforms" ) try: position = index[hall_entry] except KeyError: raise KeyError(f"no setting transform for hall_entry={hall_entry!r}") from None return _transforms().data.spacegroup_setting_transforms[position]
[docs] def spacegroup_subgroup_record(it_number: int) -> Mapping[str, Any]: """Return the subgroup record for an IT number. :param it_number: The International Tables space-group number. :return: The Bärnighausen and continuous-normalizer record. :raises KeyError: If no record is tabulated for the IT number. """ key = str(int(it_number)) try: baernighausen_position = _lookup_index( _baernighausen_std(), "baernighausen_std", "index_it_number_to_baernighausen_std" )[key] normalizer_position = _lookup_index( _continuous_euclidean_normalizer_std(), "continuous_euclidean_normalizer_std", "index_it_number_to_continuous_euclidean_normalizer_std", )[key] except KeyError: raise KeyError(f"no space-group subgroup record with IT number {it_number!r}") from None return { "it_number": int(it_number), "baernighausen": _baernighausen_std().data.baernighausen_std[baernighausen_position]["baernighausen"], "continuous_normalizer": _continuous_euclidean_normalizer_std().data.continuous_euclidean_normalizer_std[ normalizer_position ]["continuous_normalizer"], }
[docs] def isomorphic_subgroup_record(it_number: int) -> Mapping[str, Any]: """Return the standard-setting same-setting isomorphic subgroup record for an IT number. The record's ``isomorphic_subgroups["items"]`` list holds one entry per tabulated isomorphic (same IT number) subgroup transform up to index 9, each with the same ``index``/``wyckoff_splitting``/``affine_transformation`` field shapes as the Bärnighausen entries; index 1 items are identity-cell re-descriptions. :param it_number: The International Tables space-group number. :return: The isomorphic subgroup record. :raises KeyError: If no record is tabulated for the IT number. """ key = str(int(it_number)) try: position = _lookup_index( _isomorphic_subgroups_std(), "isomorphic_subgroups_std", "index_it_number_to_isomorphic_subgroups_std" )[key] except KeyError: raise KeyError(f"no isomorphic subgroup record with IT number {it_number!r}") from None return _isomorphic_subgroups_std().data.isomorphic_subgroups_std[position]
[docs] def affine_normalizer_coset_record(hall_entry: str) -> Mapping[str, Any]: """Return the affine-normalizer coset record for a Hall entry. :param hall_entry: The normalized Hall symbol identifying the setting. :return: The affine-normalizer coset record. :raises KeyError: If no record is tabulated for the Hall entry. """ index = _lookup_index(_cosets(), "affine_normalizer_coset_data", "hall_symbol_to_affine_normalizer_coset_data") try: position = index[hall_entry] except KeyError: raise KeyError(f"no affine normalizer coset record for hall_entry={hall_entry!r}") from None return _cosets().data.affine_normalizer_coset_data[position]