From 6074ebad3d07341c7e637aabb994c0f08d1fcaa5 Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:06:16 -0400 Subject: [PATCH 01/13] refactor(core): symmetry raises a core SymmetryError; gateways convert it (A61, A62) Closes the A56/A57 obligation: powderline.symmetry no longer references a gateway. The _translation_error() helper is gone; every raise is powderline.exceptions.SymmetryError(PowderLineError, ValueError). - TOPAS writer: one _symmetry() helper wraps its three symmetry calls, converts SymmetryError to TopasTranslationError (chained) and applies TOPAS's own rejection of the rhombohedral :R setting (same message). - easydiffraction policy/builder catch SymmetryError instead of TopasTranslationError (no longer importing the TOPAS gateway); the policy rejects :R itself. - Core resolve_space_group/site_dof/adp_dof accept :R (A62); cell_constraints refuses :R explicitly (its tied angles are not expressible as CellRules). Gateway-raised error types are unchanged. Direct callers of powderline.symmetry see SymmetryError, and :R is accepted by the resolver. Both are recorded in the devkit deliberate-changes table (A34). Test IDs relabelled: three test_topas_symmetry *_raises_translation_error tests became *_raises_symmetry_error; the :R test was split in two. Co-Authored-By: Claude Opus 5.5 --- src/powderline/easydiff/builder.py | 4 +- src/powderline/easydiff/policy.py | 11 ++++-- src/powderline/exceptions.py | 13 ++++++ src/powderline/symmetry.py | 63 ++++++++++++++---------------- src/powderline/topas/writer.py | 27 +++++++++++-- tests/test_easydiff_policy.py | 8 ++++ tests/test_exceptions.py | 3 +- tests/test_topas_symmetry.py | 43 ++++++++++++++------ tests/test_topas_writer.py | 16 ++++++++ 9 files changed, 131 insertions(+), 57 deletions(-) diff --git a/src/powderline/easydiff/builder.py b/src/powderline/easydiff/builder.py index 6cd44c9..c97dc54 100644 --- a/src/powderline/easydiff/builder.py +++ b/src/powderline/easydiff/builder.py @@ -22,7 +22,7 @@ ) from powderline.easydiff.errors import EasyDiffractionTranslationError from powderline.easydiff.policy import check_unsupported, param_flag, param_value -from powderline.topas.errors import TopasTranslationError +from powderline.exceptions import SymmetryError from powderline.symmetry import cell_constraints @@ -359,7 +359,7 @@ def build_project(recipe: dict, workdir) -> BuildResult: # Get symmetry rules try: rules = cell_constraints(sg_name) - except TopasTranslationError as e: + except SymmetryError as e: raise EasyDiffractionTranslationError(str(e)) from e length_map = { diff --git a/src/powderline/easydiff/policy.py b/src/powderline/easydiff/policy.py index 3cf9a6f..c004294 100644 --- a/src/powderline/easydiff/policy.py +++ b/src/powderline/easydiff/policy.py @@ -35,8 +35,8 @@ def _iparm_current(iparm1: dict, key: str, default=0.0): def check_unsupported(recipe: dict) -> list[str]: - from powderline.symmetry import cell_constraints - from powderline.topas.errors import TopasTranslationError + from powderline.exceptions import SymmetryError + from powderline.symmetry import cell_constraints, resolve_space_group warnings: list[str] = [] schema_name = recipe.get("schema_name") @@ -106,8 +106,13 @@ def check_unsupported(recipe: dict) -> list[str]: sg_name = structure.get("space_group") if sg_name: try: + if resolve_space_group(sg_name).ext == "R": + raise EasyDiffractionTranslationError( + f"rhombohedral ':R' setting not supported for {sg_name!r}; " + "use the hexagonal (:H) setting" + ) cell_constraints(sg_name) - except TopasTranslationError as exc: + except SymmetryError as exc: raise EasyDiffractionTranslationError(str(exc)) from exc pz = phase.get("parameterization") or {} diff --git a/src/powderline/exceptions.py b/src/powderline/exceptions.py index 92455c2..c129b7c 100644 --- a/src/powderline/exceptions.py +++ b/src/powderline/exceptions.py @@ -14,6 +14,9 @@ supported ``ENGINE_VERSION_SPEC``. A hard error (A44). - :class:`EngineExecutionError` — the engine ran and failed. - :class:`RecipeValidationError` — a recipe failed validation. +- :class:`SymmetryError` — core symmetry helpers (``powderline.symmetry``) + cannot interpret a space group or site. Gateways convert it to their own + error type (A61). This module must never import an engine (enforced by an import-block test). """ @@ -50,6 +53,16 @@ def from_pydantic(cls, exc, *, schema_name: Optional[str] = None) -> "RecipeVali return err +class SymmetryError(PowderLineError, ValueError): + """A space group or atomic site cannot be interpreted (``powderline.symmetry``). + + A ``ValueError`` so that, raised inside a pydantic validator, it is reported + as a validation error with the field location. Gateways that call the + symmetry helpers outside validation convert it to their own error type + (A56, A61). + """ + + class GatewayNotInstalledError(PowderLineError): """No gateway of this name is registered (the gateway package is absent).""" diff --git a/src/powderline/symmetry.py b/src/powderline/symmetry.py index 09db7e7..fc10510 100644 --- a/src/powderline/symmetry.py +++ b/src/powderline/symmetry.py @@ -1,6 +1,6 @@ -"""gemmi-backed crystallographic rules for the TOPAS writer (plan §5). +"""gemmi-backed crystallographic rules (engine-free core module). -Two pure capabilities, both GSAS-II-free: +Two pure capabilities, both engine-free: * :func:`cell_constraints` -- crystal-system cell equalities and fixed angles, so the writer can share one ``prm`` name across equal cell lengths and emit @@ -11,6 +11,10 @@ Space-group operations come from gemmi; the stabiliser / projector math is our own (~small, spec in plan §5). ``gemmi`` is an approved runtime dependency (D3). + +Errors are :class:`~powderline.exceptions.SymmetryError`; each gateway converts +it to its own error type and applies its engine's own restrictions (e.g. TOPAS +rejects the rhombohedral ``:R`` setting) (A61, A62). """ from __future__ import annotations @@ -20,6 +24,8 @@ import gemmi import numpy as np +from powderline.exceptions import SymmetryError + # Fractional-coordinate tolerances (plan §5). _POS_TOL = 1e-5 # position / orbit equality mod 1 @@ -84,20 +90,6 @@ def classification(self, axis: str) -> str: return self.axes[_AXIS_LETTERS.index(axis)] -def _translation_error(message: str) -> Exception: - """Build the error raised for unsupported symmetry input (A56, interim). - - Returns ``powderline.topas.errors.TopasTranslationError``, the type callers - catch today. Imported here, lazily, because importing ``powderline.topas`` at - module top is circular (topas/__init__ -> writer -> symmetry). This is the - module's only reference to a gateway; re/03 replaces it with a core error - that each gateway translates. - """ - from powderline.topas.errors import TopasTranslationError - - return TopasTranslationError(message) - - # --- space-group resolution ------------------------------------------------- @@ -105,26 +97,22 @@ def resolve_space_group(space_group: str) -> gemmi.SpaceGroup: """Resolve a recipe space-group string to a gemmi ``SpaceGroup``. Tries the symbol verbatim, then whitespace-stripped (gemmi accepts both - ``"P m -3 m"`` and ``"Pm-3m"``). Rhombohedral ``:R`` settings and - unresolvable symbols raise :class:`TopasTranslationError`. Two-origin groups - passed without an explicit ``:1``/``:2`` selector also error rather than - guess an origin (plan §5(6); demo groups are single-origin). + ``"P m -3 m"`` and ``"Pm-3m"``). Unresolvable symbols raise + :class:`SymmetryError`. Two-origin groups passed without an explicit + ``:1``/``:2`` selector also error rather than guess an origin (plan §5(6)). + Both rhombohedral settings (``:H``, ``:R``) are accepted; engines that + cannot handle ``:R`` reject it themselves (A62). """ raw = str(space_group) sg = gemmi.find_spacegroup_by_name(raw) if sg is None: sg = gemmi.find_spacegroup_by_name(raw.replace(" ", "")) if sg is None: - raise _translation_error( + raise SymmetryError( f"unrecognized space-group symbol {space_group!r} (gemmi could not resolve it)" ) - if sg.ext == "R": - raise _translation_error( - f"rhombohedral ':R' setting not supported for {space_group!r}; " - "use the hexagonal (:H) setting" - ) if sg.ext in ("1", "2") and ":" not in raw: - raise _translation_error( + raise SymmetryError( f"space group {space_group!r} has two origin choices; specify one " "explicitly (e.g. append ':2' for the GSAS-II origin-2 convention)" ) @@ -158,7 +146,7 @@ def _monoclinic_unique_axis(ops: list[tuple[np.ndarray, np.ndarray]]) -> str: if abs(evals[i].real - 1.0) < _MAT_TOL and abs(evals[i].imag) < _MAT_TOL: direction = np.abs(evecs[:, i].real) return _LENGTHS[int(np.argmax(direction))] - raise _translation_error( + raise SymmetryError( "could not determine the monoclinic unique axis (no proper 2-fold found)" ) @@ -166,8 +154,9 @@ def _monoclinic_unique_axis(ops: list[tuple[np.ndarray, np.ndarray]]) -> str: def cell_constraints(space_group: str) -> CellRules: """Cell-length equality groups and fixed/free angles for ``space_group``. - See :class:`CellRules`. Rhombohedral ``:R`` and unknown symbols raise - :class:`TopasTranslationError` (via :func:`resolve_space_group`). + See :class:`CellRules`. Unknown symbols raise :class:`SymmetryError` (via + :func:`resolve_space_group`), as does the rhombohedral ``:R`` setting (its + tied angles are not expressible as :class:`CellRules`). """ sg = resolve_space_group(space_group) system = sg.crystal_system_str() @@ -180,8 +169,14 @@ def cell_constraints(space_group: str) -> CellRules: if system == "tetragonal": return CellRules(system, (("a", "b"), ("c",)), all_angles_fixed, ()) if system in ("hexagonal", "trigonal"): - # trigonal reaches here only in the hexagonal (:H) setting -- :R errored - # out in resolve_space_group. Cell is a=b, 90/90/120. + if sg.ext == "R": + # Rhombohedral axes need alpha=beta=gamma tied together, which + # CellRules cannot express; refuse rather than return wrong rules. + raise SymmetryError( + f"cell rules for the rhombohedral ':R' setting are not implemented " + f"({space_group!r}); use the hexagonal (:H) setting" + ) + # Hexagonal axes (incl. the :H setting of rhombohedral groups): a=b, 90/90/120. return CellRules(system, (("a", "b"), ("c",)), all_angles_fixed, ()) if system == "orthorhombic": return CellRules(system, singles, all_angles_fixed, ()) @@ -193,7 +188,7 @@ def cell_constraints(space_group: str) -> CellRules: if system == "triclinic": return CellRules(system, singles, (), _ANGLES) - raise _translation_error( + raise SymmetryError( f"unsupported crystal system {system!r} for space group {space_group!r}" ) @@ -235,7 +230,7 @@ def site_dof(space_group: str, xyz) -> SiteDof: stab_rots = [R for (R, t) in ops if np.all(np.abs(_wrap_symmetric(R @ x + t - x)) < _POS_TOL)] if not stab_rots: # pragma: no cover - identity is always in the group - raise _translation_error("empty stabilizer (should be impossible)") + raise SymmetryError("empty stabilizer (should be impossible)") projector = sum(stab_rots) / len(stab_rots) diff --git a/src/powderline/topas/writer.py b/src/powderline/topas/writer.py index e3e9978..0f9cc10 100644 --- a/src/powderline/topas/writer.py +++ b/src/powderline/topas/writer.py @@ -24,7 +24,8 @@ from . import conversions as cv from .errors import TopasTranslationError -from powderline.symmetry import adp_dof, cell_constraints, site_dof +from powderline.exceptions import SymmetryError +from powderline.symmetry import adp_dof, cell_constraints, resolve_space_group, site_dof SCHEMA_RIETVELD = "GSASII_Rietveld" SCHEMA_SPF = "GSASII_SPF" @@ -708,8 +709,26 @@ def _emit_str(lines, phase_name, phase, payload, ctx, phase_idx=0) -> None: _emit_peak_lists(lines, phase_name) +def _symmetry(fn, space_group, *args): + """Call a core symmetry helper under the TOPAS gateway's rules (A61, A62). + + TOPAS rejects the rhombohedral ``:R`` setting, and core + :class:`~powderline.exceptions.SymmetryError` becomes + :class:`TopasTranslationError`, the type this gateway raises. + """ + try: + if resolve_space_group(space_group).ext == "R": + raise TopasTranslationError( + f"rhombohedral ':R' setting not supported for {space_group!r}; " + "use the hexagonal (:H) setting" + ) + return fn(space_group, *args) + except SymmetryError as exc: + raise TopasTranslationError(str(exc)) from exc + + def _emit_cell(lines, phase_name, pname, structure, param, ctx, phase_idx=0) -> None: - rules = cell_constraints(structure.get("space_group")) + rules = _symmetry(cell_constraints, structure.get("space_group")) ucell = structure.get("unit_cell", {}) pflags = param.get("unit_cell", {}) or {} @@ -832,7 +851,7 @@ def _emit_one_site(lines, phase_name, pname, space_group, xyz, members, atoms, p site_label = cv.sanitize(first) # Multiplicity cross-check: warn (never error) on orbit-size mismatch (plan §5(5)). - dof = site_dof(space_group, xyz) + dof = _symmetry(site_dof, space_group, xyz) for label in members: mult = atoms[label].get("Multiplicity") if mult is not None and int(mult) != dof.orbit_size: @@ -925,7 +944,7 @@ def _uaniso_tokens(pname, label, atom, patom, space_group, xyz, ctx, phase_name, """Emit the six ``u_ij`` for an anisotropic ADP atom (permissive; warns on a refine flag on a symmetry-restricted component but does not enforce it).""" aniso = patom.get("Uaniso") or {} - dof = adp_dof(space_group, xyz) + dof = _symmetry(adp_dof, space_group, xyz) tokens = [] for tkey, rkey in _UANISO_KEYS: entry = aniso.get(rkey) diff --git a/tests/test_easydiff_policy.py b/tests/test_easydiff_policy.py index 50542e6..2c38450 100644 --- a/tests/test_easydiff_policy.py +++ b/tests/test_easydiff_policy.py @@ -192,3 +192,11 @@ def test_unknown_space_group_raises(): r["payload"]["phases"]["LaB6"]["structure"]["space_group"] = "NOT A GROUP" with pytest.raises(EasyDiffractionTranslationError, match="NOT A GROUP"): check_unsupported(r) + + +def test_rhombohedral_R_setting_rejected(): + # A62: the easydiffraction layer rejects :R itself (core accepts it). + r = base_recipe() + r["payload"]["phases"]["LaB6"]["structure"]["space_group"] = "R -3 m :R" + with pytest.raises(EasyDiffractionTranslationError, match="rhombohedral ':R'"): + check_unsupported(r) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 5ae9aaf..2018344 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -13,12 +13,13 @@ PowderLineError, RecipeValidationError, StructuredWarning, + SymmetryError, ) @pytest.mark.parametrize("cls", [ RecipeValidationError, GatewayNotInstalledError, EngineNotAvailableError, - EngineVersionError, EngineExecutionError, + EngineVersionError, EngineExecutionError, SymmetryError, ]) def test_all_derive_from_powderline_error(cls): assert issubclass(cls, PowderLineError) diff --git a/tests/test_topas_symmetry.py b/tests/test_topas_symmetry.py index 5288a84..71deb03 100644 --- a/tests/test_topas_symmetry.py +++ b/tests/test_topas_symmetry.py @@ -5,7 +5,7 @@ import pytest from powderline import symmetry as sym -from powderline.topas.errors import TopasTranslationError +from powderline.exceptions import PowderLineError, SymmetryError # --- (a) cell rules --------------------------------------------------------- @@ -67,19 +67,36 @@ def test_trigonal_hex_setting_is_hexagonal_cell(): def test_unknown_symbol_errors(): - with pytest.raises(TopasTranslationError, match="unrecognized"): + with pytest.raises(SymmetryError, match="unrecognized"): sym.cell_constraints("NotASpaceGroup") -def test_rhombohedral_R_setting_errors(): - with pytest.raises(TopasTranslationError, match="rhombohedral"): +def test_rhombohedral_R_setting_accepted_by_core(): + # A62: core resolves both rhombohedral settings; engines that cannot handle + # :R (TOPAS, easydiffraction) reject it in their own layer. + assert sym.resolve_space_group("R -3 m :R").ext == "R" + assert sym.site_dof("R -3 m :R", (0.0, 0.0, 0.0)).orbit_size == 1 + assert sym.site_dof("R -3 m :H", (0.0, 0.0, 0.0)).orbit_size == 3 + + +def test_rhombohedral_R_cell_rules_not_implemented(): + # alpha=beta=gamma ties are not expressible as CellRules: refuse, don't guess. + with pytest.raises(SymmetryError, match="not implemented"): sym.cell_constraints("R -3 m :R") - with pytest.raises(TopasTranslationError, match="rhombohedral"): - sym.site_dof("R -3 m :R", (0.0, 0.0, 0.0)) + + +def test_symmetry_error_is_a_core_value_error(): + # A61: a ValueError (pydantic reports it with the field location inside a + # validator) and a PowderLineError; no gateway error type involved. + with pytest.raises(SymmetryError) as info: + sym.resolve_space_group("NotASpaceGroup") + assert isinstance(info.value, ValueError) + assert isinstance(info.value, PowderLineError) + assert type(info.value).__module__ == "powderline.exceptions" def test_two_origin_without_selector_errors(): - with pytest.raises(TopasTranslationError, match="two origin"): + with pytest.raises(SymmetryError, match="two origin"): sym.cell_constraints("F d -3 m") # explicit origin is accepted assert sym.cell_constraints("F d -3 m:2").crystal_system == "cubic" @@ -180,23 +197,23 @@ def crystal_system_str(self): return "not-a-system" -def test_unsupported_crystal_system_raises_translation_error(monkeypatch): +def test_unsupported_crystal_system_raises_symmetry_error(monkeypatch): monkeypatch.setattr(sym, "resolve_space_group", lambda _sg: _UnknownSystem()) - with pytest.raises(TopasTranslationError, match="unsupported crystal system"): + with pytest.raises(SymmetryError, match="unsupported crystal system"): sym.cell_constraints("P1") -def test_empty_stabilizer_raises_translation_error(monkeypatch): +def test_empty_stabilizer_raises_symmetry_error(monkeypatch): import numpy as np # A single translation-only op fixes no point, so the stabiliser is empty. monkeypatch.setattr(sym, "_expanded_ops", lambda _sg: [(np.eye(3), np.full(3, 0.5))]) - with pytest.raises(TopasTranslationError, match="empty stabilizer"): + with pytest.raises(SymmetryError, match="empty stabilizer"): sym.site_dof("P1", [0.1, 0.2, 0.3]) -def test_monoclinic_unique_axis_failure_raises_translation_error(): +def test_monoclinic_unique_axis_failure_raises_symmetry_error(): import numpy as np - with pytest.raises(TopasTranslationError, match="monoclinic unique axis"): + with pytest.raises(SymmetryError, match="monoclinic unique axis"): sym._monoclinic_unique_axis([(np.eye(3), np.zeros(3))]) diff --git a/tests/test_topas_writer.py b/tests/test_topas_writer.py index 5e84930..bc9a978 100644 --- a/tests/test_topas_writer.py +++ b/tests/test_topas_writer.py @@ -280,6 +280,22 @@ def test_rhombohedral_R_setting_rejected(): render_topas(recipe, "x") +@pytest.mark.parametrize("space_group, match", [ + ("NotASpaceGroup", "unrecognized"), + ("F d -3 m", "two origin"), +]) +def test_core_symmetry_error_becomes_translation_error(space_group, match): + # A61: core raises SymmetryError; the TOPAS gateway raises its own type, + # chaining the core error. + from powderline.exceptions import SymmetryError + + recipe = _recipe("example_LaB6") + recipe["payload"]["phases"]["LaB6"]["structure"]["space_group"] = space_group + with pytest.raises(TopasTranslationError, match=match) as info: + render_topas(recipe, "x") + assert isinstance(info.value.__cause__, SymmetryError) + + def test_template_skeleton_rejected_cleanly(): # The fill-in-the-blanks example_template (null instrument.parameterization, # empty tth) must fail with a clear TopasTranslationError, not a raw crash. From d7489039a6d36a24342dfe4043307eb2f069f99c Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:50:13 -0400 Subject: [PATCH 02/13] feat(core): schema_core: core schema 1.0.0 models (unused by the runtime until re/04) - Parameter models (A20, A32, A71, A72): RefinableParameter [value, flag] and BoundedRefinableParameter [value, flag, min, max]. A 4-list where bounds are unsupported is an error naming the field. The Structure* variants allow value null (start from the phase structure's value). Strict boolean flags; list-only JSON shape; the JSON Schema describes the arrays. - XRDData: the 0.26.0 validators ported verbatim; unit metadata (A19). - FitRange [min, max]: max > min, finite; check_fit_range_within_data for engine payloads (A68). ChebyshevBackground ported, strict flag. - CoreRecipe frame: schema_name ".", core/engine schema versions (core checked against SUPPORTED_CORE_SCHEMAS, A40), metadata JSON-serializable and capped at METADATA_MAX_BYTES = 1 MiB (A64). - check_element_symbol: a bare symbol spelled exactly as in gemmi's table; charged types rejected, never stripped (A63). - extra='forbid' everywhere (A21). Co-Authored-By: Claude Opus 5.5 --- src/powderline/schema_core.py | 514 +++++++++++++++++++ tests/test_schema_core.py | 903 ++++++++++++++++++++++++++++++++++ 2 files changed, 1417 insertions(+) create mode 100644 src/powderline/schema_core.py create mode 100644 tests/test_schema_core.py diff --git a/src/powderline/schema_core.py b/src/powderline/schema_core.py new file mode 100644 index 0000000..567d3cf --- /dev/null +++ b/src/powderline/schema_core.py @@ -0,0 +1,514 @@ +"""Core recipe schema (core schema 1.0.0): the models every engine schema shares. + +Engine-free and pure pydantic. Engine schemas (``gsasii.*``, ``topas.*``, +``easydiffraction.*``; re/04-06) build their payloads from these models; nothing +at runtime routes through this module until then (re/03). + +Conventions (master plan A19-A21, A32): + +- every model is ``extra='forbid'``; +- units are fixed per field in the schema (``json_schema_extra={"unit": ...}``), + never carried by a recipe; +- a refinable quantity is a :class:`RefinableParameter` (JSON + ``[value, refine_flag]``) or, where the engine honors bounds, a + :class:`BoundedRefinableParameter` (JSON ``[value, refine_flag, min, max]``); +- plain (non-refinable) values get field-specific validation. + +This module must never import an engine (enforced by an import-block test). +""" + +from __future__ import annotations + +import json +import math +from typing import Any, Optional + +import gemmi +import numpy as np +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion, Version +from pydantic import ( + BaseModel, + ConfigDict, + Field, + StrictBool, + field_validator, + model_serializer, + model_validator, +) + +# --- versions (A40, A65) --------------------------------------------------- + +#: The core schema version new recipes are written against. +CORE_SCHEMA_VERSION = "1.0.0" +#: Core schema versions this PowderLine accepts (PEP 440 specifier). Widened +#: explicitly when a new core version is compatible; never inferred (A40). +SUPPORTED_CORE_SCHEMAS = "==1.0.0" + +#: Size cap on the serialized ``metadata`` block, in bytes of UTF-8 JSON (A64). +#: A sanity limit against accidents (e.g. a pasted pattern); amend here and in +#: docs/SCHEMA_HISTORY.md. +METADATA_MAX_BYTES = 1024 * 1024 + +#: ``schema_name`` format: ``"."`` (A1, A18). +SCHEMA_NAME_PATTERN = r"^[a-z][a-z0-9_]*\.[a-z][a-z0-9_]*$" + + +def parse_version(field: str, value: str) -> Version: + """Parse a PEP 440 version string, raising ``ValueError`` naming ``field``.""" + try: + return Version(value) + except InvalidVersion: + raise ValueError(f"{field} {value!r} is not a valid version (PEP 440, e.g. '1.0.0')") from None + + +def check_core_schema_version(version: str) -> None: + """Raise ``ValueError`` unless ``version`` is in :data:`SUPPORTED_CORE_SCHEMAS`.""" + if parse_version("core_schema_version", version) not in SpecifierSet(SUPPORTED_CORE_SCHEMAS): + from powderline import __version__ + + raise ValueError( + f"core_schema_version {version!r} is not supported by PowderLine {__version__} " + f"(accepted: {SUPPORTED_CORE_SCHEMAS}; current: {CORE_SCHEMA_VERSION})" + ) + + +# --- units (A19) ------------------------------------------------------------ + +#: Unit strings used in ``json_schema_extra={"unit": ...}``. +UNIT_DEG_2THETA = "degree 2theta" +UNIT_DEGREE = "degree" +UNIT_ANGSTROM = "angstrom" +UNIT_ANGSTROM2 = "angstrom^2" +UNIT_ARBITRARY = "arbitrary" +UNIT_INV_INTENSITY2 = "1/intensity^2" + + +def _unit(unit: str) -> dict: + return {"unit": unit} + + +def _array_schema(title: str, items: list[dict], description: str, **extra) -> dict: + """JSON Schema for a model whose recipe JSON shape is a fixed-length list.""" + return {"title": title, "description": description, "type": "array", + "prefixItems": items, "minItems": len(items), "maxItems": len(items), **extra} + + +_NUMBER = {"type": "number"} +_NUMBER_OR_NULL = {"anyOf": [{"type": "number"}, {"type": "null"}]} +_BOOLEAN = {"type": "boolean"} + + +_STRICT = ConfigDict(extra="forbid") + + +# --- parameter models (A20, A32, A71) --------------------------------------- + + +def _finite(name: str, v: Optional[float]) -> Optional[float]: + if v is not None and not math.isfinite(v): + raise ValueError(f"{name} must be finite, got {v!r}") + return v + + +class RefinableParameter(BaseModel): + """A refinable value with no bounds concept: JSON ``[value, refine_flag]``. + + Used where the engine has no native bounds for the quantity. A 4-element + list here is an error, never silently truncated (A20). ``value`` is + required; see :class:`StructureRefinableParameter` for the quantities whose + starting value may come from the phase structure. + """ + + model_config = _STRICT + + value: float + refine_flag: StrictBool + + @model_validator(mode="before") + @classmethod + def _from_list(cls, data: Any) -> Any: + if isinstance(data, (list, tuple)): + if len(data) == 4: + raise ValueError( + "bounds are not supported for this parameter in this engine schema: " + "expected [value, refine_flag], got a 4-element " + "[value, refine_flag, min, max] list" + ) + if len(data) != 2: + raise ValueError( + f"expected [value, refine_flag], got a {len(data)}-element list" + ) + return {"value": data[0], "refine_flag": data[1]} + raise ValueError( + f"expected a [value, refine_flag] list, got {type(data).__name__}" + ) + + @field_validator("value") + @classmethod + def _value_finite(cls, v): + return _finite("value", v) + + @model_serializer + def _to_list(self) -> list: + return [self.value, self.refine_flag] + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler) -> dict: + nullable = cls.model_fields["value"].annotation is not float + return _array_schema(cls.__name__, [_NUMBER_OR_NULL if nullable else _NUMBER, _BOOLEAN], + "[value, refine_flag]" + (_NULL_VALUE_NOTE if nullable else "")) + + +class BoundedRefinableParameter(BaseModel): + """A refinable value with optional bounds: JSON ``[value, refine_flag, min, max]``. + + ``min``/``max`` may each be ``null`` (unbounded on that side). When given, + ``min <= max`` and ``min <= value <= max``. Used only where the engine + honors the bounds natively (A20, A33). ``value`` is required; see + :class:`StructureBoundedRefinableParameter`. + """ + + model_config = _STRICT + + value: float + refine_flag: StrictBool + min: Optional[float] = None + max: Optional[float] = None + + @model_validator(mode="before") + @classmethod + def _from_list(cls, data: Any) -> Any: + if isinstance(data, (list, tuple)): + if len(data) != 4: + raise ValueError( + "expected [value, refine_flag, min, max] (min/max may be null), " + f"got a {len(data)}-element list" + ) + return {"value": data[0], "refine_flag": data[1], "min": data[2], "max": data[3]} + raise ValueError( + f"expected a [value, refine_flag, min, max] list, got {type(data).__name__}" + ) + + @field_validator("value", "min", "max") + @classmethod + def _finite(cls, v, info): + return _finite(info.field_name, v) + + @model_validator(mode="after") + def _bounds(self) -> "BoundedRefinableParameter": + lo, hi = self.min, self.max + if lo is not None and hi is not None and lo > hi: + raise ValueError(f"min ({lo}) must be <= max ({hi})") + if self.value is not None: + check_within_bounds(self.value, self, "value") + return self + + @model_serializer + def _to_list(self) -> list: + return [self.value, self.refine_flag, self.min, self.max] + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler) -> dict: + nullable = cls.model_fields["value"].annotation is not float + return _array_schema(cls.__name__, + [_NUMBER_OR_NULL if nullable else _NUMBER, _BOOLEAN, + _NUMBER_OR_NULL, _NUMBER_OR_NULL], + "[value, refine_flag, min, max]; min/max may be null" + + (_NULL_VALUE_NOTE if nullable else "")) + + +_NULL_VALUE_NOTE = "; value null = start from the phase structure's value" + + +class StructureRefinableParameter(RefinableParameter): + """:class:`RefinableParameter` for a quantity that mirrors a phase-structure value. + + For cell lengths/angles and atom x/y/z, occupancy, Uiso/Uaniso only (A71): + ``value`` may be ``null``, meaning "start from the structure's value" + (today's behavior: the GSAS-II setters skip a null value). + """ + + value: Optional[float] + + +class StructureBoundedRefinableParameter(BoundedRefinableParameter): + """:class:`BoundedRefinableParameter` for a quantity that mirrors a phase-structure value. + + ``value`` may be ``null`` (start from the structure's value, A71); the + bounds are then checked against that value by the phase-level validator + (:func:`check_within_bounds`). + """ + + value: Optional[float] + + +def check_within_bounds(value: float, param: BoundedRefinableParameter, field: str) -> None: + """Raise ``ValueError`` unless ``param.min <= value <= param.max`` (open ends pass). + + Also used at the phase level for a null-valued + :class:`StructureBoundedRefinableParameter`, with the structure's value. + """ + if param.min is not None and value < param.min: + raise ValueError(f"{field} ({value}) is below min ({param.min})") + if param.max is not None and value > param.max: + raise ValueError(f"{field} ({value}) is above max ({param.max})") + + +# --- plain-value checks (A32) ---------------------------------------------- + + +def check_element_symbol(symbol: str) -> str: + """Return ``symbol`` if it is a bare element symbol in gemmi's table, exactly spelled. + + Raises ``ValueError`` otherwise. gemmi's own lookup is too lenient to use + alone: it accepts any case (``"FE"``), reduces charged types to the neutral + atom (``"Fe3+"`` -> Fe, which would change the X-ray scattering), and maps + unknown symbols to ``X``. Charged scattering types are not supported in core + schema 1.0.0 yet (A63 obligation); they are rejected, never stripped. + """ + element = gemmi.Element(symbol) + if element.atomic_number > 0 and element.name == symbol: + return symbol + if any(c in symbol for c in "+-") or (symbol[-1:].isdigit()): + raise ValueError( + f"element {symbol!r}: charged scattering types are not supported in core schema " + f"{CORE_SCHEMA_VERSION} yet; give the bare element symbol" + ) + if element.atomic_number > 0: + raise ValueError(f"element {symbol!r}: write the element symbol as {element.name!r}") + raise ValueError(f"element {symbol!r} is not a known element symbol") + + +# --- diffraction data ------------------------------------------------------- + + +class XRDData(BaseModel): + """Measured pattern: 2theta, intensity, weights (ported from schema 0.26 ``XRDDataModel``). + + Every engine consumes ``tth`` in degrees 2theta and ``Itth_weights`` as + 1/sigma^2, as-is. + """ + + model_config = _STRICT + + tth: list[float] = Field(description="Two-theta values", json_schema_extra=_unit(UNIT_DEG_2THETA)) + Itth: list[float] = Field(description="Intensity values", json_schema_extra=_unit(UNIT_ARBITRARY)) + Itth_weights: list[float] = Field(description="Intensity weights (1/sigma^2)", + json_schema_extra=_unit(UNIT_INV_INTENSITY2)) + filename: str | None = Field(default=None, description="Original filename for reference") + + @model_validator(mode='after') + def validate_all_arrays_same_length(self): + """Ensure tth, Itth, and Itth_weights have the same length.""" + lengths = [len(self.tth), len(self.Itth), len(self.Itth_weights)] + if len(set(lengths)) > 1: + raise ValueError( + f"XRD data arrays must have the same length. " + f"Got tth={len(self.tth)}, Itth={len(self.Itth)}, Itth_weights={len(self.Itth_weights)}" + ) + return self + + @model_validator(mode='after') + def validate_arrays_wellformed(self): + """Reject ill-formed XRD data. PowderLine never repairs data — it rejects it. + + Transforming raw data (unit conversion, weight derivation, handling + background-subtracted negatives) is the job of the reader/parser that builds + this block, not PowderLine. Here we only enforce that what arrives is usable: + + * arrays must be non-empty; + * every tth / Itth / Itth_weights value must be finite (no NaN or inf) — + this catches, e.g., a weight computed as 1/esd**2 with esd == 0 (inf); + * tth must be strictly increasing (a powder pattern is monotonic in 2-theta); + * weights must be >= 0 (a 0 weight legitimately excludes a point; a negative + weight is nonsensical) and at least one weight must be > 0 (all-zero = + nothing to fit). + + A negative *intensity* is explicitly allowed: background-subtracted data + legitimately dips below zero, so Itth is checked for finiteness only, not sign. + """ + if len(self.tth) == 0: + raise ValueError( + "XRD data arrays must be non-empty (tth/Itth/Itth_weights have length 0)." + ) + + tth = np.asarray(self.tth, dtype=float) + Itth = np.asarray(self.Itth, dtype=float) + weights = np.asarray(self.Itth_weights, dtype=float) + + # All values finite (rejects NaN and +/-inf across all three arrays). + for name, arr in (("tth", tth), ("Itth", Itth), ("Itth_weights", weights)): + bad = np.where(~np.isfinite(arr))[0] + if bad.size: + i = int(bad[0]) + raise ValueError( + f"XRD data '{name}' contains non-finite values (NaN/inf); first at " + f"index {i} (value {float(arr[i])!r}). Fix the data upstream — " + "PowderLine does not repair xrd_data." + ) + + # tth must be strictly increasing. + bad = np.where(np.diff(tth) <= 0)[0] + if bad.size: + i = int(bad[0]) + raise ValueError( + f"XRD 'tth' must be strictly increasing; tth[{i + 1}]={float(tth[i + 1])!r} " + f"<= tth[{i}]={float(tth[i])!r}. Sort/deduplicate the pattern upstream." + ) + + # Weights: non-negative, with at least one strictly positive. + bad = np.where(weights < 0)[0] + if bad.size: + i = int(bad[0]) + raise ValueError( + f"XRD 'Itth_weights' must be >= 0; first negative at index {i} " + f"(value {float(weights[i])!r}). A 0 weight excludes a point; " + "negative weights are invalid." + ) + if not np.any(weights > 0): + raise ValueError( + "XRD 'Itth_weights' are all zero — nothing to fit. At least one " + "weight must be > 0." + ) + + return self + + +class FitRange(BaseModel): + """Fit window in 2theta: JSON ``[min, max]``; either end may be ``null`` (open).""" + + model_config = _STRICT + + min: Optional[float] = None + max: Optional[float] = None + + @model_validator(mode="before") + @classmethod + def _from_list(cls, data: Any) -> Any: + if isinstance(data, (list, tuple)): + if len(data) != 2: + raise ValueError( + f"fit_range must be a list of exactly 2 elements [min, max], got {len(data)}" + ) + return {"min": data[0], "max": data[1]} + raise ValueError(f"fit_range must be a [min, max] list, got {type(data).__name__}") + + @field_validator("min", "max") + @classmethod + def _finite(cls, v, info): + return _finite(info.field_name, v) + + @model_validator(mode="after") + def _ordered(self) -> "FitRange": + if self.min is not None and self.max is not None and not self.max > self.min: + raise ValueError(f"fit_range max ({self.max}) must be greater than min ({self.min})") + return self + + @model_serializer + def _to_list(self) -> list: + return [self.min, self.max] + + @classmethod + def __get_pydantic_json_schema__(cls, core_schema, handler) -> dict: + return _array_schema("FitRange", [_NUMBER_OR_NULL, _NUMBER_OR_NULL], + "[min, max]; either end may be null (open)", + unit=UNIT_DEG_2THETA) + + +def check_fit_range_within_data(fit_range: Optional[FitRange], xrd_data: XRDData) -> None: + """Raise ``ValueError`` unless both ``fit_range`` ends lie within the data's 2theta span. + + A payload-level check (it needs the pattern): engine payload models call it + from a model validator (A68). ``None`` (or an open end) means "the data limit". + """ + if fit_range is None: + return + lo, hi = xrd_data.tth[0], xrd_data.tth[-1] + for name, end in (("min", fit_range.min), ("max", fit_range.max)): + if end is not None and not lo <= end <= hi: + raise ValueError( + f"fit_range {name} ({end}) is outside the data's 2theta range [{lo}, {hi}]" + ) + + +# --- background ------------------------------------------------------------- + + +class ChebyshevBackground(BaseModel): + """Chebyshev polynomial background (ported from schema 0.26). + + Coefficient semantics (scaling, which polynomial basis) are engine-specific + and documented in each engine schema (A22.7), not here. + """ + + model_config = _STRICT + + num_coefficients: int = Field(gt=0, description="Number of Chebyshev coefficients") + coefficients: list[float] = Field(description="Coefficient values") + refine_flag: StrictBool = Field(description="Whether to refine background") + + @field_validator('coefficients') + @classmethod + def validate_coefficients_length(cls, v, info): + """Ensure number of coefficients matches num_coefficients.""" + num_coef = info.data.get('num_coefficients') + if num_coef is not None and len(v) != num_coef: + raise ValueError(f"Number of coefficients ({len(v)}) must match num_coefficients ({num_coef})") + return v + + +# --- top-level recipe frame (A1, A18, A21, A40) ------------------------------ + + +def _check_metadata(v: Optional[dict]) -> Optional[dict]: + if v is None: + return v + try: + text = json.dumps(v, allow_nan=False, ensure_ascii=False) + except (TypeError, ValueError) as exc: + raise ValueError(f"metadata must be JSON-serializable: {exc}") from None + size = len(text.encode("utf-8")) + if size > METADATA_MAX_BYTES: + raise ValueError( + f"metadata is {size} bytes as JSON; the limit is {METADATA_MAX_BYTES} " + "(schema_core.METADATA_MAX_BYTES)" + ) + return v + + +class CoreRecipe(BaseModel): + """The core-owned top level of every recipe; engine schemas subclass it. + + An engine schema narrows ``schema_name`` to its own names, types ``payload``, + and checks ``engine_schema_version`` against its declarations + (:mod:`powderline.compat`). ``metadata`` is never interpreted: it is checked + for JSON-serializability and size, and echoed into results (A21, A64). + """ + + model_config = _STRICT + + schema_name: str = Field(pattern=SCHEMA_NAME_PATTERN, + description='Engine and workflow, e.g. "gsasii.rietveld"') + core_schema_version: str = Field(description="Core schema version the recipe is written against") + engine_schema_version: str = Field(description="Engine schema version the recipe is written against") + metadata: Optional[dict[str, Any]] = Field( + default=None, description="Free-form, JSON-serializable; echoed into results") + payload: Any = Field(description="Engine-specific; typed by the engine schema") + + @field_validator("core_schema_version") + @classmethod + def _core_version(cls, v: str) -> str: + check_core_schema_version(v) + return v + + @field_validator("engine_schema_version") + @classmethod + def _engine_version(cls, v: str) -> str: + parse_version("engine_schema_version", v) + return v + + @field_validator("metadata") + @classmethod + def _metadata(cls, v): + return _check_metadata(v) diff --git a/tests/test_schema_core.py b/tests/test_schema_core.py new file mode 100644 index 0000000..dfec521 --- /dev/null +++ b/tests/test_schema_core.py @@ -0,0 +1,903 @@ +"""Direct tests for powderline.schema_core validators. + +Pure pydantic, engine-free. Tests the core schema 1.0.0 models that every +engine schema shares (re/03). +""" +import json + +import pytest +from pydantic import ValidationError + +from powderline.schema_core import ( + CORE_SCHEMA_VERSION, + METADATA_MAX_BYTES, + SUPPORTED_CORE_SCHEMAS, + UNIT_DEG_2THETA, + ChebyshevBackground, + CoreRecipe, + FitRange, + XRDData, + check_core_schema_version, + check_fit_range_within_data, +) + + +# --- XRDData ---------------------------------------------------------------- + + +def test_xrd_arrays_same_length_mismatch_raises(): + with pytest.raises(ValidationError, match="same length"): + XRDData(tth=[1.0, 2.0, 3.0], Itth=[10.0, 20.0], Itth_weights=[1.0, 1.0]) + + +def test_xrd_arrays_same_length_ok(): + m = XRDData(tth=[1.0, 2.0], Itth=[10.0, 20.0], Itth_weights=[1.0, 1.0]) + assert len(m.tth) == 2 + + +def test_xrd_empty_arrays_raise(): + with pytest.raises(ValidationError, match="non-empty"): + XRDData(tth=[], Itth=[], Itth_weights=[]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_xrd_non_finite_tth_raises(bad): + with pytest.raises(ValidationError, match="non-finite"): + XRDData(tth=[1.0, bad, 3.0], Itth=[10.0, 20.0, 30.0], Itth_weights=[1.0, 1.0, 1.0]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_xrd_non_finite_intensity_raises(bad): + with pytest.raises(ValidationError, match="non-finite"): + XRDData(tth=[1.0, 2.0], Itth=[10.0, bad], Itth_weights=[1.0, 1.0]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_xrd_non_finite_weight_raises(bad): + with pytest.raises(ValidationError, match="non-finite"): + XRDData(tth=[1.0, 2.0], Itth=[10.0, 20.0], Itth_weights=[1.0, bad]) + + +@pytest.mark.parametrize( + "tth", + [ + [1.0, 1.0, 2.0], # equal neighbors -> not strictly increasing + [1.0, 3.0, 2.0], # out of order + [3.0, 2.0, 1.0], # descending + ], +) +def test_xrd_non_monotonic_tth_raises(tth): + with pytest.raises(ValidationError, match="strictly increasing"): + XRDData(tth=tth, Itth=[10.0, 20.0, 30.0], Itth_weights=[1.0, 1.0, 1.0]) + + +def test_xrd_negative_weight_raises(): + with pytest.raises(ValidationError, match=">= 0"): + XRDData(tth=[1.0, 2.0], Itth=[10.0, 20.0], Itth_weights=[1.0, -0.5]) + + +def test_xrd_all_zero_weights_raise(): + with pytest.raises(ValidationError, match="all zero"): + XRDData(tth=[1.0, 2.0], Itth=[10.0, 20.0], Itth_weights=[0.0, 0.0]) + + +def test_xrd_negative_intensity_ok(): + # Background-subtracted data legitimately dips below zero. + m = XRDData(tth=[1.0, 2.0, 3.0], Itth=[-5.0, 20.0, -1.0], Itth_weights=[1.0, 1.0, 1.0]) + assert m.Itth[0] == -5.0 + + +def test_xrd_extra_key_rejected(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + XRDData(tth=[1.0, 2.0], Itth=[10.0, 20.0], Itth_weights=[1.0, 1.0], unknown_field="foo") + + +def test_xrd_unit_metadata(): + schema = XRDData.model_json_schema() + assert schema["properties"]["tth"]["unit"] == UNIT_DEG_2THETA + + +# --- FitRange --------------------------------------------------------------- + + +def test_fit_range_roundtrip_model_dump(): + fr = FitRange.model_validate([5.0, 40.0]) + assert fr.model_dump() == [5.0, 40.0] + + +def test_fit_range_roundtrip_model_dump_json(): + fr = FitRange.model_validate([5.0, 40.0]) + assert json.loads(fr.model_dump_json()) == [5.0, 40.0] + + +def test_fit_range_null_ends_allowed(): + fr = FitRange.model_validate([None, None]) + assert fr.model_dump() == [None, None] + + +def test_fit_range_one_null_end_ok(): + fr1 = FitRange.model_validate([5.0, None]) + assert fr1.model_dump() == [5.0, None] + fr2 = FitRange.model_validate([None, 40.0]) + assert fr2.model_dump() == [None, 40.0] + + +def test_fit_range_wrong_length_rejected(): + with pytest.raises(ValidationError, match="exactly 2 elements"): + FitRange.model_validate([5.0, 10.0, 15.0]) + + +def test_fit_range_non_list_rejected(): + with pytest.raises(ValidationError, match="must be a \\[min, max\\] list"): + FitRange.model_validate({"min": 5.0, "max": 10.0}) + + +def test_fit_range_max_equals_min_rejected(): + with pytest.raises(ValidationError, match="must be greater than"): + FitRange.model_validate([10.0, 10.0]) + + +def test_fit_range_max_less_than_min_rejected(): + with pytest.raises(ValidationError, match="must be greater than"): + FitRange.model_validate([20.0, 10.0]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_fit_range_non_finite_min_rejected(bad): + with pytest.raises(ValidationError, match="must be finite"): + FitRange.model_validate([bad, 40.0]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_fit_range_non_finite_max_rejected(bad): + with pytest.raises(ValidationError, match="must be finite"): + FitRange.model_validate([5.0, bad]) + + +def test_fit_range_json_schema(): + schema = FitRange.model_json_schema() + assert schema["type"] == "array" + assert schema["prefixItems"] == [{"anyOf": [{"type": "number"}, {"type": "null"}]}, + {"anyOf": [{"type": "number"}, {"type": "null"}]}] + assert len(schema["prefixItems"]) == 2 + assert schema["unit"] == UNIT_DEG_2THETA + + +# --- check_fit_range_within_data -------------------------------------------- + + +def test_check_fit_range_within_data_none_passes(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + check_fit_range_within_data(None, data) # no error + + +def test_check_fit_range_within_data_inside_passes(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + fr = FitRange.model_validate([15.0, 25.0]) + check_fit_range_within_data(fr, data) # no error + + +def test_check_fit_range_within_data_exactly_at_limits_passes(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + fr = FitRange.model_validate([10.0, 30.0]) + check_fit_range_within_data(fr, data) # no error + + +def test_check_fit_range_within_data_below_min_raises(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + fr = FitRange.model_validate([5.0, 25.0]) + with pytest.raises(ValueError, match="min"): + check_fit_range_within_data(fr, data) + + +def test_check_fit_range_within_data_above_max_raises(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + fr = FitRange.model_validate([15.0, 35.0]) + with pytest.raises(ValueError, match="max"): + check_fit_range_within_data(fr, data) + + +def test_check_fit_range_within_data_open_ends_pass(): + data = XRDData(tth=[10.0, 20.0, 30.0], Itth=[100.0, 200.0, 150.0], Itth_weights=[1.0, 1.0, 1.0]) + fr1 = FitRange.model_validate([None, 25.0]) + check_fit_range_within_data(fr1, data) # no error + fr2 = FitRange.model_validate([15.0, None]) + check_fit_range_within_data(fr2, data) # no error + fr3 = FitRange.model_validate([None, None]) + check_fit_range_within_data(fr3, data) # no error + + +# --- ChebyshevBackground ---------------------------------------------------- + + +def test_chebyshev_coefficient_count_mismatch_rejected(): + with pytest.raises(ValidationError, match="must match num_coefficients"): + ChebyshevBackground(num_coefficients=3, coefficients=[1.0, 2.0], refine_flag=False) + + +def test_chebyshev_coefficient_count_match_ok(): + m = ChebyshevBackground(num_coefficients=3, coefficients=[1.0, 2.0, 3.0], refine_flag=False) + assert len(m.coefficients) == 3 + + +def test_chebyshev_num_coefficients_must_be_positive(): + with pytest.raises(ValidationError, match="greater than 0"): + ChebyshevBackground(num_coefficients=0, coefficients=[], refine_flag=False) + + +def test_chebyshev_refine_flag_null_rejected(): + with pytest.raises(ValidationError): + ChebyshevBackground(num_coefficients=1, coefficients=[1.0], refine_flag=None) + + +def test_chebyshev_refine_flag_string_rejected(): + with pytest.raises(ValidationError): + ChebyshevBackground(num_coefficients=1, coefficients=[1.0], refine_flag="true") + + +def test_chebyshev_refine_flag_int_rejected(): + with pytest.raises(ValidationError): + ChebyshevBackground(num_coefficients=1, coefficients=[1.0], refine_flag=1) + + +def test_chebyshev_extra_key_rejected(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + ChebyshevBackground(num_coefficients=1, coefficients=[1.0], refine_flag=False, unknown="bar") + + +# --- CoreRecipe ------------------------------------------------------------- + + +def test_core_recipe_minimal_valid(): + r = CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + payload={} + ) + assert r.schema_name == "gsasii.rietveld" + + +@pytest.mark.parametrize( + "bad_name", + [ + "GSASII_Rietveld", # uppercase + "gsasii", # missing workflow + "Gsasii.rietveld", # uppercase first letter + "gsasii.rietveld.x", # too many dots + "gsasii.Rietveld", # uppercase in workflow + "1gsasii.rietveld", # starts with digit + ], +) +def test_core_recipe_schema_name_pattern_rejected(bad_name): + with pytest.raises(ValidationError, match="String should match pattern"): + CoreRecipe( + schema_name=bad_name, + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + payload={} + ) + + +def test_core_recipe_schema_name_pattern_ok(): + r = CoreRecipe( + schema_name="topas.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + payload={} + ) + assert r.schema_name == "topas.rietveld" + + +def test_core_recipe_core_schema_version_1_1_0_rejected(): + with pytest.raises(ValidationError) as exc_info: + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.1.0", + engine_schema_version="1.0.0", + payload={} + ) + err_msg = str(exc_info.value) + assert SUPPORTED_CORE_SCHEMAS in err_msg + assert "PowderLine" in err_msg + + +def test_core_recipe_core_schema_version_2_0_0_rejected(): + with pytest.raises(ValidationError) as exc_info: + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="2.0.0", + engine_schema_version="1.0.0", + payload={} + ) + err_msg = str(exc_info.value) + assert SUPPORTED_CORE_SCHEMAS in err_msg + assert "PowderLine" in err_msg + + +def test_core_recipe_core_schema_version_invalid_string_rejected(): + with pytest.raises(ValidationError, match="not a valid version"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="abc", + engine_schema_version="1.0.0", + payload={} + ) + + +def test_core_recipe_engine_schema_version_invalid_string_rejected(): + with pytest.raises(ValidationError, match="not a valid version"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="abc", + payload={} + ) + + +def test_core_recipe_metadata_none_ok(): + r = CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata=None, + payload={} + ) + assert r.metadata is None + + +def test_core_recipe_metadata_nested_json_ok(): + r = CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata={"user": "test", "nested": {"key": "value"}}, + payload={} + ) + assert r.metadata["nested"]["key"] == "value" + + +def test_core_recipe_metadata_non_serializable_set_rejected(): + with pytest.raises(ValidationError, match="JSON-serializable"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata={"bad": {1, 2, 3}}, + payload={} + ) + + +def test_core_recipe_metadata_non_serializable_object_rejected(): + with pytest.raises(ValidationError, match="JSON-serializable"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata={"bad": object()}, + payload={} + ) + + +def test_core_recipe_metadata_nan_rejected(): + with pytest.raises(ValidationError, match="JSON-serializable"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata={"bad": float("nan")}, + payload={} + ) + + +def test_core_recipe_metadata_over_size_rejected(): + # Build a string just over METADATA_MAX_BYTES. + # JSON encoding of {"data": "x" * N} is roughly {"data":"xxx..."}. + # Start with a string that's definitely over the limit. + big_string = "x" * (METADATA_MAX_BYTES + 100) + metadata = {"data": big_string} + # Verify it's over the limit + size = len(json.dumps(metadata, allow_nan=False, ensure_ascii=False).encode("utf-8")) + assert size > METADATA_MAX_BYTES + + with pytest.raises(ValidationError) as exc_info: + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata=metadata, + payload={} + ) + err_msg = str(exc_info.value) + assert "METADATA_MAX_BYTES" in err_msg or str(METADATA_MAX_BYTES) in err_msg + + +def test_core_recipe_metadata_exactly_at_limit_ok(): + # Build metadata that's exactly at the limit. + # Start with a reasonable guess and adjust. + base_overhead = len(json.dumps({"data": ""}, allow_nan=False, ensure_ascii=False).encode("utf-8")) + target_string_size = METADATA_MAX_BYTES - base_overhead + big_string = "x" * target_string_size + metadata = {"data": big_string} + + # Fine-tune to get exactly at the limit + while True: + size = len(json.dumps(metadata, allow_nan=False, ensure_ascii=False).encode("utf-8")) + if size == METADATA_MAX_BYTES: + break + elif size > METADATA_MAX_BYTES: + big_string = big_string[:-1] + metadata = {"data": big_string} + else: + big_string += "x" + metadata = {"data": big_string} + + r = CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + metadata=metadata, + payload={} + ) + assert r.metadata == metadata + + +def test_core_recipe_unknown_key_rejected(): + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + CoreRecipe( + schema_name="gsasii.rietveld", + core_schema_version="1.0.0", + engine_schema_version="1.0.0", + payload={}, + unknown_field="bad" + ) + + +def test_core_recipe_multiple_errors_reported(): + # Create a recipe with multiple independent errors + with pytest.raises(ValidationError) as exc_info: + CoreRecipe( + schema_name="INVALID_NAME", # error 1: pattern mismatch + core_schema_version="abc", # error 2: invalid version + engine_schema_version="xyz", # error 3: invalid version + payload={}, + unknown_key="bad" # error 4: extra field + ) + # pydantic reports every independent problem in one ValidationError. + locs = {e["loc"] for e in exc_info.value.errors()} + assert locs == {("schema_name",), ("core_schema_version",), + ("engine_schema_version",), ("unknown_key",)} + + +# --- check_core_schema_version ---------------------------------------------- + + +def test_check_core_schema_version_accepts_current(): + check_core_schema_version(CORE_SCHEMA_VERSION) # no error + + +def test_check_core_schema_version_rejects_unsupported(): + with pytest.raises(ValueError, match="not supported"): + check_core_schema_version("2.0.0") + + +# --- parameter models (RefinableParameter, BoundedRefinableParameter, etc.) ---- + + +from powderline.schema_core import ( + BoundedRefinableParameter, + RefinableParameter, + StructureBoundedRefinableParameter, + StructureRefinableParameter, + check_within_bounds, +) +from pydantic import BaseModel + + +# Wrapper models for testing error locs +class _WrapperRP(BaseModel): + """Wrapper for RefinableParameter testing.""" + a: RefinableParameter + + +class _WrapperBRP(BaseModel): + """Wrapper for BoundedRefinableParameter testing.""" + a: BoundedRefinableParameter + + +class _WrapperSRP(BaseModel): + """Wrapper for StructureRefinableParameter testing.""" + a: StructureRefinableParameter + + +class _WrapperSBRP(BaseModel): + """Wrapper for StructureBoundedRefinableParameter testing.""" + a: StructureBoundedRefinableParameter + + +# --- 1. JSON shape round-trip ----------------------------------------------- + + +def test_refinable_parameter_model_dump(): + rp = RefinableParameter.model_validate([1.5, True]) + assert rp.model_dump() == [1.5, True] + + +def test_refinable_parameter_model_dump_json(): + rp = RefinableParameter.model_validate([1.5, True]) + assert json.loads(rp.model_dump_json()) == [1.5, True] + + +def test_bounded_refinable_parameter_model_dump(): + brp = BoundedRefinableParameter.model_validate([1.5, True, 0.0, 2.0]) + assert brp.model_dump() == [1.5, True, 0.0, 2.0] + + +def test_bounded_refinable_parameter_model_dump_json(): + brp = BoundedRefinableParameter.model_validate([1.5, True, 0.0, 2.0]) + assert json.loads(brp.model_dump_json()) == [1.5, True, 0.0, 2.0] + + +def test_structure_refinable_parameter_model_dump(): + srp = StructureRefinableParameter.model_validate([None, True]) + assert srp.model_dump() == [None, True] + + +def test_structure_refinable_parameter_model_dump_with_value(): + srp = StructureRefinableParameter.model_validate([1.5, False]) + assert srp.model_dump() == [1.5, False] + + +def test_structure_bounded_refinable_parameter_model_dump(): + sbrp = StructureBoundedRefinableParameter.model_validate([None, True, 0.0, 2.0]) + assert sbrp.model_dump() == [None, True, 0.0, 2.0] + + +def test_structure_bounded_refinable_parameter_model_dump_with_value(): + sbrp = StructureBoundedRefinableParameter.model_validate([1.5, True, 0.0, 2.0]) + assert sbrp.model_dump() == [1.5, True, 0.0, 2.0] + + +# --- 2. RefinableParameter validation --------------------------------------- + + +def test_refinable_parameter_4_element_list_rejected(): + with pytest.raises(ValidationError) as exc_info: + _WrapperRP(a=[1.5, True, 0.0, 2.0]) + err = exc_info.value + assert any("bounds are not supported" in e["msg"] for e in err.errors()) + assert any(e["loc"] == ("a",) for e in err.errors()) + + +def test_refinable_parameter_1_element_list_rejected(): + with pytest.raises(ValidationError, match="expected \\[value, refine_flag\\]"): + _WrapperRP(a=[1.5]) + + +def test_refinable_parameter_3_element_list_rejected(): + with pytest.raises(ValidationError, match="expected \\[value, refine_flag\\]"): + _WrapperRP(a=[1.5, True, 0.0]) + + +def test_refinable_parameter_dict_rejected(): + with pytest.raises(ValidationError) as exc_info: + _WrapperRP(a={"value": 1.5, "refine_flag": True}) + err = exc_info.value + assert any("got dict" in e["msg"] for e in err.errors()) + + +def test_refinable_parameter_scalar_rejected(): + with pytest.raises(ValidationError, match="expected a \\[value, refine_flag\\] list"): + _WrapperRP(a=1.5) + + +def test_refinable_parameter_null_value_rejected(): + with pytest.raises(ValidationError): + _WrapperRP(a=[None, True]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_refinable_parameter_non_finite_value_rejected(bad): + with pytest.raises(ValidationError, match="must be finite"): + _WrapperRP(a=[bad, True]) + + +def test_refinable_parameter_refine_flag_null_rejected(): + with pytest.raises(ValidationError): + _WrapperRP(a=[1.5, None]) + + +def test_refinable_parameter_refine_flag_string_rejected(): + with pytest.raises(ValidationError): + _WrapperRP(a=[1.5, "true"]) + + +def test_refinable_parameter_refine_flag_int_1_rejected(): + with pytest.raises(ValidationError): + _WrapperRP(a=[1.5, 1]) + + +def test_refinable_parameter_refine_flag_int_0_rejected(): + with pytest.raises(ValidationError): + _WrapperRP(a=[1.5, 0]) + + +def test_refinable_parameter_int_value_accepted_and_dumped_as_float(): + w = _WrapperRP(a=[2, True]) + assert w.model_dump()["a"] == [2.0, True] + + +# --- 3. BoundedRefinableParameter validation -------------------------------- + + +def test_bounded_refinable_parameter_2_element_list_rejected(): + with pytest.raises(ValidationError, match="expected \\[value, refine_flag, min, max\\]"): + _WrapperBRP(a=[1.5, True]) + + +def test_bounded_refinable_parameter_null_min_accepted(): + brp = BoundedRefinableParameter.model_validate([1.5, True, None, 2.0]) + assert brp.min is None + assert brp.max == 2.0 + + +def test_bounded_refinable_parameter_null_max_accepted(): + brp = BoundedRefinableParameter.model_validate([1.5, True, 0.0, None]) + assert brp.min == 0.0 + assert brp.max is None + + +def test_bounded_refinable_parameter_null_both_bounds_accepted(): + brp = BoundedRefinableParameter.model_validate([1.5, True, None, None]) + assert brp.min is None + assert brp.max is None + + +def test_bounded_refinable_parameter_min_greater_than_max_rejected(): + with pytest.raises(ValidationError, match="min .* must be <= max"): + _WrapperBRP(a=[1.5, True, 2.0, 1.0]) + + +def test_bounded_refinable_parameter_value_below_min_rejected(): + with pytest.raises(ValidationError, match="below min"): + _WrapperBRP(a=[0.5, True, 1.0, 2.0]) + + +def test_bounded_refinable_parameter_value_above_max_rejected(): + with pytest.raises(ValidationError, match="above max"): + _WrapperBRP(a=[2.5, True, 1.0, 2.0]) + + +def test_bounded_refinable_parameter_value_equals_min_accepted(): + brp = BoundedRefinableParameter.model_validate([1.0, True, 1.0, 2.0]) + assert brp.value == 1.0 + + +def test_bounded_refinable_parameter_value_equals_max_accepted(): + brp = BoundedRefinableParameter.model_validate([2.0, True, 1.0, 2.0]) + assert brp.value == 2.0 + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_bounded_refinable_parameter_non_finite_min_rejected(bad): + with pytest.raises(ValidationError, match="must be finite"): + _WrapperBRP(a=[1.5, True, bad, 2.0]) + + +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), float("-inf")]) +def test_bounded_refinable_parameter_non_finite_max_rejected(bad): + with pytest.raises(ValidationError, match="must be finite"): + _WrapperBRP(a=[1.5, True, 0.0, bad]) + + +def test_bounded_refinable_parameter_null_value_rejected(): + with pytest.raises(ValidationError): + _WrapperBRP(a=[None, True, 0.0, 2.0]) + + +# --- 4. StructureRefinableParameter / StructureBoundedRefinableParameter ---- + + +def test_structure_refinable_parameter_null_value_accepted_refine_true(): + srp = StructureRefinableParameter.model_validate([None, True]) + assert srp.value is None + assert srp.refine_flag is True + + +def test_structure_refinable_parameter_null_value_accepted_refine_false(): + srp = StructureRefinableParameter.model_validate([None, False]) + assert srp.value is None + assert srp.refine_flag is False + + +def test_structure_refinable_parameter_4_element_list_rejected(): + with pytest.raises(ValidationError) as exc_info: + _WrapperSRP(a=[1.5, True, 0.0, 2.0]) + err = exc_info.value + assert any("bounds are not supported" in e["msg"] for e in err.errors()) + + +def test_structure_refinable_parameter_refine_flag_null_rejected(): + with pytest.raises(ValidationError): + _WrapperSRP(a=[None, None]) + + +def test_structure_bounded_refinable_parameter_null_value_accepted(): + sbrp = StructureBoundedRefinableParameter.model_validate([None, True, 0.0, 2.0]) + assert sbrp.value is None + assert sbrp.refine_flag is True + + +def test_structure_bounded_refinable_parameter_null_value_min_max_order_enforced(): + with pytest.raises(ValidationError, match="min .* must be <= max"): + _WrapperSBRP(a=[None, True, 2.0, 1.0]) + + +def test_structure_bounded_refinable_parameter_non_null_value_enforces_bounds_below(): + with pytest.raises(ValidationError, match="below min"): + _WrapperSBRP(a=[0.5, True, 1.0, 2.0]) + + +def test_structure_bounded_refinable_parameter_non_null_value_enforces_bounds_above(): + with pytest.raises(ValidationError, match="above max"): + _WrapperSBRP(a=[2.5, True, 1.0, 2.0]) + + +def test_structure_bounded_refinable_parameter_refine_flag_null_rejected(): + with pytest.raises(ValidationError): + _WrapperSBRP(a=[None, None, 0.0, 2.0]) + + +# --- 5. check_within_bounds ------------------------------------------------- + + +def test_check_within_bounds_inside_passes(): + param = BoundedRefinableParameter.model_validate([1.5, True, 1.0, 2.0]) + check_within_bounds(1.5, param, "test_field") # no error + + +def test_check_within_bounds_on_min_passes(): + param = BoundedRefinableParameter.model_validate([1.0, True, 1.0, 2.0]) + check_within_bounds(1.0, param, "test_field") # no error + + +def test_check_within_bounds_on_max_passes(): + param = BoundedRefinableParameter.model_validate([2.0, True, 1.0, 2.0]) + check_within_bounds(2.0, param, "test_field") # no error + + +def test_check_within_bounds_open_min_passes(): + param = BoundedRefinableParameter.model_validate([0.5, True, None, 2.0]) + check_within_bounds(0.5, param, "test_field") # no error + + +def test_check_within_bounds_open_max_passes(): + param = BoundedRefinableParameter.model_validate([2.5, True, 1.0, None]) + check_within_bounds(2.5, param, "test_field") # no error + + +def test_check_within_bounds_open_both_passes(): + param = BoundedRefinableParameter.model_validate([100.0, True, None, None]) + check_within_bounds(100.0, param, "test_field") # no error + + +def test_check_within_bounds_below_min_raises(): + param = BoundedRefinableParameter.model_validate([1.5, True, 1.0, 2.0]) + with pytest.raises(ValueError, match="test_field.*below min"): + check_within_bounds(0.5, param, "test_field") + + +def test_check_within_bounds_above_max_raises(): + param = BoundedRefinableParameter.model_validate([1.5, True, 1.0, 2.0]) + with pytest.raises(ValueError, match="test_field.*above max"): + check_within_bounds(2.5, param, "test_field") + + +# --- 6. JSON schema --------------------------------------------------------- + + +def test_refinable_parameter_json_schema(): + schema = _WrapperRP.model_json_schema() + param_schema = schema["$defs"]["RefinableParameter"] + assert param_schema["type"] == "array" + assert len(param_schema["prefixItems"]) == 2 + assert param_schema["minItems"] == 2 + assert param_schema["maxItems"] == 2 + assert param_schema["prefixItems"][0] == {"type": "number"} + assert param_schema["prefixItems"][1] == {"type": "boolean"} + + +def test_bounded_refinable_parameter_json_schema(): + schema = _WrapperBRP.model_json_schema() + param_schema = schema["$defs"]["BoundedRefinableParameter"] + assert param_schema["type"] == "array" + assert len(param_schema["prefixItems"]) == 4 + assert param_schema["minItems"] == 4 + assert param_schema["maxItems"] == 4 + assert param_schema["prefixItems"][0] == {"type": "number"} + assert param_schema["prefixItems"][1] == {"type": "boolean"} + assert param_schema["prefixItems"][2] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + assert param_schema["prefixItems"][3] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + + +def test_structure_refinable_parameter_json_schema(): + schema = _WrapperSRP.model_json_schema() + param_schema = schema["$defs"]["StructureRefinableParameter"] + assert param_schema["type"] == "array" + assert len(param_schema["prefixItems"]) == 2 + assert param_schema["minItems"] == 2 + assert param_schema["maxItems"] == 2 + assert param_schema["prefixItems"][0] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + assert param_schema["prefixItems"][1] == {"type": "boolean"} + assert "structure" in param_schema["description"].lower() + + +def test_structure_bounded_refinable_parameter_json_schema(): + schema = _WrapperSBRP.model_json_schema() + param_schema = schema["$defs"]["StructureBoundedRefinableParameter"] + assert param_schema["type"] == "array" + assert len(param_schema["prefixItems"]) == 4 + assert param_schema["minItems"] == 4 + assert param_schema["maxItems"] == 4 + assert param_schema["prefixItems"][0] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + assert param_schema["prefixItems"][1] == {"type": "boolean"} + assert param_schema["prefixItems"][2] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + assert param_schema["prefixItems"][3] == {"anyOf": [{"type": "number"}, {"type": "null"}]} + assert "structure" in param_schema["description"].lower() + + +# --- 7. Identity preservation ----------------------------------------------- + + +def test_refinable_parameter_instance_identity_preserved(): + p = RefinableParameter.model_validate([1.5, True]) + w = _WrapperRP(a=p) + assert w.a is p + + +def test_bounded_refinable_parameter_instance_identity_preserved(): + p = BoundedRefinableParameter.model_validate([1.5, True, 0.0, 2.0]) + w = _WrapperBRP(a=p) + assert w.a is p + + +def test_structure_refinable_parameter_instance_identity_preserved(): + p = StructureRefinableParameter.model_validate([1.5, True]) + w = _WrapperSRP(a=p) + assert w.a is p + + +def test_structure_bounded_refinable_parameter_instance_identity_preserved(): + p = StructureBoundedRefinableParameter.model_validate([1.5, True, 0.0, 2.0]) + w = _WrapperSBRP(a=p) + assert w.a is p + + +# --- check_element_symbol (A32, A63) ---------------------------------------- + +from powderline.schema_core import check_element_symbol # noqa: E402 + + +@pytest.mark.parametrize("symbol", ["Fe", "O", "La", "B", "D", "Og"]) +def test_element_symbol_accepted(symbol): + assert check_element_symbol(symbol) == symbol + + +@pytest.mark.parametrize("symbol, canonical", [("fe", "Fe"), ("FE", "Fe"), ("la", "La"), ("Fe ", "Fe")]) +def test_element_symbol_misspelled_rejected_with_spelling(symbol, canonical): + with pytest.raises(ValueError, match=f"write the element symbol as '{canonical}'"): + check_element_symbol(symbol) + + +@pytest.mark.parametrize("symbol", ["Fe3+", "Fe+3", "O2-", "O-2", "Fe3"]) +def test_charged_types_rejected_not_stripped(symbol): + # A63: gemmi reduces e.g. "Fe3+" to Fe, which would change the scattering; + # core rejects charged types until they are supported. + with pytest.raises(ValueError, match="charged scattering types are not supported"): + check_element_symbol(symbol) + + +@pytest.mark.parametrize("symbol", ["X", "Xx", "", " Fe"]) +def test_unknown_element_rejected(symbol): + with pytest.raises(ValueError, match="not a known element symbol"): + check_element_symbol(symbol) From 3e8ba5cb29b903d6e81320c01a3f96b97cdbd507 Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:50:14 -0400 Subject: [PATCH 03/13] feat(core): compat: declared schema-version support + powderline.support_matrix() (A40, A65) Gateways declare engine_schema_version / supported_engine_schemas / requires_core_schema (a specifier, or a mapping per engine-schema range) as capabilities() keys. check_engine_schema_version raises ValueError so that engine schema models can call it from a validator. support_matrix() walks the registry (engine-free); the in-repo gateways are undeclared until re/04-06. powderline.support_matrix is re-exported, outside __all__. The import-block test now imports schema_core and compat and calls support_matrix() with GSAS-II and easydiffraction blocked. Co-Authored-By: Claude Opus 5.5 --- src/powderline/__init__.py | 3 + src/powderline/compat.py | 139 +++++++++++++++++++++ tests/test_compat.py | 244 +++++++++++++++++++++++++++++++++++++ tests/test_gateways.py | 5 + 4 files changed, 391 insertions(+) create mode 100644 src/powderline/compat.py create mode 100644 tests/test_compat.py diff --git a/src/powderline/__init__.py b/src/powderline/__init__.py index efd69dd..77713fd 100644 --- a/src/powderline/__init__.py +++ b/src/powderline/__init__.py @@ -14,6 +14,9 @@ # requiring GSAS-II. The remaining GSAS-II-backed helpers are imported eagerly # when GSAS-II is available and skipped otherwise (the TOPAS path stays usable). from powderline.engine import run +# Declared schema-version support per gateway (A65); outside __all__ until the +# public-API rework. +from powderline.compat import support_matrix try: # GSAS-II-backed helpers + client (require the GSAS-II package) from powderline.kicker import ( diff --git a/src/powderline/compat.py b/src/powderline/compat.py new file mode 100644 index 0000000..7f2c0c5 --- /dev/null +++ b/src/powderline/compat.py @@ -0,0 +1,139 @@ +"""Schema-version compatibility: declared support sets, never version arithmetic (A40, A65). + +Declarations: + +- core (:mod:`powderline.schema_core`): ``CORE_SCHEMA_VERSION`` and + ``SUPPORTED_CORE_SCHEMAS`` (a PEP 440 specifier); +- each gateway, as keys of its ``capabilities()`` dict (re/04-06): + + - ``engine_schema_version`` -- the version new recipes are written against; + - ``supported_engine_schemas`` -- specifier of accepted engine schema versions; + - ``requires_core_schema`` -- specifier of the core versions it needs, or a + mapping ``{engine-schema specifier: core specifier}`` when different engine + schema versions need different core versions. + +A gateway that does not declare these yet is reported as undeclared by +:func:`support_matrix` and rejected by :func:`check_engine_schema_version`. +All checks raise ``ValueError`` so engine schema models can call them from a +pydantic validator and have them reported with the other validation errors. + +Engine-free (enforced by an import-block test). +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Mapping, Optional, Union + +from packaging.specifiers import InvalidSpecifier, SpecifierSet + +from powderline.schema_core import ( + CORE_SCHEMA_VERSION, + SUPPORTED_CORE_SCHEMAS, + check_core_schema_version, + parse_version, +) + +DECLARATION_KEYS = ("engine_schema_version", "supported_engine_schemas", "requires_core_schema") + +RequiresCore = Union[str, Mapping[str, str]] + + +def _powderline_version() -> str: + from powderline import __version__ + + return __version__ + + +def _specifier(what: str, spec: str) -> SpecifierSet: + try: + return SpecifierSet(spec) + except InvalidSpecifier: + raise ValueError(f"{what} {spec!r} is not a valid PEP 440 specifier") from None + + +def declarations(capabilities: Mapping[str, Any]) -> Optional[Dict[str, Any]]: + """The schema declarations in a gateway's ``capabilities()``, or ``None`` if absent. + + Raises ``ValueError`` when only some of the keys are declared. + """ + present = [k for k in DECLARATION_KEYS if k in capabilities] + if not present: + return None + if len(present) != len(DECLARATION_KEYS): + missing = sorted(set(DECLARATION_KEYS) - set(present)) + raise ValueError(f"gateway {capabilities.get('name')!r} declares {present} but not {missing}") + return {k: capabilities[k] for k in DECLARATION_KEYS} + + +def required_core_schemas(requires: RequiresCore, engine_schema_version: str) -> str: + """The core specifier that ``engine_schema_version`` requires. + + ``requires`` is a single specifier, or a mapping whose first key (in + declaration order) containing ``engine_schema_version`` applies. + """ + if isinstance(requires, str): + return requires + version = parse_version("engine_schema_version", engine_schema_version) + for engine_spec, core_spec in requires.items(): + if version in _specifier("requires_core_schema key", engine_spec): + return core_spec + raise ValueError( + f"requires_core_schema has no entry for engine schema {engine_schema_version} " + f"(keys: {', '.join(requires)})" + ) + + +def check_engine_schema_version(core_schema_version: str, engine_schema_version: str, *, + gateway: str, capabilities: Mapping[str, Any]) -> None: + """Raise ``ValueError`` unless the recipe's version pair is accepted. + + Checks, in order: the core version against :data:`SUPPORTED_CORE_SCHEMAS`; the + engine version against the gateway's ``supported_engine_schemas``; the core + version against what that engine version ``requires_core_schema``. + """ + check_core_schema_version(core_schema_version) + decl = declarations(capabilities) + if decl is None: + raise ValueError(f"gateway {gateway!r} declares no supported engine schema versions") + pair = (f"core_schema_version {core_schema_version!r}, " + f"engine_schema_version {engine_schema_version!r}") + engine_v = parse_version("engine_schema_version", engine_schema_version) + supported = decl["supported_engine_schemas"] + if engine_v not in _specifier("supported_engine_schemas", supported): + raise ValueError( + f"{pair}: the {gateway!r} gateway of PowderLine {_powderline_version()} accepts " + f"engine schema {supported} (current: {decl['engine_schema_version']})" + ) + needs = required_core_schemas(decl["requires_core_schema"], engine_schema_version) + if parse_version("core_schema_version", core_schema_version) not in _specifier( + "requires_core_schema", needs): + raise ValueError( + f"{pair}: {gateway!r} engine schema {engine_schema_version} requires core " + f"schema {needs} (PowderLine {_powderline_version()})" + ) + + +def support_matrix() -> List[Dict[str, Any]]: + """One row per registered gateway: its declarations plus core's (A40). + + Loads each gateway's engine-free factory through the registry; imports no + engine. Undeclared gateways get ``"declared": False``. Consumed by the docs + generation in re/10. + """ + from powderline import registry + + rows = [] + for name in registry.available(): + caps = registry.get(name).capabilities() + decl = declarations(caps) + rows.append({ + "gateway": name, + "powderline_version": _powderline_version(), + "core_schema_version": CORE_SCHEMA_VERSION, + "supported_core_schemas": SUPPORTED_CORE_SCHEMAS, + "workflows": tuple(caps.get("workflows", ())), + "engine_version_spec": caps.get("engine_version_spec"), + "declared": decl is not None, + **(decl or {k: None for k in DECLARATION_KEYS}), + }) + return rows diff --git a/tests/test_compat.py b/tests/test_compat.py new file mode 100644 index 0000000..de73256 --- /dev/null +++ b/tests/test_compat.py @@ -0,0 +1,244 @@ +"""Direct tests for powderline.compat schema-version compatibility functions. + +Engine-free, no GSAS-II import (re/03). +""" +import pytest + +from powderline import registry +from powderline.compat import ( + check_engine_schema_version, + declarations, + required_core_schemas, + support_matrix, +) +from powderline.schema_core import CORE_SCHEMA_VERSION + + +# --- declarations ----------------------------------------------------------- + + +def test_declarations_none_when_no_keys(): + caps = {"name": "test", "workflows": ["rietveld"]} + assert declarations(caps) is None + + +def test_declarations_dict_when_all_three_present(): + caps = { + "name": "test", + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "==1.0.0", + } + decl = declarations(caps) + assert decl is not None + assert decl["engine_schema_version"] == "1.0.0" + assert decl["supported_engine_schemas"] == "==1.0.0" + assert decl["requires_core_schema"] == "==1.0.0" + + +def test_declarations_raises_when_only_some_present(): + caps = { + "name": "test", + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + # missing requires_core_schema + } + with pytest.raises(ValueError, match="declares.*but not"): + declarations(caps) + + +# --- required_core_schemas -------------------------------------------------- + + +def test_required_core_schemas_plain_string_returned_as_is(): + result = required_core_schemas("==1.0.0", "1.0.0") + assert result == "==1.0.0" + + +def test_required_core_schemas_mapping_picks_first_matching_key(): + requires = { + ">=2.0.0": "==2.0.0", + ">=1.0.0": "==1.0.0", + } + result = required_core_schemas(requires, "1.5.0") + assert result == "==1.0.0" + + +def test_required_core_schemas_mapping_picks_highest_version_match(): + requires = { + ">=2.0.0": "==2.0.0", + ">=1.0.0": "==1.0.0", + } + result = required_core_schemas(requires, "2.5.0") + assert result == "==2.0.0" + + +def test_required_core_schemas_no_matching_key_raises(): + requires = { + ">=2.0.0": "==2.0.0", + } + with pytest.raises(ValueError, match="no entry for engine schema"): + required_core_schemas(requires, "1.0.0") + + +# --- check_engine_schema_version -------------------------------------------- + + +def test_check_engine_schema_version_accepted_pair_passes(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "==1.0.0", + } + # Should not raise + check_engine_schema_version("1.0.0", "1.0.0", gateway="test", capabilities=caps) + + +def test_check_engine_schema_version_engine_version_outside_supported_raises(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "==1.0.0", + } + with pytest.raises(ValueError) as exc_info: + check_engine_schema_version("1.0.0", "2.0.0", gateway="test", capabilities=caps) + err_msg = str(exc_info.value) + assert "test" in err_msg + assert "==1.0.0" in err_msg + + +def test_check_engine_schema_version_core_version_not_in_requires_raises(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "==2.0.0", # requires different core version + } + with pytest.raises(ValueError, match="requires core"): + check_engine_schema_version("1.0.0", "1.0.0", gateway="test", capabilities=caps) + + +def test_check_engine_schema_version_unsupported_core_version_raises(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "==1.0.0", + } + with pytest.raises(ValueError, match="not supported by PowderLine"): + check_engine_schema_version("2.0.0", "1.0.0", gateway="test", capabilities=caps) + + +def test_check_engine_schema_version_gateway_with_no_declarations_raises(): + caps = {"name": "test"} # no declarations + with pytest.raises(ValueError, match="declares no supported engine schema versions"): + check_engine_schema_version("1.0.0", "1.0.0", gateway="test", capabilities=caps) + + +def test_check_engine_schema_version_invalid_specifier_in_supported_engine_schemas_raises(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "not-a-valid-specifier", + "requires_core_schema": "==1.0.0", + } + with pytest.raises(ValueError, match="not a valid PEP 440 specifier"): + check_engine_schema_version("1.0.0", "1.0.0", gateway="test", capabilities=caps) + + +def test_check_engine_schema_version_invalid_specifier_in_requires_core_schema_raises(): + caps = { + "engine_schema_version": "1.0.0", + "supported_engine_schemas": "==1.0.0", + "requires_core_schema": "not-a-valid-specifier", + } + with pytest.raises(ValueError, match="not a valid PEP 440 specifier"): + check_engine_schema_version("1.0.0", "1.0.0", gateway="test", capabilities=caps) + + +# --- support_matrix --------------------------------------------------------- + + +@pytest.fixture +def fake_gateway_declared(): + """Register a fake gateway with declarations, unregister on teardown.""" + name = "test_declared" + + def factory(): + return registry.Gateway( + name=name, + capabilities=lambda: { + "name": name, + "workflows": ["rietveld"], + "engine_schema_version": "1.5.0", + "supported_engine_schemas": ">=1.0.0", + "requires_core_schema": "==1.0.0", + }, + validate=lambda r, *, verbose=False: r, + run=lambda *a, **k: {}, + ) + + registry.register_gateway(name, factory) + yield name + registry.unregister_gateway(name) + + +@pytest.fixture +def fake_gateway_undeclared(): + """Register a fake gateway without declarations, unregister on teardown.""" + name = "test_undeclared" + + def factory(): + return registry.Gateway( + name=name, + capabilities=lambda: { + "name": name, + "workflows": ["spf"], + }, + validate=lambda r, *, verbose=False: r, + run=lambda *a, **k: {}, + ) + + registry.register_gateway(name, factory) + yield name + registry.unregister_gateway(name) + + +def test_support_matrix_declared_gateway_has_values(fake_gateway_declared): + matrix = support_matrix() + rows = [r for r in matrix if r["gateway"] == fake_gateway_declared] + assert len(rows) == 1 + row = rows[0] + assert row["declared"] is True + assert row["engine_schema_version"] == "1.5.0" + assert row["supported_engine_schemas"] == ">=1.0.0" + assert row["requires_core_schema"] == "==1.0.0" + + +def test_support_matrix_undeclared_gateway_has_none_values(fake_gateway_undeclared): + matrix = support_matrix() + rows = [r for r in matrix if r["gateway"] == fake_gateway_undeclared] + assert len(rows) == 1 + row = rows[0] + assert row["declared"] is False + assert row["engine_schema_version"] is None + assert row["supported_engine_schemas"] is None + assert row["requires_core_schema"] is None + + +def test_support_matrix_every_row_has_core_schema_version(): + matrix = support_matrix() + for row in matrix: + assert row["core_schema_version"] == CORE_SCHEMA_VERSION + + +def test_support_matrix_is_powderline_support_matrix(): + # Check that powderline.support_matrix is the same as compat.support_matrix + import powderline + assert powderline.support_matrix is support_matrix + + +def test_support_matrix_has_in_repo_gateways(): + matrix = support_matrix() + gateway_names = {r["gateway"] for r in matrix} + # The three in-repo gateways (undeclared until re/04-06) + assert "gsasii" in gateway_names + assert "topas" in gateway_names + assert "easydiffraction" in gateway_names diff --git a/tests/test_gateways.py b/tests/test_gateways.py index a1e8cc8..1156041 100644 --- a/tests/test_gateways.py +++ b/tests/test_gateways.py @@ -465,6 +465,8 @@ def find_spec(self, name, path, target=None): import powderline.fitstats import powderline.reports import powderline.symmetry +import powderline.schema_core +import powderline.compat import powderline.gateways.gsasii import powderline.gateways.gsasii.gateway import powderline.gateways.gsasii.validation @@ -486,6 +488,9 @@ def find_spec(self, name, path, target=None): assert caps["name"] == name and "engine_version_spec" in caps assert type(gw.validate(recipe)).__name__ == "RecipeModel" +# The support matrix loads every gateway factory (A65) +assert {{row["gateway"] for row in powderline.support_matrix()}} >= set(recipes) + # Assert GSASII and easydiffraction are NOT in sys.modules assert "GSASII" not in sys.modules, "GSASII was imported despite the block" assert "easydiffraction" not in sys.modules, "easydiffraction was imported despite the block" From 0072a6884dbc9cd40eb5f46217d6b6db8c98eefb Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:50:14 -0400 Subject: [PATCH 04/13] refactor(core): drop RecipeValidationError; invalid recipes raise pydantic's ValidationError (A70) All recipe checks live in the schema models, so pydantic's own ValidationError reports every problem with its location; a wrapper only duplicated it (and would have broken except-ValidationError callers, superseding A60). The class was unused outside its unit test. A new test shows a SymmetryError raised in a validator joining the aggregated error. Co-Authored-By: Claude Opus 5.5 --- src/powderline/exceptions.py | 28 ++++------------------------ tests/test_exceptions.py | 31 +++++++++++++++++++------------ 2 files changed, 23 insertions(+), 36 deletions(-) diff --git a/src/powderline/exceptions.py b/src/powderline/exceptions.py index c129b7c..ec7aeb2 100644 --- a/src/powderline/exceptions.py +++ b/src/powderline/exceptions.py @@ -13,11 +13,14 @@ - :class:`EngineVersionError` — the installed engine is outside the gateway's supported ``ENGINE_VERSION_SPEC``. A hard error (A44). - :class:`EngineExecutionError` — the engine ran and failed. -- :class:`RecipeValidationError` — a recipe failed validation. - :class:`SymmetryError` — core symmetry helpers (``powderline.symmetry``) cannot interpret a space group or site. Gateways convert it to their own error type (A61). +An invalid recipe raises pydantic's own ``ValidationError``, which lists every +problem with its field location. Recipe checks live in the schema models +(core + engine), so there is no PowderLine wrapper for it (A70). + This module must never import an engine (enforced by an import-block test). """ @@ -30,29 +33,6 @@ class PowderLineError(Exception): """Base class for all PowderLine errors.""" -class RecipeValidationError(PowderLineError, ValueError): - """A recipe failed validation. - - Wraps (and may annotate) the underlying pydantic errors. ``errors`` holds - pydantic's ``ValidationError.errors()`` list when built from one. - """ - - def __init__(self, message: str, *, errors: Optional[list] = None, - schema_name: Optional[str] = None) -> None: - super().__init__(message) - self.errors = list(errors or []) - self.schema_name = schema_name - - @classmethod - def from_pydantic(cls, exc, *, schema_name: Optional[str] = None) -> "RecipeValidationError": - """Build from a ``pydantic.ValidationError``, keeping its error list.""" - prefix = f"{schema_name} recipe" if schema_name else "recipe" - err = cls(f"{prefix} failed validation:\n{exc}", errors=exc.errors(), - schema_name=schema_name) - err.__cause__ = exc - return err - - class SymmetryError(PowderLineError, ValueError): """A space group or atomic site cannot be interpreted (``powderline.symmetry``). diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 2018344..134a370 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -11,14 +11,13 @@ EngineVersionError, GatewayNotInstalledError, PowderLineError, - RecipeValidationError, StructuredWarning, SymmetryError, ) @pytest.mark.parametrize("cls", [ - RecipeValidationError, GatewayNotInstalledError, EngineNotAvailableError, + GatewayNotInstalledError, EngineNotAvailableError, EngineVersionError, EngineExecutionError, SymmetryError, ]) def test_all_derive_from_powderline_error(cls): @@ -57,19 +56,27 @@ def test_gateway_not_installed_keeps_name(): assert str(err) == "no gateway 'foo'" -class _M(BaseModel): - x: int +def test_symmetry_error_raised_in_a_validator_is_reported_by_pydantic(): + # A61/A70: a SymmetryError raised inside a validator joins pydantic's + # aggregated ValidationError with its field location. + from pydantic import field_validator + from powderline.exceptions import SymmetryError + + class _S(BaseModel): + sg: str + n: int + + @field_validator("sg") + @classmethod + def _sg(cls, v): + raise SymmetryError(f"bad group {v!r}") -def test_recipe_validation_error_from_pydantic(): with pytest.raises(ValidationError) as info: - _M.model_validate({"x": "not-an-int"}) - err = RecipeValidationError.from_pydantic(info.value, schema_name="GSASII_Rietveld") - assert isinstance(err, ValueError) - assert err.schema_name == "GSASII_Rietveld" - assert err.errors and err.errors[0]["loc"] == ("x",) - assert err.__cause__ is info.value - assert str(err).startswith("GSASII_Rietveld recipe failed validation:") + _S.model_validate({"sg": "Q", "n": "x"}) + locs = {e["loc"]: e["msg"] for e in info.value.errors()} + assert set(locs) == {("sg",), ("n",)} + assert "bad group 'Q'" in locs[("sg",)] def test_structured_warning_shape(): From 4ceea800ceb4cb05f671959ab46dd8fe363d7086 Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 10:50:14 -0400 Subject: [PATCH 05/13] docs: restructure SCHEMA_HISTORY into core + per-engine sections with a 0.21-0.26 archive (A37) Core 1.0.0 is documented as in development and not yet used. The engine sections are placeholders for re/04-06. The existing history is kept verbatim below, with headings moved one level down. A banner states that the runtime still validates schema 0.26.0 until re/07. Co-Authored-By: Claude Opus 5.5 --- docs/SCHEMA_HISTORY.md | 94 ++++++++++++++++++++++++++++++++++++------ 1 file changed, 81 insertions(+), 13 deletions(-) diff --git a/docs/SCHEMA_HISTORY.md b/docs/SCHEMA_HISTORY.md index c7c65b2..449da94 100644 --- a/docs/SCHEMA_HISTORY.md +++ b/docs/SCHEMA_HISTORY.md @@ -1,10 +1,78 @@ # Schema Evolution History +PowderLine recipes are validated against a **core schema** (the shared top level +and structural models, owned by PowderLine) plus one **engine schema** per +gateway (`gsasii`, `topas`, `easydiffraction`), each in its engine's native +conventions. Every schema has its own version. A recipe states both +(`core_schema_version`, `engine_schema_version`), and each PowderLine release +**declares** which versions it accepts. Compatibility is never inferred from the +version numbers (`powderline.support_matrix()` lists the declarations). + +> **Refactor in progress (v0.2.0).** The schemas below are being introduced on +> the `refactor/multi-engine` branch. Until the 0.26.0 schema is removed (re/07), +> `powderline.run()` / `validate()` still validate recipes against the +> **unified 0.26.0 schema** documented in the archive at the bottom of this page. + +--- + +## Core schema + +### 1.0.0 — introduced in PowderLine 0.2.0 (in development) + +Defined in `src/powderline/schema_core.py`. Not used by any gateway yet: the +engine schemas adopt it in re/04–06. + +- **Recipe frame** (`CoreRecipe`): `schema_name` (`"."`), + `core_schema_version`, `engine_schema_version`, `metadata`, `payload` (typed + by the engine schema). Unknown keys are errors at every level. +- **`metadata`**: free-form and never interpreted; must be JSON-serializable and + at most **1 MiB** as UTF-8 JSON. The cap is the constant + `schema_core.METADATA_MAX_BYTES`; change it there and record the change here. +- **Parameters**: `[value, refine_flag]` (no bounds) or + `[value, refine_flag, min, max]` (bounds the engine honors). A bounded list + where bounds aren't supported is an error. `refine_flag` is a JSON boolean. + `value` may be `null` (= start from the phase structure's value) only for + quantities that mirror a structure value. +- **Data and ranges**: `xrd_data` (2θ in degrees, weights 1/σ², validated as in + 0.26.0); `fit_range` `[min, max]`, with `max > min`, inside the data's 2θ range. +- **Background**: Chebyshev (`num_coefficients`, `coefficients`, `refine_flag`). + Coefficient semantics are documented per engine. +- **Units** are fixed per field in the schema, never written in a recipe. +- **Accepted core versions** (this release): `==1.0.0`. + +Phase structure and symmetry validation (space-group setting, element symbols, +special positions) are documented here when they land. + +## gsasii engine schema + +### 1.0.0 — introduced in PowderLine 0.2.0 + +Entries added by re/04 (`gsasii.rietveld`, `gsasii.spf`). + +## topas engine schema + +### 1.0.0 — introduced in PowderLine 0.2.0 + +Entries added by re/05 (`topas.rietveld`, `topas.spf`). + +## easydiffraction engine schema + +### 1.0.0 — introduced in PowderLine 0.2.0 + +Entries added by re/06 (`easydiffraction.rietveld`). + +--- + +## Archive: unified-schema era (0.21–0.26) + +The single schema shared by all engines up to PowderLine 0.1.1, kept verbatim +(headings moved one level down). + This document provides a concise summary of PowderLine's schema evolution for context. For detailed commit history, see the repository's git log. Current schema: **0.26.0** --- -## Schema 0.26.0: Per-Parameter Refinement Flags Honored (Current) +### Schema 0.26.0: Per-Parameter Refinement Flags Honored (Current) **Status**: Active (Q3 2026) @@ -64,7 +132,7 @@ file reader/parser that builds the block. --- -## Schema 0.25.4: Consistent Failure Metadata +### Schema 0.25.4: Consistent Failure Metadata **Status**: Superseded by 0.26.0 @@ -90,7 +158,7 @@ No other recipe changes required. --- -## Schema 0.25.3: Refinement Failure Detection and Windows Support +### Schema 0.25.3: Refinement Failure Detection and Windows Support **Status**: Superseded by 0.25.4 @@ -110,7 +178,7 @@ No other recipe changes required. --- -## Schema 0.25.2: Programmatic Python API +### Schema 0.25.2: Programmatic Python API **Status**: Superseded by 0.25.3 @@ -147,7 +215,7 @@ No other recipe changes required. --- -## Schema 0.25.1: Refined Parameters Export + Extended SPF Returns +### Schema 0.25.1: Refined Parameters Export + Extended SPF Returns **Status**: Superseded by 0.25.2 @@ -175,7 +243,7 @@ No other recipe changes required. --- -## Schema 0.25: Simplification +### Schema 0.25: Simplification **Status**: Superseded by 0.25.1 @@ -202,7 +270,7 @@ No other recipe changes required. --- -## Schema 0.24: Multi-Strategy Refinement System +### Schema 0.24: Multi-Strategy Refinement System **Status**: Deprecated (replaced by schema 0.25) @@ -222,7 +290,7 @@ No other recipe changes required. --- -## Schema 0.23: Initial Multi-Strategy Exploration +### Schema 0.23: Initial Multi-Strategy Exploration **Status**: Deprecated @@ -235,7 +303,7 @@ No other recipe changes required. --- -## Schema 0.22: ADP Field Requirements +### Schema 0.22: ADP Field Requirements **Status**: Deprecated @@ -249,7 +317,7 @@ No other recipe changes required. --- -## Schema 0.21 and Earlier +### Schema 0.21 and Earlier **Status**: Historical @@ -262,7 +330,7 @@ Early development versions focused on basic Rietveld refinement capabilities: --- -## Origin (Schema 0.21–0.24) +### Origin (Schema 0.21–0.24) PowderLine began as a minimal proof-of-concept to confirm that GSAS-II could be driven from a JSON file via the G2scripts API. The initial scope established the directory @@ -276,7 +344,7 @@ configuration system, programmatic Python API) were subsequently delivered in sc --- -## Schema Design Philosophy +### Schema Design Philosophy **Example-Driven Development**: Features are added when real refinement examples need them, not speculatively. Schema evolution is guided by committed examples in `examples/` directory. @@ -288,7 +356,7 @@ configuration system, programmatic Python API) were subsequently delivered in sc --- -## Migration Philosophy +### Migration Philosophy - **Explicit schema versions**: Every recipe declares its `"schema_version"`. The code accepts one current version — **0.26.0** — and recipes written for older versions must From 2632ac9cba289dfca185e32e53714b85334c6a11 Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:13:32 -0400 Subject: [PATCH 06/13] feat(core): symmetry.analyze_site (special positions, A69/A73); correct site DOF classification analyze_site(space_group, xyz) is the core schema's site validation (KI-03): - the space group must name its setting (two-origin :1/:2, rhombohedral :H/:R; A62); - symmetry images closer than 5e-6 put the operation in the stabilizer (>= 6 decimals declare a special position); - an image between 5e-6 and 2e-3 raises SymmetryError naming the exact special position and its multiplicity (ambiguous band); - canonical coordinates: the barycenter of the stabilizer images, with fixed axes set to their exact fraction (denominator <= 48); `adjusted` reports a difference above 1e-12; - multiplicity = |G| / |stabilizer|, with consistency checks; coordinate and Uij DOF classification. DOF classification fix (P1): FIXED is now a row test on the stabilizer projector, FREE a column test. The old column-only test misread trigonal and hexagonal sites, whose rotations are not orthogonal in fractional coordinates (e.g. 6h (x, 2x, 1/4) gave x FIXED). The legacy site_dof/adp_dof (0.26.0 TOPAS writer warnings) share the corrected helper. Recorded as a deliberate change. tests/test_symmetry_characterization.py compares multiplicity, coordinate DOF and Uij DOF with GSAS-II's SytSym/GetCSxinel/GetCSuinel for all 230 space groups (GSAS-II symbol verified per group by matching the orbits), plus canonicalization, ambiguous-band and committed-example checks. With the old column test reinstated, 13 trigonal/hexagonal groups fail. Skipped without GSAS-II. Co-Authored-By: Claude Opus 5.5 --- src/powderline/symmetry.py | 247 +++++++++++++++++++----- tests/test_symmetry_characterization.py | 177 +++++++++++++++++ 2 files changed, 379 insertions(+), 45 deletions(-) create mode 100644 tests/test_symmetry_characterization.py diff --git a/src/powderline/symmetry.py b/src/powderline/symmetry.py index fc10510..0523e8a 100644 --- a/src/powderline/symmetry.py +++ b/src/powderline/symmetry.py @@ -1,6 +1,6 @@ """gemmi-backed crystallographic rules (engine-free core module). -Two pure capabilities, both engine-free: +Pure, engine-free capabilities: * :func:`cell_constraints` -- crystal-system cell equalities and fixed angles, so the writer can share one ``prm`` name across equal cell lengths and emit @@ -9,6 +9,11 @@ COUPLED) plus the orbit size, so the writer can decide whether a *refined* atomic coordinate is legal and warn on suspicious multiplicities. +* :func:`analyze_site` -- the core schema's site validation (KI-03; A35, A69, + A73): multiplicity + DOF from the space group, the ambiguous-band error, and + the canonical (exact) special-position coordinates every gateway sends to its + engine. Policy and evidence: devkit ``tasks/re03-symmetry-tolerance.md``. + Space-group operations come from gemmi; the stabiliser / projector math is our own (~small, spec in plan §5). ``gemmi`` is an approved runtime dependency (D3). @@ -20,6 +25,7 @@ from __future__ import annotations from dataclasses import dataclass +from fractions import Fraction import gemmi import numpy as np @@ -28,9 +34,20 @@ # Fractional-coordinate tolerances (plan §5). -_POS_TOL = 1e-5 # position / orbit equality mod 1 +_POS_TOL = 1e-5 # position / orbit equality mod 1 (legacy site_dof/adp_dof) _MAT_TOL = 1e-6 # rotation-matrix / projector comparisons +#: Core schema special-position rules (A69, A73), as a fractional *image +#: distance* d = max_i |wrap(R x + t - x)_i|: d < SPECIAL_POSITION_TOL means x is +#: fixed by that operation (>= 6 decimals declare a special position); +#: SPECIAL_POSITION_TOL <= d < AMBIGUOUS_BAND is an error (ambiguous intent). +SPECIAL_POSITION_TOL = 5e-6 +AMBIGUOUS_BAND = 2e-3 +#: Canonical coordinates that differ from the stated ones by more than this are +#: reported as adjusted (floating-point noise is not). +ADJUSTMENT_REPORT_TOL = 1e-12 +_MAX_DENOMINATOR = 48 # fixed special-position coordinates are multiples of 1/24 + _AXIS_LETTERS = ("x", "y", "z") _LENGTHS = ("a", "b", "c") _ANGLES = ("alpha", "beta", "gamma") @@ -93,7 +110,7 @@ def classification(self, axis: str) -> str: # --- space-group resolution ------------------------------------------------- -def resolve_space_group(space_group: str) -> gemmi.SpaceGroup: +def resolve_space_group(space_group: str, *, explicit_setting: bool = False) -> gemmi.SpaceGroup: """Resolve a recipe space-group string to a gemmi ``SpaceGroup``. Tries the symbol verbatim, then whitespace-stripped (gemmi accepts both @@ -101,7 +118,9 @@ def resolve_space_group(space_group: str) -> gemmi.SpaceGroup: :class:`SymmetryError`. Two-origin groups passed without an explicit ``:1``/``:2`` selector also error rather than guess an origin (plan §5(6)). Both rhombohedral settings (``:H``, ``:R``) are accepted; engines that - cannot handle ``:R`` reject it themselves (A62). + cannot handle ``:R`` reject it themselves (A62). With + ``explicit_setting=True`` (the core schema rule, A62) a rhombohedral group + must also name its setting (gemmi would otherwise assume ``:H``). """ raw = str(space_group) sg = gemmi.find_spacegroup_by_name(raw) @@ -111,6 +130,11 @@ def resolve_space_group(space_group: str) -> gemmi.SpaceGroup: raise SymmetryError( f"unrecognized space-group symbol {space_group!r} (gemmi could not resolve it)" ) + if explicit_setting and sg.ext in ("H", "R") and ":" not in raw: + raise SymmetryError( + f"space group {space_group!r} is rhombohedral; specify the setting " + "explicitly: append ':H' (hexagonal axes) or ':R' (rhombohedral axes)" + ) if sg.ext in ("1", "2") and ":" not in raw: raise SymmetryError( f"space group {space_group!r} has two origin choices; specify one " @@ -210,6 +234,42 @@ def _orbit_size(ops, xyz: np.ndarray) -> int: return len(points) +def _classify(projector: np.ndarray) -> list[str]: + """Per-component DOF from a projector ``P`` onto the allowed displacements. + + FIXED: row j of P is zero (no allowed displacement changes component j). + FREE: ``P e_j == e_j`` (component j changes on its own). Otherwise COUPLED. + Rows and columns of P differ when the rotations are not orthogonal in + fractional coordinates (hexagonal/trigonal axes), so the FIXED test must use + the row: e.g. 6h ``(x, 2x, 1/4)`` in P6_3/mmc has x COUPLED, not FIXED. The + classification is checked against GSAS-II's site-symmetry tables for all 230 + space groups (``tests/test_symmetry_characterization.py``). + """ + n = projector.shape[0] + out = [] + for j in range(n): + unit = np.zeros(n) + unit[j] = 1.0 + if np.linalg.norm(projector[j, :]) < _MAT_TOL: + out.append("FIXED") + elif np.linalg.norm(projector[:, j] - unit) < _MAT_TOL: + out.append("FREE") + else: + out.append("COUPLED") + return out + + +def _adp_projector(stab_rots) -> np.ndarray: + """Projector onto site-allowed U tensors (``U -> R U R^T``) in u11..u23 coordinates.""" + projector = np.zeros((6, 6)) + for R in stab_rots: + for j in range(6): + basis = np.zeros(6) + basis[j] = 1.0 + projector[:, j] += _vec_from_sym(R @ _sym_from_vec(basis) @ R.T) + return projector / len(stab_rots) + + def site_dof(space_group: str, xyz) -> SiteDof: """Classify the site DOF of fractional position ``xyz`` in ``space_group``. @@ -218,10 +278,9 @@ def site_dof(space_group: str, xyz) -> SiteDof: 1. Expand all symmetry operations (with centering). 2. Stabiliser = ops with ``R x + t == x (mod 1)`` within ``_POS_TOL``. 3. Allowed-displacement subspace = fixed space of the stabiliser rotations, - given by the Reynolds projector ``P = mean(R_i)`` (an orthogonal - projector for a group of orthogonal matrices). - 4. Per axis ``e_j``: ``P e_j == e_j`` -> FREE; ``P e_j == 0`` -> FIXED; - otherwise -> COUPLED (subspace not axis-aligned along ``j``). + given by the Reynolds projector ``P = mean(R_i)``. + 4. Per axis: see :func:`_classify` (FIXED = row of P is zero, FREE = + ``P e_j == e_j``, else COUPLED). """ x = np.asarray(xyz, dtype=float) if x.shape != (3,): @@ -232,21 +291,8 @@ def site_dof(space_group: str, xyz) -> SiteDof: if not stab_rots: # pragma: no cover - identity is always in the group raise SymmetryError("empty stabilizer (should be impossible)") - projector = sum(stab_rots) / len(stab_rots) - - axes: list[str] = [] - coupled: list[str] = [] - for j in range(3): - col = projector[:, j] - unit = np.zeros(3) - unit[j] = 1.0 - if np.linalg.norm(col - unit) < _MAT_TOL: - axes.append("FREE") - elif np.linalg.norm(col) < _MAT_TOL: - axes.append("FIXED") - else: - axes.append("COUPLED") - coupled.append(_AXIS_LETTERS[j]) + axes = _classify(sum(stab_rots) / len(stab_rots)) + coupled = [_AXIS_LETTERS[j] for j in range(3) if axes[j] == "COUPLED"] return SiteDof( axes=(axes[0], axes[1], axes[2]), @@ -294,33 +340,144 @@ def adp_dof(space_group: str, xyz) -> AdpDof: The symmetric U tensor transforms as ``U -> R U R^T`` under a site-symmetry rotation ``R``; the site-allowed U is the fixed space of the stabiliser, - given by the Reynolds projector on the 6-dim symmetric-tensor space. A - component ``e_j`` with ``P e_j == e_j`` is FREE, ``== 0`` is FIXED, else - COUPLED (e.g. cubic ``m-3m`` -> ``u11=u22=u33`` coupled, off-diagonals FIXED). + given by the Reynolds projector on the 6-dim symmetric-tensor space, + classified by :func:`_classify` (e.g. cubic ``m-3m`` -> ``u11=u22=u33`` + coupled, off-diagonals FIXED). """ x = np.asarray(xyz, dtype=float) if x.shape != (3,): raise ValueError("xyz must be a 3-vector") ops = _expanded_ops(resolve_space_group(space_group)) stab = [R for (R, t) in ops if np.all(np.abs(_wrap_symmetric(R @ x + t - x)) < _POS_TOL)] + comps = _classify(_adp_projector(stab)) + return AdpDof(components=tuple(comps), stabilizer_order=len(stab)) - projector = np.zeros((6, 6)) - for R in stab: - for j in range(6): - basis = np.zeros(6) - basis[j] = 1.0 - projector[:, j] += _vec_from_sym(R @ _sym_from_vec(basis) @ R.T) - projector /= len(stab) - comps = [] - for j in range(6): - col = projector[:, j] - unit = np.zeros(6) - unit[j] = 1.0 - if np.linalg.norm(col - unit) < _MAT_TOL: - comps.append("FREE") - elif np.linalg.norm(col) < _MAT_TOL: - comps.append("FIXED") - else: - comps.append("COUPLED") - return AdpDof(components=tuple(comps), stabilizer_order=len(stab)) +# --- (d) core site validation (KI-03; A35, A69, A73) ------------------------ + + +@dataclass(frozen=True) +class SiteAnalysis: + """One atomic site, analyzed under the core schema rules. + + Attributes: + stated: the coordinates as given. + canonical: the coordinates every gateway uses -- equal to ``stated`` for + a general position; on a special position, symmetry-fixed axes are + exact (see ``exact``) and coupled axes satisfy their relation. + exact: per axis, the exact rational value of a symmetry-fixed + coordinate (``None`` for a free or coupled axis). + axes: ``"FREE"`` / ``"FIXED"`` / ``"COUPLED"`` per axis. + adp: the same classification for ``U11, U22, U33, U12, U13, U23``. + multiplicity: site multiplicity in the conventional cell. + stabilizer_order: number of operations fixing the site. + """ + + stated: tuple[float, float, float] + canonical: tuple[float, float, float] + exact: tuple[Fraction | None, Fraction | None, Fraction | None] + axes: tuple[str, str, str] + adp: tuple[str, str, str, str, str, str] + multiplicity: int + stabilizer_order: int + + @property + def adjusted(self) -> bool: + """True when ``canonical`` differs from ``stated`` beyond float noise.""" + return any(abs(c - s) > ADJUSTMENT_REPORT_TOL for c, s in zip(self.canonical, self.stated)) + + +def _fmt_xyz(xyz) -> str: + return "(" + ", ".join(f"{v:.6g}" for v in xyz) + ")" + + +def _fmt_exact(values, exact) -> str: + return "(" + ", ".join(str(e) if e is not None else f"{v:.6g}" for v, e in zip(values, exact)) + ")" + + +def _barycenter(x: np.ndarray, ops) -> np.ndarray: + """Mean of the stabilizer images of ``x``, each unwrapped next to ``x``. + + The barycenter of an orbit under a finite group is fixed by every element, + so this is the nearest point fixed by all of ``ops``. + """ + return x + np.mean([_wrap_symmetric(R @ x + t - x) for R, t in ops], axis=0) + + +def _axes(stab_rots) -> list[str]: + return _classify(sum(stab_rots) / len(stab_rots)) + + +def analyze_site(space_group: str, xyz) -> SiteAnalysis: + """Validate one site under the core schema rules and return its :class:`SiteAnalysis`. + + For every operation (centering expanded), the image distance ``d`` is + computed. ``d < SPECIAL_POSITION_TOL`` puts the operation in the stabilizer. + Any ``SPECIAL_POSITION_TOL <= d < AMBIGUOUS_BAND`` raises + :class:`SymmetryError`: the site is near a special position without being on + it (or is stated with too few decimals). The message names the special + position and its multiplicity. + + ``space_group`` must name its setting explicitly (two-origin and + rhombohedral groups, A62). + """ + x = np.asarray(xyz, dtype=float) + if x.shape != (3,) or not np.all(np.isfinite(x)): + raise SymmetryError("xyz must be a finite 3-vector") + ops = _expanded_ops(resolve_space_group(space_group, explicit_setting=True)) + dist = [float(np.max(np.abs(_wrap_symmetric(R @ x + t - x)))) for R, t in ops] + + ambiguous = [d for d in dist if SPECIAL_POSITION_TOL <= d < AMBIGUOUS_BAND] + if ambiguous: + near = [op for op, d in zip(ops, dist) if d < AMBIGUOUS_BAND] + target = _barycenter(x, near) + axes = _axes([R for R, _t in near]) + exact = [Fraction(float(v)).limit_denominator(_MAX_DENOMINATOR) if a == "FIXED" else None + for v, a in zip(target, axes)] + raise SymmetryError( + f"position {_fmt_xyz(x)} is {max(ambiguous):.2g} from the special position " + f"{_fmt_exact(target, exact)} (multiplicity {len(ops) // len(near)}) in " + f"{space_group!r} without being on it: state the special-position " + f"coordinates to at least 6 decimals, or move the atom at least " + f"{AMBIGUOUS_BAND:g} (fractional) off it" + ) + + stab = [op for op, d in zip(ops, dist) if d < SPECIAL_POSITION_TOL] + if not stab: # pragma: no cover - identity is always in the group + raise SymmetryError("empty stabilizer (should be impossible)") + axes = _axes([R for R, _t in stab]) + + canonical = _barycenter(x, stab) + exact: list[Fraction | None] = [] + for j in range(3): + if axes[j] != "FIXED": + exact.append(None) + continue + frac = Fraction(float(canonical[j])).limit_denominator(_MAX_DENOMINATOR) + if abs(float(frac) - canonical[j]) > 1e-9: # pragma: no cover - guards the 1/24 premise + raise SymmetryError( + f"fixed coordinate {canonical[j]!r} of {_fmt_xyz(x)} in {space_group!r} is not a " + f"fraction with denominator <= {_MAX_DENOMINATOR}" + ) + exact.append(frac) + canonical[j] = float(frac) + + # Consistency: the canonical point is fixed exactly by the whole stabilizer, + # and multiplicity x stabilizer order = group order. + residual = max(float(np.max(np.abs(_wrap_symmetric(R @ canonical + t - canonical)))) + for R, t in stab) + if residual > 1e-9 or len(ops) % len(stab): # pragma: no cover - internal invariant + raise SymmetryError( + f"inconsistent site analysis for {_fmt_xyz(x)} in {space_group!r} " + f"(residual {residual:.2g}, |G|={len(ops)}, |stabilizer|={len(stab)})" + ) + + return SiteAnalysis( + stated=(float(x[0]), float(x[1]), float(x[2])), + canonical=(float(canonical[0]), float(canonical[1]), float(canonical[2])), + exact=(exact[0], exact[1], exact[2]), + axes=(axes[0], axes[1], axes[2]), + adp=tuple(_classify(_adp_projector([R for R, _t in stab]))), + multiplicity=len(ops) // len(stab), + stabilizer_order=len(stab), + ) diff --git a/tests/test_symmetry_characterization.py b/tests/test_symmetry_characterization.py new file mode 100644 index 0000000..dc91bb7 --- /dev/null +++ b/tests/test_symmetry_characterization.py @@ -0,0 +1,177 @@ +"""Characterization: core site analysis vs GSAS-II, all 230 space groups (re/03 T5, A69). + +For every space group (two-origin groups in origin choice 2, rhombohedral groups +on hexagonal axes: the settings GSAS-II uses), many special and general +positions are analyzed by ``powderline.symmetry.analyze_site`` and by GSAS-II's +own ``SytSym`` / ``GetCSxinel`` / ``GetCSuinel``. They must agree on: + +- multiplicity; +- per-axis coordinate DOF (FIXED / FREE / COUPLED); +- per-component Uij DOF. + +The legacy ``site_dof`` / ``adp_dof`` (used by the 0.26.0 TOPAS writer) are held +to the same reference. GSAS-II is read only; nothing in it is changed. Skipped +where GSAS-II is not installed. A disagreement must be escalated, not +tolerated (A35). +""" + +from __future__ import annotations + +import json +import random +from collections import Counter +from pathlib import Path + +import numpy as np +import pytest + +G2spc = pytest.importorskip("GSASII.GSASIIspc") +gemmi = pytest.importorskip("gemmi") + +from powderline.exceptions import SymmetryError # noqa: E402 +from powderline.symmetry import _expanded_ops, adp_dof, analyze_site, site_dof # noqa: E402 + +EXAMPLES = Path(__file__).resolve().parents[1] / "examples" +_POINTS_PER_PATTERN = 2 # per space group: 12 patterns x this many draws + + +def _gemmi_group(number: int): + sg = gemmi.find_spacegroup_by_number(number) + if sg.ext == "1": # two-origin group: GSAS-II uses origin choice 2 + sg = gemmi.find_spacegroup_by_name(sg.hm + ":2") + return sg # rhombohedral groups default to ':H' in gemmi, as in GSAS-II + + +def _same_mod1(p, q, tol=1e-6) -> bool: + return bool(np.all(np.abs(((p - q) + 0.5) % 1.0 - 0.5) < tol)) + + +def _orbit(ops, x): + pts = [] + for R, t in ops: + y = (R @ x + t) % 1.0 + if not any(_same_mod1(y, p, 1e-8) for p in pts): + pts.append(y) + return pts + + +def _gsas_symbol(sg): + """A GSAS-II symbol for the same group *and setting*: the general-position orbits must match.""" + x = np.array([0.1234, 0.2345, 0.3456]) + ref = _orbit(_expanded_ops(sg), x) + parts = sg.hm.split() + candidates = [sg.hm, G2spc.StandardizeSpcName(sg.short_name())] + if len(parts) == 4 and parts[1] == "1" and parts[3] == "1": # 'P 1 21/c 1' -> 'P 21/c' + candidates.append(f"{parts[0]} {parts[2]}") + for cand in candidates: + err, sgdata = G2spc.SpcGroup(cand) + if err: + continue + theirs = [np.array(e[0]) % 1.0 for e in G2spc.GenAtom(x, sgdata, All=False)] + if len(theirs) == len(ref) and all(any(_same_mod1(p, q) for q in theirs) for p in ref): + return cand, sgdata + return None, None + + +def _gsas_classes(codes) -> tuple: + """GSAS-II constraint codes -> FIXED (0) / FREE (own group) / COUPLED (shared group).""" + groups = Counter(c for c in codes if c != 0) + return tuple("FIXED" if c == 0 else ("FREE" if groups[c] == 1 else "COUPLED") for c in codes) + + +def _positions(rng): + """Grid points (multiples of 1/24) in patterns that hit special positions, incl. coupled ones.""" + g = lambda: rng.randrange(24) / 24 # noqa: E731 + pts = [] + for _ in range(_POINTS_PER_PATTERN): + a, b, c = g(), g(), g() + pts += [(a, b, c), (a, a, c), (a, -a, c), (a, 2 * a, c), (2 * a, a, c), (a, a, a), + (a, a + 0.5, c), (a, a + 0.25, c), (a, c, c), (a, c, -c), (a, -a + 0.5, c), + (a, b, b + 0.25)] + return sorted({tuple(round(v % 1.0, 12) for v in p) for p in pts}) + + +@pytest.mark.parametrize("number", range(1, 231)) +def test_site_analysis_matches_gsasii(number): + sg = _gemmi_group(number) + name = sg.xhm() + symbol, sgdata = _gsas_symbol(sg) + assert symbol is not None, f"no GSAS-II symbol reproduces {name!r}" + + rng = random.Random(number) + disagreements = [] + for x in _positions(rng): + site = analyze_site(name, x) # exact grid points never fall in the ambiguous band + sitesym, mult = G2spc.SytSym(list(x), sgdata)[0:2] + ours = {"multiplicity": site.multiplicity, "legacy multiplicity": site_dof(name, x).orbit_size} + theirs = {"multiplicity": mult, "legacy multiplicity": mult} + try: + xyz = _gsas_classes(G2spc.GetCSxinel(sitesym)[0]) + uij = _gsas_classes(G2spc.GetCSuinel(sitesym)[0]) + except KeyError: + # GSAS-II could not name this site symmetry ('sp'), so it has no + # constraint table entry (seen once over all 230 groups: F432 24e). + # Multiplicity is still compared. + pass + else: + ours.update({"xyz": site.axes, "uij": site.adp, + "legacy xyz": site_dof(name, x).axes, + "legacy uij": adp_dof(name, x).components}) + theirs.update({"xyz": xyz, "uij": uij, "legacy xyz": xyz, "legacy uij": uij}) + for key in ours: + if tuple(np.atleast_1d(ours[key])) != tuple(np.atleast_1d(theirs[key])): + disagreements.append((x, sitesym, key, ours[key], theirs[key])) + assert not disagreements, f"{name} (GSAS-II {symbol!r}): {disagreements[:5]}" + + +@pytest.mark.parametrize("space_group, exact, rounded", [ + ("P 63/m m c", (1 / 3, 2 / 3, 0.25), (0.333333, 0.666667, 0.25)), + ("P 63/m m c", (1 / 6, 1 / 3, 0.25), (0.166667, 0.333333, 0.25)), + ("F d -3 m:2", (0.125, 0.125, 0.125), (0.125, 0.125, 0.125)), + ("R -3 m:H", (0.5, 0.0, 0.0), (0.5, 0.0, 0.0)), + ("F m -3 m", (0.0, 0.0, 0.0), (-1e-9, 1e-9, 0.0)), +]) +def test_canonical_coordinates_are_read_identically_by_gsasii(space_group, exact, rounded): + """6-decimal input is canonicalized; GSAS-II gets the same site from the canonical values. + + The last case is GSAS-II's cell-edge defect (it misreads values just below an + integer): the canonical exact 0 avoids it. + """ + site = analyze_site(space_group, rounded) + sg = gemmi.find_spacegroup_by_name(space_group) + _symbol, sgdata = _gsas_symbol(sg) + assert G2spc.SytSym(list(site.canonical), sgdata)[1] == site.multiplicity + assert G2spc.SytSym(list(exact), sgdata)[1] == site.multiplicity + exact_site = analyze_site(space_group, exact) + assert np.allclose(site.canonical, exact_site.canonical, atol=1e-12) + + +@pytest.mark.parametrize("space_group, xyz", [ + ("P 63/m m c", (0.3333, 0.6667, 0.25)), + ("P 63/m m c", (0.33333, 0.66667, 0.25)), + ("P 63/m m c", (0.333, 0.667, 0.25)), + ("F m -3 m", (1e-5, 0.0, 0.0)), + ("F d -3 m:2", (0.12501, 0.125, 0.125)), +]) +def test_ambiguous_band_is_rejected(space_group, xyz): + with pytest.raises(SymmetryError, match="without being on it"): + analyze_site(space_group, xyz) + + +def test_committed_examples_agree_with_gsasii(): + """Every atom of every committed example: same multiplicity from core and GSAS-II.""" + checked = 0 + for path in sorted(EXAMPLES.glob("*/input.json")): + if path.parent.name == "example_template": + continue + phases = (json.loads(path.read_text(encoding="utf-8")).get("payload") or {}).get("phases") or {} + for phase in phases.values(): + structure = phase["structure"] + sg = gemmi.find_spacegroup_by_name(structure["space_group"]) + _symbol, sgdata = _gsas_symbol(sg) + for atom in structure["atoms"].values(): + xyz = (atom["x"], atom["y"], atom["z"]) + site = analyze_site(sg.xhm(), xyz) + assert site.multiplicity == G2spc.SytSym(list(xyz), sgdata)[1] + checked += 1 + assert checked >= 20 From e3e1a774d7cc991cfa3d428ef501e1fcd611faac Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:29:05 -0400 Subject: [PATCH 07/13] feat(core): cell and Uij symmetry checks; least-change canonicalization (A77, A78) - check_cell(space_group, cell): the metric tensor must be invariant under every group rotation; compared with the group-averaged cell within CELL_TOL = 1e-9 (float noise only). Errors name each parameter and its symmetric value. Covers all crystal systems incl. monoclinic unique axes and rhombohedral :R. - check_uij(space_group, xyz, uij): U must be invariant under the site stabilizer (R U R^T, the GSAS-II convention). Within UIJ_TOL = 1e-6 A^2 the symmetric values are returned with adjusted=True; beyond that, SymmetryError. - Canonical coordinates and symmetric Uij use the least-change (orthogonal) projection onto the symmetric subspace, anchored at the stabilizer barycenter. The Reynolds average alone is oblique on hexagonal/trigonal axes and moved free components (e.g. U11 on 6h). Changes <= 1e-12 snap back to the stated value; SiteAnalysis gains `adp`. - tests/test_symmetry_core.py (core-only, no GSAS-II); the characterization canonicalization tests now assert exact values (the previous np.allclose silently allowed rtol=1e-5) and the coupled least-change result. Co-Authored-By: Claude Opus 5.5 --- src/powderline/symmetry.py | 110 ++++++++++- tests/test_symmetry_characterization.py | 39 ++-- tests/test_symmetry_core.py | 236 ++++++++++++++++++++++++ 3 files changed, 368 insertions(+), 17 deletions(-) create mode 100644 tests/test_symmetry_core.py diff --git a/src/powderline/symmetry.py b/src/powderline/symmetry.py index 0523e8a..6c15acf 100644 --- a/src/powderline/symmetry.py +++ b/src/powderline/symmetry.py @@ -364,7 +364,8 @@ class SiteAnalysis: stated: the coordinates as given. canonical: the coordinates every gateway uses -- equal to ``stated`` for a general position; on a special position, symmetry-fixed axes are - exact (see ``exact``) and coupled axes satisfy their relation. + exact (see ``exact``) and coupled axes satisfy their relation (to + floating-point precision), by the least change from ``stated``. exact: per axis, the exact rational value of a symmetry-fixed coordinate (``None`` for a free or coupled axis). axes: ``"FREE"`` / ``"FIXED"`` / ``"COUPLED"`` per axis. @@ -408,6 +409,19 @@ def _axes(stab_rots) -> list[str]: return _classify(sum(stab_rots) / len(stab_rots)) +def _orthogonal_projector(reynolds: np.ndarray) -> np.ndarray: + """Orthogonal projector onto the image of a (possibly oblique) Reynolds projector. + + The Reynolds average ``mean(R)`` projects onto the symmetric subspace, but not + orthogonally when the rotations are not orthogonal (hexagonal/trigonal axes). + The orthogonal projector gives the *least change* onto the same subspace, so + a free component stays as stated and only inconsistent ones move (A73, A78). + """ + u, sv, _vt = np.linalg.svd(reynolds) + basis = u[:, sv > 0.5] # a projector's singular values are ~1 on its image, 0 off it + return basis @ basis.T + + def analyze_site(space_group: str, xyz) -> SiteAnalysis: """Validate one site under the core schema rules and return its :class:`SiteAnalysis`. @@ -447,7 +461,11 @@ def analyze_site(space_group: str, xyz) -> SiteAnalysis: raise SymmetryError("empty stabilizer (should be impossible)") axes = _axes([R for R, _t in stab]) - canonical = _barycenter(x, stab) + # Least change onto the stabilizer's fixed set: anchor at a fixed point (the + # barycenter), then project the offset orthogonally onto the allowed directions. + anchor = _barycenter(x, stab) + canonical = anchor + _orthogonal_projector(sum(R for R, _t in stab) / len(stab)) @ (x - anchor) + canonical = np.where(np.abs(canonical - x) <= ADJUSTMENT_REPORT_TOL, x, canonical) # drop float noise exact: list[Fraction | None] = [] for j in range(3): if axes[j] != "FIXED": @@ -481,3 +499,91 @@ def analyze_site(space_group: str, xyz) -> SiteAnalysis: multiplicity=len(ops) // len(stab), stabilizer_order=len(stab), ) + + +# --- (e) cell and Uij consistency with symmetry (A76-A78) ------------------- + +#: Cell parameters must equal their symmetric values up to floating-point noise +#: (relative on lengths, degrees on angles): no adjustment, only an error (A77). +CELL_TOL = 1e-9 +#: Uij within this of their site-symmetric values (Angstrom^2) are set to those +#: values and reported; beyond it is an error (A78). Allows for values printed +#: to ~6 decimals breaking relations such as U12 = U22/2. +UIJ_TOL = 1e-6 + +_CELL_NAMES = ("a", "b", "c", "alpha", "beta", "gamma") + + +def _metric(cell) -> np.ndarray: + a, b, c, al, be, ga = cell + ca, cb, cg = (np.cos(np.radians(v)) for v in (al, be, ga)) + return np.array([[a * a, a * b * cg, a * c * cb], + [a * b * cg, b * b, b * c * ca], + [a * c * cb, b * c * ca, c * c]]) + + +def _cell_from_metric(g: np.ndarray) -> tuple[float, ...]: + a, b, c = np.sqrt(np.diag(g)) + al = np.degrees(np.arccos(np.clip(g[1, 2] / (b * c), -1.0, 1.0))) + be = np.degrees(np.arccos(np.clip(g[0, 2] / (a * c), -1.0, 1.0))) + ga = np.degrees(np.arccos(np.clip(g[0, 1] / (a * b), -1.0, 1.0))) + return tuple(float(v) for v in (a, b, c, al, be, ga)) + + +def check_cell(space_group: str, cell) -> tuple[float, ...]: + """Raise :class:`SymmetryError` unless ``cell`` (a, b, c, alpha, beta, gamma) fits the group. + + The cell's metric tensor must be invariant under every rotation of the + space group (``R^T G R = G``). The check compares the stated cell with the + cell of the group-averaged metric, within :data:`CELL_TOL`. Returns the + symmetric cell. Covers every crystal system, including monoclinic unique + axes and the rhombohedral ``:R`` setting. + """ + rots = [R for R, _t in _expanded_ops(resolve_space_group(space_group, explicit_setting=True))] + stated = tuple(float(v) for v in cell) + g = _metric(stated) + symmetric = _cell_from_metric(sum(R.T @ g @ R for R in rots) / len(rots)) + bad = [] + for name, v, s in zip(_CELL_NAMES, stated, symmetric): + off = abs(v - s) / s if name in ("a", "b", "c") else abs(v - s) + if off > CELL_TOL: + bad.append(f"{name} = {v:g} (symmetric value {s:.10g})") + if bad: + system = resolve_space_group(space_group, explicit_setting=True).crystal_system_str() + raise SymmetryError( + f"unit cell is inconsistent with {space_group!r} ({system}): " + "; ".join(bad) + + ". Symmetry-equivalent lengths must be equal and symmetry-fixed angles exact " + "(e.g. 90 or 120 degrees)" + ) + return symmetric + + +_UIJ_NAMES = ("U11", "U22", "U33", "U12", "U13", "U23") + + +def check_uij(space_group: str, xyz, uij) -> tuple[tuple[float, ...], bool]: + """Fit the anisotropic ADPs ``uij`` (U11, U22, U33, U12, U13, U23) to the site symmetry. + + ``U`` must be invariant under the site's stabilizer (``R U R^T = U``, the + convention GSAS-II uses). The site-symmetric tensor is the least-change + (orthogonal) projection of ``uij`` onto the invariant tensors. Components further than :data:`UIJ_TOL` from it raise + :class:`SymmetryError`; otherwise the symmetric values are returned, with a + flag saying whether any component changed (by more than 1e-12). + """ + site = analyze_site(space_group, xyz) + ops = _expanded_ops(resolve_space_group(space_group, explicit_setting=True)) + x = np.asarray(site.canonical) + stab = [R for R, t in ops if np.max(np.abs(_wrap_symmetric(R @ x + t - x))) < SPECIAL_POSITION_TOL] + stated = np.asarray(uij, dtype=float) + symmetric = _orthogonal_projector(_adp_projector(stab)) @ stated + symmetric = np.where(np.abs(symmetric - stated) <= ADJUSTMENT_REPORT_TOL, stated, symmetric) + bad = [f"{n} = {v:g} (site-symmetric value {s:.6g})" + for n, v, s in zip(_UIJ_NAMES, stated, symmetric) if abs(v - s) > UIJ_TOL] + if bad: + raise SymmetryError( + f"anisotropic ADPs break the site symmetry at {_fmt_xyz(x)} in {space_group!r}: " + + "; ".join(bad) + ) + if not np.any(np.abs(symmetric - stated) > ADJUSTMENT_REPORT_TOL): + return tuple(float(v) for v in stated), False + return tuple(float(v) for v in symmetric), True diff --git a/tests/test_symmetry_characterization.py b/tests/test_symmetry_characterization.py index dc91bb7..185b1b7 100644 --- a/tests/test_symmetry_characterization.py +++ b/tests/test_symmetry_characterization.py @@ -124,26 +124,35 @@ def test_site_analysis_matches_gsasii(number): assert not disagreements, f"{name} (GSAS-II {symbol!r}): {disagreements[:5]}" -@pytest.mark.parametrize("space_group, exact, rounded", [ - ("P 63/m m c", (1 / 3, 2 / 3, 0.25), (0.333333, 0.666667, 0.25)), - ("P 63/m m c", (1 / 6, 1 / 3, 0.25), (0.166667, 0.333333, 0.25)), +@pytest.mark.parametrize("space_group, rounded, expected", [ + # fully fixed sites: canonical == the exact special position + ("P 63/m m c", (0.333333, 0.666667, 0.25), (1 / 3, 2 / 3, 0.25)), ("F d -3 m:2", (0.125, 0.125, 0.125), (0.125, 0.125, 0.125)), ("R -3 m:H", (0.5, 0.0, 0.0), (0.5, 0.0, 0.0)), - ("F m -3 m", (0.0, 0.0, 0.0), (-1e-9, 1e-9, 0.0)), + # GSAS-II's cell-edge defect (misreads values just below an integer): the + # canonical exact 0 avoids it + ("F m -3 m", (-1e-9, 1e-9, 0.0), (0.0, 0.0, 0.0)), + # free axis kept as stated, fixed axes exact + ("P 63/m m c", (0.333333, 0.666667, 0.06), (1 / 3, 2 / 3, 0.06)), ]) -def test_canonical_coordinates_are_read_identically_by_gsasii(space_group, exact, rounded): - """6-decimal input is canonicalized; GSAS-II gets the same site from the canonical values. - - The last case is GSAS-II's cell-edge defect (it misreads values just below an - integer): the canonical exact 0 avoids it. - """ +def test_canonical_coordinates(space_group, rounded, expected): + """6-decimal input is canonicalized exactly; GSAS-II reads the same site from it.""" site = analyze_site(space_group, rounded) - sg = gemmi.find_spacegroup_by_name(space_group) - _symbol, sgdata = _gsas_symbol(sg) + assert site.canonical == expected + _symbol, sgdata = _gsas_symbol(gemmi.find_spacegroup_by_name(space_group)) assert G2spc.SytSym(list(site.canonical), sgdata)[1] == site.multiplicity - assert G2spc.SytSym(list(exact), sgdata)[1] == site.multiplicity - exact_site = analyze_site(space_group, exact) - assert np.allclose(site.canonical, exact_site.canonical, atol=1e-12) + + +def test_coupled_coordinates_canonicalized_by_least_change(): + """6h (x, 2x, 1/4): the relation is restored with the smallest change; z is exact.""" + site = analyze_site("P 63/m m c", (0.166667, 0.333333, 0.25)) + x, y, z = site.canonical + assert site.axes == ("COUPLED", "COUPLED", "FIXED") and site.adjusted + assert z == 0.25 and abs(y - 2 * x) < 1e-15 + # orthogonal projection onto (t, 2t): t = (x + 2y) / 5 + assert abs(x - (0.166667 + 2 * 0.333333) / 5) < 1e-15 + _symbol, sgdata = _gsas_symbol(gemmi.find_spacegroup_by_name("P 63/m m c")) + assert G2spc.SytSym(list(site.canonical), sgdata)[1] == site.multiplicity == 6 @pytest.mark.parametrize("space_group, xyz", [ diff --git a/tests/test_symmetry_core.py b/tests/test_symmetry_core.py new file mode 100644 index 0000000..3ebf199 --- /dev/null +++ b/tests/test_symmetry_core.py @@ -0,0 +1,236 @@ +"""Tests for powderline.symmetry core validation functions (check_cell, check_uij, analyze_site). + +Core-only: no GSAS-II needed; must run in a core-only environment. +""" +import pytest + +from powderline.exceptions import SymmetryError +from powderline.symmetry import check_cell, check_uij, analyze_site, SiteAnalysis +from fractions import Fraction + + +# --- check_cell ------------------------------------------------------------- + + +def test_check_cell_cubic_valid_returned(): + """check_cell: cubic P m -3 m (a=b=c, 90/90/90) returns symmetric cell.""" + sg = "P m -3 m" + cell = (4.15692, 4.15692, 4.15692, 90.0, 90.0, 90.0) + symmetric = check_cell(sg, cell) + assert len(symmetric) == 6 + assert abs(symmetric[0] - cell[0]) < 1e-12 + + +def test_check_cell_cubic_a_perturbed_rejected(): + """check_cell: cubic with b != a by 1e-6 relative rejected.""" + sg = "P m -3 m" + a = 4.15692 + cell = (a, a * (1 + 1e-6), a, 90.0, 90.0, 90.0) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +def test_check_cell_cubic_gamma_perturbed_rejected(): + """check_cell: cubic with gamma != 90 by 1e-6 degree rejected.""" + sg = "P m -3 m" + cell = (4.0, 4.0, 4.0, 90.0, 90.0, 90.0 + 1e-6) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +def test_check_cell_cubic_tiny_perturbation_accepted(): + """check_cell: cubic with perturbation below CELL_TOL (1e-12 relative) accepted.""" + sg = "P m -3 m" + a = 4.0 + cell = (a, a * (1 + 1e-13), a, 90.0, 90.0, 90.0) + symmetric = check_cell(sg, cell) + assert symmetric is not None + + +def test_check_cell_tetragonal_valid_returned(): + """check_cell: tetragonal I 41/a m d:2 (a=b, c, 90/90/90) returns symmetric cell.""" + sg = "I 41/a m d:2" + cell = (5.0, 5.0, 7.0, 90.0, 90.0, 90.0) + symmetric = check_cell(sg, cell) + assert abs(symmetric[0] - symmetric[1]) < 1e-12 + assert abs(symmetric[2] - 7.0) < 1e-12 + + +def test_check_cell_tetragonal_a_neq_b_rejected(): + """check_cell: tetragonal with a != b by 1e-6 relative rejected.""" + sg = "I 41/a m d:2" + cell = (5.0, 5.0 * (1 + 1e-6), 7.0, 90.0, 90.0, 90.0) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +def test_check_cell_hexagonal_valid_returned(): + """check_cell: hexagonal P 63/m m c (a=b, c, 90/90/120) returns symmetric cell.""" + sg = "P 63/m m c" + cell = (3.2, 3.2, 5.2, 90.0, 90.0, 120.0) + symmetric = check_cell(sg, cell) + assert abs(symmetric[0] - symmetric[1]) < 1e-12 + assert abs(symmetric[5] - 120.0) < 1e-12 + + +def test_check_cell_hexagonal_gamma_neq_120_rejected(): + """check_cell: hexagonal with gamma != 120 by 1e-6 degree rejected.""" + sg = "P 63/m m c" + cell = (3.2, 3.2, 5.2, 90.0, 90.0, 120.0 + 1e-6) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +def test_check_cell_monoclinic_valid_returned(): + """check_cell: monoclinic C 1 2/m 1 (a!=b!=c, 90/beta/90) returns symmetric cell.""" + sg = "C 1 2/m 1" + cell = (5.0, 6.0, 7.0, 90.0, 104.3, 90.0) + symmetric = check_cell(sg, cell) + assert abs(symmetric[4] - 104.3) < 1e-9 + + +def test_check_cell_monoclinic_alpha_neq_90_rejected(): + """check_cell: monoclinic with alpha != 90 by 1e-6 degree rejected.""" + sg = "C 1 2/m 1" + cell = (5.0, 6.0, 7.0, 90.0 + 1e-6, 104.3, 90.0) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +def test_check_cell_triclinic_valid_returned(): + """check_cell: triclinic P 1 (all free) returns cell unchanged.""" + sg = "P 1" + cell = (5.0, 6.0, 7.0, 80.0, 85.0, 95.0) + symmetric = check_cell(sg, cell) + for i in range(6): + assert abs(symmetric[i] - cell[i]) < 1e-12 + + +def test_check_cell_rhombohedral_r_setting_valid_returned(): + """check_cell: rhombohedral R -3 m:R (a=b=c, alpha=beta=gamma!=90) returns symmetric cell.""" + sg = "R -3 m:R" + alpha = 60.0 + cell = (5.0, 5.0, 5.0, alpha, alpha, alpha) + symmetric = check_cell(sg, cell) + assert abs(symmetric[0] - symmetric[1]) < 1e-12 + assert abs(symmetric[1] - symmetric[2]) < 1e-12 + assert abs(symmetric[3] - symmetric[4]) < 1e-12 + assert abs(symmetric[4] - symmetric[5]) < 1e-12 + + +def test_check_cell_rhombohedral_r_setting_angle_perturbed_rejected(): + """check_cell: rhombohedral R -3 m:R with alpha != beta by 1e-6 degree rejected.""" + sg = "R -3 m:R" + cell = (5.0, 5.0, 5.0, 60.0, 60.0 + 1e-6, 60.0) + with pytest.raises(SymmetryError, match="inconsistent"): + check_cell(sg, cell) + + +# --- check_uij -------------------------------------------------------------- + + +def test_check_uij_identity_unchanged(): + """check_uij: conforming tensor returned unchanged, adjusted=False.""" + sg = "P m -3 m" + xyz = (0.0, 0.0, 0.0) + # Cubic m-3m: U11=U22=U33, off-diag 0 + uij = (0.01, 0.01, 0.01, 0.0, 0.0, 0.0) + symmetric, adjusted = check_uij(sg, xyz, uij) + assert adjusted is False + for i in range(6): + assert abs(symmetric[i] - uij[i]) < 1e-15 + + +def test_check_uij_adjusted_within_tol(): + """check_uij: tensor within UIJ_TOL adjusted, adjusted=True.""" + sg = "P 63/m m c" + xyz = (0.2, 0.4, 0.25) + # Site 6h: U11 free, U22 free, U33 free, U12 = U22/2, U13=U23=0 + # State U12 slightly off from U22/2 by < UIJ_TOL + uij = (0.012, 0.024, 0.02, 0.012 + 5e-7, 0.0, 0.0) + symmetric, adjusted = check_uij(sg, xyz, uij) + assert adjusted is True + # Check U12 became exactly U22/2 + assert abs(symmetric[3] - symmetric[1] / 2) < 1e-15 + + +def test_check_uij_beyond_tol_raises(): + """check_uij: tensor beyond UIJ_TOL raises SymmetryError.""" + sg = "P m -3 m" + xyz = (0.0, 0.0, 0.0) + # Cubic m-3m: U11=U22=U33; state U11 != U22 by > UIJ_TOL + uij = (0.01, 0.02, 0.01, 0.0, 0.0, 0.0) + with pytest.raises(SymmetryError, match="break the site symmetry"): + check_uij(sg, xyz, uij) + + +def test_check_uij_general_position_unchanged(): + """check_uij: general position in P 1 accepts any tensor unchanged.""" + sg = "P 1" + xyz = (0.123, 0.456, 0.789) + uij = (0.01, 0.02, 0.03, 0.005, 0.007, 0.009) + symmetric, adjusted = check_uij(sg, xyz, uij) + assert adjusted is False + for i in range(6): + assert abs(symmetric[i] - uij[i]) < 1e-15 + + +# --- analyze_site ----------------------------------------------------------- + + +def test_analyze_site_general_position_multiplicity(): + """analyze_site: P m -3 m general position -> multiplicity 48 (number of operations).""" + sg = "P m -3 m" + xyz = (0.123, 0.456, 0.789) + site = analyze_site(sg, xyz) + assert site.multiplicity == 48 + + +def test_analyze_site_adjusted_false_for_exact(): + """analyze_site: exact input -> adjusted=False.""" + sg = "P m -3 m" + xyz = (0.0, 0.0, 0.0) + site = analyze_site(sg, xyz) + assert site.adjusted is False + + +def test_analyze_site_exact_fractions(): + """analyze_site: fixed coordinates in `exact` as Fraction(1, 3) etc.""" + sg = "P 63/m m c" + xyz = (0.333333, 0.666667, 0.25) + site = analyze_site(sg, xyz) + # Check exact contains Fraction(1, 3), Fraction(2, 3), Fraction(1, 4) + assert site.exact[0] == Fraction(1, 3) + assert site.exact[1] == Fraction(2, 3) + assert site.exact[2] == Fraction(1, 4) + + +def test_analyze_site_explicit_setting_required(): + """analyze_site: 'F d -3 m' without :1/:2 raises SymmetryError.""" + xyz = (0.125, 0.125, 0.125) + with pytest.raises(SymmetryError, match="two origin choices"): + analyze_site("F d -3 m", xyz) + + +def test_analyze_site_non_finite_raises(): + """analyze_site: non-finite xyz raises SymmetryError.""" + sg = "P 1" + xyz = (float("nan"), 0.0, 0.0) + with pytest.raises(SymmetryError, match="must be a finite 3-vector"): + analyze_site(sg, xyz) + + +def test_analyze_site_cubic_origin_multiplicity_1(): + """analyze_site: P m -3 m origin (0,0,0) -> multiplicity 1.""" + sg = "P m -3 m" + xyz = (0.0, 0.0, 0.0) + site = analyze_site(sg, xyz) + assert site.multiplicity == 1 + + +def test_analyze_site_hexagonal_6h_multiplicity_6(): + """analyze_site: P 63/m m c (0.2,0.4,0.25) -> multiplicity 6 (site 6h).""" + sg = "P 63/m m c" + xyz = (0.2, 0.4, 0.25) + site = analyze_site(sg, xyz) + assert site.multiplicity == 6 From d39f254ffce6c68189634e0bc8ff892f2d88feeb Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:28:48 -0400 Subject: [PATCH 08/13] feat(core): schema_core phase structure: UnitCell, Atom, PhaseStructure (A35, A62-A63, A69, A73-A78) PhaseStructure validates the structure once, in core: - explicit space-group setting (A62); - cell lengths > 0, angles in (0, 180), no volume (A75), cell consistent with the group (A77); - exact bare element symbols (A63); - occupancy 0..1 (A76); - ADP 'Uiso' with Uiso or 'Uaniso' with all six Uij, Uij consistent with the site symmetry, adjusted within 1e-6 A^2 (A78); - per-atom analyze_site: ambiguous band = error, stated Multiplicity cross-checked (A69), special-position coordinates replaced by their exact canonical values (A73). Every adjustment becomes a StructuredWarning (special_position_adjusted / adp_symmetry_adjusted, field_path relative to the structure), available from warnings(). The site analyses are available from site(label). All atom problems are reported together in one error. Co-Authored-By: Claude Opus 5.5 --- src/powderline/schema_core.py | 187 +++++++++++++++- tests/test_schema_core.py | 407 ++++++++++++++++++++++++++++++++++ 2 files changed, 593 insertions(+), 1 deletion(-) diff --git a/src/powderline/schema_core.py b/src/powderline/schema_core.py index 567d3cf..76e1e0e 100644 --- a/src/powderline/schema_core.py +++ b/src/powderline/schema_core.py @@ -21,7 +21,7 @@ import json import math -from typing import Any, Optional +from typing import Any, Literal, Optional import gemmi import numpy as np @@ -31,12 +31,16 @@ BaseModel, ConfigDict, Field, + PrivateAttr, StrictBool, field_validator, model_serializer, model_validator, ) +from powderline.exceptions import StructuredWarning, SymmetryError +from powderline.symmetry import analyze_site, check_cell, check_uij, resolve_space_group + # --- versions (A40, A65) --------------------------------------------------- #: The core schema version new recipes are written against. @@ -458,6 +462,187 @@ def validate_coefficients_length(cls, v, info): return v +# --- phase structure (A32, A35, A62, A63, A69, A73) ------------------------- + + +class UnitCell(BaseModel): + """Cell constants (values, not refinables). No ``volume``: every engine + derives it from a..gamma (A74).""" + + model_config = _STRICT + + a: float = Field(json_schema_extra=_unit(UNIT_ANGSTROM)) + b: float = Field(json_schema_extra=_unit(UNIT_ANGSTROM)) + c: float = Field(json_schema_extra=_unit(UNIT_ANGSTROM)) + alpha: float = Field(json_schema_extra=_unit(UNIT_DEGREE)) + beta: float = Field(json_schema_extra=_unit(UNIT_DEGREE)) + gamma: float = Field(json_schema_extra=_unit(UNIT_DEGREE)) + + @field_validator("a", "b", "c") + @classmethod + def _length(cls, v, info): + if not (math.isfinite(v) and v > 0): + raise ValueError(f"cell length {info.field_name} must be positive, got {v}") + return v + + @field_validator("alpha", "beta", "gamma") + @classmethod + def _angle(cls, v, info): + if not (math.isfinite(v) and 0 < v < 180): + raise ValueError(f"cell angle {info.field_name} must be between 0 and 180 degrees, got {v}") + return v + + +_UANISO_KEYS = ("U11", "U22", "U33", "U12", "U13", "U23") + + +class Atom(BaseModel): + """One atom of a phase structure (values, not refinables). + + Coordinates are fractional JSON numbers. A special position is declared by + stating it to at least 6 decimals. The validated phase holds canonical + (exact) coordinates and reports every adjustment (A73). ``Multiplicity`` is + optional and, when stated, must match the derived value (A69 T3). + ``occupancy`` is 0 to 1 inclusive, even where an engine would accept more + (A76). ``Uaniso`` must respect the site symmetry: within 1e-6 A^2 it is set + to the symmetric values and reported, beyond that it is an error (A78). + """ + + model_config = _STRICT + + element: str = Field(description="Bare element symbol, exactly as in the periodic table (e.g. 'Fe')") + x: float = Field(description="Fractional x coordinate") + y: float = Field(description="Fractional y coordinate") + z: float = Field(description="Fractional z coordinate") + occupancy: float = Field(default=1.0, ge=0.0, le=1.0, description="Site occupancy, 0 to 1 inclusive (A76)") + Multiplicity: Optional[int] = Field(default=None, description="Site multiplicity; cross-checked when stated") + ADP: Literal["Uiso", "Uaniso"] = Field(description="Displacement-parameter type") + Uiso: Optional[float] = Field(default=None, json_schema_extra=_unit(UNIT_ANGSTROM2)) + Uaniso: Optional[dict[Literal["U11", "U22", "U33", "U12", "U13", "U23"], float]] = Field( + default=None, json_schema_extra=_unit(UNIT_ANGSTROM2), + description="All six of U11, U22, U33, U12, U13, U23") + + @field_validator("element") + @classmethod + def _element(cls, v: str) -> str: + return check_element_symbol(v) + + @field_validator("x", "y", "z", "occupancy", "Uiso") + @classmethod + def _finite(cls, v, info): + return _finite(info.field_name, v) + + @field_validator("Uaniso") + @classmethod + def _uaniso_finite(cls, v): + if v is not None: + for k, u in v.items(): + _finite(k, u) + return v + + @model_validator(mode="after") + def _adp_block(self) -> "Atom": + if self.ADP == "Uiso": + if self.Uiso is None: + raise ValueError("ADP 'Uiso' requires a Uiso value") + if self.Uaniso is not None: + raise ValueError("ADP 'Uiso' must not also give Uaniso") + else: + if self.Uaniso is None or set(self.Uaniso) != set(_UANISO_KEYS): + raise ValueError(f"ADP 'Uaniso' requires all of {', '.join(_UANISO_KEYS)}") + if self.Uiso is not None: + raise ValueError("ADP 'Uaniso' must not also give Uiso") + return self + + +class PhaseStructure(BaseModel): + """A phase's crystal structure: space group, cell, atoms (core-owned, A35). + + Validation derives each atom's site (multiplicity, DOF) from the space group + (:func:`powderline.symmetry.analyze_site`). It rejects ambiguous positions + and stated multiplicities that don't match. It replaces stated coordinates + with the canonical ones, so every engine receives the same exact structure. + Each adjustment is reported by :meth:`warnings` (A73). The unit cell must + fit the space group exactly (A77), and anisotropic ADPs the site symmetry + (A78). + """ + + model_config = _STRICT + + phase_name: str = Field(min_length=1) + space_group: str = Field(description="Hermann-Mauguin symbol with an explicit setting " + "for two-origin (':1'/':2') and rhombohedral (':H'/':R') groups") + unit_cell: UnitCell + atoms: dict[str, Atom] = Field(min_length=1, description="Atoms keyed by unique label") + + _sites: dict = PrivateAttr(default_factory=dict) + _warnings: list = PrivateAttr(default_factory=list) + + @field_validator("space_group") + @classmethod + def _space_group(cls, v: str) -> str: + resolve_space_group(v, explicit_setting=True) # SymmetryError is a ValueError + return v + + @model_validator(mode="after") + def _sites_check(self) -> "PhaseStructure": + problems: list[str] = [] + cell = self.unit_cell + try: + check_cell(self.space_group, (cell.a, cell.b, cell.c, cell.alpha, cell.beta, cell.gamma)) + except SymmetryError as exc: + problems.append(str(exc)) + for label, atom in self.atoms.items(): + try: + site = analyze_site(self.space_group, (atom.x, atom.y, atom.z)) + except SymmetryError as exc: + problems.append(f"atom {label!r}: {exc}") + continue + if atom.Multiplicity is not None and atom.Multiplicity != site.multiplicity: + problems.append( + f"atom {label!r}: stated Multiplicity {atom.Multiplicity}, derived " + f"{site.multiplicity} ({self.space_group!r}, site {site.stated})" + ) + continue + if atom.Uaniso is not None: + stated_u = tuple(atom.Uaniso[k] for k in _UANISO_KEYS) + try: + symmetric_u, u_adjusted = check_uij(self.space_group, site.canonical, stated_u) + except SymmetryError as exc: + problems.append(f"atom {label!r}: {exc}") + continue + if u_adjusted: + atom.Uaniso = dict(zip(_UANISO_KEYS, symmetric_u)) + self._warnings.append(StructuredWarning( + code="adp_symmetry_adjusted", + message=(f"atom {label!r}: Uaniso {dict(zip(_UANISO_KEYS, stated_u))} adjusted to " + f"the site-symmetric values {atom.Uaniso}"), + field_path=f"atoms.{label}.Uaniso", + )) + self._sites[label] = site + if site.adjusted: + atom.x, atom.y, atom.z = site.canonical + self._warnings.append(StructuredWarning( + code="special_position_adjusted", + message=(f"atom {label!r}: coordinates {site.stated} are on a special position " + f"(multiplicity {site.multiplicity}); using the exact values " + f"{site.canonical}"), + field_path=f"atoms.{label}", + )) + if problems: + raise ValueError("; ".join(problems)) + return self + + def site(self, label: str): + """The :class:`~powderline.symmetry.SiteAnalysis` of atom ``label``.""" + return self._sites[label] + + def warnings(self) -> list: + """Structured warnings raised while validating this structure (paths relative to it).""" + return list(self._warnings) + + + # --- top-level recipe frame (A1, A18, A21, A40) ------------------------------ diff --git a/tests/test_schema_core.py b/tests/test_schema_core.py index dfec521..bd49812 100644 --- a/tests/test_schema_core.py +++ b/tests/test_schema_core.py @@ -901,3 +901,410 @@ def test_charged_types_rejected_not_stripped(symbol): def test_unknown_element_rejected(symbol): with pytest.raises(ValueError, match="not a known element symbol"): check_element_symbol(symbol) + + +# --- phase structure --- + +import pytest # noqa: E402 +from fractions import Fraction # noqa: E402 +from pydantic import ValidationError # noqa: E402 + +from powderline.schema_core import UnitCell, Atom, PhaseStructure # noqa: E402 +from powderline.exceptions import SymmetryError # noqa: E402 + + +def _valid_pmm_lab6_phase_dict(): + """Helper: valid P m -3 m LaB6-like phase (cubic a=4.15692, La at origin, B at 0.5,0.5,0.2021).""" + return { + "phase_name": "LaB6", + "space_group": "P m -3 m", + "unit_cell": { + "a": 4.15692, + "b": 4.15692, + "c": 4.15692, + "alpha": 90.0, + "beta": 90.0, + "gamma": 90.0, + }, + "atoms": { + "La1": { + "element": "La", + "x": 0.0, + "y": 0.0, + "z": 0.0, + "occupancy": 1.0, + "ADP": "Uiso", + "Uiso": 0.005, + }, + "B1": { + "element": "B", + "x": 0.5, + "y": 0.5, + "z": 0.2021, + "occupancy": 1.0, + "ADP": "Uiso", + "Uiso": 0.006, + }, + }, + } + + +def test_phase_structure_valid_validates(): + """Valid phase validates with no warnings.""" + phase = PhaseStructure.model_validate(_valid_pmm_lab6_phase_dict()) + assert phase.warnings() == [] + + +def test_phase_structure_site_multiplicity(): + """Site multiplicity: La (origin) -> 1, B (0.5,0.5,0.2021) -> 6 in P m -3 m.""" + phase = PhaseStructure.model_validate(_valid_pmm_lab6_phase_dict()) + assert phase.site("La1").multiplicity == 1 + assert phase.site("B1").multiplicity == 6 + + +def test_unit_cell_length_zero_rejected(): + """UnitCell: a=0 rejected.""" + with pytest.raises(ValidationError, match="must be positive"): + UnitCell(a=0.0, b=4.0, c=4.0, alpha=90, beta=90, gamma=90) + + +def test_unit_cell_length_negative_rejected(): + """UnitCell: b=-1 rejected.""" + with pytest.raises(ValidationError, match="must be positive"): + UnitCell(a=4.0, b=-1.0, c=4.0, alpha=90, beta=90, gamma=90) + + +def test_unit_cell_length_non_finite_rejected(): + """UnitCell: c=nan rejected.""" + with pytest.raises(ValidationError, match="must be positive"): + UnitCell(a=4.0, b=4.0, c=float("nan"), alpha=90, beta=90, gamma=90) + + +def test_unit_cell_angle_zero_rejected(): + """UnitCell: alpha=0 rejected.""" + with pytest.raises(ValidationError, match="must be between 0 and 180"): + UnitCell(a=4.0, b=4.0, c=4.0, alpha=0.0, beta=90, gamma=90) + + +def test_unit_cell_angle_180_rejected(): + """UnitCell: beta=180 rejected.""" + with pytest.raises(ValidationError, match="must be between 0 and 180"): + UnitCell(a=4.0, b=4.0, c=4.0, alpha=90, beta=180.0, gamma=90) + + +def test_unit_cell_angle_above_180_rejected(): + """UnitCell: gamma=181 rejected.""" + with pytest.raises(ValidationError, match="must be between 0 and 180"): + UnitCell(a=4.0, b=4.0, c=4.0, alpha=90, beta=90, gamma=181.0) + + +def test_unit_cell_angle_non_finite_rejected(): + """UnitCell: alpha=inf rejected.""" + with pytest.raises(ValidationError, match="must be between 0 and 180"): + UnitCell(a=4.0, b=4.0, c=4.0, alpha=float("inf"), beta=90, gamma=90) + + +def test_unit_cell_extra_key_rejected(): + """UnitCell: 'volume' key rejected (extra='forbid').""" + with pytest.raises(ValidationError, match="Extra inputs are not permitted"): + UnitCell(a=4.0, b=4.0, c=4.0, alpha=90, beta=90, gamma=90, volume=64.0) + + +def test_atom_element_uppercase_rejected_with_suggestion(): + """Atom: 'FE' -> message suggests 'Fe'.""" + with pytest.raises(ValidationError, match="write the element symbol as 'Fe'"): + Atom(element="FE", x=0.0, y=0.0, z=0.0, ADP="Uiso", Uiso=0.01) + + +def test_atom_element_charged_rejected(): + """Atom: 'Fe3+' rejected as charged.""" + with pytest.raises(ValidationError, match="charged scattering types are not supported"): + Atom(element="Fe3+", x=0.0, y=0.0, z=0.0, ADP="Uiso", Uiso=0.01) + + +def test_atom_occupancy_zero_accepted(): + """Atom: occupancy=0 accepted.""" + atom = Atom(element="Fe", x=0.0, y=0.0, z=0.0, occupancy=0.0, ADP="Uiso", Uiso=0.01) + assert atom.occupancy == 0.0 + + +def test_atom_occupancy_one_accepted(): + """Atom: occupancy=1 accepted.""" + atom = Atom(element="Fe", x=0.0, y=0.0, z=0.0, occupancy=1.0, ADP="Uiso", Uiso=0.01) + assert atom.occupancy == 1.0 + + +def test_atom_occupancy_negative_rejected(): + """Atom: occupancy=-0.01 rejected.""" + with pytest.raises(ValidationError, match="greater than or equal to 0"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, occupancy=-0.01, ADP="Uiso", Uiso=0.01) + + +def test_atom_occupancy_above_one_rejected(): + """Atom: occupancy=1.01 rejected.""" + with pytest.raises(ValidationError, match="less than or equal to 1"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, occupancy=1.01, ADP="Uiso", Uiso=0.01) + + +def test_atom_adp_uiso_without_value_rejected(): + """Atom: ADP='Uiso' without Uiso rejected.""" + with pytest.raises(ValidationError, match="requires a Uiso value"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uiso") + + +def test_atom_adp_uiso_with_uaniso_rejected(): + """Atom: ADP='Uiso' with Uaniso also given rejected.""" + with pytest.raises(ValidationError, match="must not also give Uaniso"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uiso", Uiso=0.01, + Uaniso={"U11": 0.01, "U22": 0.01, "U33": 0.01, "U12": 0, "U13": 0, "U23": 0}) + + +def test_atom_adp_uaniso_missing_component_rejected(): + """Atom: ADP='Uaniso' missing U23 rejected.""" + with pytest.raises(ValidationError, match="requires all of U11"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uaniso", + Uaniso={"U11": 0.01, "U22": 0.01, "U33": 0.01, "U12": 0, "U13": 0}) + + +def test_atom_adp_uaniso_unknown_key_rejected(): + """Atom: ADP='Uaniso' with U14 rejected.""" + with pytest.raises(ValidationError, match="Input should be"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uaniso", + Uaniso={"U11": 0.01, "U22": 0.01, "U33": 0.01, "U12": 0, "U13": 0, "U23": 0, "U14": 0}) + + +def test_atom_adp_uaniso_with_uiso_rejected(): + """Atom: ADP='Uaniso' with Uiso also given rejected.""" + with pytest.raises(ValidationError, match="must not also give Uiso"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uaniso", Uiso=0.01, + Uaniso={"U11": 0.01, "U22": 0.01, "U33": 0.01, "U12": 0, "U13": 0, "U23": 0}) + + +def test_atom_adp_wrong_value_rejected(): + """Atom: ADP='Uaniso' but only Uiso given rejected.""" + with pytest.raises(ValidationError, match="requires all of U11"): + Atom(element="Fe", x=0.0, y=0.0, z=0.0, ADP="Uaniso", Uiso=0.01) + + +def test_atom_xyz_non_finite_rejected(): + """Atom: x=nan rejected.""" + with pytest.raises(ValidationError, match="must be finite"): + Atom(element="Fe", x=float("nan"), y=0.0, z=0.0, ADP="Uiso", Uiso=0.01) + + +def test_phase_structure_space_group_no_origin_rejected(): + """PhaseStructure: 'F d -3 m' (no :1/:2) rejected.""" + d = _valid_pmm_lab6_phase_dict() + d["space_group"] = "F d -3 m" + with pytest.raises(ValidationError, match="two origin choices"): + PhaseStructure.model_validate(d) + + +def test_phase_structure_space_group_no_rhombohedral_setting_rejected(): + """PhaseStructure: 'R -3 m' (no :H/:R) rejected.""" + d = _valid_pmm_lab6_phase_dict() + d["space_group"] = "R -3 m" + with pytest.raises(ValidationError, match="rhombohedral"): + PhaseStructure.model_validate(d) + + +def test_phase_structure_space_group_fd3m_origin2_accepted(): + """PhaseStructure: 'F d -3 m:2' (cubic) accepted.""" + d = _valid_pmm_lab6_phase_dict() + d["space_group"] = "F d -3 m:2" + # Adjust cell to cubic (already is in the helper, but be explicit) + d["unit_cell"] = {"a": 8.0, "b": 8.0, "c": 8.0, "alpha": 90, "beta": 90, "gamma": 90} + # Put an atom at a general position to avoid special-position complications + d["atoms"] = { + "A1": {"element": "O", "x": 0.125, "y": 0.125, "z": 0.125, "ADP": "Uiso", "Uiso": 0.01} + } + phase = PhaseStructure.model_validate(d) + assert phase.space_group == "F d -3 m:2" + + +def test_phase_structure_space_group_r3m_hexagonal_accepted(): + """PhaseStructure: 'R -3 m:H' (hexagonal a=b, gamma=120) accepted.""" + d = { + "phase_name": "test", + "space_group": "R -3 m:H", + "unit_cell": {"a": 3.2, "b": 3.2, "c": 5.0, "alpha": 90, "beta": 90, "gamma": 120}, + "atoms": { + "A1": {"element": "O", "x": 0.2, "y": 0.4, "z": 0.1, "ADP": "Uiso", "Uiso": 0.01} + }, + } + phase = PhaseStructure.model_validate(d) + assert phase.space_group == "R -3 m:H" + + +def test_phase_structure_special_position_adjusted(): + """PhaseStructure: P 63/m m c atom at (0.333333,0.666667,0.25) adjusted to 1/3,2/3,1/4.""" + d = { + "phase_name": "test", + "space_group": "P 63/m m c", + "unit_cell": {"a": 3.2, "b": 3.2, "c": 5.2, "alpha": 90, "beta": 90, "gamma": 120}, + "atoms": { + "A1": {"element": "O", "x": 0.333333, "y": 0.666667, "z": 0.25, "ADP": "Uiso", "Uiso": 0.01} + }, + } + phase = PhaseStructure.model_validate(d) + # Check coordinates are exact fractions + assert abs(phase.atoms["A1"].x - 1/3) < 1e-15 + assert abs(phase.atoms["A1"].y - 2/3) < 1e-15 + # Check warning + warnings = phase.warnings() + assert len(warnings) == 1 + assert warnings[0]["code"] == "special_position_adjusted" + assert "A1" in warnings[0]["message"] + assert warnings[0]["field_path"] == "atoms.A1" + + +def test_phase_structure_special_position_ambiguous_rejected(): + """PhaseStructure: atom at (0.3333,0.6667,0.25) in P 63/m m c rejected (ambiguous).""" + d = { + "phase_name": "test", + "space_group": "P 63/m m c", + "unit_cell": {"a": 3.2, "b": 3.2, "c": 5.2, "alpha": 90, "beta": 90, "gamma": 120}, + "atoms": { + "A1": {"element": "O", "x": 0.3333, "y": 0.6667, "z": 0.25, "ADP": "Uiso", "Uiso": 0.01} + }, + } + with pytest.raises(ValidationError, match="without being on it") as exc_info: + PhaseStructure.model_validate(d) + # Check that the atom label appears in the error + assert "A1" in str(exc_info.value) + + +def test_phase_structure_multiplicity_match_accepted(): + """PhaseStructure: stated Multiplicity equal to derived accepted.""" + d = _valid_pmm_lab6_phase_dict() + d["atoms"]["La1"]["Multiplicity"] = 1 + d["atoms"]["B1"]["Multiplicity"] = 6 + phase = PhaseStructure.model_validate(d) + assert phase.site("La1").multiplicity == 1 + assert phase.site("B1").multiplicity == 6 + + +def test_phase_structure_multiplicity_mismatch_rejected(): + """PhaseStructure: stated Multiplicity != derived rejected.""" + d = _valid_pmm_lab6_phase_dict() + d["atoms"]["B1"]["Multiplicity"] = 12 # should be 6 + with pytest.raises(ValidationError) as exc_info: + PhaseStructure.model_validate(d) + msg = str(exc_info.value) + assert "stated Multiplicity" in msg + assert "derived" in msg + + +def test_phase_structure_cell_cubic_b_neq_a_rejected(): + """PhaseStructure: cubic with b != a rejected.""" + d = _valid_pmm_lab6_phase_dict() + d["unit_cell"]["b"] = 4.2 + with pytest.raises(ValidationError) as exc_info: + PhaseStructure.model_validate(d) + msg = str(exc_info.value) + assert "b" in msg.lower() + assert "inconsistent" in msg.lower() + + +def test_phase_structure_cell_cubic_gamma_91_rejected(): + """PhaseStructure: cubic with gamma=91 rejected.""" + d = _valid_pmm_lab6_phase_dict() + d["unit_cell"]["gamma"] = 91.0 + with pytest.raises(ValidationError, match="inconsistent"): + PhaseStructure.model_validate(d) + + +def test_phase_structure_cell_hexagonal_gamma_90_rejected(): + """PhaseStructure: hexagonal with gamma=90 rejected.""" + d = { + "phase_name": "test", + "space_group": "P 63/m m c", + "unit_cell": {"a": 3.2, "b": 3.2, "c": 5.2, "alpha": 90, "beta": 90, "gamma": 90}, + "atoms": { + "A1": {"element": "O", "x": 0.2, "y": 0.4, "z": 0.1, "ADP": "Uiso", "Uiso": 0.01} + }, + } + with pytest.raises(ValidationError, match="inconsistent"): + PhaseStructure.model_validate(d) + + +def test_phase_structure_cell_monoclinic_accepted(): + """PhaseStructure: valid monoclinic C 1 2/m 1 with beta=104.3 accepted.""" + d = { + "phase_name": "test", + "space_group": "C 1 2/m 1", + "unit_cell": {"a": 5.0, "b": 6.0, "c": 7.0, "alpha": 90, "beta": 104.3, "gamma": 90}, + "atoms": { + "A1": {"element": "O", "x": 0.2, "y": 0.0, "z": 0.1, "ADP": "Uiso", "Uiso": 0.01} + }, + } + phase = PhaseStructure.model_validate(d) + assert phase.space_group == "C 1 2/m 1" + + +def test_phase_structure_uaniso_cubic_m3m_isotropic_accepted(): + """PhaseStructure: La at (0,0,0) in P m -3 m with isotropic Uaniso (U11=U22=U33, off-diag 0) accepted.""" + d = _valid_pmm_lab6_phase_dict() + d["atoms"]["La1"]["ADP"] = "Uaniso" + d["atoms"]["La1"]["Uaniso"] = {"U11": 0.01, "U22": 0.01, "U33": 0.01, "U12": 0, "U13": 0, "U23": 0} + del d["atoms"]["La1"]["Uiso"] + phase = PhaseStructure.model_validate(d) + assert phase.warnings() == [] + + +def test_phase_structure_uaniso_cubic_m3m_anisotropic_rejected(): + """PhaseStructure: La at (0,0,0) in P m -3 m with U11!=U22 rejected (breaks symmetry).""" + d = _valid_pmm_lab6_phase_dict() + d["atoms"]["La1"]["ADP"] = "Uaniso" + d["atoms"]["La1"]["Uaniso"] = {"U11": 0.01, "U22": 0.02, "U33": 0.01, "U12": 0, "U13": 0, "U23": 0} + del d["atoms"]["La1"]["Uiso"] + with pytest.raises(ValidationError, match="break the site symmetry"): + PhaseStructure.model_validate(d) + + +def test_phase_structure_uaniso_hexagonal_6h_adjusted(): + """PhaseStructure: P 63/m m c, site 6h, Uaniso with U11=U22/2 adjusted and reported.""" + d = { + "phase_name": "test", + "space_group": "P 63/m m c", + "unit_cell": {"a": 3.2, "b": 3.2, "c": 5.2, "alpha": 90, "beta": 90, "gamma": 120}, + "atoms": { + "A1": { + "element": "O", + "x": 0.2, + "y": 0.4, + "z": 0.25, + "ADP": "Uaniso", + "Uaniso": { + "U11": 0.012, + "U22": 0.012347, + "U33": 0.02, + "U12": 0.006174, + "U13": 0.0, + "U23": 0.0, + }, + } + }, + } + phase = PhaseStructure.model_validate(d) + # Check U11 stayed + assert abs(phase.atoms["A1"].Uaniso["U11"] - 0.012) < 1e-15 + # Check U12 became U22/2 + u22 = phase.atoms["A1"].Uaniso["U22"] + u12 = phase.atoms["A1"].Uaniso["U12"] + assert abs(u12 - u22 / 2) < 1e-15 + # Check warning + warnings = phase.warnings() + assert any(w["code"] == "adp_symmetry_adjusted" for w in warnings) + + +def test_phase_structure_multiple_atom_problems_all_reported(): + """PhaseStructure: two atoms with wrong Multiplicity -> both labels appear in one error.""" + d = _valid_pmm_lab6_phase_dict() + d["atoms"]["La1"]["Multiplicity"] = 2 + d["atoms"]["B1"]["Multiplicity"] = 12 + with pytest.raises(ValidationError) as exc_info: + PhaseStructure.model_validate(d) + msg = str(exc_info.value) + assert "La1" in msg + assert "B1" in msg From 69a40f85a0a036601eca13d85a1eb34151cb564f Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 13:28:48 -0400 Subject: [PATCH 09/13] docs(SCHEMA_HISTORY): core 1.0.0 phase-structure rules for recipe builders Co-Authored-By: Claude Opus 5.5 --- docs/SCHEMA_HISTORY.md | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/docs/SCHEMA_HISTORY.md b/docs/SCHEMA_HISTORY.md index 449da94..f0f4173 100644 --- a/docs/SCHEMA_HISTORY.md +++ b/docs/SCHEMA_HISTORY.md @@ -40,8 +40,37 @@ engine schemas adopt it in re/04–06. - **Units** are fixed per field in the schema, never written in a recipe. - **Accepted core versions** (this release): `==1.0.0`. -Phase structure and symmetry validation (space-group setting, element symbols, -special positions) are documented here when they land. +- **Phase structure** (`PhaseStructure`): `phase_name`, `space_group`, + `unit_cell`, `atoms` (keyed by label). Structural interpretation is checked + once, in core, so every engine gets the same structure: + - **Space group**: a Hermann–Mauguin symbol. Two-origin groups need an explicit + `:1`/`:2` and rhombohedral groups `:H`/`:R` (e.g. `"F d -3 m:2"`, + `"R -3 m:H"`). Individual engines may accept fewer settings. + - **Unit cell**: `a`, `b`, `c` (Å, > 0), `alpha`, `beta`, `gamma` (degrees, + 0–180). No `volume` (every engine derives it). The cell must fit the + space group **exactly** (to floating-point precision): e.g. cubic + `a = b = c` and all angles 90°. A mismatch is an error naming each + parameter and its symmetric value. (GSAS-II alone would silently apply the + symmetry only when the cell is refined.) + - **Elements**: a bare element symbol spelled exactly (`"Fe"`, not `"FE"`). + Charged scattering types (`"Fe3+"`) are not supported yet and are rejected, + never reduced to the neutral atom. + - **Occupancy**: 0 to 1 inclusive, even where an engine would accept more. + - **Special positions**: an atom whose symmetry images lie within 5e-6 + (fractional) of itself is on a special position, so write special + coordinates with **at least 6 decimals** (e.g. `0.333333`). An atom 5e-6 to + 2e-3 from a special position is an **error** (`0.33`, `0.3333`, `0.33333`): + state it to ≥ 6 decimals or move it off. Accepted special positions are + replaced by their **exact** coordinates (fixed coordinates become the exact + fraction; coupled ones satisfy their relation, by the smallest change). Each + adjustment is reported as a structured warning, and these exact values are + what every engine receives. + - **Multiplicity**: optional; when stated it must equal the multiplicity + derived from the space group. + - **ADPs**: `ADP` is `"Uiso"` (with `Uiso`, Ų) or `"Uaniso"` (with all of + `U11 U22 U33 U12 U13 U23`, Ų). Anisotropic ADPs must respect the site + symmetry: within 1e-6 Ų they are set to the symmetric values and reported; + beyond that it is an error. ## gsasii engine schema From 8f7991fa01bceefc5bdc68bb19a010167129f92b Mon Sep 17 00:00:00 2001 From: acorrao <95718914+AdamCorrao@users.noreply.github.com> Date: Tue, 6 Oct 2026 14:46:51 -0400 Subject: [PATCH 10/13] fix(core): PhaseStructure reports every structural problem at its own location (A70) Review of PR #3 (S2, F1, N2): - All of an atom's problems are reported: a wrong Multiplicity no longer skips that atom's Uaniso check. - Each problem is a separate pydantic error at its field: unit_cell, atoms.