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,428 @@
|
|
|
1
|
+
"""VASP POSCAR / CONTCAR parser (MASTER_SPEC Part 3 §3, §8.2).
|
|
2
|
+
|
|
3
|
+
Hand-rolled (DECISIONS.md D7): hand control over the selective-dynamics → ``Constraint``
|
|
4
|
+
mapping and the fractional→Cartesian conversion, and no pymatgen dependency. POSCAR and
|
|
5
|
+
CONTCAR are structurally the *same* format (Part 3 §6.1) — CONTCAR is just the name VASP
|
|
6
|
+
writes a POSCAR-shaped file to, optionally with a velocity / predictor-corrector tail — so
|
|
7
|
+
one implementation backs both, registered twice under two ``format_id``s that differ only
|
|
8
|
+
in their conventional filename and sniff bias.
|
|
9
|
+
|
|
10
|
+
Format-defined facts handled at parse time, recorded in ``parse_notes`` rather than guessed
|
|
11
|
+
(Part 3 §5 rule 3): full 3-D periodicity (``pbc = (T,T,T)``, §3 n.3) and the
|
|
12
|
+
fractional→Cartesian coordinate conversion (§4). A VASP-4 file (counts, no species line)
|
|
13
|
+
cannot supply the *required* ``atoms.symbols`` (Part 2 §3.3), so it is a **recoverable**
|
|
14
|
+
parse error carrying ``recovery_hint="supply_species"`` (§3 n.1) — never invented placeholder
|
|
15
|
+
elements.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
from typing import BinaryIO
|
|
21
|
+
|
|
22
|
+
import numpy as np
|
|
23
|
+
from pydantic import JsonValue
|
|
24
|
+
|
|
25
|
+
from xtalate.parsers._common import build_provenance
|
|
26
|
+
from xtalate.schema import (
|
|
27
|
+
AtomsBlock,
|
|
28
|
+
CanonicalObject,
|
|
29
|
+
Cell,
|
|
30
|
+
Constraint,
|
|
31
|
+
Dynamics,
|
|
32
|
+
Frame,
|
|
33
|
+
SimulationMetadata,
|
|
34
|
+
UserMetadata,
|
|
35
|
+
)
|
|
36
|
+
from xtalate.schema.elements import is_valid_symbol
|
|
37
|
+
from xtalate.sdk import (
|
|
38
|
+
CapabilityLevel,
|
|
39
|
+
FieldCapability,
|
|
40
|
+
FormatCapabilities,
|
|
41
|
+
ParseError,
|
|
42
|
+
ParseIssue,
|
|
43
|
+
ParseResult,
|
|
44
|
+
ParserPlugin,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
_COMMENT_KEY = "poscar:comment"
|
|
48
|
+
_SCALE_KEY = "poscar:scaling_factor"
|
|
49
|
+
_PREDICTOR_KEY = "contcar:predictor_corrector"
|
|
50
|
+
_PBC_NOTE = (
|
|
51
|
+
"pbc set to (true,true,true) per POSCAR format definition (format-defined, not assumed)."
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _error(
|
|
56
|
+
code: str, message: str, *, location: str | None = None, hint: str | None = None
|
|
57
|
+
) -> ParseError:
|
|
58
|
+
return ParseError(
|
|
59
|
+
[
|
|
60
|
+
ParseIssue(
|
|
61
|
+
severity="error",
|
|
62
|
+
code=code,
|
|
63
|
+
message=message,
|
|
64
|
+
location=location,
|
|
65
|
+
recovery_hint=hint,
|
|
66
|
+
)
|
|
67
|
+
]
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _all_ints(tokens: list[str]) -> bool:
|
|
72
|
+
"""True if every token parses as an int — the VASP-4 signature (counts, no species)."""
|
|
73
|
+
if not tokens:
|
|
74
|
+
return False
|
|
75
|
+
try:
|
|
76
|
+
for t in tokens:
|
|
77
|
+
int(t)
|
|
78
|
+
except ValueError:
|
|
79
|
+
return False
|
|
80
|
+
return True
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _floats(line: str, code: str, location: str) -> list[float]:
|
|
84
|
+
try:
|
|
85
|
+
return [float(t) for t in line.split()]
|
|
86
|
+
except ValueError as exc:
|
|
87
|
+
raise _error(code, f"expected numeric values, found {line!r}", location=location) from exc
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class PoscarParser(ParserPlugin):
|
|
91
|
+
"""POSCAR/CONTCAR reader. One class, two registrations (``poscar`` and ``contcar``).
|
|
92
|
+
|
|
93
|
+
``conventional_name`` is VASP's fixed filename for this reading (``POSCAR``/``CONTCAR``);
|
|
94
|
+
an exact match returns sniff confidence 1.0 (Part 3 §6.1). On a nameless file both
|
|
95
|
+
readings match structurally; ``base_score`` biases the tie (POSCAR wins a plain file,
|
|
96
|
+
CONTCAR is preferred only when a velocity tail is present) while the ambiguity is still
|
|
97
|
+
surfaced by the sniffer.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
version = "0.1.0"
|
|
101
|
+
|
|
102
|
+
def __init__(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
format_id: str = "poscar",
|
|
106
|
+
conventional_name: str = "POSCAR",
|
|
107
|
+
base_score: float = 0.6,
|
|
108
|
+
tail_bonus: float = 0.0,
|
|
109
|
+
) -> None:
|
|
110
|
+
self.format_id = format_id
|
|
111
|
+
self.format_name = "VASP POSCAR" if format_id == "poscar" else "VASP CONTCAR"
|
|
112
|
+
self.file_extensions = () # POSCAR/CONTCAR are conventional *names*, not extensions.
|
|
113
|
+
self._conventional_name = conventional_name
|
|
114
|
+
self._base_score = base_score
|
|
115
|
+
self._tail_bonus = tail_bonus
|
|
116
|
+
|
|
117
|
+
# -- sniff -------------------------------------------------------------------------
|
|
118
|
+
|
|
119
|
+
def sniff(self, head: bytes, filename: str | None) -> float:
|
|
120
|
+
if filename is not None and filename == self._conventional_name:
|
|
121
|
+
return 1.0 # VASP's exact conventional name selects this reading (§6.1)
|
|
122
|
+
text = head.decode("utf-8", errors="replace")
|
|
123
|
+
lines = text.splitlines()
|
|
124
|
+
if len(lines) < 7:
|
|
125
|
+
return 0.0
|
|
126
|
+
# Structural signature: line 2 is a lone scale float, lines 3-5 are 3 floats each.
|
|
127
|
+
try:
|
|
128
|
+
float(lines[1].strip())
|
|
129
|
+
for row in lines[2:5]:
|
|
130
|
+
if len(row.split()) != 3:
|
|
131
|
+
return 0.0
|
|
132
|
+
[float(t) for t in row.split()]
|
|
133
|
+
except ValueError:
|
|
134
|
+
return 0.0
|
|
135
|
+
score = self._base_score
|
|
136
|
+
if self._tail_bonus and self._has_velocity_tail(lines):
|
|
137
|
+
# Only CONTCAR conventionally carries a velocity/predictor tail (§6.1 rule 2).
|
|
138
|
+
score += self._tail_bonus
|
|
139
|
+
return min(score, 1.0)
|
|
140
|
+
|
|
141
|
+
@staticmethod
|
|
142
|
+
def _has_velocity_tail(lines: list[str]) -> bool:
|
|
143
|
+
# Heuristic used only for sniff biasing: a blank line followed by more content after
|
|
144
|
+
# what looks like the coordinate block. Cheap and never authoritative.
|
|
145
|
+
try:
|
|
146
|
+
count_line = lines[6].split()
|
|
147
|
+
n = sum(int(t) for t in count_line)
|
|
148
|
+
except ValueError:
|
|
149
|
+
return False
|
|
150
|
+
# coords start ~line 8 (0-indexed 7 or 8 with a mode line); a tail is any content
|
|
151
|
+
# well past n coordinate lines.
|
|
152
|
+
return len([ln for ln in lines[8 + n :] if ln.strip()]) > 0
|
|
153
|
+
|
|
154
|
+
# -- parse -------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
def parse(self, stream: BinaryIO, *, filename: str | None) -> ParseResult:
|
|
157
|
+
lines = stream.read().decode("utf-8").splitlines()
|
|
158
|
+
if len(lines) < 7:
|
|
159
|
+
raise _error("POSCAR_MALFORMED", "file is too short to be a POSCAR (need >= 7 lines)")
|
|
160
|
+
|
|
161
|
+
issues: list[ParseIssue] = []
|
|
162
|
+
title = lines[0].strip()
|
|
163
|
+
|
|
164
|
+
# --- scaling factor (line 2) --------------------------------------------------
|
|
165
|
+
scale_token = lines[1].strip()
|
|
166
|
+
try:
|
|
167
|
+
scale = float(scale_token)
|
|
168
|
+
except ValueError as exc:
|
|
169
|
+
raise _error(
|
|
170
|
+
"POSCAR_MALFORMED",
|
|
171
|
+
f"scaling factor is not numeric: {lines[1]!r}",
|
|
172
|
+
location="line 2",
|
|
173
|
+
) from exc
|
|
174
|
+
if scale == 0.0:
|
|
175
|
+
raise _error("POSCAR_MALFORMED", "scaling factor must be non-zero", location="line 2")
|
|
176
|
+
|
|
177
|
+
# --- lattice (lines 3-5) ------------------------------------------------------
|
|
178
|
+
raw_rows = [_floats(lines[2 + k], "POSCAR_MALFORMED", f"line {3 + k}") for k in range(3)]
|
|
179
|
+
for k, row in enumerate(raw_rows):
|
|
180
|
+
if len(row) != 3:
|
|
181
|
+
raise _error(
|
|
182
|
+
"POSCAR_MALFORMED",
|
|
183
|
+
f"lattice row must have 3 components, found {len(row)}",
|
|
184
|
+
location=f"line {3 + k}",
|
|
185
|
+
)
|
|
186
|
+
raw_lattice = np.asarray(raw_rows, dtype=float)
|
|
187
|
+
# A negative scale is a target *volume*: multiplier makes det(lattice) == |scale| (§4).
|
|
188
|
+
if scale < 0:
|
|
189
|
+
det = float(np.linalg.det(raw_lattice))
|
|
190
|
+
if det == 0.0:
|
|
191
|
+
raise _error(
|
|
192
|
+
"POSCAR_MALFORMED", "degenerate lattice (zero volume)", location="line 3"
|
|
193
|
+
)
|
|
194
|
+
multiplier = (abs(scale) / abs(det)) ** (1.0 / 3.0)
|
|
195
|
+
else:
|
|
196
|
+
multiplier = scale
|
|
197
|
+
lattice = multiplier * raw_lattice
|
|
198
|
+
|
|
199
|
+
# --- species / counts (VASP 5 has a symbol line; VASP 4 does not) -------------
|
|
200
|
+
line6 = lines[5].split()
|
|
201
|
+
if _all_ints(line6):
|
|
202
|
+
# VASP-4: counts with no species. symbols are required (§3.3) — recoverable error.
|
|
203
|
+
raise _error(
|
|
204
|
+
"POSCAR_MISSING_SPECIES",
|
|
205
|
+
"VASP-4 POSCAR lists atom counts but no element symbols; species must be "
|
|
206
|
+
"supplied before this file can be represented (Part 3 §3 n.1)",
|
|
207
|
+
location="line 6",
|
|
208
|
+
hint="supply_species",
|
|
209
|
+
)
|
|
210
|
+
species = line6
|
|
211
|
+
for sym in species:
|
|
212
|
+
if not is_valid_symbol(sym):
|
|
213
|
+
raise _error(
|
|
214
|
+
"POSCAR_INVALID_SYMBOL",
|
|
215
|
+
f"unknown element symbol {sym!r} on the species line (Part 2 §3.3)",
|
|
216
|
+
location="line 6",
|
|
217
|
+
)
|
|
218
|
+
count_tokens = lines[6].split()
|
|
219
|
+
if not _all_ints(count_tokens) or len(count_tokens) != len(species):
|
|
220
|
+
raise _error(
|
|
221
|
+
"POSCAR_MALFORMED",
|
|
222
|
+
f"expected {len(species)} integer counts to match species {species}, "
|
|
223
|
+
f"found {lines[6]!r}",
|
|
224
|
+
location="line 7",
|
|
225
|
+
)
|
|
226
|
+
counts = [int(t) for t in count_tokens]
|
|
227
|
+
symbols: list[str] = []
|
|
228
|
+
for sym, c in zip(species, counts, strict=True):
|
|
229
|
+
symbols.extend([sym] * c)
|
|
230
|
+
n_atoms = len(symbols)
|
|
231
|
+
|
|
232
|
+
# --- optional 'Selective dynamics' + coordinate-mode lines --------------------
|
|
233
|
+
cursor = 7
|
|
234
|
+
selective = False
|
|
235
|
+
if cursor < len(lines) and lines[cursor].strip()[:1] in ("S", "s"):
|
|
236
|
+
selective = True
|
|
237
|
+
cursor += 1
|
|
238
|
+
if cursor >= len(lines):
|
|
239
|
+
raise _error("POSCAR_MALFORMED", "missing coordinate-mode line", location="end of file")
|
|
240
|
+
mode_char = lines[cursor].strip()[:1].lower()
|
|
241
|
+
fractional = mode_char in ("d",) # 'Direct' == fractional; 'C'/'K' == Cartesian
|
|
242
|
+
cursor += 1
|
|
243
|
+
|
|
244
|
+
# --- coordinates (+ optional selective-dynamics flags) ------------------------
|
|
245
|
+
coords: list[list[float]] = []
|
|
246
|
+
masks: list[list[bool]] = []
|
|
247
|
+
for a in range(n_atoms):
|
|
248
|
+
idx = cursor + a
|
|
249
|
+
if idx >= len(lines) or lines[idx].strip() == "":
|
|
250
|
+
raise _error(
|
|
251
|
+
"POSCAR_INCONSISTENT_ATOM_COUNT",
|
|
252
|
+
f"expected {n_atoms} coordinate lines (from counts {counts}) but only "
|
|
253
|
+
f"{a} are present",
|
|
254
|
+
location=f"line {idx + 1}",
|
|
255
|
+
hint="truncate_at_last_valid_frame",
|
|
256
|
+
)
|
|
257
|
+
parts = lines[idx].split()
|
|
258
|
+
if len(parts) < 3:
|
|
259
|
+
raise _error(
|
|
260
|
+
"POSCAR_MALFORMED",
|
|
261
|
+
f"coordinate line must have >= 3 components: {lines[idx]!r}",
|
|
262
|
+
location=f"line {idx + 1}",
|
|
263
|
+
)
|
|
264
|
+
try:
|
|
265
|
+
coords.append([float(parts[0]), float(parts[1]), float(parts[2])])
|
|
266
|
+
except ValueError as exc:
|
|
267
|
+
raise _error(
|
|
268
|
+
"POSCAR_MALFORMED",
|
|
269
|
+
f"non-numeric coordinate: {lines[idx]!r}",
|
|
270
|
+
location=f"line {idx + 1}",
|
|
271
|
+
) from exc
|
|
272
|
+
if selective:
|
|
273
|
+
flags = parts[3:6]
|
|
274
|
+
if len(flags) != 3:
|
|
275
|
+
raise _error(
|
|
276
|
+
"POSCAR_MALFORMED",
|
|
277
|
+
"selective dynamics requires 3 T/F flags per atom, found "
|
|
278
|
+
f"{flags} on {lines[idx]!r}",
|
|
279
|
+
location=f"line {idx + 1}",
|
|
280
|
+
)
|
|
281
|
+
masks.append([f.upper().startswith("T") for f in flags])
|
|
282
|
+
cursor += n_atoms
|
|
283
|
+
|
|
284
|
+
frac = np.asarray(coords, dtype=float)
|
|
285
|
+
if fractional:
|
|
286
|
+
positions = frac @ lattice # cart = fx*a + fy*b + fz*c (rows of lattice are a,b,c)
|
|
287
|
+
coord_system = "fractional"
|
|
288
|
+
source_units = {"positions": "fractional", "lattice_vectors": "angstrom"}
|
|
289
|
+
coord_note = "Direct coordinates converted to Cartesian using lattice matrix (§4)."
|
|
290
|
+
else:
|
|
291
|
+
positions = frac * multiplier # Cartesian coordinates are scaled too (§4)
|
|
292
|
+
coord_system = "cartesian"
|
|
293
|
+
source_units = {"positions": "angstrom", "lattice_vectors": "angstrom"}
|
|
294
|
+
coord_note = "Cartesian coordinates scaled by the scaling factor (§4)."
|
|
295
|
+
|
|
296
|
+
# --- selective dynamics -> Constraint (Part 3 §3 n.7) -------------------------
|
|
297
|
+
constraints: list[Constraint] | None
|
|
298
|
+
if not selective:
|
|
299
|
+
constraints = None # source said nothing about constraints
|
|
300
|
+
elif all(all(m) for m in masks):
|
|
301
|
+
constraints = [] # present but all-T: explicitly unconstrained (distinct from None)
|
|
302
|
+
else:
|
|
303
|
+
constraints = [
|
|
304
|
+
Constraint(
|
|
305
|
+
kind="selective_dynamics",
|
|
306
|
+
atom_indices=list(range(n_atoms)),
|
|
307
|
+
parameters={"mask": [list(m) for m in masks]},
|
|
308
|
+
)
|
|
309
|
+
]
|
|
310
|
+
|
|
311
|
+
# --- optional velocity / predictor-corrector tail (CONTCAR) -------------------
|
|
312
|
+
velocities, predictor = self._parse_tail(lines, cursor, n_atoms)
|
|
313
|
+
custom_global: dict[str, JsonValue] = {_COMMENT_KEY: title}
|
|
314
|
+
if predictor is not None:
|
|
315
|
+
custom_global[_PREDICTOR_KEY] = predictor
|
|
316
|
+
issues.append(
|
|
317
|
+
ParseIssue(
|
|
318
|
+
severity="warning",
|
|
319
|
+
code="POSCAR_PREDICTOR_CORRECTOR_CARRIED",
|
|
320
|
+
message="predictor-corrector block has no canonical mapping; carried "
|
|
321
|
+
"verbatim in user_metadata.custom_global['contcar:predictor_corrector'] "
|
|
322
|
+
"(Part 3 §3 n.12)",
|
|
323
|
+
)
|
|
324
|
+
)
|
|
325
|
+
|
|
326
|
+
cell = Cell(lattice_vectors=lattice, pbc=(True, True, True))
|
|
327
|
+
dynamics = Dynamics(
|
|
328
|
+
velocities=None if velocities is None else np.asarray(velocities, dtype=float),
|
|
329
|
+
constraints=constraints,
|
|
330
|
+
)
|
|
331
|
+
provenance = build_provenance(
|
|
332
|
+
format_id=self.format_id,
|
|
333
|
+
filename=filename,
|
|
334
|
+
original_coordinate_system=coord_system,
|
|
335
|
+
source_units=source_units,
|
|
336
|
+
parse_notes=[coord_note, _PBC_NOTE],
|
|
337
|
+
)
|
|
338
|
+
canonical = CanonicalObject(
|
|
339
|
+
frames=[
|
|
340
|
+
Frame(
|
|
341
|
+
index=0,
|
|
342
|
+
atoms=AtomsBlock(symbols=symbols, positions=positions),
|
|
343
|
+
cell=cell,
|
|
344
|
+
dynamics=dynamics,
|
|
345
|
+
)
|
|
346
|
+
],
|
|
347
|
+
trajectory=None, # a POSCAR is a single structure, no time axis (§3.2)
|
|
348
|
+
simulation=SimulationMetadata(extra={_SCALE_KEY: scale_token}),
|
|
349
|
+
provenance=provenance,
|
|
350
|
+
user_metadata=UserMetadata(custom_global=custom_global),
|
|
351
|
+
)
|
|
352
|
+
return ParseResult(canonical=canonical, issues=issues)
|
|
353
|
+
|
|
354
|
+
@staticmethod
|
|
355
|
+
def _parse_tail(
|
|
356
|
+
lines: list[str], cursor: int, n_atoms: int
|
|
357
|
+
) -> tuple[list[list[float]] | None, str | None]:
|
|
358
|
+
"""Read an optional velocity block and predictor-corrector remainder after the
|
|
359
|
+
coordinates. Velocities are the first ``n_atoms`` rows of three floats (an optional
|
|
360
|
+
single mode line is skipped); anything after them is carried verbatim (§3 n.12)."""
|
|
361
|
+
# Skip a single blank separator line.
|
|
362
|
+
i = cursor
|
|
363
|
+
while i < len(lines) and lines[i].strip() == "":
|
|
364
|
+
i += 1
|
|
365
|
+
if i >= len(lines):
|
|
366
|
+
return None, None
|
|
367
|
+
# An optional mode line (e.g. 'Cartesian') precedes the velocities in some writers.
|
|
368
|
+
first = lines[i].split()
|
|
369
|
+
if len(first) < 3:
|
|
370
|
+
i += 1 # treat as a mode/label line
|
|
371
|
+
velocities: list[list[float]] = []
|
|
372
|
+
for _ in range(n_atoms):
|
|
373
|
+
if i >= len(lines):
|
|
374
|
+
break
|
|
375
|
+
parts = lines[i].split()
|
|
376
|
+
try:
|
|
377
|
+
velocities.append([float(parts[0]), float(parts[1]), float(parts[2])])
|
|
378
|
+
except (ValueError, IndexError):
|
|
379
|
+
break
|
|
380
|
+
i += 1
|
|
381
|
+
vel_out = velocities if len(velocities) == n_atoms else None
|
|
382
|
+
if vel_out is None:
|
|
383
|
+
i = cursor # velocities not cleanly present; the whole tail is predictor data
|
|
384
|
+
remainder = "\n".join(lines[i:]).strip("\n")
|
|
385
|
+
predictor = remainder if remainder.strip() else None
|
|
386
|
+
return vel_out, predictor
|
|
387
|
+
|
|
388
|
+
# -- capabilities ------------------------------------------------------------------
|
|
389
|
+
|
|
390
|
+
def capabilities(self) -> FormatCapabilities:
|
|
391
|
+
full = FieldCapability(level=CapabilityLevel.FULL)
|
|
392
|
+
return FormatCapabilities(
|
|
393
|
+
format_id=self.format_id,
|
|
394
|
+
format_name=self.format_name,
|
|
395
|
+
direction="read",
|
|
396
|
+
fields={
|
|
397
|
+
"atoms.symbols": full,
|
|
398
|
+
"atoms.positions": full,
|
|
399
|
+
"cell.lattice_vectors": full,
|
|
400
|
+
"cell.pbc": FieldCapability(
|
|
401
|
+
level=CapabilityLevel.PARTIAL,
|
|
402
|
+
notes="Always (T,T,T) by format definition; POSCAR carries no explicit PBC.",
|
|
403
|
+
),
|
|
404
|
+
"dynamics.velocities": full,
|
|
405
|
+
"dynamics.constraints": FieldCapability(
|
|
406
|
+
level=CapabilityLevel.PARTIAL,
|
|
407
|
+
notes="Only per-axis fixed-atom masks (selective dynamics).",
|
|
408
|
+
),
|
|
409
|
+
"user_metadata.custom_global": FieldCapability(
|
|
410
|
+
level=CapabilityLevel.FULL, notes="Title line and CONTCAR predictor block."
|
|
411
|
+
),
|
|
412
|
+
},
|
|
413
|
+
max_frames=1,
|
|
414
|
+
required_fields=[], # read side: absence is honoured, not required
|
|
415
|
+
native_coordinate_system="both",
|
|
416
|
+
lossy_notes=[],
|
|
417
|
+
)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def make_poscar_parser() -> PoscarParser:
|
|
421
|
+
return PoscarParser(format_id="poscar", conventional_name="POSCAR", base_score=0.6)
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def make_contcar_parser() -> PoscarParser:
|
|
425
|
+
# Marginally lower base so POSCAR wins a nameless tie; a velocity tail tips it to CONTCAR.
|
|
426
|
+
return PoscarParser(
|
|
427
|
+
format_id="contcar", conventional_name="CONTCAR", base_score=0.55, tail_bonus=0.1
|
|
428
|
+
)
|