chemparseplot 1.7.0__py3-none-any.whl → 1.9.0__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 (61) hide show
  1. _version.py +2 -2
  2. chemparseplot/__init__.py +22 -1
  3. chemparseplot/parse/chemgp_hdf5.py +13 -12
  4. chemparseplot/parse/chemgp_jsonl.py +206 -70
  5. chemparseplot/parse/eon/__init__.py +7 -0
  6. chemparseplot/parse/eon/_trajectory_common.py +161 -0
  7. chemparseplot/parse/eon/con_io.py +136 -0
  8. chemparseplot/parse/eon/dimer_trajectory.py +49 -36
  9. chemparseplot/parse/eon/min_trajectory.py +41 -27
  10. chemparseplot/parse/eon/minimization.py +3 -3
  11. chemparseplot/parse/eon/neb.py +39 -5
  12. chemparseplot/parse/eon/saddle_search.py +44 -29
  13. chemparseplot/parse/eon/stitch.py +252 -0
  14. chemparseplot/parse/neb_utils.py +9 -1
  15. chemparseplot/parse/orca/neb/opi_parser.py +94 -77
  16. chemparseplot/parse/plumed.py +28 -36
  17. chemparseplot/parse/sella/saddle_search.py +34 -20
  18. chemparseplot/parse/trajectory/hdf5.py +89 -75
  19. chemparseplot/parse/trajectory/neb.py +17 -6
  20. chemparseplot/parse/types.py +156 -0
  21. chemparseplot/plot/geomscan.py +8 -10
  22. chemparseplot/plot/neb.py +613 -168
  23. chemparseplot/plot/optimization.py +445 -11
  24. chemparseplot/plot/structs.py +132 -20
  25. chemparseplot/plot/theme.py +21 -0
  26. chemparseplot/units.py +116 -8
  27. chemparseplot/util.py +27 -29
  28. {chemparseplot-1.7.0.dist-info → chemparseplot-1.9.0.dist-info}/METADATA +18 -3
  29. chemparseplot-1.9.0.dist-info/RECORD +84 -0
  30. {chemparseplot-1.7.0.dist-info → chemparseplot-1.9.0.dist-info}/WHEEL +1 -1
  31. tests/__init__.py +0 -0
  32. tests/_optional_imports.py +24 -0
  33. tests/conftest.py +12 -1
  34. tests/parse/__init__.py +0 -0
  35. tests/parse/_con_fixtures.py +89 -0
  36. tests/parse/test_chemgp_hdf5.py +15 -5
  37. tests/parse/test_chemgp_jsonl_types.py +142 -0
  38. tests/parse/test_con_io.py +237 -0
  39. tests/parse/test_dimer_trajectory.py +142 -0
  40. tests/parse/test_eon_neb_types.py +80 -0
  41. tests/parse/test_eon_trajectory_common.py +190 -0
  42. tests/parse/test_min_trajectory.py +135 -7
  43. tests/parse/test_minimization_result.py +22 -0
  44. tests/parse/test_neb_stitch.py +167 -0
  45. tests/parse/test_parser_types.py +86 -0
  46. tests/parse/test_plumed.py +10 -2
  47. tests/parse/test_readcon_boundary.py +78 -0
  48. tests/parse/test_saddle_results.py +44 -0
  49. tests/parse/test_sella_saddle.py +80 -0
  50. tests/parse/test_trajectory_hdf5.py +30 -0
  51. tests/parse/test_trajectory_neb.py +27 -0
  52. tests/plot/test_neb_helpers.py +113 -0
  53. tests/plot/test_optimization.py +328 -0
  54. tests/plot/test_orca_neb_payload.py +43 -0
  55. tests/plot/test_strip_rendering.py +47 -6
  56. tests/plot/test_structs_units.py +48 -0
  57. tests/test_coverage_batch.py +31 -5
  58. tests/test_full_coverage.py +116 -34
  59. tests/tutorials/test_chemparseplot.py +67 -92
  60. chemparseplot-1.7.0.dist-info/RECORD +0 -63
  61. {chemparseplot-1.7.0.dist-info → chemparseplot-1.9.0.dist-info}/licenses/LICENSE +0 -0
_version.py CHANGED
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '1.7.0'
22
- __version_tuple__ = version_tuple = (1, 7, 0)
21
+ __version__ = version = '1.9.0'
22
+ __version_tuple__ = version_tuple = (1, 9, 0)
23
23
 
24
24
  __commit_id__ = commit_id = None
chemparseplot/__init__.py CHANGED
@@ -1,7 +1,28 @@
1
1
  # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
2
  #
3
3
  # SPDX-License-Identifier: MIT
4
+ """chemparseplot public surface with deferred heavy submodules.
4
5
 
5
- from chemparseplot import parse, units
6
+ ``parse`` and ``units`` remain attributes; they load via importlib on first
7
+ access so a bare ``import chemparseplot`` does not construct the pint registry
8
+ or pull the full parse package graph until needed.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ from typing import TYPE_CHECKING, Any
6
15
 
7
16
  __all__ = ["parse", "units"]
17
+
18
+ if TYPE_CHECKING:
19
+ from chemparseplot import parse as parse
20
+ from chemparseplot import units as units
21
+
22
+
23
+ def __getattr__(name: str) -> Any:
24
+ if name == "parse":
25
+ return importlib.import_module("chemparseplot.parse")
26
+ if name == "units":
27
+ return importlib.import_module("chemparseplot.units")
28
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -22,6 +22,7 @@ HDF5 Layout
22
22
  from typing import Any
23
23
 
24
24
  import numpy as np
25
+ from chemparseplot.parse.types import ArrayGroup, ParserAttrs
25
26
 
26
27
 
27
28
  def read_h5_table(f: Any, name: str = "table") -> Any:
@@ -88,7 +89,7 @@ def read_h5_grid(
88
89
  return data, x_coords, y_coords
89
90
 
90
91
 
91
- def read_h5_path(f: Any, name: str) -> dict[str, np.ndarray]:
92
+ def read_h5_path(f: Any, name: str) -> ArrayGroup:
92
93
  """Read a path (ordered point sequence).
93
94
 
94
95
  Parameters
@@ -100,14 +101,14 @@ def read_h5_path(f: Any, name: str) -> dict[str, np.ndarray]:
100
101
 
101
102
  Returns
102
103
  -------
103
- dict
104
- Dictionary mapping coordinate names to arrays
104
+ ArrayGroup
105
+ Named mapping of coordinate names to arrays
105
106
  """
106
107
  g = f[f"paths/{name}"]
107
- return {k: g[k][()] for k in g.keys()}
108
+ return ArrayGroup(data={k: g[k][()] for k in g.keys()})
108
109
 
109
110
 
110
- def read_h5_points(f: Any, name: str) -> dict[str, np.ndarray]:
111
+ def read_h5_points(f: Any, name: str) -> ArrayGroup:
111
112
  """Read a point set.
112
113
 
113
114
  Parameters
@@ -119,14 +120,14 @@ def read_h5_points(f: Any, name: str) -> dict[str, np.ndarray]:
119
120
 
120
121
  Returns
121
122
  -------
122
- dict
123
- Dictionary mapping coordinate names to arrays
123
+ ArrayGroup
124
+ Named mapping of coordinate names to arrays
124
125
  """
125
126
  g = f[f"points/{name}"]
126
- return {k: g[k][()] for k in g.keys()}
127
+ return ArrayGroup(data={k: g[k][()] for k in g.keys()})
127
128
 
128
129
 
129
- def read_h5_metadata(f: Any) -> dict[str, Any]:
130
+ def read_h5_metadata(f: Any) -> ParserAttrs:
130
131
  """Read root-level metadata attributes.
131
132
 
132
133
  Parameters
@@ -136,10 +137,10 @@ def read_h5_metadata(f: Any) -> dict[str, Any]:
136
137
 
137
138
  Returns
138
139
  -------
139
- dict
140
- Dictionary of metadata attributes
140
+ ParserAttrs
141
+ Named mapping of metadata attributes
141
142
  """
142
- return {k: f.attrs[k] for k in f.attrs.keys()}
143
+ return ParserAttrs(data={k: f.attrs[k] for k in f.attrs.keys()})
143
144
 
144
145
 
145
146
  def validate_hdf5_structure(
@@ -17,10 +17,34 @@ import json
17
17
  from collections import defaultdict
18
18
  from dataclasses import dataclass, field
19
19
  from pathlib import Path
20
- from typing import Any
21
20
 
21
+ from chemparseplot.parse.types import ParserAttrs
22
22
 
23
- @dataclass
23
+
24
+ @dataclass(frozen=True, slots=True)
25
+ class ComparisonRecord:
26
+ """One optimizer-step record from a comparison JSONL."""
27
+
28
+ method: str
29
+ oracle_calls: int
30
+ step: int | None = None
31
+ energy: float | None = None
32
+ force: float | None = None
33
+ max_force: float | None = None
34
+
35
+ @classmethod
36
+ def from_mapping(cls, rec: ParserAttrs) -> ComparisonRecord:
37
+ return cls(
38
+ method=str(rec["method"]),
39
+ oracle_calls=int(rec["oracle_calls"]),
40
+ step=int(rec["step"]) if "step" in rec else None,
41
+ energy=float(rec["energy"]) if "energy" in rec else None,
42
+ force=float(rec["force"]) if "force" in rec else None,
43
+ max_force=float(rec["max_force"]) if "max_force" in rec else None,
44
+ )
45
+
46
+
47
+ @dataclass(slots=True)
24
48
  class OptimizerTrace:
25
49
  """Single optimizer trace from a comparison JSONL.
26
50
 
@@ -44,8 +68,23 @@ class OptimizerTrace:
44
68
  energies: list[float] | None = None
45
69
  forces: list[float] | None = None
46
70
 
71
+ def add_record(self, rec: ComparisonRecord) -> None:
72
+ """Accumulate one typed optimizer record into the trace."""
73
+
74
+ self.steps.append(rec.step if rec.step is not None else len(self.steps))
75
+ self.oracle_calls.append(rec.oracle_calls)
76
+ if rec.energy is not None:
77
+ if self.energies is None:
78
+ self.energies = []
79
+ self.energies.append(rec.energy)
80
+ force_value = rec.force if rec.force is not None else rec.max_force
81
+ if force_value is not None:
82
+ if self.forces is None:
83
+ self.forces = []
84
+ self.forces.append(force_value)
85
+
47
86
 
48
- @dataclass
87
+ @dataclass(slots=True)
49
88
  class ComparisonData:
50
89
  """Parsed optimizer comparison from a single JSONL file.
51
90
 
@@ -53,12 +92,19 @@ class ComparisonData:
53
92
  ----------
54
93
  traces : dict[str, OptimizerTrace]
55
94
  Keyed by method name.
56
- summary : dict | None
95
+ summary : ParserAttrs | None
57
96
  Summary record if present.
58
97
  """
59
98
 
60
99
  traces: dict[str, OptimizerTrace] = field(default_factory=dict)
61
- summary: dict[str, Any] | None = None
100
+ summary: ParserAttrs | None = None
101
+
102
+ def ensure_trace(self, method: str) -> OptimizerTrace:
103
+ """Return the named optimizer trace, creating it if needed."""
104
+
105
+ if method not in self.traces:
106
+ self.traces[method] = OptimizerTrace(method=method)
107
+ return self.traces[method]
62
108
 
63
109
 
64
110
  def parse_comparison_jsonl(path: str | Path) -> ComparisonData:
@@ -80,29 +126,53 @@ def parse_comparison_jsonl(path: str | Path) -> ComparisonData:
80
126
  data = ComparisonData()
81
127
  with open(path) as f:
82
128
  for line in f:
83
- rec = json.loads(line.strip())
129
+ rec = ParserAttrs(data=json.loads(line.strip()))
84
130
  if rec.get("summary"):
85
131
  data.summary = rec
86
132
  continue
87
- method = rec["method"]
88
- if method not in data.traces:
89
- data.traces[method] = OptimizerTrace(method=method)
90
- trace = data.traces[method]
91
- trace.steps.append(rec.get("step", len(trace.steps)))
92
- trace.oracle_calls.append(rec["oracle_calls"])
93
- if "energy" in rec:
94
- if trace.energies is None:
95
- trace.energies = []
96
- trace.energies.append(rec["energy"])
97
- force_key = "force" if "force" in rec else "max_force"
98
- if force_key in rec:
99
- if trace.forces is None:
100
- trace.forces = []
101
- trace.forces.append(rec[force_key])
133
+ record = ComparisonRecord.from_mapping(rec)
134
+ trace = data.ensure_trace(record.method)
135
+ trace.add_record(record)
102
136
  return data
103
137
 
104
138
 
105
- @dataclass
139
+ @dataclass(frozen=True, slots=True)
140
+ class RFFExactRecord:
141
+ """Exact-GP benchmark reference record."""
142
+
143
+ energy_mae: float
144
+ gradient_mae: float
145
+
146
+ @classmethod
147
+ def from_mapping(cls, rec: ParserAttrs) -> RFFExactRecord:
148
+ return cls(
149
+ energy_mae=float(rec["energy_mae"]),
150
+ gradient_mae=float(rec["gradient_mae"]),
151
+ )
152
+
153
+
154
+ @dataclass(frozen=True, slots=True)
155
+ class RFFApproxRecord:
156
+ """One random-feature approximation benchmark record."""
157
+
158
+ d_rff: int
159
+ energy_mae_vs_true: float
160
+ gradient_mae_vs_true: float
161
+ energy_mae_vs_gp: float
162
+ gradient_mae_vs_gp: float
163
+
164
+ @classmethod
165
+ def from_mapping(cls, rec: ParserAttrs) -> RFFApproxRecord:
166
+ return cls(
167
+ d_rff=int(rec["d_rff"]),
168
+ energy_mae_vs_true=float(rec["energy_mae_vs_true"]),
169
+ gradient_mae_vs_true=float(rec["gradient_mae_vs_true"]),
170
+ energy_mae_vs_gp=float(rec["energy_mae_vs_gp"]),
171
+ gradient_mae_vs_gp=float(rec["gradient_mae_vs_gp"]),
172
+ )
173
+
174
+
175
+ @dataclass(slots=True)
106
176
  class RFFQualityData:
107
177
  """Parsed RFF approximation quality data.
108
178
 
@@ -132,6 +202,21 @@ class RFFQualityData:
132
202
  energy_mae_vs_gp: list[float] = field(default_factory=list)
133
203
  gradient_mae_vs_gp: list[float] = field(default_factory=list)
134
204
 
205
+ def add_exact_gp(self, rec: RFFExactRecord) -> None:
206
+ """Store the exact-GP reference metrics."""
207
+
208
+ self.exact_energy_mae = rec.energy_mae
209
+ self.exact_gradient_mae = rec.gradient_mae
210
+
211
+ def add_rff(self, rec: RFFApproxRecord) -> None:
212
+ """Store one RFF approximation record."""
213
+
214
+ self.d_rff_values.append(rec.d_rff)
215
+ self.energy_mae_vs_true.append(rec.energy_mae_vs_true)
216
+ self.gradient_mae_vs_true.append(rec.gradient_mae_vs_true)
217
+ self.energy_mae_vs_gp.append(rec.energy_mae_vs_gp)
218
+ self.gradient_mae_vs_gp.append(rec.gradient_mae_vs_gp)
219
+
135
220
 
136
221
  def parse_rff_quality_jsonl(path: str | Path) -> RFFQualityData:
137
222
  """Parse a ChemGP RFF quality JSONL file.
@@ -149,20 +234,15 @@ def parse_rff_quality_jsonl(path: str | Path) -> RFFQualityData:
149
234
  data = RFFQualityData()
150
235
  with open(path) as f:
151
236
  for line in f:
152
- rec = json.loads(line.strip())
237
+ rec = ParserAttrs(data=json.loads(line.strip()))
153
238
  if rec["type"] == "exact_gp":
154
- data.exact_energy_mae = rec["energy_mae"]
155
- data.exact_gradient_mae = rec["gradient_mae"]
239
+ data.add_exact_gp(RFFExactRecord.from_mapping(rec))
156
240
  elif rec["type"] == "rff":
157
- data.d_rff_values.append(rec["d_rff"])
158
- data.energy_mae_vs_true.append(rec["energy_mae_vs_true"])
159
- data.gradient_mae_vs_true.append(rec["gradient_mae_vs_true"])
160
- data.energy_mae_vs_gp.append(rec["energy_mae_vs_gp"])
161
- data.gradient_mae_vs_gp.append(rec["gradient_mae_vs_gp"])
241
+ data.add_rff(RFFApproxRecord.from_mapping(rec))
162
242
  return data
163
243
 
164
244
 
165
- @dataclass
245
+ @dataclass(slots=True)
166
246
  class GPQualityGrid:
167
247
  """GP quality grid data for a single training set size.
168
248
 
@@ -204,8 +284,80 @@ class GPQualityGrid:
204
284
  train_y: list[float] = field(default_factory=list)
205
285
  train_e: list[float] = field(default_factory=list)
206
286
 
287
+ @classmethod
288
+ def from_records(
289
+ cls,
290
+ *,
291
+ n_train: int,
292
+ meta: ParserAttrs,
293
+ records: list[GPGridRecord],
294
+ train_points: TrainingPointSet | None = None,
295
+ ) -> GPQualityGrid:
296
+ """Build a typed grid from JSONL records and parsed metadata."""
297
+
298
+ nx = int(meta["nx"]) if "nx" in meta else 0
299
+ ny = int(meta["ny"]) if "ny" in meta else 0
300
+ grid = cls(n_train=n_train, nx=nx, ny=ny)
301
+ grid.x = [[0.0] * nx for _ in range(ny)]
302
+ grid.y = [[0.0] * nx for _ in range(ny)]
303
+ grid.true_e = [[0.0] * nx for _ in range(ny)]
304
+ grid.gp_e = [[0.0] * nx for _ in range(ny)]
305
+ grid.gp_var = [[0.0] * nx for _ in range(ny)]
306
+
307
+ for rec in records:
308
+ grid.x[rec.iy][rec.ix] = rec.x
309
+ grid.y[rec.iy][rec.ix] = rec.y
310
+ grid.true_e[rec.iy][rec.ix] = rec.true_e
311
+ grid.gp_e[rec.iy][rec.ix] = rec.gp_e
312
+ grid.gp_var[rec.iy][rec.ix] = rec.gp_var
313
+
314
+ if train_points is not None:
315
+ grid.train_x = list(train_points.x)
316
+ grid.train_y = list(train_points.y)
317
+ grid.train_e = list(train_points.e)
318
+ return grid
319
+
207
320
 
208
- @dataclass
321
+ @dataclass(frozen=True, slots=True)
322
+ class GPGridRecord:
323
+ """One grid-sample record from the GP-quality JSONL."""
324
+
325
+ ix: int
326
+ iy: int
327
+ x: float
328
+ y: float
329
+ true_e: float
330
+ gp_e: float
331
+ gp_var: float
332
+
333
+ @classmethod
334
+ def from_mapping(cls, rec: ParserAttrs) -> GPGridRecord:
335
+ return cls(
336
+ ix=int(rec["ix"]),
337
+ iy=int(rec["iy"]),
338
+ x=float(rec["x"]),
339
+ y=float(rec["y"]),
340
+ true_e=float(rec["true_e"]),
341
+ gp_e=float(rec["gp_e"]),
342
+ gp_var=float(rec["gp_var"]),
343
+ )
344
+
345
+
346
+ @dataclass(slots=True)
347
+ class TrainingPointSet:
348
+ """Accumulated training points for a single ``n_train`` value."""
349
+
350
+ x: list[float] = field(default_factory=list)
351
+ y: list[float] = field(default_factory=list)
352
+ e: list[float] = field(default_factory=list)
353
+
354
+ def append(self, *, x: float, y: float, energy: float) -> None:
355
+ self.x.append(x)
356
+ self.y.append(y)
357
+ self.e.append(energy)
358
+
359
+
360
+ @dataclass(frozen=True, slots=True)
209
361
  class StationaryPoint:
210
362
  """A stationary point (minimum or saddle) on the PES."""
211
363
 
@@ -216,13 +368,13 @@ class StationaryPoint:
216
368
  energy: float
217
369
 
218
370
 
219
- @dataclass
371
+ @dataclass(slots=True)
220
372
  class GPQualityData:
221
373
  """Complete GP quality data from mb_gp_quality.jsonl.
222
374
 
223
375
  Attributes
224
376
  ----------
225
- meta : dict
377
+ meta : ParserAttrs
226
378
  Grid metadata (nx, ny, x_min, x_max, y_min, y_max).
227
379
  stationary : list[StationaryPoint]
228
380
  Minima and saddle points.
@@ -230,7 +382,7 @@ class GPQualityData:
230
382
  Grid data keyed by n_train.
231
383
  """
232
384
 
233
- meta: dict[str, Any] = field(default_factory=dict)
385
+ meta: ParserAttrs = field(default_factory=ParserAttrs)
234
386
  stationary: list[StationaryPoint] = field(default_factory=list)
235
387
  grids: dict[int, GPQualityGrid] = field(default_factory=dict)
236
388
 
@@ -249,12 +401,12 @@ def parse_gp_quality_jsonl(path: str | Path) -> GPQualityData:
249
401
  Structured grid data with metadata and stationary points.
250
402
  """
251
403
  data = GPQualityData()
252
- train_points = defaultdict(lambda: {"x": [], "y": [], "e": []})
404
+ train_points = defaultdict(TrainingPointSet)
253
405
  grid_records = defaultdict(list)
254
406
 
255
407
  with open(path) as f:
256
408
  for line in f:
257
- rec = json.loads(line.strip())
409
+ rec = ParserAttrs(data=json.loads(line.strip()))
258
410
  t = rec["type"]
259
411
  if t == "grid_meta":
260
412
  data.meta = rec
@@ -262,44 +414,28 @@ def parse_gp_quality_jsonl(path: str | Path) -> GPQualityData:
262
414
  data.stationary.append(
263
415
  StationaryPoint(
264
416
  kind=t,
265
- id=rec["id"],
266
- x=rec["x"],
267
- y=rec["y"],
268
- energy=rec["energy"],
417
+ id=int(rec["id"]),
418
+ x=float(rec["x"]),
419
+ y=float(rec["y"]),
420
+ energy=float(rec["energy"]),
269
421
  )
270
422
  )
271
423
  elif t == "train_point":
272
- n = rec["n_train"]
273
- train_points[n]["x"].append(rec["x"])
274
- train_points[n]["y"].append(rec["y"])
275
- train_points[n]["e"].append(rec["energy"])
424
+ n = int(rec["n_train"])
425
+ train_points[n].append(
426
+ x=float(rec["x"]),
427
+ y=float(rec["y"]),
428
+ energy=float(rec["energy"]),
429
+ )
276
430
  elif t == "grid":
277
- grid_records[rec["n_train"]].append(rec)
278
-
279
- nx = data.meta.get("nx", 0)
280
- ny = data.meta.get("ny", 0)
431
+ grid_records[int(rec["n_train"])].append(GPGridRecord.from_mapping(rec))
281
432
 
282
433
  for n_train, records in grid_records.items():
283
- grid = GPQualityGrid(n_train=n_train, nx=nx, ny=ny)
284
- # Initialize 2D arrays
285
- grid.x = [[0.0] * nx for _ in range(ny)]
286
- grid.y = [[0.0] * nx for _ in range(ny)]
287
- grid.true_e = [[0.0] * nx for _ in range(ny)]
288
- grid.gp_e = [[0.0] * nx for _ in range(ny)]
289
- grid.gp_var = [[0.0] * nx for _ in range(ny)]
290
-
291
- for rec in records:
292
- ix, iy = rec["ix"], rec["iy"]
293
- grid.x[iy][ix] = rec["x"]
294
- grid.y[iy][ix] = rec["y"]
295
- grid.true_e[iy][ix] = rec["true_e"]
296
- grid.gp_e[iy][ix] = rec["gp_e"]
297
- grid.gp_var[iy][ix] = rec["gp_var"]
298
-
299
- tp = train_points.get(n_train, {"x": [], "y": [], "e": []})
300
- grid.train_x = tp["x"]
301
- grid.train_y = tp["y"]
302
- grid.train_e = tp["e"]
303
- data.grids[n_train] = grid
434
+ data.grids[n_train] = GPQualityGrid.from_records(
435
+ n_train=n_train,
436
+ meta=data.meta,
437
+ records=records,
438
+ train_points=train_points.get(n_train),
439
+ )
304
440
 
305
441
  return data
@@ -1 +1,8 @@
1
+ # SPDX-FileCopyrightText: 2023-present Rohit Goswami <rog32@hi.is>
2
+ #
3
+ # SPDX-License-Identifier: MIT
1
4
  """eOn trajectory parsers."""
5
+
6
+ from chemparseplot.parse.eon import con_io
7
+
8
+ __all__ = ["con_io"]
@@ -0,0 +1,161 @@
1
+ """Shared helpers for eOn trajectory parsers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from collections.abc import Callable, Sequence
7
+ from pathlib import Path
8
+ from typing import Any, TypeVar
9
+
10
+ import polars as pl
11
+ from ase import Atoms
12
+
13
+ from chemparseplot.parse.eon import con_io
14
+
15
+ log = logging.getLogger(__name__)
16
+
17
+ T = TypeVar("T")
18
+
19
+
20
+ _COLUMN_CASTERS: dict[str, Callable[[Any], Any]] = {
21
+ "frame_index": int,
22
+ "iteration": int,
23
+ "neb_band": int,
24
+ "neb_bead": int,
25
+ "rotations": int,
26
+ "energy": float,
27
+ "step_size": float,
28
+ "convergence": float,
29
+ "delta_e": float,
30
+ "eigenvalue": float,
31
+ "torque": float,
32
+ "angle": float,
33
+ }
34
+
35
+
36
+ def coerce_metadata_value(column: str, value: Any) -> Any:
37
+ """Coerce metadata strings back to the expected scalar type."""
38
+ if value is None:
39
+ return None
40
+ caster = _COLUMN_CASTERS.get(column)
41
+ if caster is None:
42
+ return value
43
+ if isinstance(value, caster):
44
+ return value
45
+ return caster(value)
46
+
47
+
48
+ def resolve_movie_file(job_dir: Path, stem: str) -> Path:
49
+ """Resolve an eOn movie file that may omit the ``.con`` suffix."""
50
+
51
+ for candidate in (job_dir / stem, job_dir / f"{stem}.con"):
52
+ if candidate.exists():
53
+ return candidate
54
+ msg = f"No {stem} movie file found in {job_dir}"
55
+ raise FileNotFoundError(msg)
56
+
57
+
58
+ def require_dat_file(job_dir: Path, name: str) -> Path:
59
+ """Return a required trajectory data file or raise a helpful error."""
60
+
61
+ path = job_dir / name
62
+ if path.exists():
63
+ return path
64
+ msg = f"No {name} found in {job_dir} (was write_movies enabled?)"
65
+ raise FileNotFoundError(msg)
66
+
67
+
68
+ def read_first_structure(path: Path) -> Atoms:
69
+ """Read the first structure from an eOn CON file."""
70
+
71
+ return con_io.read_first_atoms(path)
72
+
73
+
74
+ def read_optional_first(job_dir: Path, names: Sequence[str]) -> Atoms | None:
75
+ """Return the first matching optional CON structure from ``job_dir``."""
76
+
77
+ for name in names:
78
+ candidate = job_dir / name
79
+ if candidate.exists():
80
+ return read_first_structure(candidate)
81
+ return None
82
+
83
+
84
+ def load_movie_and_table(
85
+ job_dir: Path,
86
+ *,
87
+ movie_stem: str,
88
+ dat_name: str,
89
+ parse_dat: Callable[[Path], pl.DataFrame],
90
+ metadata_columns: Sequence[str],
91
+ build_table_from_metadata: Callable[[Sequence[object]], pl.DataFrame],
92
+ log_label: str,
93
+ ) -> tuple[list[Atoms], pl.DataFrame]:
94
+ """Load the shared movie/data payload for an eOn trajectory parser."""
95
+
96
+ movie_file = resolve_movie_file(job_dir, movie_stem)
97
+ log.info("Loading %s trajectory from %s", log_label, job_dir)
98
+ frames = con_io.read_con_frames(movie_file)
99
+ atoms_list = [frame.to_ase() for frame in frames]
100
+
101
+ dat_df = build_table_from_metadata(frames)
102
+ if not dat_df.is_empty():
103
+ log.info(
104
+ "Using %s metrics from frame metadata (%d rows)",
105
+ log_label,
106
+ dat_df.height,
107
+ )
108
+ else:
109
+ dat_file = job_dir / dat_name
110
+ if dat_file.exists():
111
+ dat_df = parse_dat(dat_file)
112
+ log.info("Fell back to %s sidecar table %s", log_label, dat_file.name)
113
+ else:
114
+ msg = (
115
+ f"No compatible frame metadata for columns {', '.join(metadata_columns)} "
116
+ f"and no {dat_name} found in {job_dir}"
117
+ )
118
+ raise FileNotFoundError(msg)
119
+ log.info("Loaded %d frames, %d data rows", len(atoms_list), dat_df.height)
120
+ return atoms_list, dat_df
121
+
122
+
123
+ def metadata_value(frame: object, key: str) -> Any:
124
+ """Return a typed metadata value from a readcon frame."""
125
+
126
+ if key == "frame_index":
127
+ return frame.frame_index
128
+ if key == "energy":
129
+ return frame.energy
130
+ return frame.metadata.get(key)
131
+
132
+
133
+ def frame_rows_to_table(
134
+ frames: Sequence[object],
135
+ columns: Sequence[str],
136
+ *,
137
+ allow_leading_incomplete: bool = False,
138
+ ) -> pl.DataFrame:
139
+ """Build a table from frame metadata when sidecar TSV data is absent."""
140
+
141
+ rows: list[dict[str, Any]] = []
142
+ saw_complete_row = False
143
+ for frame in frames:
144
+ row = {column: coerce_metadata_value(column, metadata_value(frame, column)) for column in columns}
145
+ if any(value is None for value in row.values()):
146
+ if saw_complete_row or not allow_leading_incomplete:
147
+ return pl.DataFrame()
148
+ continue
149
+ saw_complete_row = True
150
+ rows.append(row)
151
+ if not rows:
152
+ return pl.DataFrame()
153
+ return pl.DataFrame(rows).select(list(columns))
154
+
155
+
156
+ def load_optional_payload(path: Path, loader: Callable[[Path], T]) -> T | None:
157
+ """Load an optional file payload if the path exists."""
158
+
159
+ if not path.exists():
160
+ return None
161
+ return loader(path)