xtalate 0.1.0.dev0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. xtalate/__init__.py +10 -0
  2. xtalate/capabilities/__init__.py +19 -0
  3. xtalate/capabilities/registry.py +126 -0
  4. xtalate/cli/__init__.py +11 -0
  5. xtalate/cli/main.py +431 -0
  6. xtalate/cli/render.py +126 -0
  7. xtalate/conversion/__init__.py +48 -0
  8. xtalate/conversion/engine.py +560 -0
  9. xtalate/conversion/preflight.py +127 -0
  10. xtalate/conversion/report.py +74 -0
  11. xtalate/discovery/__init__.py +28 -0
  12. xtalate/discovery/engine.py +202 -0
  13. xtalate/discovery/report.py +50 -0
  14. xtalate/discovery/sniffer.py +95 -0
  15. xtalate/exporters/__init__.py +34 -0
  16. xtalate/exporters/extxyz.py +140 -0
  17. xtalate/exporters/poscar.py +180 -0
  18. xtalate/exporters/xyz.py +67 -0
  19. xtalate/parsers/__init__.py +31 -0
  20. xtalate/parsers/_common.py +57 -0
  21. xtalate/parsers/extxyz.py +452 -0
  22. xtalate/parsers/poscar.py +428 -0
  23. xtalate/parsers/xyz.py +286 -0
  24. xtalate/py.typed +0 -0
  25. xtalate/recovery/__init__.py +41 -0
  26. xtalate/recovery/engine.py +321 -0
  27. xtalate/recovery/scenarios.py +86 -0
  28. xtalate/registry.py +28 -0
  29. xtalate/schema/__init__.py +40 -0
  30. xtalate/schema/arrays.py +115 -0
  31. xtalate/schema/elements.py +150 -0
  32. xtalate/schema/models.py +283 -0
  33. xtalate/schema/paths.py +88 -0
  34. xtalate/schema/presence.py +152 -0
  35. xtalate/sdk/__init__.py +27 -0
  36. xtalate/sdk/capabilities.py +57 -0
  37. xtalate/sdk/plugins.py +72 -0
  38. xtalate/sdk/results.py +45 -0
  39. xtalate/validation/__init__.py +25 -0
  40. xtalate/validation/engine.py +585 -0
  41. xtalate/validation/report.py +48 -0
  42. xtalate/validation/rethreshold.py +122 -0
  43. xtalate/validation/tolerance.py +125 -0
  44. xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
  45. xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
  46. xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
  47. xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
  48. xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
  49. xtalate-0.1.0.dev0.dist-info/licenses/NOTICE +11 -0
@@ -0,0 +1,86 @@
1
+ """Recovery scenario catalog: hazard classes and per-scenario option computation (Part 4 §3).
2
+
3
+ Every conversion hazard the pre-flight diff detects (Part 3 §4.3) falls into exactly one of
4
+ three hazard classes (Part 4 §3.1), and the class governs what the Recovery Engine may do:
5
+
6
+ * **bulk reductive** — information the target cannot carry is dropped wholesale; no scientific
7
+ choice selects *which* survives. Never a recovery scenario: it is reported as ``removed`` and
8
+ (in permissive mode) proceeds without a choice.
9
+ * **selective reductive** — data is dropped but *which subset is kept changes the scientific
10
+ meaning*; Xtalate will not choose unrecorded. Requires an explicit choice, records an
11
+ ``Assumption``, produces **no** ``supplied`` entry (the kept data is genuine source data).
12
+ * **fabricative** — information required/offered by the target does not exist in the source; it
13
+ must be *created*. Requires an explicit choice, records an ``Assumption`` **and** a ``supplied``
14
+ entry. Never automatic in any mode (**P4**).
15
+
16
+ The bright line is deliberately discrete (a three-value enum, not a risk scale) so no mode or
17
+ future convenience can make Xtalate invent scientific data or silently choose which real data
18
+ to discard (Part 4 §3.1, "Alternative rejected: a single risk-level scale").
19
+
20
+ **v0.1 scope (review §4.4 trim).** Two scenarios resolve: ``missing_lattice`` (fabricative) and
21
+ ``frame_selection`` (selective reductive), preset-only. Option lists are **computed**, not static
22
+ (Part 4 §3.3): a choice that is not scientifically coherent for the concrete source/target pair,
23
+ or not implemented in v0.1, is honestly absent from ``available_options`` rather than offered and
24
+ then refused. ``missing_species``/``missing_velocities``/``missing_masses``/``missing_energy``/
25
+ ``truncate_corrupt_tail``/``constraint_representation`` are catalogued in the spec but not
26
+ triggered by any v0.1 (source, target) pair; they attach at this same seam later (**P6**).
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ from dataclasses import dataclass
32
+ from enum import StrEnum
33
+
34
+
35
+ @dataclass
36
+ class UnresolvedScenario:
37
+ """A recovery the pre-flight diff detected but has not resolved (Part 4 §3).
38
+
39
+ Emitted by ``conversion.preflight`` (conversion → recovery is a legal downward import) and
40
+ consumed by ``RecoveryEngine.resolve``; it lives here, in the recovery layer, so both sides
41
+ share one descriptor. ``path`` is the canonical field a *fabricative* scenario would supply
42
+ (``None`` for a selective-reductive one like ``frame_selection``)."""
43
+
44
+ scenario: str # e.g. "missing_lattice" | "frame_selection".
45
+ path: str | None = None
46
+ detail: str | None = None
47
+
48
+
49
+ class HazardClass(StrEnum):
50
+ """The three-way classification of Part 4 §3.1. Discrete by design."""
51
+
52
+ BULK_REDUCTIVE = "bulk_reductive"
53
+ SELECTIVE_REDUCTIVE = "selective_reductive"
54
+ FABRICATIVE = "fabricative"
55
+
56
+
57
+ # The v0.1 resolvable scenarios and their hazard class. A scenario absent here is not one the
58
+ # Recovery Engine resolves in v0.1 (it either does not arise for the implemented formats, or is a
59
+ # bulk-reductive drop handled by the mode table, not by a choice).
60
+ SCENARIO_HAZARD: dict[str, HazardClass] = {
61
+ "frame_selection": HazardClass.SELECTIVE_REDUCTIVE,
62
+ "missing_lattice": HazardClass.FABRICATIVE,
63
+ }
64
+
65
+
66
+ def available_options(scenario: str, *, target_can_be_nonperiodic: bool = False) -> list[str]:
67
+ """The *computed* option list for ``scenario`` (Part 4 §3.3), honestly excluding choices not
68
+ offered for this pair or not implemented in v0.1.
69
+
70
+ ``missing_lattice``: ``manual_input`` (user supplies a 3×3 lattice) and ``bounding_box``
71
+ (axis-aligned box of the selected frame's positions + ``padding_ang``). ``non_periodic`` is
72
+ offered only when the target can express ``pbc=(F,F,F)`` — never for POSCAR, whose ``cell.pbc``
73
+ is PARTIAL/fully-periodic-only (Part 3 §4.2). ``upload_reference`` (lattice from a second file)
74
+ needs a second input stream and is out of v0.1 scope, so it is honestly not listed.
75
+
76
+ ``frame_selection``: ``first`` / ``last`` / ``index``. ``split_all`` (one file per frame)
77
+ needs a multi-file output mode absent from the v0.1 library API, so it is not listed.
78
+ """
79
+ if scenario == "missing_lattice":
80
+ options = ["manual_input", "bounding_box"]
81
+ if target_can_be_nonperiodic:
82
+ options.append("non_periodic")
83
+ return options
84
+ if scenario == "frame_selection":
85
+ return ["first", "last", "index"]
86
+ return []
xtalate/registry.py ADDED
@@ -0,0 +1,28 @@
1
+ """``default_registry()`` — the standard first-party registry (MASTER_SPEC Part 3 §7.1).
2
+
3
+ A convenience factory that registers every built-in parser and exporter into one
4
+ :class:`~xtalate.capabilities.Registry`, so callers (the CLI, examples, and any embedder)
5
+ get the full v0.1 format set without hand-wiring it. This is the "explicit first-party
6
+ registration list" the spec calls sufficient before entry-point plugin discovery exists
7
+ (Part 3 §7.1); third-party plugins attach at the same registry later (**P6**).
8
+
9
+ Lives at the package root, not in ``capabilities``: the registry *machinery* must not depend on
10
+ the concrete parsers/exporters (that would invert the import graph), so the wiring that pulls
11
+ them together sits above both — the same reason the CLI, not the engine, composes the pipeline.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from xtalate.capabilities import Registry
17
+ from xtalate.exporters import builtin_exporters
18
+ from xtalate.parsers import builtin_parsers
19
+
20
+
21
+ def default_registry() -> Registry:
22
+ """A fresh :class:`Registry` with all built-in parsers and exporters registered."""
23
+ registry = Registry()
24
+ for parser in builtin_parsers():
25
+ registry.register_parser(parser)
26
+ for exporter in builtin_exporters():
27
+ registry.register_exporter(exporter)
28
+ return registry
@@ -0,0 +1,40 @@
1
+ """Canonical Model — the single in-memory/serialized representation (MASTER_SPEC Part 2).
2
+
3
+ Depends on nothing else in the package (lowest layer of the P2 dependency graph, Part 1
4
+ §5.1). Implemented in M1.
5
+ """
6
+
7
+ from xtalate.schema.models import (
8
+ SCHEMA_VERSION,
9
+ AtomsBlock,
10
+ CanonicalObject,
11
+ Cell,
12
+ Constraint,
13
+ ConversionRecord,
14
+ Dynamics,
15
+ Electronic,
16
+ Frame,
17
+ Provenance,
18
+ SimulationMetadata,
19
+ TrajectoryMetadata,
20
+ UserMetadata,
21
+ )
22
+ from xtalate.schema.presence import PathPresence, PresenceMap
23
+
24
+ __all__ = [
25
+ "SCHEMA_VERSION",
26
+ "AtomsBlock",
27
+ "CanonicalObject",
28
+ "Cell",
29
+ "Constraint",
30
+ "ConversionRecord",
31
+ "Dynamics",
32
+ "Electronic",
33
+ "Frame",
34
+ "PathPresence",
35
+ "PresenceMap",
36
+ "Provenance",
37
+ "SimulationMetadata",
38
+ "TrajectoryMetadata",
39
+ "UserMetadata",
40
+ ]
@@ -0,0 +1,115 @@
1
+ """NumPy array field types for the Canonical Model (MASTER_SPEC Part 2 §1, §3).
2
+
3
+ Numeric array fields (``positions``, ``lattice_vectors``, ``velocities``, ...) are
4
+ ``np.ndarray`` of ``float64`` in memory — for zero-copy interop with the array-native
5
+ scientific libraries (ASE) the pipeline delegates to — and serialize to **nested JSON
6
+ lists**. This module provides the pydantic v2 custom types that enforce that contract.
7
+
8
+ Design (DECISIONS.md D8):
9
+
10
+ * In memory: ``np.ndarray``, dtype ``float64``, always a fresh copy (inputs are never
11
+ aliased, so a caller mutating the array it passed in cannot corrupt the model).
12
+ * Serialized (JSON mode): nested lists via ``ndarray.tolist()`` — Python's default
13
+ shortest-round-tripping float ``repr``, making ``float64 -> JSON -> float64`` lossless.
14
+ * Golden equality is defined as *deserialize-then-compare* (``np.array_equal`` on the
15
+ parsed values), never a comparison of serialized text — see the round-trip tests.
16
+
17
+ Each type validates only its **local** shape (rank and any fixed axis, e.g. the trailing
18
+ ``3`` of an ``(N, 3)`` array). Cross-field agreement — that ``positions``,
19
+ ``velocities`` and ``forces`` share one ``N``, that ``N`` is constant across frames — is a
20
+ *model*-level invariant enforced by validators on the models (§3.3, §3.2), not here: a
21
+ lone array cannot know the object's atom count.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+ from typing import Annotated, Any
28
+
29
+ import numpy as np
30
+ from numpy.typing import NDArray
31
+ from pydantic import GetCoreSchemaHandler
32
+ from pydantic_core import core_schema
33
+
34
+
35
+ @dataclass(frozen=True)
36
+ class _ArraySpec:
37
+ """pydantic-v2 metadata object describing a ``float64`` ndarray field.
38
+
39
+ Placed inside ``Annotated[...]``; pydantic calls ``__get_pydantic_core_schema__``
40
+ to obtain the validate/serialize schema for the annotated field.
41
+ """
42
+
43
+ # Exact rank, or None for "any rank >= 1" (the custom-array case, Part 2 §3.10,
44
+ # whose trailing shape is arbitrary and whose first-axis == N/F is a model check).
45
+ ndim: int | None
46
+ # (axis_index, required_size) pairs, e.g. (1, 3) for the trailing 3 of an (N, 3) array.
47
+ fixed_axes: tuple[tuple[int, int], ...] = ()
48
+
49
+ def _shape_label(self) -> str:
50
+ if self.ndim is None:
51
+ return "(first-axis, ...)"
52
+ dims = ["?"] * self.ndim
53
+ for axis, size in self.fixed_axes:
54
+ dims[axis] = str(size)
55
+ return "(" + ", ".join(dims) + ")"
56
+
57
+ def _validate(self, value: Any) -> NDArray[np.float64]:
58
+ if isinstance(value, np.ndarray) and value.dtype == np.float64:
59
+ # Copy so a later mutation of the caller's array cannot reach into the model.
60
+ arr = value.copy()
61
+ else:
62
+ try:
63
+ arr = np.asarray(value, dtype=np.float64)
64
+ except (ValueError, TypeError) as exc:
65
+ raise ValueError(
66
+ f"expected a real-valued array coercible to float64, got {value!r}"
67
+ ) from exc
68
+ if self.ndim is None:
69
+ if arr.ndim < 1:
70
+ raise ValueError(
71
+ f"expected an array of rank >= 1 {self._shape_label()}, got a 0-D array"
72
+ )
73
+ elif arr.ndim != self.ndim:
74
+ raise ValueError(
75
+ f"expected a {self.ndim}-D array of shape {self._shape_label()}, "
76
+ f"got a {arr.ndim}-D array of shape {arr.shape}"
77
+ )
78
+ for axis, size in self.fixed_axes:
79
+ if arr.shape[axis] != size:
80
+ raise ValueError(
81
+ f"expected axis {axis} of length {size} "
82
+ f"(shape {self._shape_label()}), got shape {arr.shape}"
83
+ )
84
+ if not np.all(np.isfinite(arr)):
85
+ raise ValueError("array contains non-finite values (nan/inf); refusing")
86
+ return arr
87
+
88
+ def __get_pydantic_core_schema__(
89
+ self, source_type: Any, handler: GetCoreSchemaHandler
90
+ ) -> core_schema.CoreSchema:
91
+ return core_schema.no_info_plain_validator_function(
92
+ self._validate,
93
+ serialization=core_schema.plain_serializer_function_ser_schema(
94
+ lambda arr: arr.tolist(),
95
+ when_used="json",
96
+ ),
97
+ )
98
+
99
+
100
+ # --- Concrete field types, named after their canonical shapes (Part 2 §3). -----------
101
+
102
+ #: ``(N, 3)`` — per-atom Cartesian triples: positions, velocities, forces.
103
+ ArrayN3 = Annotated[NDArray[np.float64], _ArraySpec(ndim=2, fixed_axes=((1, 3),))]
104
+
105
+ #: ``(N,)`` — per-atom scalars: masses, charges, magnetic moments.
106
+ ArrayN = Annotated[NDArray[np.float64], _ArraySpec(ndim=1)]
107
+
108
+ #: ``(3, 3)`` — lattice vectors (rows a, b, c) and the stress tensor.
109
+ Array33 = Annotated[NDArray[np.float64], _ArraySpec(ndim=2, fixed_axes=((0, 3), (1, 3)))]
110
+
111
+ #: First dimension ``N`` (per-atom), any trailing shape — ``user_metadata.custom_per_atom``.
112
+ ArrayNx = Annotated[NDArray[np.float64], _ArraySpec(ndim=None)]
113
+
114
+ #: First dimension ``F`` (per-frame), any trailing shape — ``user_metadata.custom_per_frame``.
115
+ ArrayFx = Annotated[NDArray[np.float64], _ArraySpec(ndim=None)]
@@ -0,0 +1,150 @@
1
+ """Chemical element symbol <-> atomic number table (MASTER_SPEC Part 2 §3.3).
2
+
3
+ ``AtomsBlock`` carries both ``symbols`` and ``atomic_numbers``; the numbers are derived
4
+ from the symbols at construction (§3.3) and validated for agreement. This module is the
5
+ single source of that mapping. The reserved pseudo-element ``"X"`` (unknown species,
6
+ atomic number 0) is valid, but a parser emitting it must accompany it with a warning
7
+ (§3.3) — that policy lives in the parsers, not here.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ # Symbols indexed by atomic number: SYMBOLS[Z] == symbol. Index 0 is the reserved
13
+ # unknown-species marker "X" (§3.3). Covers Z = 1..118 (all IUPAC-named elements).
14
+ _SYMBOLS: tuple[str, ...] = (
15
+ "X",
16
+ "H",
17
+ "He",
18
+ "Li",
19
+ "Be",
20
+ "B",
21
+ "C",
22
+ "N",
23
+ "O",
24
+ "F",
25
+ "Ne",
26
+ "Na",
27
+ "Mg",
28
+ "Al",
29
+ "Si",
30
+ "P",
31
+ "S",
32
+ "Cl",
33
+ "Ar",
34
+ "K",
35
+ "Ca",
36
+ "Sc",
37
+ "Ti",
38
+ "V",
39
+ "Cr",
40
+ "Mn",
41
+ "Fe",
42
+ "Co",
43
+ "Ni",
44
+ "Cu",
45
+ "Zn",
46
+ "Ga",
47
+ "Ge",
48
+ "As",
49
+ "Se",
50
+ "Br",
51
+ "Kr",
52
+ "Rb",
53
+ "Sr",
54
+ "Y",
55
+ "Zr",
56
+ "Nb",
57
+ "Mo",
58
+ "Tc",
59
+ "Ru",
60
+ "Rh",
61
+ "Pd",
62
+ "Ag",
63
+ "Cd",
64
+ "In",
65
+ "Sn",
66
+ "Sb",
67
+ "Te",
68
+ "I",
69
+ "Xe",
70
+ "Cs",
71
+ "Ba",
72
+ "La",
73
+ "Ce",
74
+ "Pr",
75
+ "Nd",
76
+ "Pm",
77
+ "Sm",
78
+ "Eu",
79
+ "Gd",
80
+ "Tb",
81
+ "Dy",
82
+ "Ho",
83
+ "Er",
84
+ "Tm",
85
+ "Yb",
86
+ "Lu",
87
+ "Hf",
88
+ "Ta",
89
+ "W",
90
+ "Re",
91
+ "Os",
92
+ "Ir",
93
+ "Pt",
94
+ "Au",
95
+ "Hg",
96
+ "Tl",
97
+ "Pb",
98
+ "Bi",
99
+ "Po",
100
+ "At",
101
+ "Rn",
102
+ "Fr",
103
+ "Ra",
104
+ "Ac",
105
+ "Th",
106
+ "Pa",
107
+ "U",
108
+ "Np",
109
+ "Pu",
110
+ "Am",
111
+ "Cm",
112
+ "Bk",
113
+ "Cf",
114
+ "Es",
115
+ "Fm",
116
+ "Md",
117
+ "No",
118
+ "Lr",
119
+ "Rf",
120
+ "Db",
121
+ "Sg",
122
+ "Bh",
123
+ "Hs",
124
+ "Mt",
125
+ "Ds",
126
+ "Rg",
127
+ "Cn",
128
+ "Nh",
129
+ "Fl",
130
+ "Mc",
131
+ "Lv",
132
+ "Ts",
133
+ "Og",
134
+ )
135
+
136
+ #: symbol -> atomic number (Z). "X" maps to 0.
137
+ SYMBOL_TO_Z: dict[str, int] = {sym: z for z, sym in enumerate(_SYMBOLS)}
138
+
139
+ #: The reserved symbol for an unidentified species (§3.3).
140
+ UNKNOWN_SYMBOL = "X"
141
+
142
+
143
+ def is_valid_symbol(symbol: str) -> bool:
144
+ """True if ``symbol`` is an element symbol or the reserved ``"X"`` (§3.3)."""
145
+ return symbol in SYMBOL_TO_Z
146
+
147
+
148
+ def atomic_number(symbol: str) -> int:
149
+ """Atomic number for ``symbol``. Raises ``KeyError`` for an unknown symbol."""
150
+ return SYMBOL_TO_Z[symbol]