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,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