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,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")
|
xtalate/exporters/xyz.py
ADDED
|
@@ -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
|
+
)
|