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.
- xtalate/__init__.py +10 -0
- xtalate/capabilities/__init__.py +19 -0
- xtalate/capabilities/registry.py +126 -0
- xtalate/cli/__init__.py +11 -0
- xtalate/cli/main.py +431 -0
- xtalate/cli/render.py +126 -0
- xtalate/conversion/__init__.py +48 -0
- xtalate/conversion/engine.py +560 -0
- xtalate/conversion/preflight.py +127 -0
- xtalate/conversion/report.py +74 -0
- xtalate/discovery/__init__.py +28 -0
- xtalate/discovery/engine.py +202 -0
- xtalate/discovery/report.py +50 -0
- xtalate/discovery/sniffer.py +95 -0
- xtalate/exporters/__init__.py +34 -0
- xtalate/exporters/extxyz.py +140 -0
- xtalate/exporters/poscar.py +180 -0
- xtalate/exporters/xyz.py +67 -0
- xtalate/parsers/__init__.py +31 -0
- xtalate/parsers/_common.py +57 -0
- xtalate/parsers/extxyz.py +452 -0
- xtalate/parsers/poscar.py +428 -0
- xtalate/parsers/xyz.py +286 -0
- xtalate/py.typed +0 -0
- xtalate/recovery/__init__.py +41 -0
- xtalate/recovery/engine.py +321 -0
- xtalate/recovery/scenarios.py +86 -0
- xtalate/registry.py +28 -0
- xtalate/schema/__init__.py +40 -0
- xtalate/schema/arrays.py +115 -0
- xtalate/schema/elements.py +150 -0
- xtalate/schema/models.py +283 -0
- xtalate/schema/paths.py +88 -0
- xtalate/schema/presence.py +152 -0
- xtalate/sdk/__init__.py +27 -0
- xtalate/sdk/capabilities.py +57 -0
- xtalate/sdk/plugins.py +72 -0
- xtalate/sdk/results.py +45 -0
- xtalate/validation/__init__.py +25 -0
- xtalate/validation/engine.py +585 -0
- xtalate/validation/report.py +48 -0
- xtalate/validation/rethreshold.py +122 -0
- xtalate/validation/tolerance.py +125 -0
- xtalate-0.1.0.dev0.dist-info/METADATA +161 -0
- xtalate-0.1.0.dev0.dist-info/RECORD +49 -0
- xtalate-0.1.0.dev0.dist-info/WHEEL +4 -0
- xtalate-0.1.0.dev0.dist-info/entry_points.txt +2 -0
- xtalate-0.1.0.dev0.dist-info/licenses/LICENSE +201 -0
- 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
|