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,180 @@
1
+ """VASP POSCAR / CONTCAR exporter (MASTER_SPEC Part 3 §3, Part 4 §1, §8.2).
2
+
3
+ Writes a single-structure POSCAR. Coordinates are emitted in **Cartesian** mode with a
4
+ scaling factor of ``1.0`` (canonical lattice vectors are already absolute Å), so a
5
+ parsed→exported→parsed round-trip reproduces the Cartesian positions exactly with no matrix
6
+ inversion (DECISIONS.md D8). POSCAR requires atoms of one element to be contiguous, so the
7
+ exporter **groups by element** in first-occurrence order; that grouping is a permutation of
8
+ the atom list (the same permutation Part 5 validation reconstructs) applied consistently to
9
+ positions, velocities, and selective-dynamics masks. A CONTCAR velocity / predictor-corrector
10
+ tail carried through on parse is written back so CONTCAR identity round-trips.
11
+
12
+ Multi-frame input is refused here rather than silently truncated: reducing a trajectory to
13
+ one structure is a *selective-reductive* choice the Conversion Engine records as an
14
+ Assumption before it ever calls this exporter (Part 4 §3).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from typing import BinaryIO
20
+
21
+ from xtalate.schema import CanonicalObject
22
+ from xtalate.sdk import (
23
+ CapabilityLevel,
24
+ ExporterPlugin,
25
+ FieldCapability,
26
+ FormatCapabilities,
27
+ )
28
+
29
+ _COMMENT_KEY = "poscar:comment"
30
+ _PREDICTOR_KEY = "contcar:predictor_corrector"
31
+
32
+
33
+ def _fmt(x: float) -> str:
34
+ return repr(float(x))
35
+
36
+
37
+ def _grouping(symbols: list[str]) -> tuple[list[str], list[int], list[int]]:
38
+ """Group atoms by element in first-occurrence order (POSCAR requires one element's atoms to be
39
+ contiguous). Returns ``(order, permutation, counts)`` where ``order`` is the element sequence,
40
+ ``permutation[i]`` is the source index written at output position *i* (the Part 5 permutation
41
+ map), and ``counts`` is the per-element atom count. Used by both ``export`` (to write the file)
42
+ and ``atom_permutation`` (to report the map), so the two can never disagree."""
43
+ order: list[str] = []
44
+ groups: dict[str, list[int]] = {}
45
+ for i, sym in enumerate(symbols):
46
+ if sym not in groups:
47
+ groups[sym] = []
48
+ order.append(sym)
49
+ groups[sym].append(i)
50
+ permutation = [i for sym in order for i in groups[sym]]
51
+ counts = [len(groups[sym]) for sym in order]
52
+ return order, permutation, counts
53
+
54
+
55
+ class PoscarExporter(ExporterPlugin):
56
+ """POSCAR/CONTCAR writer. One class, registered under ``poscar`` and ``contcar``
57
+ (the canonical fields written are identical; only the reported label differs)."""
58
+
59
+ version = "0.1.0"
60
+
61
+ def __init__(self, *, format_id: str = "poscar") -> None:
62
+ self.format_id = format_id
63
+ self.format_name = "VASP POSCAR" if format_id == "poscar" else "VASP CONTCAR"
64
+
65
+ def export(self, canonical: CanonicalObject, stream: BinaryIO) -> None:
66
+ if len(canonical.frames) != 1:
67
+ raise ValueError(
68
+ "POSCAR holds a single structure; reduce the trajectory to one frame via the "
69
+ "Conversion Engine's frame_selection recovery before export (Part 4 §3)"
70
+ )
71
+ frame = canonical.frames[0]
72
+ atoms = frame.atoms
73
+ cell = frame.cell
74
+ if cell is None:
75
+ raise ValueError(
76
+ "POSCAR requires cell.lattice_vectors; supply it via the missing_lattice "
77
+ "recovery before export (Part 4 §3)"
78
+ )
79
+
80
+ # Group atoms by element in first-occurrence order -> a permutation applied to every
81
+ # per-atom array so the written file is internally consistent (Part 5 permutation map).
82
+ order, permutation, counts = _grouping(atoms.symbols)
83
+
84
+ title = canonical.user_metadata.custom_global.get(_COMMENT_KEY, "")
85
+ out: list[str] = [str(title) if title is not None else ""]
86
+ out.append("1.0") # canonical lattice is absolute; no rescale needed
87
+ for row in cell.lattice_vectors:
88
+ out.append(f" {_fmt(row[0])} {_fmt(row[1])} {_fmt(row[2])}")
89
+ out.append(" ".join(order))
90
+ out.append(" ".join(str(c) for c in counts))
91
+
92
+ # Selective dynamics: present (even all-T) => write the block so [] round-trips as
93
+ # distinct from None (Part 3 §3 n.7).
94
+ constraints = frame.dynamics.constraints
95
+ mask_by_atom: list[list[bool]] | None = None
96
+ if constraints is not None:
97
+ out.append("Selective dynamics")
98
+ if constraints:
99
+ # Exactly one selective_dynamics constraint carrying a per-atom mask.
100
+ raw = constraints[0].parameters.get("mask", [])
101
+ mask_by_atom = [list(m) for m in raw]
102
+ else:
103
+ mask_by_atom = [[True, True, True] for _ in atoms.symbols]
104
+
105
+ out.append("Cartesian")
106
+ for atom_idx in permutation:
107
+ pos = atoms.positions[atom_idx]
108
+ line = f" {_fmt(pos[0])} {_fmt(pos[1])} {_fmt(pos[2])}"
109
+ if mask_by_atom is not None:
110
+ flags = mask_by_atom[atom_idx]
111
+ line += " " + " ".join("T" if f else "F" for f in flags)
112
+ out.append(line)
113
+
114
+ velocities = frame.dynamics.velocities
115
+ if velocities is not None:
116
+ out.append("") # blank separator before the velocity block
117
+ out.append("Cartesian")
118
+ for atom_idx in permutation:
119
+ v = velocities[atom_idx]
120
+ out.append(f" {_fmt(v[0])} {_fmt(v[1])} {_fmt(v[2])}")
121
+
122
+ predictor = canonical.user_metadata.custom_global.get(_PREDICTOR_KEY)
123
+ if isinstance(predictor, str) and predictor:
124
+ out.append("")
125
+ out.append(predictor)
126
+
127
+ stream.write(("\n".join(out) + "\n").encode("utf-8"))
128
+
129
+ def capabilities(self) -> FormatCapabilities:
130
+ full = FieldCapability(level=CapabilityLevel.FULL)
131
+ none = FieldCapability(level=CapabilityLevel.NONE)
132
+ return FormatCapabilities(
133
+ format_id=self.format_id,
134
+ format_name=self.format_name,
135
+ direction="write",
136
+ fields={
137
+ "atoms.symbols": full,
138
+ "atoms.positions": full,
139
+ "cell.lattice_vectors": full,
140
+ "cell.pbc": FieldCapability(
141
+ level=CapabilityLevel.PARTIAL,
142
+ notes="Only fully periodic (T,T,T); other PBC combinations cannot be "
143
+ "represented.",
144
+ ),
145
+ "cell.space_group": none,
146
+ "dynamics.velocities": full,
147
+ "dynamics.forces": none,
148
+ "dynamics.constraints": FieldCapability(
149
+ level=CapabilityLevel.PARTIAL,
150
+ notes="Only per-axis fixed-atom masks (selective dynamics).",
151
+ ),
152
+ "electronic.total_energy": none,
153
+ "electronic.stress": none,
154
+ "electronic.charges": none,
155
+ "electronic.magnetic_moments": none,
156
+ "simulation.*": none,
157
+ "user_metadata.custom_global": FieldCapability(
158
+ level=CapabilityLevel.PARTIAL,
159
+ notes="Only the POSCAR title (poscar:comment) and CONTCAR predictor-corrector "
160
+ "block (contcar:predictor_corrector); other custom_global keys are dropped.",
161
+ ),
162
+ "user_metadata.custom_per_atom": none,
163
+ "user_metadata.custom_per_frame": none,
164
+ },
165
+ max_frames=1,
166
+ required_fields=["atoms.symbols", "atoms.positions", "cell.lattice_vectors"],
167
+ native_coordinate_system="both",
168
+ lossy_notes=[
169
+ "Positions written with full float64 precision; sub-ulp differences possible "
170
+ "on round-trip through fixed-width VASP readers."
171
+ ],
172
+ )
173
+
174
+
175
+ def make_poscar_exporter() -> PoscarExporter:
176
+ return PoscarExporter(format_id="poscar")
177
+
178
+
179
+ def make_contcar_exporter() -> PoscarExporter:
180
+ return PoscarExporter(format_id="contcar")
@@ -0,0 +1,67 @@
1
+ """Plain XYZ exporter (MASTER_SPEC Part 3 §3, Part 4 §1).
2
+
3
+ The mirror of ``parsers.xyz``: it writes exactly what plain XYZ can express — element
4
+ symbols, Cartesian positions, and the per-frame comment line — and nothing else. Fields
5
+ the format cannot hold (cell, velocities, energies, ...) are *not* this exporter's concern
6
+ to lose quietly: the Conversion Engine's capability pre-flight (Part 4) reports them as
7
+ ``removed`` before a byte is written. Coordinates are emitted with Python's shortest
8
+ round-tripping float ``repr`` (DECISIONS.md D8), so ``float64 → text → float64`` is lossless
9
+ and identity round-trips are exact.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from typing import BinaryIO
15
+
16
+ from xtalate.schema import CanonicalObject
17
+ from xtalate.sdk import (
18
+ CapabilityLevel,
19
+ ExporterPlugin,
20
+ FieldCapability,
21
+ FormatCapabilities,
22
+ )
23
+
24
+ FORMAT_ID = "xyz"
25
+ _COMMENT_KEY = "xyz:comment"
26
+
27
+
28
+ class XyzExporter(ExporterPlugin):
29
+ format_id = FORMAT_ID
30
+ format_name = "Plain XYZ"
31
+ version = "0.1.0"
32
+
33
+ def export(self, canonical: CanonicalObject, stream: BinaryIO) -> None:
34
+ comments = canonical.user_metadata.custom_per_frame.get(_COMMENT_KEY)
35
+ out: list[str] = []
36
+ for i, frame in enumerate(canonical.frames):
37
+ atoms = frame.atoms
38
+ out.append(str(len(atoms.symbols)))
39
+ # Per-frame comment carried through on parse; empty string when the source had none.
40
+ comment = ""
41
+ if comments is not None and i < len(comments):
42
+ value = comments[i]
43
+ comment = value if isinstance(value, str) else str(value)
44
+ out.append(comment)
45
+ for symbol, pos in zip(atoms.symbols, atoms.positions, strict=True):
46
+ x, y, z = (repr(float(c)) for c in pos)
47
+ out.append(f"{symbol} {x} {y} {z}")
48
+ stream.write(("\n".join(out) + "\n").encode("utf-8"))
49
+
50
+ def capabilities(self) -> FormatCapabilities:
51
+ full = FieldCapability(level=CapabilityLevel.FULL)
52
+ return FormatCapabilities(
53
+ format_id=FORMAT_ID,
54
+ format_name=self.format_name,
55
+ direction="write",
56
+ fields={
57
+ "atoms.symbols": full,
58
+ "atoms.positions": full,
59
+ "user_metadata.custom_per_frame": FieldCapability(
60
+ level=CapabilityLevel.FULL, notes="Free-text comment line, one per frame."
61
+ ),
62
+ },
63
+ max_frames=None,
64
+ required_fields=["atoms.symbols", "atoms.positions"],
65
+ native_coordinate_system="cartesian",
66
+ lossy_notes=[],
67
+ )
@@ -0,0 +1,31 @@
1
+ """Parsers — one ``ParserPlugin`` per format: native file → Canonical Object (Part 3).
2
+
3
+ Never reads another format, calls another parser, writes files, or defaults an
4
+ absent field (Part 1 §2, Part 2 §2). Depends on ``schema`` and ``sdk``.
5
+ XYZ in M3a, POSCAR/CONTCAR in M3b, extXYZ in M3c.
6
+
7
+ ``builtin_parsers()`` is a pure list of the v0.1 parsers a higher layer (discovery /
8
+ conversion) assembles into a Registry — it lives here, in the parsers layer, so that
9
+ assembly imports *downward* only and the P2 import contract holds.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from xtalate.parsers.extxyz import ExtxyzParser
15
+ from xtalate.parsers.poscar import PoscarParser, make_contcar_parser, make_poscar_parser
16
+ from xtalate.parsers.xyz import XyzParser
17
+ from xtalate.sdk import ParserPlugin
18
+
19
+ __all__ = [
20
+ "ExtxyzParser",
21
+ "PoscarParser",
22
+ "XyzParser",
23
+ "builtin_parsers",
24
+ "make_contcar_parser",
25
+ "make_poscar_parser",
26
+ ]
27
+
28
+
29
+ def builtin_parsers() -> list[ParserPlugin]:
30
+ """The parsers shipped in v0.1 (M3a XYZ, M3b POSCAR/CONTCAR, M3c extXYZ)."""
31
+ return [XyzParser(), ExtxyzParser(), make_poscar_parser(), make_contcar_parser()]
@@ -0,0 +1,57 @@
1
+ """Shared parser helpers (MASTER_SPEC Part 3 §2, §5).
2
+
3
+ Every parser establishes Provenance the same way: it records the source format, the
4
+ coordinate system the *source* used, the source units, and appends exactly one
5
+ ``ConversionRecord(operation="parse")`` to the history (Part 2 §3.9). This module holds
6
+ that boilerplate so each format module stays focused on its grammar. It imports only
7
+ ``schema`` (and the package version), never another parser — the P2 boundary holds.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from datetime import UTC, datetime
13
+
14
+ from xtalate import __version__
15
+ from xtalate.schema import ConversionRecord, Provenance
16
+
17
+
18
+ def utc_now() -> str:
19
+ """Current UTC instant as an ISO-8601 ``...Z`` string (Part 2 §3.9 timestamp form).
20
+
21
+ Seconds precision, ``Z`` suffix — matching the worked-example fixtures (Part 2 §8).
22
+ """
23
+ return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
24
+
25
+
26
+ def parse_record(format_id: str) -> ConversionRecord:
27
+ """The single ``operation="parse"`` history entry a successful parse appends (§3.9)."""
28
+ return ConversionRecord(
29
+ timestamp=utc_now(),
30
+ operation="parse",
31
+ source_format=format_id,
32
+ target_format=None,
33
+ tool_version=__version__,
34
+ parser_version=f"{format_id}-parser {__version__}",
35
+ assumptions=[],
36
+ )
37
+
38
+
39
+ def build_provenance(
40
+ *,
41
+ format_id: str,
42
+ filename: str | None,
43
+ original_coordinate_system: str,
44
+ source_units: dict[str, str],
45
+ parse_notes: list[str],
46
+ ) -> Provenance:
47
+ """Assemble the Provenance for a freshly parsed object, history seeded with the parse
48
+ record (§3.9). ``original_coordinate_system`` is what the *source* used, not what the
49
+ canonical object stores (canonical positions are always Cartesian, §4)."""
50
+ return Provenance(
51
+ source_filename=filename,
52
+ source_format=format_id,
53
+ source_units=source_units,
54
+ original_coordinate_system=original_coordinate_system,
55
+ parse_notes=list(parse_notes),
56
+ history=[parse_record(format_id)],
57
+ )