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
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The Conversion Report — exact schema (MASTER_SPEC Part 4 §2).
|
|
2
|
+
|
|
3
|
+
The structured record of what a conversion kept, dropped, fabricated, transformed, or
|
|
4
|
+
assumed — **Preserved / Removed / Supplied / Assumptions / Warnings** (Part 0 §6 plus the
|
|
5
|
+
normative `Supplied` addition of §2). One schema serves both the *pre-flight draft* (shown
|
|
6
|
+
before conversion; Part 3 §4.3) and the *final report*, distinguished by `stage`, so the
|
|
7
|
+
promise and the record are structurally comparable and any divergence is itself a defect the
|
|
8
|
+
Validation Engine flags (Part 5).
|
|
9
|
+
|
|
10
|
+
`Removed` entries each carry their own `reason` — "Reason" is not a separate list (§2).
|
|
11
|
+
`Supplied` and `Assumptions` are one-to-(one-or-more): an `Assumption` records the *decision*,
|
|
12
|
+
each `SuppliedEntry` the *canonical field that decision wrote* (§2). Every field is a canonical
|
|
13
|
+
path (Part 2 §3); these are the vocabulary the completeness invariant (§2) is stated over.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any, Literal
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class _Model(BaseModel):
|
|
24
|
+
model_config = ConfigDict(extra="forbid")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PreservedEntry(_Model):
|
|
28
|
+
path: str # Canonical field path, e.g. "atoms.positions" (Part 2 §3).
|
|
29
|
+
detail: str | None = None # e.g. "1 frame × 64 atoms", "converted to fractional (Direct)".
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class RemovedEntry(_Model):
|
|
33
|
+
path: str # Canonical path present in the source but absent from the output.
|
|
34
|
+
reason: str # REQUIRED. From the target FieldCapability.notes, or generated from the level.
|
|
35
|
+
detail: str | None = None # e.g. "10 frames × 64 atoms × 3 dropped".
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SuppliedEntry(_Model):
|
|
39
|
+
path: str # Canonical path fabricated by Recovery and written out — absent on the source.
|
|
40
|
+
from_assumption: str # REQUIRED. The Assumption.id that authorized this value (P4).
|
|
41
|
+
detail: str | None = None # e.g. "3×3 lattice; pbc (T,T,T) — bounding box of frame 9 + 5 Å".
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class Assumption(_Model):
|
|
45
|
+
id: str # Stable per-report identifier, e.g. "A1".
|
|
46
|
+
scenario: str # Machine code: "missing_lattice", "frame_selection", … (Part 4 §3).
|
|
47
|
+
choice: str # Machine code of the selected option: "bounding_box", … (Part 4 §3).
|
|
48
|
+
parameters: dict[str, Any] = Field(default_factory=dict) # e.g. {"padding_ang": 5.0}.
|
|
49
|
+
origin: Literal["user", "preset"] # Interactive choice vs pre-supplied in the API call.
|
|
50
|
+
description: str # Human-readable sentence describing the decision.
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ReportWarning(_Model):
|
|
54
|
+
code: str # Stable machine code, e.g. "COORDINATE_REPRESENTATION_CHANGED".
|
|
55
|
+
message: str
|
|
56
|
+
# ParseIssue echo (Part 3 §5 rule 5), lossy_notes/capability caveat, or exporter transform.
|
|
57
|
+
source: Literal["parse", "capability", "export"]
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class ConversionReport(_Model):
|
|
61
|
+
report_id: str # UUID.
|
|
62
|
+
stage: Literal["preflight", "final"]
|
|
63
|
+
status: Literal["completed", "awaiting_recovery", "refused"]
|
|
64
|
+
mode: Literal["strict", "permissive"] # Part 4 §4.
|
|
65
|
+
created_at: str # ISO 8601 UTC.
|
|
66
|
+
source: dict[str, Any] # { format_id, filename, sha256, schema_version }.
|
|
67
|
+
target: dict[str, Any] # { format_id, filename }.
|
|
68
|
+
preserved: list[PreservedEntry] = Field(default_factory=list)
|
|
69
|
+
removed: list[RemovedEntry] = Field(default_factory=list) # Every entry carries its Reason.
|
|
70
|
+
supplied: list[SuppliedEntry] = Field(default_factory=list) # [] = nothing fabricated.
|
|
71
|
+
assumptions: list[Assumption] = Field(default_factory=list) # [] = no fabricated information.
|
|
72
|
+
warnings: list[ReportWarning] = Field(default_factory=list)
|
|
73
|
+
# Populated iff status="refused": { code, message, unresolved_scenarios: [...] } (Part 4 §4).
|
|
74
|
+
refusal: dict[str, Any] | None = None
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Discovery — Format Sniffer + Information Discovery Engine (Part 3 §6).
|
|
2
|
+
|
|
3
|
+
Generic by construction: no per-format logic. Sniffs via each registered parser's
|
|
4
|
+
``sniff()`` (incl. the POSCAR⇄CONTCAR filename rule, Part 3 §6.1), parses via the
|
|
5
|
+
winning parser, and produces the ``DiscoveryReport`` from ``field_presence()``.
|
|
6
|
+
Sniffer + registry in M2; the Discovery Report in M6's inspect command.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from xtalate.discovery.engine import DiscoveryEngine
|
|
10
|
+
from xtalate.discovery.report import DiscoveryReport, FieldPresenceEntry
|
|
11
|
+
from xtalate.discovery.sniffer import (
|
|
12
|
+
DEFAULT_ACCEPT_THRESHOLD,
|
|
13
|
+
DEFAULT_AMBIGUITY_MARGIN,
|
|
14
|
+
SniffCandidate,
|
|
15
|
+
Sniffer,
|
|
16
|
+
SniffResult,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
__all__ = [
|
|
20
|
+
"DEFAULT_ACCEPT_THRESHOLD",
|
|
21
|
+
"DEFAULT_AMBIGUITY_MARGIN",
|
|
22
|
+
"DiscoveryEngine",
|
|
23
|
+
"DiscoveryReport",
|
|
24
|
+
"FieldPresenceEntry",
|
|
25
|
+
"SniffCandidate",
|
|
26
|
+
"SniffResult",
|
|
27
|
+
"Sniffer",
|
|
28
|
+
]
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""The Information Discovery Engine (MASTER_SPEC Part 3 §6.1).
|
|
2
|
+
|
|
3
|
+
Generic by construction: **sniff → parse → introspect → report**, with no per-format logic.
|
|
4
|
+
The winning parser produces the Canonical Object under the full absence convention, and the
|
|
5
|
+
report is derived from ``field_presence()`` — so *discovery is exactly as trustworthy as
|
|
6
|
+
parsing* (§6.1): there is no separate lightweight "peek" path that could disagree with a real
|
|
7
|
+
conversion. A file whose format cannot be determined, or that fails to parse, raises
|
|
8
|
+
``ParseError`` — the same error contract every parser obeys (§5), which the CLI maps to its
|
|
9
|
+
parse-error exit code.
|
|
10
|
+
|
|
11
|
+
Layering (Part 1 §5.1): ``discovery`` sits above ``capabilities``/``parsers``/``exporters``,
|
|
12
|
+
so it drives the registry and reads the Capability Matrix, but is itself consumed only by
|
|
13
|
+
``conversion`` and the CLI.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import hashlib
|
|
19
|
+
from io import BytesIO
|
|
20
|
+
|
|
21
|
+
from xtalate.capabilities import Registry
|
|
22
|
+
from xtalate.discovery.report import DiscoveryReport, FieldPresenceEntry
|
|
23
|
+
from xtalate.discovery.sniffer import Sniffer, SniffResult
|
|
24
|
+
from xtalate.schema import CanonicalObject
|
|
25
|
+
from xtalate.sdk import CapabilityLevel, ParseError, ParseIssue
|
|
26
|
+
|
|
27
|
+
# The canonical scientific leaf paths the Discovery Report is complete over (Part 3 §6.2, §6.3):
|
|
28
|
+
# Part 2 §3.3–§3.7 plus frame.time and trajectory.timestep, in the §6.3 worked-example order.
|
|
29
|
+
# `atoms.atomic_numbers` (a derived mirror of symbols) and the metadata containers are excluded —
|
|
30
|
+
# they are summarized in `structure`/`extras`, not enumerated as losable leaf fields.
|
|
31
|
+
_LEAF_PATHS: tuple[str, ...] = (
|
|
32
|
+
"atoms.symbols",
|
|
33
|
+
"atoms.positions",
|
|
34
|
+
"atoms.masses",
|
|
35
|
+
"frame.time",
|
|
36
|
+
"cell.lattice_vectors",
|
|
37
|
+
"cell.pbc",
|
|
38
|
+
"cell.space_group",
|
|
39
|
+
"trajectory.timestep",
|
|
40
|
+
"dynamics.velocities",
|
|
41
|
+
"dynamics.forces",
|
|
42
|
+
"dynamics.constraints",
|
|
43
|
+
"electronic.total_energy",
|
|
44
|
+
"electronic.stress",
|
|
45
|
+
"electronic.charges",
|
|
46
|
+
"electronic.magnetic_moments",
|
|
47
|
+
"electronic.total_spin",
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class DiscoveryEngine:
|
|
52
|
+
def __init__(self, registry: Registry) -> None:
|
|
53
|
+
self._registry = registry
|
|
54
|
+
self._sniffer = Sniffer(registry)
|
|
55
|
+
|
|
56
|
+
def discover(
|
|
57
|
+
self, data: bytes, *, filename: str | None = None, format_override: str | None = None
|
|
58
|
+
) -> DiscoveryReport:
|
|
59
|
+
"""Inspect ``data`` and return its Discovery Report (Part 3 §6). ``format_override``
|
|
60
|
+
forces a parser (the sniff-override of §6.1); otherwise the sniffer selects one, and an
|
|
61
|
+
undetermined format raises ``ParseError`` (nothing can be inspected without a parser)."""
|
|
62
|
+
sniff = self._sniffer.sniff(data, filename)
|
|
63
|
+
format_id = format_override or sniff.format_id
|
|
64
|
+
if format_id is None:
|
|
65
|
+
raise ParseError(
|
|
66
|
+
[
|
|
67
|
+
ParseIssue(
|
|
68
|
+
severity="error",
|
|
69
|
+
code="UNKNOWN_FORMAT",
|
|
70
|
+
message=(
|
|
71
|
+
"no registered format matched with sufficient confidence "
|
|
72
|
+
f"(top score {sniff.confidence:.2f}); pass an explicit "
|
|
73
|
+
"format to override"
|
|
74
|
+
),
|
|
75
|
+
)
|
|
76
|
+
]
|
|
77
|
+
)
|
|
78
|
+
if format_id not in {p.format_id for p in self._registry.parsers()}:
|
|
79
|
+
raise ParseError(
|
|
80
|
+
[
|
|
81
|
+
ParseIssue(
|
|
82
|
+
severity="error",
|
|
83
|
+
code="UNKNOWN_FORMAT",
|
|
84
|
+
message=f"no parser registered for format {format_id!r}",
|
|
85
|
+
)
|
|
86
|
+
]
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
result = self._registry.get_parser(format_id).parse(BytesIO(data), filename=filename)
|
|
90
|
+
canonical = result.canonical
|
|
91
|
+
|
|
92
|
+
return DiscoveryReport(
|
|
93
|
+
file={
|
|
94
|
+
"filename": filename,
|
|
95
|
+
"size_bytes": len(data),
|
|
96
|
+
"sha256": hashlib.sha256(data).hexdigest(),
|
|
97
|
+
},
|
|
98
|
+
format=self._format_block(format_id, sniff, format_override),
|
|
99
|
+
structure=_structure(canonical),
|
|
100
|
+
fields=self._fields(canonical, format_id),
|
|
101
|
+
extras=_extras(canonical),
|
|
102
|
+
issues=list(result.issues),
|
|
103
|
+
schema_version=canonical.schema_version,
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def _format_block(
|
|
107
|
+
self, format_id: str, sniff: SniffResult, override: str | None
|
|
108
|
+
) -> dict[str, object]:
|
|
109
|
+
caps = self._registry.capability_matrix()
|
|
110
|
+
try:
|
|
111
|
+
format_name = caps.get(format_id, "read").format_name
|
|
112
|
+
except KeyError:
|
|
113
|
+
format_name = format_id
|
|
114
|
+
# Confidence is the sniffer's own score for the *selected* format — one honest rule for
|
|
115
|
+
# both paths: the winning score when the sniffer chose, or the overridden format's real
|
|
116
|
+
# score when the caller forced one (never a fabricated 1.0; the override is recorded
|
|
117
|
+
# separately). A forced format that sniffed poorly shows its poor score, by design.
|
|
118
|
+
confidence = next((c.confidence for c in sniff.candidates if c.format_id == format_id), 0.0)
|
|
119
|
+
# Evidence = every *other* scored candidate, so the reader sees the full ranking that led
|
|
120
|
+
# to this pick — including the format the sniffer *would* have chosen when overridden
|
|
121
|
+
# (P1: show the alternatives, never just the winner).
|
|
122
|
+
evidence = [
|
|
123
|
+
{"format_id": c.format_id, "confidence": c.confidence}
|
|
124
|
+
for c in sniff.candidates
|
|
125
|
+
if c.format_id != format_id
|
|
126
|
+
]
|
|
127
|
+
return {
|
|
128
|
+
"format_id": format_id,
|
|
129
|
+
"format_name": format_name,
|
|
130
|
+
"confidence": confidence,
|
|
131
|
+
"overridden": override is not None,
|
|
132
|
+
"ambiguous": sniff.ambiguous,
|
|
133
|
+
"sniff_evidence": evidence,
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
def _fields(self, canonical: CanonicalObject, format_id: str) -> list[FieldPresenceEntry]:
|
|
137
|
+
presence = canonical.field_presence()
|
|
138
|
+
by_path = {e.path: e for e in presence.entries}
|
|
139
|
+
caps = self._registry.capability_matrix()
|
|
140
|
+
entries: list[FieldPresenceEntry] = []
|
|
141
|
+
for path in _LEAF_PATHS:
|
|
142
|
+
pres = by_path.get(path)
|
|
143
|
+
status = pres.status if pres is not None else "absent"
|
|
144
|
+
present_frames = pres.present_frames if pres is not None else None
|
|
145
|
+
level = caps.field_capability(format_id, "read", path).level
|
|
146
|
+
detail = _detail(canonical, path) if status != "absent" else None
|
|
147
|
+
entries.append(
|
|
148
|
+
FieldPresenceEntry(
|
|
149
|
+
path=path,
|
|
150
|
+
status=status,
|
|
151
|
+
present_frames=present_frames,
|
|
152
|
+
format_capability=level,
|
|
153
|
+
detail=detail,
|
|
154
|
+
)
|
|
155
|
+
)
|
|
156
|
+
return entries
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _structure(obj: CanonicalObject) -> dict[str, object]:
|
|
160
|
+
symbols = obj.frames[0].atoms.symbols if obj.frames else []
|
|
161
|
+
# Constant atom count across frames (Part 2 §3.2 invariant), so a single integer suffices.
|
|
162
|
+
species: list[str] = []
|
|
163
|
+
for sym in symbols:
|
|
164
|
+
if sym not in species:
|
|
165
|
+
species.append(sym) # first-occurrence order, matching the §6.3 example (["O", "H"]).
|
|
166
|
+
return {"frame_count": obj.frame_count, "atom_count": len(symbols), "species": species}
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _extras(obj: CanonicalObject) -> list[str]:
|
|
170
|
+
"""Carried-through keys not part of the fixed leaf schema (Part 3 §6.2): the per-file
|
|
171
|
+
``custom_*`` namespaces and ``simulation.extra`` keys, reported at container granularity."""
|
|
172
|
+
um = obj.user_metadata
|
|
173
|
+
extras: list[str] = []
|
|
174
|
+
for container, keys in (
|
|
175
|
+
("user_metadata.custom_global", um.custom_global),
|
|
176
|
+
("user_metadata.custom_per_atom", um.custom_per_atom),
|
|
177
|
+
("user_metadata.custom_per_frame", um.custom_per_frame),
|
|
178
|
+
):
|
|
179
|
+
extras.extend(f"{container}['{key}']" for key in keys)
|
|
180
|
+
if obj.simulation is not None and obj.simulation.extra:
|
|
181
|
+
extras.extend(f"simulation.extra['{key}']" for key in obj.simulation.extra)
|
|
182
|
+
return extras
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _detail(obj: CanonicalObject, path: str) -> str | None:
|
|
186
|
+
"""A short human-readable descriptor for a present field (Part 3 §6.3). Detail richness is a
|
|
187
|
+
documented cut-line (IMPLEMENTATION_PLAN M6); the high-value cases are covered, others None."""
|
|
188
|
+
frames = obj.frame_count
|
|
189
|
+
atoms = len(obj.frames[0].atoms.symbols) if obj.frames else 0
|
|
190
|
+
if path == "atoms.symbols":
|
|
191
|
+
return ", ".join(obj.frames[0].atoms.symbols) if obj.frames else None
|
|
192
|
+
if path == "atoms.positions":
|
|
193
|
+
return f"{frames} frame(s) × {atoms} atoms, Cartesian (Å)"
|
|
194
|
+
if path == "cell.lattice_vectors":
|
|
195
|
+
return "3×3 lattice (Å)"
|
|
196
|
+
if path in ("dynamics.velocities", "dynamics.forces"):
|
|
197
|
+
return f"{frames} frame(s) × {atoms} atoms × 3"
|
|
198
|
+
return None
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# `CapabilityLevel` is re-exported for renderers that annotate the inventory by capability.
|
|
202
|
+
__all__ = ["DiscoveryEngine", "DiscoveryReport", "FieldPresenceEntry", "CapabilityLevel"]
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""The Discovery Report — exact schema (MASTER_SPEC Part 3 §6.2).
|
|
2
|
+
|
|
3
|
+
The ✓/✗ inventory the Information Discovery Engine produces: for a sniffed-and-parsed file,
|
|
4
|
+
which canonical fields it contains, each annotated with the *read-side* capability of the
|
|
5
|
+
detected format (so a reader sees not just "present" but "and this format could express it").
|
|
6
|
+
The ``fields`` list is **complete over the canonical scientific leaf paths** (Part 2 §3.3–§3.7
|
|
7
|
+
plus ``frame.time`` and ``trajectory.timestep``) — every one appears exactly once, present or
|
|
8
|
+
absent, so "not shown" can never be mistaken for "not checked" (§6.3). Root metadata containers
|
|
9
|
+
and carried-through user data are summarized in ``structure``/``extras`` instead, since their
|
|
10
|
+
contents are format-specific rather than a fixed schema of leaf paths.
|
|
11
|
+
|
|
12
|
+
Emitted verbatim by the API's ``/v1/inspect`` (Part 6) and the CLI's ``inspect`` (Appendix A);
|
|
13
|
+
the CLI renders it as a terminal inventory, never a parallel DTO.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
21
|
+
|
|
22
|
+
from xtalate.sdk import CapabilityLevel, ParseIssue
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class _Model(BaseModel):
|
|
26
|
+
model_config = ConfigDict(extra="forbid")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class FieldPresenceEntry(_Model):
|
|
30
|
+
path: str # Canonical field path, e.g. "dynamics.velocities".
|
|
31
|
+
status: (
|
|
32
|
+
str # Literal["present", "absent", "mixed"] — "mixed" mirrors PresenceMap (Part 2 §3.11).
|
|
33
|
+
)
|
|
34
|
+
# Populated only when status="mixed": the frame indices where the field is present.
|
|
35
|
+
present_frames: list[int] | None = None
|
|
36
|
+
format_capability: CapabilityLevel # Read-side capability of the detected format for this path.
|
|
37
|
+
detail: str | None = None # e.g. "2 frames × 3 atoms, Cartesian (Å)".
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class DiscoveryReport(_Model):
|
|
41
|
+
file: dict[str, Any] # { filename, size_bytes, sha256 }.
|
|
42
|
+
# { format_id, format_name, confidence, sniff_evidence: [{format_id, confidence}, ...] }.
|
|
43
|
+
format: dict[str, Any]
|
|
44
|
+
structure: dict[str, Any] # { frame_count, atom_count, species: [...] }.
|
|
45
|
+
fields: list[FieldPresenceEntry] = Field(default_factory=list) # One entry per leaf path.
|
|
46
|
+
extras: list[str] = Field(
|
|
47
|
+
default_factory=list
|
|
48
|
+
) # Carried-through custom_* / simulation.extra keys.
|
|
49
|
+
issues: list[ParseIssue] = Field(default_factory=list) # Warnings from the parse (Part 3 §5).
|
|
50
|
+
schema_version: str # Of the Canonical Object produced.
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Format Sniffer (MASTER_SPEC Part 3 §6.1).
|
|
2
|
+
|
|
3
|
+
Generic by construction — **no per-format logic** (§6.1). Every registered parser scores
|
|
4
|
+
the file head via its own ``sniff(head, filename)``; the sniffer only ranks the scores and
|
|
5
|
+
applies two instance-configurable thresholds:
|
|
6
|
+
|
|
7
|
+
* ``accept_threshold`` (default **0.5**): the top score must reach this or the file is
|
|
8
|
+
``UNKNOWN_FORMAT`` (``format_id = None``), all candidates recorded so the caller can see
|
|
9
|
+
why — a low-confidence guess silently applied would be a misparse waiting to happen.
|
|
10
|
+
* ``ambiguity_margin`` (default **0.2**): if the top two scores are within this, the result
|
|
11
|
+
is flagged ``ambiguous`` and every candidate is kept as evidence for a user override.
|
|
12
|
+
|
|
13
|
+
Format-specific tie preferences are expressed through the *parsers' own scores and
|
|
14
|
+
filename handling*, not hardcoded here. The **POSCAR ⇄ CONTCAR** case (§6.1) is the
|
|
15
|
+
canonical example: each parser returns 1.0 for its exact conventional filename, and the
|
|
16
|
+
more-specific CONTCAR reading scores marginally lower on a generic file, so POSCAR wins a
|
|
17
|
+
nameless tie while the ambiguity is still surfaced — all without the sniffer knowing either
|
|
18
|
+
format exists. When two formats genuinely tie, the deterministic pick is the top score,
|
|
19
|
+
ties broken by ``format_id`` order, always with ``ambiguous=True``.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict
|
|
25
|
+
|
|
26
|
+
from xtalate.capabilities import Registry
|
|
27
|
+
|
|
28
|
+
DEFAULT_ACCEPT_THRESHOLD = 0.5
|
|
29
|
+
DEFAULT_AMBIGUITY_MARGIN = 0.2
|
|
30
|
+
HEAD_SIZE = 64 * 1024 # Parsers see at most the first 64 KiB (§2).
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class SniffCandidate(BaseModel):
|
|
34
|
+
model_config = ConfigDict(extra="forbid")
|
|
35
|
+
|
|
36
|
+
format_id: str
|
|
37
|
+
confidence: float
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SniffResult(BaseModel):
|
|
41
|
+
model_config = ConfigDict(extra="forbid")
|
|
42
|
+
|
|
43
|
+
format_id: str | None # None => UNKNOWN_FORMAT (top score below accept_threshold).
|
|
44
|
+
confidence: float # The top score (0.0 when there are no parsers / unknown).
|
|
45
|
+
ambiguous: bool # True when the top two scores are within ambiguity_margin.
|
|
46
|
+
candidates: list[SniffCandidate] # All scored parsers, highest first — the sniff evidence.
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Sniffer:
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
registry: Registry,
|
|
53
|
+
*,
|
|
54
|
+
accept_threshold: float = DEFAULT_ACCEPT_THRESHOLD,
|
|
55
|
+
ambiguity_margin: float = DEFAULT_AMBIGUITY_MARGIN,
|
|
56
|
+
) -> None:
|
|
57
|
+
self._registry = registry
|
|
58
|
+
self.accept_threshold = accept_threshold
|
|
59
|
+
self.ambiguity_margin = ambiguity_margin
|
|
60
|
+
|
|
61
|
+
def sniff(self, data: bytes, filename: str | None = None) -> SniffResult:
|
|
62
|
+
head = data[:HEAD_SIZE]
|
|
63
|
+
candidates: list[SniffCandidate] = []
|
|
64
|
+
for parser in self._registry.parsers():
|
|
65
|
+
try:
|
|
66
|
+
score = float(parser.sniff(head, filename))
|
|
67
|
+
except Exception:
|
|
68
|
+
# sniff() "must never raise" (§2); a misbehaving plugin scores 0, never
|
|
69
|
+
# crashes detection of the other formats.
|
|
70
|
+
score = 0.0
|
|
71
|
+
score = max(0.0, min(1.0, score))
|
|
72
|
+
candidates.append(SniffCandidate(format_id=parser.format_id, confidence=score))
|
|
73
|
+
|
|
74
|
+
# Highest confidence first; ties broken by format_id for a deterministic winner.
|
|
75
|
+
candidates.sort(key=lambda c: (-c.confidence, c.format_id))
|
|
76
|
+
|
|
77
|
+
if not candidates:
|
|
78
|
+
return SniffResult(format_id=None, confidence=0.0, ambiguous=False, candidates=[])
|
|
79
|
+
|
|
80
|
+
best = candidates[0]
|
|
81
|
+
if best.confidence < self.accept_threshold:
|
|
82
|
+
# UNKNOWN_FORMAT: below the acceptance bar. Report candidates so the caller can
|
|
83
|
+
# override, but select nothing (§6.1).
|
|
84
|
+
return SniffResult(
|
|
85
|
+
format_id=None, confidence=best.confidence, ambiguous=False, candidates=candidates
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
runner_up = candidates[1].confidence if len(candidates) > 1 else 0.0
|
|
89
|
+
ambiguous = (best.confidence - runner_up) < self.ambiguity_margin
|
|
90
|
+
return SniffResult(
|
|
91
|
+
format_id=best.format_id,
|
|
92
|
+
confidence=best.confidence,
|
|
93
|
+
ambiguous=ambiguous,
|
|
94
|
+
candidates=candidates,
|
|
95
|
+
)
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""Exporters — one ``ExporterPlugin`` per format: Canonical Object → native file (Part 4 §1).
|
|
2
|
+
|
|
3
|
+
Writes exactly the ``write_plan`` handed to it; never reads native files, calls a
|
|
4
|
+
parser, or fabricates absent fields (Part 1 §2, Part 4 §1). Depends on ``schema``
|
|
5
|
+
and ``sdk``. Lands alongside its paired parser in M3.
|
|
6
|
+
|
|
7
|
+
``builtin_exporters()`` mirrors ``parsers.builtin_parsers()`` — a downward-only list a
|
|
8
|
+
higher layer assembles into a Registry.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from xtalate.exporters.extxyz import ExtxyzExporter
|
|
14
|
+
from xtalate.exporters.poscar import (
|
|
15
|
+
PoscarExporter,
|
|
16
|
+
make_contcar_exporter,
|
|
17
|
+
make_poscar_exporter,
|
|
18
|
+
)
|
|
19
|
+
from xtalate.exporters.xyz import XyzExporter
|
|
20
|
+
from xtalate.sdk import ExporterPlugin
|
|
21
|
+
|
|
22
|
+
__all__ = [
|
|
23
|
+
"ExtxyzExporter",
|
|
24
|
+
"PoscarExporter",
|
|
25
|
+
"XyzExporter",
|
|
26
|
+
"builtin_exporters",
|
|
27
|
+
"make_contcar_exporter",
|
|
28
|
+
"make_poscar_exporter",
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def builtin_exporters() -> list[ExporterPlugin]:
|
|
33
|
+
"""The exporters shipped in v0.1 (M3a XYZ, M3b POSCAR/CONTCAR, M3c extXYZ)."""
|
|
34
|
+
return [XyzExporter(), ExtxyzExporter(), make_poscar_exporter(), make_contcar_exporter()]
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Extended XYZ exporter (MASTER_SPEC Part 3 §2, Part 4 §1).
|
|
2
|
+
|
|
3
|
+
The mirror of ``parsers.extxyz``: it rebuilds an ASE ``Atoms`` per frame from the Canonical
|
|
4
|
+
Object and lets ASE serialise the ``Lattice=`` / ``Properties=`` grammar. Every mapping is the
|
|
5
|
+
exact inverse of the parser's (DECISIONS.md D18), including the velocity unit conversion
|
|
6
|
+
(canonical Å/fs → ASE internal units) so that ``A → Canonical → A' → Canonical'`` reproduces
|
|
7
|
+
the scientific content exactly. Fields extXYZ cannot express are the Conversion Engine's to
|
|
8
|
+
report as ``removed`` (Part 4); this exporter simply writes what the object holds.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import io
|
|
14
|
+
from typing import Any, BinaryIO
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from ase import Atoms
|
|
18
|
+
from ase import units as ase_units
|
|
19
|
+
from ase.calculators.singlepoint import SinglePointCalculator
|
|
20
|
+
from ase.io import write as ase_write
|
|
21
|
+
|
|
22
|
+
from xtalate.schema import CanonicalObject, Frame
|
|
23
|
+
from xtalate.sdk import (
|
|
24
|
+
CapabilityLevel,
|
|
25
|
+
ExporterPlugin,
|
|
26
|
+
FieldCapability,
|
|
27
|
+
FormatCapabilities,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
FORMAT_ID = "extxyz"
|
|
31
|
+
_KEY_PREFIX = "extxyz:"
|
|
32
|
+
_STRESS_KEY = "extxyz:stress"
|
|
33
|
+
# Å/fs → ASE internal velocity units: the exact inverse of the parser's conversion. Defined
|
|
34
|
+
# here (not imported from parsers) because exporters and parsers are import-sibling layers
|
|
35
|
+
# that must not depend on each other (pyproject import-linter contract, P2).
|
|
36
|
+
_VEL_ASE_TO_ANG_PER_FS = ase_units.fs
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ExtxyzExporter(ExporterPlugin):
|
|
40
|
+
format_id = FORMAT_ID
|
|
41
|
+
format_name = "Extended XYZ"
|
|
42
|
+
version = "0.1.0"
|
|
43
|
+
|
|
44
|
+
def export(self, canonical: CanonicalObject, stream: BinaryIO) -> None:
|
|
45
|
+
images = [self._frame_to_atoms(canonical, frame) for frame in canonical.frames]
|
|
46
|
+
buf = io.StringIO()
|
|
47
|
+
ase_write(buf, images, format="extxyz")
|
|
48
|
+
stream.write(buf.getvalue().encode("utf-8"))
|
|
49
|
+
|
|
50
|
+
def _frame_to_atoms(self, canonical: CanonicalObject, frame: Frame) -> Atoms:
|
|
51
|
+
atoms = Atoms(
|
|
52
|
+
symbols=list(frame.atoms.symbols),
|
|
53
|
+
positions=np.asarray(frame.atoms.positions, dtype=float),
|
|
54
|
+
)
|
|
55
|
+
if frame.atoms.masses is not None:
|
|
56
|
+
atoms.set_masses(np.asarray(frame.atoms.masses, dtype=float))
|
|
57
|
+
if frame.cell is not None:
|
|
58
|
+
atoms.set_cell(np.asarray(frame.cell.lattice_vectors, dtype=float))
|
|
59
|
+
atoms.set_pbc(frame.cell.pbc)
|
|
60
|
+
if frame.dynamics.velocities is not None:
|
|
61
|
+
v_ase = np.asarray(frame.dynamics.velocities, dtype=float) / _VEL_ASE_TO_ANG_PER_FS
|
|
62
|
+
atoms.set_velocities(v_ase)
|
|
63
|
+
|
|
64
|
+
# Object-level per-atom carry-through columns apply to every frame (Part 2 §3.10).
|
|
65
|
+
for key, values in canonical.user_metadata.custom_per_atom.items():
|
|
66
|
+
atoms.new_array(_strip(key), np.asarray(values))
|
|
67
|
+
|
|
68
|
+
# Per-frame comment key-values (+ stress) for this frame index.
|
|
69
|
+
stress = None
|
|
70
|
+
for key, per_frame in canonical.user_metadata.custom_per_frame.items():
|
|
71
|
+
value = per_frame[frame.index] if frame.index < len(per_frame) else None
|
|
72
|
+
if value is None:
|
|
73
|
+
continue
|
|
74
|
+
if key == _STRESS_KEY:
|
|
75
|
+
stress = np.asarray(value, dtype=float)
|
|
76
|
+
else:
|
|
77
|
+
atoms.info[_strip(key)] = value
|
|
78
|
+
|
|
79
|
+
results: dict[str, Any] = {}
|
|
80
|
+
if frame.electronic.total_energy is not None:
|
|
81
|
+
results["energy"] = float(frame.electronic.total_energy)
|
|
82
|
+
if frame.dynamics.forces is not None:
|
|
83
|
+
results["forces"] = np.asarray(frame.dynamics.forces, dtype=float)
|
|
84
|
+
if frame.electronic.charges is not None:
|
|
85
|
+
results["charges"] = np.asarray(frame.electronic.charges, dtype=float)
|
|
86
|
+
if frame.electronic.magnetic_moments is not None:
|
|
87
|
+
results["magmoms"] = np.asarray(frame.electronic.magnetic_moments, dtype=float)
|
|
88
|
+
if stress is not None:
|
|
89
|
+
results["stress"] = stress
|
|
90
|
+
if results:
|
|
91
|
+
atoms.calc = SinglePointCalculator(atoms, **results)
|
|
92
|
+
return atoms
|
|
93
|
+
|
|
94
|
+
def capabilities(self) -> FormatCapabilities:
|
|
95
|
+
full = FieldCapability(level=CapabilityLevel.FULL)
|
|
96
|
+
partial = CapabilityLevel.PARTIAL
|
|
97
|
+
return FormatCapabilities(
|
|
98
|
+
format_id=FORMAT_ID,
|
|
99
|
+
format_name=self.format_name,
|
|
100
|
+
direction="write",
|
|
101
|
+
fields={
|
|
102
|
+
"atoms.symbols": full,
|
|
103
|
+
"atoms.positions": full,
|
|
104
|
+
"atoms.masses": FieldCapability(level=partial, notes="Written as a masses column."),
|
|
105
|
+
"cell.lattice_vectors": FieldCapability(
|
|
106
|
+
level=partial, notes="Written as the Lattice= key when a cell is present."
|
|
107
|
+
),
|
|
108
|
+
"cell.pbc": FieldCapability(level=partial, notes="Written as the pbc= key."),
|
|
109
|
+
"dynamics.velocities": FieldCapability(
|
|
110
|
+
level=partial, notes="Written as a momenta column; unit-converted."
|
|
111
|
+
),
|
|
112
|
+
"dynamics.forces": FieldCapability(
|
|
113
|
+
level=partial, notes="Written as a forces column."
|
|
114
|
+
),
|
|
115
|
+
"electronic.total_energy": FieldCapability(
|
|
116
|
+
level=partial, notes="Written as the energy= key."
|
|
117
|
+
),
|
|
118
|
+
"electronic.charges": FieldCapability(
|
|
119
|
+
level=partial, notes="Written as a per-atom charge column."
|
|
120
|
+
),
|
|
121
|
+
"electronic.magnetic_moments": FieldCapability(
|
|
122
|
+
level=partial, notes="Written as a per-atom magmoms column."
|
|
123
|
+
),
|
|
124
|
+
"user_metadata.custom_per_atom": FieldCapability(
|
|
125
|
+
level=CapabilityLevel.FULL, notes="Written back as Properties= columns."
|
|
126
|
+
),
|
|
127
|
+
"user_metadata.custom_per_frame": FieldCapability(
|
|
128
|
+
level=CapabilityLevel.FULL, notes="Written back as comment key-values."
|
|
129
|
+
),
|
|
130
|
+
},
|
|
131
|
+
max_frames=None,
|
|
132
|
+
required_fields=["atoms.symbols", "atoms.positions"],
|
|
133
|
+
native_coordinate_system="cartesian",
|
|
134
|
+
lossy_notes=[],
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _strip(key: str) -> str:
|
|
139
|
+
"""``'extxyz:foo'`` → ``'foo'``; a user/plugin key without the prefix is written as-is."""
|
|
140
|
+
return key[len(_KEY_PREFIX) :] if key.startswith(_KEY_PREFIX) else key
|