chemparseplot 1.7.0__py3-none-any.whl → 1.8.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.
- _version.py +2 -2
- chemparseplot/parse/chemgp_hdf5.py +13 -12
- chemparseplot/parse/chemgp_jsonl.py +206 -70
- chemparseplot/parse/eon/_trajectory_common.py +160 -0
- chemparseplot/parse/eon/dimer_trajectory.py +46 -35
- chemparseplot/parse/eon/min_trajectory.py +38 -26
- chemparseplot/parse/eon/minimization.py +3 -3
- chemparseplot/parse/eon/neb.py +39 -5
- chemparseplot/parse/eon/saddle_search.py +44 -29
- chemparseplot/parse/eon/stitch.py +251 -0
- chemparseplot/parse/neb_utils.py +9 -1
- chemparseplot/parse/orca/neb/opi_parser.py +94 -77
- chemparseplot/parse/plumed.py +28 -36
- chemparseplot/parse/sella/saddle_search.py +34 -20
- chemparseplot/parse/trajectory/hdf5.py +89 -75
- chemparseplot/parse/trajectory/neb.py +17 -6
- chemparseplot/parse/types.py +156 -0
- chemparseplot/plot/geomscan.py +8 -10
- chemparseplot/plot/neb.py +613 -168
- chemparseplot/plot/optimization.py +287 -11
- chemparseplot/plot/structs.py +111 -20
- chemparseplot/plot/theme.py +21 -0
- chemparseplot/units.py +3 -1
- chemparseplot/util.py +29 -32
- {chemparseplot-1.7.0.dist-info → chemparseplot-1.8.0.dist-info}/METADATA +16 -1
- chemparseplot-1.8.0.dist-info/RECORD +77 -0
- {chemparseplot-1.7.0.dist-info → chemparseplot-1.8.0.dist-info}/WHEEL +1 -1
- tests/_optional_imports.py +24 -0
- tests/conftest.py +12 -1
- tests/parse/test_chemgp_hdf5.py +15 -5
- tests/parse/test_chemgp_jsonl_types.py +142 -0
- tests/parse/test_dimer_trajectory.py +142 -0
- tests/parse/test_eon_neb_types.py +80 -0
- tests/parse/test_eon_trajectory_common.py +117 -0
- tests/parse/test_min_trajectory.py +135 -7
- tests/parse/test_minimization_result.py +22 -0
- tests/parse/test_neb_stitch.py +70 -0
- tests/parse/test_parser_types.py +86 -0
- tests/parse/test_plumed.py +10 -2
- tests/parse/test_saddle_results.py +44 -0
- tests/parse/test_sella_saddle.py +80 -0
- tests/parse/test_trajectory_hdf5.py +30 -0
- tests/parse/test_trajectory_neb.py +27 -0
- tests/plot/test_neb_helpers.py +113 -0
- tests/plot/test_optimization.py +285 -0
- tests/plot/test_orca_neb_payload.py +43 -0
- tests/plot/test_strip_rendering.py +47 -6
- tests/test_coverage_batch.py +31 -5
- tests/test_full_coverage.py +116 -34
- tests/tutorials/test_chemparseplot.py +67 -92
- chemparseplot-1.7.0.dist-info/RECORD +0 -63
- {chemparseplot-1.7.0.dist-info → chemparseplot-1.8.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.
|
|
22
|
-
__version_tuple__ = version_tuple = (1,
|
|
21
|
+
__version__ = version = '1.8.0'
|
|
22
|
+
__version_tuple__ = version_tuple = (1, 8, 0)
|
|
23
23
|
|
|
24
24
|
__commit_id__ = commit_id = None
|
|
@@ -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) ->
|
|
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
|
-
|
|
104
|
-
|
|
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) ->
|
|
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
|
-
|
|
123
|
-
|
|
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) ->
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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 :
|
|
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:
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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.
|
|
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.
|
|
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 :
|
|
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:
|
|
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(
|
|
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]
|
|
274
|
-
|
|
275
|
-
|
|
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
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
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
|
|
@@ -0,0 +1,160 @@
|
|
|
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
|
+
import readcon
|
|
12
|
+
from ase import Atoms
|
|
13
|
+
|
|
14
|
+
log = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
T = TypeVar("T")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
_COLUMN_CASTERS: dict[str, Callable[[Any], Any]] = {
|
|
20
|
+
"frame_index": int,
|
|
21
|
+
"iteration": int,
|
|
22
|
+
"neb_band": int,
|
|
23
|
+
"neb_bead": int,
|
|
24
|
+
"rotations": int,
|
|
25
|
+
"energy": float,
|
|
26
|
+
"step_size": float,
|
|
27
|
+
"convergence": float,
|
|
28
|
+
"delta_e": float,
|
|
29
|
+
"eigenvalue": float,
|
|
30
|
+
"torque": float,
|
|
31
|
+
"angle": float,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def coerce_metadata_value(column: str, value: Any) -> Any:
|
|
36
|
+
"""Coerce metadata strings back to the expected scalar type."""
|
|
37
|
+
if value is None:
|
|
38
|
+
return None
|
|
39
|
+
caster = _COLUMN_CASTERS.get(column)
|
|
40
|
+
if caster is None:
|
|
41
|
+
return value
|
|
42
|
+
if isinstance(value, caster):
|
|
43
|
+
return value
|
|
44
|
+
return caster(value)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def resolve_movie_file(job_dir: Path, stem: str) -> Path:
|
|
48
|
+
"""Resolve an eOn movie file that may omit the ``.con`` suffix."""
|
|
49
|
+
|
|
50
|
+
for candidate in (job_dir / stem, job_dir / f"{stem}.con"):
|
|
51
|
+
if candidate.exists():
|
|
52
|
+
return candidate
|
|
53
|
+
msg = f"No {stem} movie file found in {job_dir}"
|
|
54
|
+
raise FileNotFoundError(msg)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def require_dat_file(job_dir: Path, name: str) -> Path:
|
|
58
|
+
"""Return a required trajectory data file or raise a helpful error."""
|
|
59
|
+
|
|
60
|
+
path = job_dir / name
|
|
61
|
+
if path.exists():
|
|
62
|
+
return path
|
|
63
|
+
msg = f"No {name} found in {job_dir} (was write_movies enabled?)"
|
|
64
|
+
raise FileNotFoundError(msg)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def read_first_structure(path: Path) -> Atoms:
|
|
68
|
+
"""Read the first structure from an eOn CON file."""
|
|
69
|
+
|
|
70
|
+
return readcon.read_con_as_ase(str(path))[0]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def read_optional_first(job_dir: Path, names: Sequence[str]) -> Atoms | None:
|
|
74
|
+
"""Return the first matching optional CON structure from ``job_dir``."""
|
|
75
|
+
|
|
76
|
+
for name in names:
|
|
77
|
+
candidate = job_dir / name
|
|
78
|
+
if candidate.exists():
|
|
79
|
+
return read_first_structure(candidate)
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_movie_and_table(
|
|
84
|
+
job_dir: Path,
|
|
85
|
+
*,
|
|
86
|
+
movie_stem: str,
|
|
87
|
+
dat_name: str,
|
|
88
|
+
parse_dat: Callable[[Path], pl.DataFrame],
|
|
89
|
+
metadata_columns: Sequence[str],
|
|
90
|
+
build_table_from_metadata: Callable[[Sequence[readcon.ConFrame]], pl.DataFrame],
|
|
91
|
+
log_label: str,
|
|
92
|
+
) -> tuple[list[Atoms], pl.DataFrame]:
|
|
93
|
+
"""Load the shared movie/data payload for an eOn trajectory parser."""
|
|
94
|
+
|
|
95
|
+
movie_file = resolve_movie_file(job_dir, movie_stem)
|
|
96
|
+
log.info("Loading %s trajectory from %s", log_label, job_dir)
|
|
97
|
+
frames = readcon.read_con(str(movie_file))
|
|
98
|
+
atoms_list = [frame.to_ase() for frame in frames]
|
|
99
|
+
|
|
100
|
+
dat_df = build_table_from_metadata(frames)
|
|
101
|
+
if not dat_df.is_empty():
|
|
102
|
+
log.info(
|
|
103
|
+
"Using %s metrics from frame metadata (%d rows)",
|
|
104
|
+
log_label,
|
|
105
|
+
dat_df.height,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
dat_file = job_dir / dat_name
|
|
109
|
+
if dat_file.exists():
|
|
110
|
+
dat_df = parse_dat(dat_file)
|
|
111
|
+
log.info("Fell back to %s sidecar table %s", log_label, dat_file.name)
|
|
112
|
+
else:
|
|
113
|
+
msg = (
|
|
114
|
+
f"No compatible frame metadata for columns {', '.join(metadata_columns)} "
|
|
115
|
+
f"and no {dat_name} found in {job_dir}"
|
|
116
|
+
)
|
|
117
|
+
raise FileNotFoundError(msg)
|
|
118
|
+
log.info("Loaded %d frames, %d data rows", len(atoms_list), dat_df.height)
|
|
119
|
+
return atoms_list, dat_df
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def metadata_value(frame: readcon.ConFrame, key: str) -> Any:
|
|
123
|
+
"""Return a typed metadata value from a readcon frame."""
|
|
124
|
+
|
|
125
|
+
if key == "frame_index":
|
|
126
|
+
return frame.frame_index
|
|
127
|
+
if key == "energy":
|
|
128
|
+
return frame.energy
|
|
129
|
+
return frame.metadata.get(key)
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def frame_rows_to_table(
|
|
133
|
+
frames: Sequence[readcon.ConFrame],
|
|
134
|
+
columns: Sequence[str],
|
|
135
|
+
*,
|
|
136
|
+
allow_leading_incomplete: bool = False,
|
|
137
|
+
) -> pl.DataFrame:
|
|
138
|
+
"""Build a table from frame metadata when sidecar TSV data is absent."""
|
|
139
|
+
|
|
140
|
+
rows: list[dict[str, Any]] = []
|
|
141
|
+
saw_complete_row = False
|
|
142
|
+
for frame in frames:
|
|
143
|
+
row = {column: coerce_metadata_value(column, metadata_value(frame, column)) for column in columns}
|
|
144
|
+
if any(value is None for value in row.values()):
|
|
145
|
+
if saw_complete_row or not allow_leading_incomplete:
|
|
146
|
+
return pl.DataFrame()
|
|
147
|
+
continue
|
|
148
|
+
saw_complete_row = True
|
|
149
|
+
rows.append(row)
|
|
150
|
+
if not rows:
|
|
151
|
+
return pl.DataFrame()
|
|
152
|
+
return pl.DataFrame(rows).select(list(columns))
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def load_optional_payload(path: Path, loader: Callable[[Path], T]) -> T | None:
|
|
156
|
+
"""Load an optional file payload if the path exists."""
|
|
157
|
+
|
|
158
|
+
if not path.exists():
|
|
159
|
+
return None
|
|
160
|
+
return loader(path)
|