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
xtalate/parsers/xyz.py ADDED
@@ -0,0 +1,286 @@
1
+ """Plain XYZ parser (MASTER_SPEC Part 3 §3, §8.1).
2
+
3
+ Hand-rolled (DECISIONS.md D7): the grammar is trivial and hand-rolling avoids importing a
4
+ library that would invent defaults we would then have to launder back into absences. XYZ
5
+ carries exactly three things — element symbols, Cartesian positions (Å), and a free-text
6
+ comment line per frame — and *nothing* else. Every other canonical field is therefore
7
+ ``None`` on the resulting object; that is the absence convention (Part 2 §2), not a gap to
8
+ be filled.
9
+
10
+ Grammar (one or more concatenated frames)::
11
+
12
+ <atom count N>
13
+ <comment: free text, may be empty>
14
+ <symbol> <x> <y> <z> # N lines
15
+ ... # next frame's count, or EOF
16
+
17
+ Comment lines are information (they routinely hold frame labels / energies as free text),
18
+ so they are carried verbatim — one per frame — under
19
+ ``user_metadata.custom_per_frame["xyz:comment"]`` (Part 3 §6.1 carry-through rule).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import BinaryIO
25
+
26
+ import numpy as np
27
+ from pydantic import JsonValue
28
+
29
+ from xtalate.parsers._common import build_provenance
30
+ from xtalate.schema import (
31
+ AtomsBlock,
32
+ CanonicalObject,
33
+ Frame,
34
+ TrajectoryMetadata,
35
+ UserMetadata,
36
+ )
37
+ from xtalate.schema.elements import is_valid_symbol
38
+ from xtalate.sdk import (
39
+ CapabilityLevel,
40
+ FieldCapability,
41
+ FormatCapabilities,
42
+ ParseError,
43
+ ParseIssue,
44
+ ParseResult,
45
+ ParserPlugin,
46
+ )
47
+
48
+ FORMAT_ID = "xyz"
49
+ _COMMENT_KEY = "xyz:comment"
50
+ # extXYZ (an XYZ superset, Part 3 §3 n.2) puts key=value metadata on the comment line;
51
+ # these markers let plain XYZ yield to a future extXYZ parser (M3c) on such files (§6.1).
52
+ _EXTXYZ_MARKERS = ("Lattice=", "Properties=")
53
+
54
+
55
+ def _looks_like_coordinate(line: str) -> bool:
56
+ """True if ``line`` is ``<symbol> <float> <float> <float>`` (the XYZ atom row)."""
57
+ parts = line.split()
58
+ if len(parts) < 4:
59
+ return False
60
+ if not is_valid_symbol(parts[0]):
61
+ return False
62
+ try:
63
+ float(parts[1]), float(parts[2]), float(parts[3])
64
+ except ValueError:
65
+ return False
66
+ return True
67
+
68
+
69
+ class XyzParser(ParserPlugin):
70
+ format_id = FORMAT_ID
71
+ format_name = "Plain XYZ"
72
+ version = "0.1.0"
73
+ file_extensions = (".xyz",)
74
+
75
+ def sniff(self, head: bytes, filename: str | None) -> float:
76
+ # Cheap, never raises (§2): structural check on the first frame only.
77
+ text = head.decode("utf-8", errors="replace")
78
+ lines = text.splitlines()
79
+ if not lines:
80
+ return 0.0
81
+ try:
82
+ count = int(lines[0].strip())
83
+ except ValueError:
84
+ return 0.0 # first line is not an atom count — not XYZ
85
+ if count <= 0:
86
+ return 0.0
87
+ extxyz_comment = False
88
+ if len(lines) < 3:
89
+ # Header parses but we cannot see a coordinate row (tiny head); weak signal,
90
+ # leaned on the .xyz filename hint if present.
91
+ score = 0.4
92
+ else:
93
+ score = 0.9 if _looks_like_coordinate(lines[2]) else 0.3
94
+ extxyz_comment = any(marker in lines[1] for marker in _EXTXYZ_MARKERS)
95
+ if filename is not None and filename.lower().endswith(".xyz"):
96
+ score = max(score, 0.7)
97
+ if extxyz_comment:
98
+ # extXYZ territory (a superset): cap last — after the filename hint, which does
99
+ # not distinguish the two — so the more-expressive parser wins when registered.
100
+ score = min(score, 0.6)
101
+ return score
102
+
103
+ def parse(self, stream: BinaryIO, *, filename: str | None) -> ParseResult:
104
+ lines = stream.read().decode("utf-8").splitlines()
105
+ frames: list[Frame] = []
106
+ comments: list[JsonValue] = []
107
+ i = 0
108
+ n_lines = len(lines)
109
+
110
+ # Skip any leading blank lines, but a wholly blank/empty file is a parse error.
111
+ while i < n_lines and lines[i].strip() == "":
112
+ i += 1
113
+ if i >= n_lines:
114
+ raise ParseError(
115
+ [ParseIssue(severity="error", code="XYZ_EMPTY", message="file contains no frames")]
116
+ )
117
+
118
+ frame_index = 0
119
+ while i < n_lines:
120
+ header = lines[i].strip()
121
+ if header == "":
122
+ i += 1
123
+ continue # tolerate blank separators between frames
124
+ try:
125
+ count = int(header)
126
+ except ValueError as exc:
127
+ raise ParseError(
128
+ [
129
+ ParseIssue(
130
+ severity="error",
131
+ code="XYZ_MALFORMED_HEADER",
132
+ message=f"expected an integer atom count, found {header!r}",
133
+ location=f"line {i + 1}",
134
+ )
135
+ ]
136
+ ) from exc
137
+ if count <= 0:
138
+ raise ParseError(
139
+ [
140
+ ParseIssue(
141
+ severity="error",
142
+ code="XYZ_MALFORMED_HEADER",
143
+ message=f"atom count must be positive, found {count}",
144
+ location=f"line {i + 1}",
145
+ )
146
+ ]
147
+ )
148
+ # Comment line (may be empty; the line must still exist).
149
+ if i + 1 >= n_lines:
150
+ raise ParseError(
151
+ [
152
+ ParseIssue(
153
+ severity="error",
154
+ code="XYZ_INCONSISTENT_ATOM_COUNT",
155
+ message=(
156
+ f"frame {frame_index} declares {count} atoms but the file ends "
157
+ "before its comment line and coordinates"
158
+ ),
159
+ location=f"frame {frame_index}",
160
+ recovery_hint="truncate_at_last_valid_frame",
161
+ )
162
+ ]
163
+ )
164
+ comment = lines[i + 1]
165
+ body_start = i + 2
166
+ body_end = body_start + count
167
+ if body_end > n_lines:
168
+ found = n_lines - body_start
169
+ raise ParseError(
170
+ [
171
+ ParseIssue(
172
+ severity="error",
173
+ code="XYZ_INCONSISTENT_ATOM_COUNT",
174
+ message=(
175
+ f"frame {frame_index} declares {count} atoms but only {found} "
176
+ "coordinate lines are present before end of file"
177
+ ),
178
+ location=f"frame {frame_index}",
179
+ recovery_hint="truncate_at_last_valid_frame",
180
+ )
181
+ ]
182
+ )
183
+
184
+ symbols: list[str] = []
185
+ positions: list[list[float]] = []
186
+ for j in range(body_start, body_end):
187
+ parts = lines[j].split()
188
+ if len(parts) < 4:
189
+ # Fewer tokens than "<symbol> x y z" means the declared count is wrong:
190
+ # a coordinate row is missing (Part 3 §5 rule 4 — mid-file corruption).
191
+ raise ParseError(
192
+ [
193
+ ParseIssue(
194
+ severity="error",
195
+ code="XYZ_INCONSISTENT_ATOM_COUNT",
196
+ message=(
197
+ f"frame {frame_index} declares {count} atoms but line "
198
+ f"{j + 1} is not a '<symbol> x y z' coordinate row: "
199
+ f"{lines[j]!r}"
200
+ ),
201
+ location=f"frame {frame_index}",
202
+ recovery_hint="truncate_at_last_valid_frame",
203
+ )
204
+ ]
205
+ )
206
+ symbol = parts[0]
207
+ if not is_valid_symbol(symbol):
208
+ raise ParseError(
209
+ [
210
+ ParseIssue(
211
+ severity="error",
212
+ code="XYZ_INVALID_SYMBOL",
213
+ message=(
214
+ f"unknown element symbol {symbol!r} at line {j + 1} "
215
+ "(use 'X' for a genuinely unknown species, Part 2 §3.3)"
216
+ ),
217
+ location=f"line {j + 1}",
218
+ )
219
+ ]
220
+ )
221
+ try:
222
+ xyz = [float(parts[1]), float(parts[2]), float(parts[3])]
223
+ except ValueError as exc:
224
+ raise ParseError(
225
+ [
226
+ ParseIssue(
227
+ severity="error",
228
+ code="XYZ_MALFORMED_COORDINATE",
229
+ message=f"non-numeric coordinate at line {j + 1}: {lines[j]!r}",
230
+ location=f"line {j + 1}",
231
+ )
232
+ ]
233
+ ) from exc
234
+ symbols.append(symbol)
235
+ positions.append(xyz)
236
+
237
+ frames.append(
238
+ Frame(
239
+ index=frame_index,
240
+ atoms=AtomsBlock(symbols=symbols, positions=np.asarray(positions, dtype=float)),
241
+ )
242
+ )
243
+ comments.append(comment)
244
+ frame_index += 1
245
+ i = body_end
246
+
247
+ # Comment lines are information — one per frame, carried verbatim (§6.1).
248
+ user_metadata = UserMetadata(custom_per_frame={_COMMENT_KEY: comments})
249
+ provenance = build_provenance(
250
+ format_id=FORMAT_ID,
251
+ filename=filename,
252
+ original_coordinate_system="cartesian",
253
+ source_units={"positions": "angstrom"},
254
+ parse_notes=[
255
+ "comment lines preserved in user_metadata.custom_per_frame['xyz:comment']"
256
+ ],
257
+ )
258
+ # A single-frame XYZ is a static structure (trajectory=None, Part 2 §3.2); multiple
259
+ # frames form a trajectory whose source declares no time base (timestep=None, §8.1).
260
+ trajectory = None if len(frames) == 1 else TrajectoryMetadata(timestep=None)
261
+ canonical = CanonicalObject(
262
+ frames=frames,
263
+ trajectory=trajectory,
264
+ provenance=provenance,
265
+ user_metadata=user_metadata,
266
+ )
267
+ return ParseResult(canonical=canonical)
268
+
269
+ def capabilities(self) -> FormatCapabilities:
270
+ full = FieldCapability(level=CapabilityLevel.FULL)
271
+ return FormatCapabilities(
272
+ format_id=FORMAT_ID,
273
+ format_name=self.format_name,
274
+ direction="read",
275
+ fields={
276
+ "atoms.symbols": full,
277
+ "atoms.positions": full,
278
+ "user_metadata.custom_per_frame": FieldCapability(
279
+ level=CapabilityLevel.FULL, notes="Free-text comment line, one per frame."
280
+ ),
281
+ },
282
+ max_frames=None,
283
+ required_fields=[],
284
+ native_coordinate_system="cartesian",
285
+ lossy_notes=[],
286
+ )
xtalate/py.typed ADDED
File without changes
@@ -0,0 +1,41 @@
1
+ """Recovery Engine — explicit, never-guessed handling of target-required-but-absent fields.
2
+
3
+ Implements the three-way hazard model and the fabricative bright line (Part 4 §3):
4
+ every applied recovery records an ``Assumption`` (and a ``supplied`` entry when it
5
+ fabricates); no default is ever applied silently. v0.1 scenarios: ``missing_lattice``
6
+ and ``frame_selection`` (preset-only). Implemented in M5.
7
+
8
+ Sits below ``conversion`` in the import graph (Part 1 §5.1), so it returns plain result
9
+ types (``AppliedAssumption`` etc.) the ``ConversionEngine`` maps onto the Conversion Report;
10
+ it never imports ``conversion``.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from xtalate.recovery.engine import (
16
+ AppliedAssumption,
17
+ FrameDrop,
18
+ RecoveryEngine,
19
+ RecoveryError,
20
+ RecoveryResult,
21
+ SuppliedField,
22
+ )
23
+ from xtalate.recovery.scenarios import (
24
+ SCENARIO_HAZARD,
25
+ HazardClass,
26
+ UnresolvedScenario,
27
+ available_options,
28
+ )
29
+
30
+ __all__ = [
31
+ "SCENARIO_HAZARD",
32
+ "AppliedAssumption",
33
+ "FrameDrop",
34
+ "HazardClass",
35
+ "RecoveryEngine",
36
+ "RecoveryError",
37
+ "RecoveryResult",
38
+ "SuppliedField",
39
+ "UnresolvedScenario",
40
+ "available_options",
41
+ ]
@@ -0,0 +1,321 @@
1
+ """The Recovery Engine — explicit, never-guessed resolution of conversion hazards (Part 4 §3).
2
+
3
+ Given the scenarios the pre-flight diff could not resolve and a set of caller-supplied choices
4
+ (v0.1 is **preset-only**, DECISIONS.md D22), the engine either resolves *every* scenario — each
5
+ producing exactly one recorded ``Assumption`` — or resolves **none** and reports the refusal
6
+ (Part 4 §3.2: "refusal is the default"; there is no timeout-triggered or per-scenario silent
7
+ fallback). It never applies a choice the caller did not name (**P4**).
8
+
9
+ Layering (Part 1 §5.1). This module sits *below* ``conversion`` in the import graph, so it may
10
+ not import the Conversion Report schema (``conversion.report``). It returns its own plain result
11
+ types (``AppliedAssumption``, ``SuppliedField``, ``FrameDrop``); the ``ConversionEngine`` (top
12
+ layer) maps them onto ``Assumption``/``SuppliedEntry``/``RemovedEntry`` for the report. It depends
13
+ only on ``schema``.
14
+
15
+ **Dependency ordering (Part 4 §3.3).** ``frame_selection`` is resolved before ``missing_lattice``
16
+ because a ``bounding_box`` lattice is computed on the *selected* frame's positions. Assumptions are
17
+ numbered ``A1, A2, …`` in application order, matching the worked example (A1 = frame_selection,
18
+ A2 = missing_lattice; Part 4 §5).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass, field
24
+ from typing import Any
25
+
26
+ import numpy as np
27
+
28
+ from xtalate.recovery.scenarios import (
29
+ SCENARIO_HAZARD,
30
+ UnresolvedScenario,
31
+ available_options,
32
+ )
33
+ from xtalate.schema import AtomsBlock, CanonicalObject, Cell, Frame
34
+
35
+ # Resolution order (Part 4 §3.3): a bounding box is computed on the frame chosen by
36
+ # frame_selection, so frame_selection must run first.
37
+ _DEP_ORDER = ("frame_selection", "missing_lattice")
38
+
39
+
40
+ class RecoveryError(ValueError):
41
+ """A preset named an option that is not offered for this pair, or omitted a required
42
+ parameter. Distinct from a *refusal* (no choice supplied): a refusal is a legitimate
43
+ conversion outcome (Part 4 §4); an invalid preset is a caller error."""
44
+
45
+
46
+ @dataclass
47
+ class SuppliedField:
48
+ """One canonical field a fabricative recovery wrote into the object (Part 4 §2, `supplied`)."""
49
+
50
+ path: str
51
+ detail: str | None = None
52
+
53
+
54
+ @dataclass
55
+ class FrameDrop:
56
+ """The `removed` accounting for a selective-reductive frame reduction (Part 4 §5 removed[0])."""
57
+
58
+ path: str
59
+ reason: str
60
+ detail: str
61
+
62
+
63
+ @dataclass
64
+ class AppliedAssumption:
65
+ """One recorded recovery decision (Part 4 §2 `Assumption`), plus its field-level effects.
66
+
67
+ ``supplied`` is non-empty only for fabricative scenarios; ``removed`` only for selective-
68
+ reductive ones. The two never overlap — the bright line of Part 4 §3.1."""
69
+
70
+ id: str
71
+ scenario: str
72
+ choice: str
73
+ parameters: dict[str, Any]
74
+ origin: str # "preset" | "user". v0.1 is preset-only (D22).
75
+ description: str
76
+ supplied: list[SuppliedField] = field(default_factory=list)
77
+ removed: list[FrameDrop] = field(default_factory=list)
78
+
79
+
80
+ @dataclass
81
+ class RecoveryResult:
82
+ """Outcome of ``resolve``. ``canonical`` is ``None`` iff the conversion is refused, in which
83
+ case ``unresolved`` lists the scenarios that lacked a choice (Part 4 §3.2)."""
84
+
85
+ canonical: CanonicalObject | None
86
+ assumptions: list[AppliedAssumption]
87
+ unresolved: list[UnresolvedScenario]
88
+
89
+
90
+ class RecoveryEngine:
91
+ def resolve(
92
+ self,
93
+ source: CanonicalObject,
94
+ scenarios: list[UnresolvedScenario],
95
+ recovery_choices: dict[str, dict[str, Any]],
96
+ *,
97
+ origin: str = "preset",
98
+ ) -> RecoveryResult:
99
+ """Resolve every scenario in ``scenarios`` using ``recovery_choices`` (keyed by scenario
100
+ code, each ``{"choice": str, "parameters": {...}}``), or refuse if any lacks a choice.
101
+
102
+ Resolution is all-or-nothing: a single missing choice refuses the whole conversion, so
103
+ no partially-recovered object is ever produced (a half-recovered structure presented as
104
+ complete would be the very silent failure this engine exists to prevent)."""
105
+ # A scenario is unresolvable if v0.1 does not know how to resolve it, or the caller
106
+ # supplied no choice for it. Both refuse (Part 4 §3.2).
107
+ unresolved = [
108
+ s
109
+ for s in scenarios
110
+ if s.scenario not in SCENARIO_HAZARD or s.scenario not in recovery_choices
111
+ ]
112
+ if unresolved:
113
+ return RecoveryResult(canonical=None, assumptions=[], unresolved=unresolved)
114
+
115
+ working = source
116
+ assumptions: list[AppliedAssumption] = []
117
+ selected_source_index = 0 # threaded from frame_selection into bounding_box's description.
118
+ counter = 1
119
+ for scenario_code in _DEP_ORDER:
120
+ match = next((s for s in scenarios if s.scenario == scenario_code), None)
121
+ if match is None:
122
+ continue
123
+ aid = f"A{counter}"
124
+ counter += 1
125
+ choice = recovery_choices[scenario_code]
126
+ if scenario_code == "frame_selection":
127
+ working, applied, selected_source_index = _apply_frame_selection(
128
+ working, aid, choice, origin
129
+ )
130
+ else: # missing_lattice
131
+ working, applied = _apply_missing_lattice(
132
+ working, aid, choice, origin, computed_on_frame=selected_source_index
133
+ )
134
+ assumptions.append(applied)
135
+
136
+ return RecoveryResult(canonical=working, assumptions=assumptions, unresolved=[])
137
+
138
+
139
+ def _choice_code(choice: dict[str, Any], scenario: str) -> str:
140
+ code = choice.get("choice")
141
+ if not isinstance(code, str) or code not in available_options(scenario):
142
+ raise RecoveryError(
143
+ f"{scenario!r}: choice {code!r} is not an offered option "
144
+ f"{available_options(scenario)!r}"
145
+ )
146
+ return code
147
+
148
+
149
+ def _apply_frame_selection(
150
+ canonical: CanonicalObject, aid: str, choice: dict[str, Any], origin: str
151
+ ) -> tuple[CanonicalObject, AppliedAssumption, int]:
152
+ """Reduce a multi-frame object to the single chosen frame (selective reductive, Part 4 §3.1).
153
+
154
+ Records an ``Assumption`` and a ``FrameDrop`` (the dropped frames as a `removed` entry) but
155
+ **no** ``SuppliedField`` — the retained frame is genuine source data, not fabricated."""
156
+ code = _choice_code(choice, "frame_selection")
157
+ params = choice.get("parameters", {}) or {}
158
+ n = canonical.frame_count
159
+ if code == "first":
160
+ index = 0
161
+ elif code == "last":
162
+ index = n - 1
163
+ else: # "index"
164
+ raw = params.get("frame_index")
165
+ if not isinstance(raw, int) or isinstance(raw, bool) or not (0 <= raw < n):
166
+ raise RecoveryError(
167
+ f"frame_selection 'index' needs an in-range integer frame_index (0..{n - 1}), "
168
+ f"got {raw!r}"
169
+ )
170
+ index = raw
171
+
172
+ selected = canonical.frames[index]
173
+ reduced_frame = selected.model_copy(update={"index": 0})
174
+ # custom_per_frame arrays carry first-dim = frame count (Part 2 §3.10); slice to the kept frame.
175
+ um = canonical.user_metadata
176
+ sliced_per_frame: dict[str, Any] = {}
177
+ for key, val in um.custom_per_frame.items():
178
+ sliced_per_frame[key] = (
179
+ val[index : index + 1] if isinstance(val, np.ndarray) else [val[index]]
180
+ )
181
+ new_um = um.model_copy(update={"custom_per_frame": sliced_per_frame})
182
+
183
+ reduced = canonical.model_copy(
184
+ update={"frames": [reduced_frame], "trajectory": None, "user_metadata": new_um}
185
+ )
186
+ dropped = n - 1
187
+ assumption = AppliedAssumption(
188
+ id=aid,
189
+ scenario="frame_selection",
190
+ choice=code,
191
+ parameters={"frame_index": index},
192
+ origin=origin,
193
+ description=(
194
+ f"Frame {index} of {n} selected for the single-structure target; the other "
195
+ f"{dropped} frame(s) are dropped. Which frame survives changes the scientific "
196
+ "meaning of the output, so this is a recorded choice, not a silent default."
197
+ ),
198
+ removed=[
199
+ FrameDrop(
200
+ path="atoms.positions",
201
+ reason="Target format stores a single structure (max_frames = 1).",
202
+ detail=f"{dropped} of {n} frames dropped; frame {index} retained per {aid}.",
203
+ )
204
+ ],
205
+ )
206
+ return reduced, assumption, index
207
+
208
+
209
+ def _apply_missing_lattice(
210
+ canonical: CanonicalObject,
211
+ aid: str,
212
+ choice: dict[str, Any],
213
+ origin: str,
214
+ *,
215
+ computed_on_frame: int,
216
+ ) -> tuple[CanonicalObject, AppliedAssumption]:
217
+ """Fabricate the target-required lattice the source lacks (fabricative, Part 4 §3.1).
218
+
219
+ Writes ``cell.lattice_vectors`` and ``cell.pbc`` into every frame and records an
220
+ ``Assumption`` **and** two ``SuppliedField`` entries — the cell did not exist in the source,
221
+ so it is filed as created, never carried (**P4**). ``pbc`` is set to (T,T,T): POSCAR, the only
222
+ v0.1 lattice-requiring target, is fully periodic by definition (Part 3 §3 n.3)."""
223
+ code = _choice_code(choice, "missing_lattice")
224
+ params = choice.get("parameters", {}) or {}
225
+ pbc = (True, True, True)
226
+
227
+ if code == "manual_input":
228
+ raw = params.get("lattice")
229
+ lattice = _as_lattice(raw)
230
+ new_frames = [_frame_with_cell(f, lattice, pbc) for f in canonical.frames]
231
+ report_params: dict[str, Any] = {"lattice_ang": lattice.tolist()}
232
+ description = (
233
+ "Lattice supplied manually by the caller; pbc set to (T,T,T) as required by the "
234
+ "target. The source expressed no lattice — this cell is an artifact of conversion, "
235
+ "not simulation data."
236
+ )
237
+ else: # "bounding_box"
238
+ padding = params.get("padding_ang")
239
+ if not isinstance(padding, (int, float)) or padding < 0:
240
+ raise RecoveryError(
241
+ f"missing_lattice 'bounding_box' needs a non-negative padding_ang, got {padding!r}"
242
+ )
243
+ padding = float(padding)
244
+ # Box is computed on the (already-selected) single frame; if several frames remain the
245
+ # same box is applied to each (no v0.1 pair reaches here multi-frame).
246
+ positions = canonical.frames[0].atoms.positions
247
+ lattice, shift = _bounding_box(positions, padding)
248
+ new_frames = [_frame_with_cell(f, lattice, pbc, shift=shift) for f in canonical.frames]
249
+ report_params = {"padding_ang": padding, "computed_on_frame": computed_on_frame}
250
+ description = (
251
+ f"Lattice constructed as the axis-aligned bounding box of frame {computed_on_frame}'s "
252
+ f"positions plus {padding} Å padding on each side; atoms rigidly translated into that "
253
+ "box (a shift preserves all interatomic distances). pbc set to (T,T,T) as required by "
254
+ "the target. The source expressed no lattice — this cell is a conversion artifact, "
255
+ "not simulation data."
256
+ )
257
+
258
+ updated = canonical.model_copy(update={"frames": new_frames})
259
+ assumption = AppliedAssumption(
260
+ id=aid,
261
+ scenario="missing_lattice",
262
+ choice=code,
263
+ parameters=report_params,
264
+ origin=origin,
265
+ description=description,
266
+ supplied=[
267
+ SuppliedField(
268
+ path="cell.lattice_vectors",
269
+ detail="3×3 lattice fabricated by recovery — not present in the source.",
270
+ ),
271
+ SuppliedField(
272
+ path="cell.pbc",
273
+ detail="(T, T, T) — required by the target; set by the same recovery choice.",
274
+ ),
275
+ ],
276
+ )
277
+ return updated, assumption
278
+
279
+
280
+ def _bounding_box(positions: np.ndarray, padding: float) -> tuple[np.ndarray, np.ndarray]:
281
+ """Return (orthorhombic lattice, translation) for an axis-aligned box of ``positions`` with
282
+ ``padding`` on every side. Atoms are shifted by ``-min + padding`` so they sit inside the box
283
+ with a ``padding`` margin from each face; the box side per axis is ``extent + 2·padding``."""
284
+ lo = positions.min(axis=0)
285
+ hi = positions.max(axis=0)
286
+ extent = hi - lo
287
+ side = extent + 2.0 * padding
288
+ lattice = np.diag(side).astype(np.float64)
289
+ shift = padding - lo
290
+ return lattice, shift
291
+
292
+
293
+ def _frame_with_cell(
294
+ frame: Frame,
295
+ lattice: np.ndarray,
296
+ pbc: tuple[bool, bool, bool],
297
+ *,
298
+ shift: np.ndarray | None = None,
299
+ ) -> Frame:
300
+ atoms = frame.atoms
301
+ if shift is not None:
302
+ atoms = AtomsBlock(
303
+ symbols=list(atoms.symbols),
304
+ positions=atoms.positions + shift,
305
+ masses=atoms.masses,
306
+ )
307
+ return frame.model_copy(update={"atoms": atoms, "cell": Cell(lattice_vectors=lattice, pbc=pbc)})
308
+
309
+
310
+ def _as_lattice(raw: Any) -> np.ndarray:
311
+ try:
312
+ lattice = np.asarray(raw, dtype=np.float64)
313
+ except (TypeError, ValueError) as exc:
314
+ raise RecoveryError(
315
+ f"missing_lattice 'manual_input' lattice is not numeric: {exc}"
316
+ ) from exc
317
+ if lattice.shape != (3, 3):
318
+ raise RecoveryError(
319
+ f"missing_lattice 'manual_input' needs a 3×3 lattice, got shape {lattice.shape}"
320
+ )
321
+ return lattice