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