reactra 0.1.0.dev1__tar.gz
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.
- reactra-0.1.0.dev1/PKG-INFO +109 -0
- reactra-0.1.0.dev1/README.md +95 -0
- reactra-0.1.0.dev1/pyproject.toml +33 -0
- reactra-0.1.0.dev1/src/reactra/__init__.py +3 -0
- reactra-0.1.0.dev1/src/reactra/artifacts.py +41 -0
- reactra-0.1.0.dev1/src/reactra/cli.py +108 -0
- reactra-0.1.0.dev1/src/reactra/colvar.py +124 -0
- reactra-0.1.0.dev1/tests/test_results.py +137 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: reactra
|
|
3
|
+
Version: 0.1.0.dev1
|
|
4
|
+
Summary: Inspect, summarize, and plot exported molecular-simulation trajectories
|
|
5
|
+
Author: MechanismAtlas
|
|
6
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
7
|
+
Classifier: Intended Audience :: Science/Research
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Topic :: Scientific/Engineering :: Chemistry
|
|
10
|
+
Requires-Python: >=3.12
|
|
11
|
+
Provides-Extra: plot
|
|
12
|
+
Requires-Dist: matplotlib<4,>=3.8; extra == 'plot'
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
|
|
15
|
+
# Reactra
|
|
16
|
+
|
|
17
|
+
**Reaction Engine for Atomistic Catalytic Trajectory Reconstruction and Automation**
|
|
18
|
+
|
|
19
|
+
Reactra is the simulation platform being developed for the MechanismAtlas
|
|
20
|
+
enzyme-reaction ecosystem. This **0.1.0.dev1** public preview provides useful,
|
|
21
|
+
standalone tools for **exported simulation results**. The simulation engine is
|
|
22
|
+
currently private and is **not included in this distribution**.
|
|
23
|
+
|
|
24
|
+
The public tools work with ordinary PLUMED/COLVAR numeric tables, including
|
|
25
|
+
exported OPES traces. They require no model weights, accelerator, private
|
|
26
|
+
credentials, molecular structures, or mechanism definitions.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
Python 3.12 or later:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
python -m pip install reactra==0.1.0.dev1
|
|
34
|
+
# Optional plotting support:
|
|
35
|
+
python -m pip install 'reactra[plot]==0.1.0.dev1'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Inspect a trajectory
|
|
39
|
+
|
|
40
|
+
Given a text file named `COLVAR` with PLUMED's standard header:
|
|
41
|
+
|
|
42
|
+
```text
|
|
43
|
+
#! FIELDS time cv opes.bias
|
|
44
|
+
0.0 0.20 0.0
|
|
45
|
+
0.1 0.23 -0.4
|
|
46
|
+
0.2 0.27 -0.8
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
reactra summary COLVAR
|
|
51
|
+
reactra summary COLVAR --columns cv opes.bias
|
|
52
|
+
reactra export-csv COLVAR --columns time cv --output selected.csv
|
|
53
|
+
reactra plot COLVAR --x time --y cv opes.bias --output traces.png
|
|
54
|
+
reactra fingerprint COLVAR
|
|
55
|
+
# Verify an independently recorded SHA-256 digest:
|
|
56
|
+
reactra fingerprint COLVAR --sha256 YOUR_64_CHARACTER_HEXADECIMAL_DIGEST
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Summary reports the number of rows, per-column first/last/minimum/maximum/mean
|
|
60
|
+
and population standard deviation, and repeated or backward time steps. Summary
|
|
61
|
+
and CSV export stream the input instead of loading the trajectory into memory.
|
|
62
|
+
Identical repeated headers are accepted; changing headers, duplicate column names,
|
|
63
|
+
ragged rows, nonnumeric values, and NaN/infinity are refused with line diagnostics.
|
|
64
|
+
|
|
65
|
+
Plots use one panel per requested column and preserve row order. For a large file,
|
|
66
|
+
choose an explicit stride, for example `--stride 100`; plotting retains the
|
|
67
|
+
selected points in memory. Output supports PNG, SVG, and PDF. CSV and plot exports
|
|
68
|
+
refuse existing destinations and publish only after processing succeeds.
|
|
69
|
+
|
|
70
|
+
Units are preserved as recorded, not inferred. Statistics are linear, including
|
|
71
|
+
for angular columns: this release does not perform circular statistics or
|
|
72
|
+
periodic unwrapping. Time differences are not interpreted as completed continuous
|
|
73
|
+
sampling when a trace repeats or moves backward.
|
|
74
|
+
|
|
75
|
+
**A recorded bias or potential-energy trace is not a free-energy barrier.** These
|
|
76
|
+
tools do not infer reaction crossings, reconstruct free energies, estimate rates,
|
|
77
|
+
or qualify convergence. Artifact hashing checks byte identity against a supplied
|
|
78
|
+
digest; it does not establish scientific validity or authenticity.
|
|
79
|
+
|
|
80
|
+
## Python API
|
|
81
|
+
|
|
82
|
+
```python
|
|
83
|
+
from reactra.artifacts import fingerprint
|
|
84
|
+
from reactra.colvar import iter_colvar, summarize
|
|
85
|
+
|
|
86
|
+
report = summarize("COLVAR", columns=["cv"])
|
|
87
|
+
for row in iter_colvar("COLVAR"):
|
|
88
|
+
print(row["time"], row["cv"])
|
|
89
|
+
identity = fingerprint("COLVAR")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
CLI output is JSON. Exit codes are `0` for successful processing or a matching
|
|
93
|
+
digest, `1` for a digest mismatch, and `2` for invalid input or an I/O error.
|
|
94
|
+
The tools inspect explicitly named files and do not open or modify engine stores.
|
|
95
|
+
|
|
96
|
+
## Development status
|
|
97
|
+
|
|
98
|
+
This is an early, intentionally limited public component of a larger research
|
|
99
|
+
project. Its API may change before a stable release. It does not provide the
|
|
100
|
+
private engine's run, restart, YAML-authoring, or chemistry interfaces. Install
|
|
101
|
+
this public preview in its own environment if you also use a private Reactra
|
|
102
|
+
deployment; both distributions use the `reactra` package and command name.
|
|
103
|
+
|
|
104
|
+
The release source includes focused software tests. Run them with:
|
|
105
|
+
|
|
106
|
+
```sh
|
|
107
|
+
python -m pip install -e . pytest
|
|
108
|
+
python -m pytest
|
|
109
|
+
```
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Reactra
|
|
2
|
+
|
|
3
|
+
**Reaction Engine for Atomistic Catalytic Trajectory Reconstruction and Automation**
|
|
4
|
+
|
|
5
|
+
Reactra is the simulation platform being developed for the MechanismAtlas
|
|
6
|
+
enzyme-reaction ecosystem. This **0.1.0.dev1** public preview provides useful,
|
|
7
|
+
standalone tools for **exported simulation results**. The simulation engine is
|
|
8
|
+
currently private and is **not included in this distribution**.
|
|
9
|
+
|
|
10
|
+
The public tools work with ordinary PLUMED/COLVAR numeric tables, including
|
|
11
|
+
exported OPES traces. They require no model weights, accelerator, private
|
|
12
|
+
credentials, molecular structures, or mechanism definitions.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
Python 3.12 or later:
|
|
17
|
+
|
|
18
|
+
```sh
|
|
19
|
+
python -m pip install reactra==0.1.0.dev1
|
|
20
|
+
# Optional plotting support:
|
|
21
|
+
python -m pip install 'reactra[plot]==0.1.0.dev1'
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Inspect a trajectory
|
|
25
|
+
|
|
26
|
+
Given a text file named `COLVAR` with PLUMED's standard header:
|
|
27
|
+
|
|
28
|
+
```text
|
|
29
|
+
#! FIELDS time cv opes.bias
|
|
30
|
+
0.0 0.20 0.0
|
|
31
|
+
0.1 0.23 -0.4
|
|
32
|
+
0.2 0.27 -0.8
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
```sh
|
|
36
|
+
reactra summary COLVAR
|
|
37
|
+
reactra summary COLVAR --columns cv opes.bias
|
|
38
|
+
reactra export-csv COLVAR --columns time cv --output selected.csv
|
|
39
|
+
reactra plot COLVAR --x time --y cv opes.bias --output traces.png
|
|
40
|
+
reactra fingerprint COLVAR
|
|
41
|
+
# Verify an independently recorded SHA-256 digest:
|
|
42
|
+
reactra fingerprint COLVAR --sha256 YOUR_64_CHARACTER_HEXADECIMAL_DIGEST
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
Summary reports the number of rows, per-column first/last/minimum/maximum/mean
|
|
46
|
+
and population standard deviation, and repeated or backward time steps. Summary
|
|
47
|
+
and CSV export stream the input instead of loading the trajectory into memory.
|
|
48
|
+
Identical repeated headers are accepted; changing headers, duplicate column names,
|
|
49
|
+
ragged rows, nonnumeric values, and NaN/infinity are refused with line diagnostics.
|
|
50
|
+
|
|
51
|
+
Plots use one panel per requested column and preserve row order. For a large file,
|
|
52
|
+
choose an explicit stride, for example `--stride 100`; plotting retains the
|
|
53
|
+
selected points in memory. Output supports PNG, SVG, and PDF. CSV and plot exports
|
|
54
|
+
refuse existing destinations and publish only after processing succeeds.
|
|
55
|
+
|
|
56
|
+
Units are preserved as recorded, not inferred. Statistics are linear, including
|
|
57
|
+
for angular columns: this release does not perform circular statistics or
|
|
58
|
+
periodic unwrapping. Time differences are not interpreted as completed continuous
|
|
59
|
+
sampling when a trace repeats or moves backward.
|
|
60
|
+
|
|
61
|
+
**A recorded bias or potential-energy trace is not a free-energy barrier.** These
|
|
62
|
+
tools do not infer reaction crossings, reconstruct free energies, estimate rates,
|
|
63
|
+
or qualify convergence. Artifact hashing checks byte identity against a supplied
|
|
64
|
+
digest; it does not establish scientific validity or authenticity.
|
|
65
|
+
|
|
66
|
+
## Python API
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
from reactra.artifacts import fingerprint
|
|
70
|
+
from reactra.colvar import iter_colvar, summarize
|
|
71
|
+
|
|
72
|
+
report = summarize("COLVAR", columns=["cv"])
|
|
73
|
+
for row in iter_colvar("COLVAR"):
|
|
74
|
+
print(row["time"], row["cv"])
|
|
75
|
+
identity = fingerprint("COLVAR")
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
CLI output is JSON. Exit codes are `0` for successful processing or a matching
|
|
79
|
+
digest, `1` for a digest mismatch, and `2` for invalid input or an I/O error.
|
|
80
|
+
The tools inspect explicitly named files and do not open or modify engine stores.
|
|
81
|
+
|
|
82
|
+
## Development status
|
|
83
|
+
|
|
84
|
+
This is an early, intentionally limited public component of a larger research
|
|
85
|
+
project. Its API may change before a stable release. It does not provide the
|
|
86
|
+
private engine's run, restart, YAML-authoring, or chemistry interfaces. Install
|
|
87
|
+
this public preview in its own environment if you also use a private Reactra
|
|
88
|
+
deployment; both distributions use the `reactra` package and command name.
|
|
89
|
+
|
|
90
|
+
The release source includes focused software tests. Run them with:
|
|
91
|
+
|
|
92
|
+
```sh
|
|
93
|
+
python -m pip install -e . pytest
|
|
94
|
+
python -m pytest
|
|
95
|
+
```
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.27,<2"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "reactra"
|
|
7
|
+
version = "0.1.0.dev1"
|
|
8
|
+
description = "Inspect, summarize, and plot exported molecular-simulation trajectories"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.12"
|
|
11
|
+
authors = [{name = "MechanismAtlas"}]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Topic :: Scientific/Engineering :: Chemistry",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
[project.optional-dependencies]
|
|
20
|
+
plot = ["matplotlib>=3.8,<4"]
|
|
21
|
+
|
|
22
|
+
[project.scripts]
|
|
23
|
+
reactra = "reactra.cli:main"
|
|
24
|
+
|
|
25
|
+
[tool.hatch.build.targets.wheel]
|
|
26
|
+
packages = ["src/reactra"]
|
|
27
|
+
|
|
28
|
+
[tool.hatch.build.targets.sdist]
|
|
29
|
+
only-include = ["src/reactra", "tests", "pyproject.toml", "README.md"]
|
|
30
|
+
|
|
31
|
+
[tool.pytest.ini_options]
|
|
32
|
+
testpaths = ["tests"]
|
|
33
|
+
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Exact artifact identity and publication to new local paths."""
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import tempfile
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def fingerprint(path: str | Path, *, expected_sha256=None) -> dict:
|
|
12
|
+
if expected_sha256 is not None and not re.fullmatch(r"[0-9a-fA-F]{64}", expected_sha256):
|
|
13
|
+
raise ValueError("Expected SHA-256 must contain exactly 64 hexadecimal characters")
|
|
14
|
+
digest = hashlib.sha256()
|
|
15
|
+
size = 0
|
|
16
|
+
with Path(path).open("rb") as stream:
|
|
17
|
+
while chunk := stream.read(1024 * 1024):
|
|
18
|
+
size += len(chunk)
|
|
19
|
+
digest.update(chunk)
|
|
20
|
+
actual = digest.hexdigest()
|
|
21
|
+
result = {"path": str(Path(path)), "byte_count": size, "sha256": actual}
|
|
22
|
+
if expected_sha256 is not None:
|
|
23
|
+
result["matches"] = actual == expected_sha256.lower()
|
|
24
|
+
return result
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@contextmanager
|
|
28
|
+
def new_output(path: str | Path):
|
|
29
|
+
"""Publish a completed temporary file without overwriting an existing path."""
|
|
30
|
+
destination = Path(path)
|
|
31
|
+
if destination.exists() or destination.is_symlink():
|
|
32
|
+
raise FileExistsError(f"Output already exists: {destination}")
|
|
33
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
34
|
+
prefix=".reactra-", suffix=destination.suffix, dir=destination.parent
|
|
35
|
+
)
|
|
36
|
+
os.close(descriptor)
|
|
37
|
+
try:
|
|
38
|
+
yield Path(temporary)
|
|
39
|
+
os.link(temporary, destination)
|
|
40
|
+
finally:
|
|
41
|
+
Path(temporary).unlink(missing_ok=True)
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"""Command-line tools for exported results, independent of the private engine."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import csv
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from reactra import __version__
|
|
10
|
+
from reactra.artifacts import fingerprint, new_output
|
|
11
|
+
from reactra.colvar import iter_colvar, select_columns, summarize
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def export_csv(source, output, columns=None):
|
|
15
|
+
with new_output(output) as temporary:
|
|
16
|
+
with temporary.open("w", newline="", encoding="utf-8") as stream:
|
|
17
|
+
writer = csv.writer(stream)
|
|
18
|
+
selected = None
|
|
19
|
+
for row in iter_colvar(source):
|
|
20
|
+
if selected is None:
|
|
21
|
+
selected = select_columns(row, columns)
|
|
22
|
+
writer.writerow(selected)
|
|
23
|
+
writer.writerow(row[name] for name in selected)
|
|
24
|
+
return {"output": str(output), "columns": list(selected)}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def plot_trace(source, output, x, ys, stride=1):
|
|
28
|
+
if stride < 1:
|
|
29
|
+
raise ValueError("Plot stride must be positive")
|
|
30
|
+
if Path(output).suffix.lower() not in (".png", ".svg", ".pdf"):
|
|
31
|
+
raise ValueError("Plot output must end in .png, .svg, or .pdf")
|
|
32
|
+
try:
|
|
33
|
+
import matplotlib
|
|
34
|
+
|
|
35
|
+
matplotlib.use("Agg")
|
|
36
|
+
import matplotlib.pyplot as plt
|
|
37
|
+
except ImportError as error:
|
|
38
|
+
raise ValueError("Plotting requires: python -m pip install 'reactra[plot]'") from error
|
|
39
|
+
selected = None
|
|
40
|
+
values = []
|
|
41
|
+
rows = 0
|
|
42
|
+
for index, row in enumerate(iter_colvar(source)):
|
|
43
|
+
if selected is None:
|
|
44
|
+
selected = select_columns(row, [x, *ys])
|
|
45
|
+
rows += 1
|
|
46
|
+
if index % stride == 0:
|
|
47
|
+
values.append(tuple(row[name] for name in selected))
|
|
48
|
+
figure, axes = plt.subplots(
|
|
49
|
+
len(ys), 1, sharex=True, squeeze=False, figsize=(8, 2.8 * len(ys)), layout="constrained"
|
|
50
|
+
)
|
|
51
|
+
try:
|
|
52
|
+
for column, axis in enumerate(axes[:, 0], 1):
|
|
53
|
+
axis.plot([row[0] for row in values], [row[column] for row in values], linewidth=1)
|
|
54
|
+
axis.set_ylabel(ys[column - 1])
|
|
55
|
+
axis.grid(alpha=0.2)
|
|
56
|
+
axes[-1, 0].set_xlabel(x + " (recorded units)")
|
|
57
|
+
figure.suptitle("Recorded simulation traces")
|
|
58
|
+
with new_output(output) as temporary:
|
|
59
|
+
figure.savefig(temporary)
|
|
60
|
+
finally:
|
|
61
|
+
plt.close(figure)
|
|
62
|
+
return {"output": str(output), "rows": rows, "plotted_rows": len(values), "stride": stride}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def build_parser():
|
|
66
|
+
parser = argparse.ArgumentParser(description="Reactra public simulation-result tools")
|
|
67
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
68
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
69
|
+
summary = commands.add_parser("summary", help="Summarize a PLUMED/COLVAR numeric table")
|
|
70
|
+
summary.add_argument("source", type=Path)
|
|
71
|
+
summary.add_argument("--columns", nargs="+")
|
|
72
|
+
summary.add_argument("--time-column", default="time")
|
|
73
|
+
export = commands.add_parser("export-csv", help="Export selected COLVAR columns to a new CSV")
|
|
74
|
+
export.add_argument("source", type=Path)
|
|
75
|
+
export.add_argument("--columns", nargs="+")
|
|
76
|
+
export.add_argument("--output", type=Path, required=True)
|
|
77
|
+
plot = commands.add_parser("plot", help="Plot recorded columns; no free-energy reconstruction")
|
|
78
|
+
plot.add_argument("source", type=Path)
|
|
79
|
+
plot.add_argument("--x", default="time")
|
|
80
|
+
plot.add_argument("--y", nargs="+", required=True)
|
|
81
|
+
plot.add_argument("--stride", type=int, default=1)
|
|
82
|
+
plot.add_argument("--output", type=Path, required=True)
|
|
83
|
+
digest = commands.add_parser("fingerprint", help="Hash exact artifact bytes, optionally verify")
|
|
84
|
+
digest.add_argument("source", type=Path)
|
|
85
|
+
digest.add_argument("--sha256")
|
|
86
|
+
return parser
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def main(argv=None):
|
|
90
|
+
args = build_parser().parse_args(argv)
|
|
91
|
+
try:
|
|
92
|
+
if args.command == "summary":
|
|
93
|
+
result = summarize(args.source, columns=args.columns, time_column=args.time_column)
|
|
94
|
+
elif args.command == "export-csv":
|
|
95
|
+
result = export_csv(args.source, args.output, args.columns)
|
|
96
|
+
elif args.command == "plot":
|
|
97
|
+
result = plot_trace(args.source, args.output, args.x, args.y, args.stride)
|
|
98
|
+
else:
|
|
99
|
+
result = fingerprint(args.source, expected_sha256=args.sha256)
|
|
100
|
+
print(json.dumps(result, indent=2, allow_nan=False))
|
|
101
|
+
return 0 if result.get("matches", True) else 1
|
|
102
|
+
except (OSError, ValueError) as error:
|
|
103
|
+
print(json.dumps({"error": str(error)}), file=sys.stderr)
|
|
104
|
+
return 2
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
if __name__ == "__main__":
|
|
108
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
"""Strict, streaming access to PLUMED text tables; no simulation dependencies."""
|
|
2
|
+
|
|
3
|
+
import math
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def iter_colvar(path: str | Path):
|
|
8
|
+
"""Yield numeric rows by field name, refusing missing or changing schemas.
|
|
9
|
+
|
|
10
|
+
Identical repeated FIELDS headers are accepted (for concatenated chunks).
|
|
11
|
+
No sorting, deduplication, unit conversion, or periodic unwrapping occurs.
|
|
12
|
+
"""
|
|
13
|
+
fields = None
|
|
14
|
+
count = 0
|
|
15
|
+
with Path(path).open(encoding="utf-8") as stream:
|
|
16
|
+
for line_number, line in enumerate(stream, 1):
|
|
17
|
+
tokens = line.split()
|
|
18
|
+
if not tokens:
|
|
19
|
+
continue
|
|
20
|
+
if tokens[:2] == ["#!", "FIELDS"]:
|
|
21
|
+
declared = tuple(tokens[2:])
|
|
22
|
+
if not declared or len(set(declared)) != len(declared):
|
|
23
|
+
raise ValueError(f"Line {line_number}: FIELDS must be nonempty and unique")
|
|
24
|
+
if fields is not None and fields != declared:
|
|
25
|
+
raise ValueError(f"Line {line_number}: FIELDS changed within the file")
|
|
26
|
+
fields = declared
|
|
27
|
+
continue
|
|
28
|
+
if tokens[0].startswith("#"):
|
|
29
|
+
continue
|
|
30
|
+
if fields is None:
|
|
31
|
+
raise ValueError(f"Line {line_number}: data precedes the FIELDS header")
|
|
32
|
+
if len(tokens) != len(fields):
|
|
33
|
+
raise ValueError(f"Line {line_number}: expected {len(fields)} numeric values")
|
|
34
|
+
try:
|
|
35
|
+
values = tuple(float(token) for token in tokens)
|
|
36
|
+
except ValueError as error:
|
|
37
|
+
raise ValueError(f"Line {line_number}: invalid numeric value") from error
|
|
38
|
+
if not all(math.isfinite(value) for value in values):
|
|
39
|
+
raise ValueError(f"Line {line_number}: nonfinite numeric value")
|
|
40
|
+
count += 1
|
|
41
|
+
yield dict(zip(fields, values, strict=True))
|
|
42
|
+
if not count:
|
|
43
|
+
raise ValueError("COLVAR contains no data rows")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def select_columns(row, columns):
|
|
47
|
+
selected = tuple(columns) if columns is not None else tuple(row)
|
|
48
|
+
if not selected or len(set(selected)) != len(selected):
|
|
49
|
+
raise ValueError("Selected columns must be nonempty and unique")
|
|
50
|
+
missing = set(selected) - row.keys()
|
|
51
|
+
if missing:
|
|
52
|
+
raise ValueError(f"Unknown columns: {', '.join(sorted(missing))}")
|
|
53
|
+
return selected
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def summarize(path: str | Path, *, columns=None, time_column="time") -> dict:
|
|
57
|
+
"""Compute linear population statistics in one pass and report time anomalies.
|
|
58
|
+
|
|
59
|
+
Circular CVs need circular analysis; these statistics intentionally do not
|
|
60
|
+
infer periodicity, units, reaction basins, convergence, or free energies.
|
|
61
|
+
"""
|
|
62
|
+
statistics = {}
|
|
63
|
+
count = 0
|
|
64
|
+
previous_time = None
|
|
65
|
+
time = None
|
|
66
|
+
for row in iter_colvar(path):
|
|
67
|
+
if not count:
|
|
68
|
+
selected = select_columns(row, columns)
|
|
69
|
+
statistics = {
|
|
70
|
+
name: {
|
|
71
|
+
"first": row[name],
|
|
72
|
+
"min": row[name],
|
|
73
|
+
"max": row[name],
|
|
74
|
+
"mean": 0.0,
|
|
75
|
+
"m2": 0.0,
|
|
76
|
+
}
|
|
77
|
+
for name in selected
|
|
78
|
+
}
|
|
79
|
+
if time_column in row:
|
|
80
|
+
time = {
|
|
81
|
+
"column": time_column,
|
|
82
|
+
"first": row[time_column],
|
|
83
|
+
"last": row[time_column],
|
|
84
|
+
"repeated_steps": 0,
|
|
85
|
+
"backward_steps": 0,
|
|
86
|
+
}
|
|
87
|
+
elif time_column != "time":
|
|
88
|
+
raise ValueError(f"Unknown time column: {time_column}")
|
|
89
|
+
count += 1
|
|
90
|
+
for name, stats in statistics.items():
|
|
91
|
+
value = row[name]
|
|
92
|
+
delta = value - stats["mean"]
|
|
93
|
+
stats["mean"] += delta / count
|
|
94
|
+
stats["m2"] += delta * (value - stats["mean"])
|
|
95
|
+
stats["min"] = min(stats["min"], value)
|
|
96
|
+
stats["max"] = max(stats["max"], value)
|
|
97
|
+
stats["last"] = value
|
|
98
|
+
if time is not None:
|
|
99
|
+
current = row[time_column]
|
|
100
|
+
if previous_time is not None:
|
|
101
|
+
time["repeated_steps"] += current == previous_time
|
|
102
|
+
time["backward_steps"] += current < previous_time
|
|
103
|
+
time["last"] = current
|
|
104
|
+
previous_time = current
|
|
105
|
+
for stats in statistics.values():
|
|
106
|
+
if not all(math.isfinite(value) for value in stats.values()):
|
|
107
|
+
raise ValueError("Numeric range exceeds supported summary precision")
|
|
108
|
+
stats["std_population"] = math.sqrt(max(0.0, stats.pop("m2") / count))
|
|
109
|
+
if not all(math.isfinite(value) for value in stats.values()):
|
|
110
|
+
raise ValueError("Numeric range exceeds supported summary precision")
|
|
111
|
+
if time is not None:
|
|
112
|
+
span = time["last"] - time["first"]
|
|
113
|
+
if not math.isfinite(span):
|
|
114
|
+
raise ValueError("Time range exceeds supported summary precision")
|
|
115
|
+
time["last_minus_first"] = span
|
|
116
|
+
time["strictly_increasing"] = not (time["repeated_steps"] or time["backward_steps"])
|
|
117
|
+
return {
|
|
118
|
+
"source": str(Path(path)),
|
|
119
|
+
"rows": count,
|
|
120
|
+
"columns": statistics,
|
|
121
|
+
"time": time,
|
|
122
|
+
"units": "as recorded; not inferred",
|
|
123
|
+
"statistics": "linear; no periodic unwrapping or free-energy estimation",
|
|
124
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import pytest
|
|
6
|
+
|
|
7
|
+
from reactra.artifacts import fingerprint
|
|
8
|
+
from reactra.cli import export_csv, main, plot_trace
|
|
9
|
+
from reactra.colvar import iter_colvar, summarize
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@pytest.fixture
|
|
13
|
+
def source(tmp_path):
|
|
14
|
+
path = tmp_path / "COLVAR"
|
|
15
|
+
path.write_text(
|
|
16
|
+
"#! FIELDS time cv bias\n#! SET label ignored\n"
|
|
17
|
+
"0 1 -1\n1 2 -2\n#! FIELDS time cv bias\n2 3 -3\n"
|
|
18
|
+
)
|
|
19
|
+
return path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_summary_matches_known_population_statistics(source):
|
|
23
|
+
result = summarize(source, columns=["cv"])
|
|
24
|
+
assert result["rows"] == 3
|
|
25
|
+
assert result["columns"]["cv"] == {
|
|
26
|
+
"first": 1,
|
|
27
|
+
"last": 3,
|
|
28
|
+
"min": 1,
|
|
29
|
+
"max": 3,
|
|
30
|
+
"mean": 2,
|
|
31
|
+
"std_population": pytest.approx((2 / 3) ** 0.5),
|
|
32
|
+
}
|
|
33
|
+
assert result["time"]["last_minus_first"] == 2
|
|
34
|
+
assert result["time"]["strictly_increasing"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_time_resets_and_duplicates_are_reported_not_deduplicated(source):
|
|
38
|
+
source.write_text("#! FIELDS time cv\n0 1\n1 2\n1 3\n0 4\n")
|
|
39
|
+
result = summarize(source)
|
|
40
|
+
assert result["rows"] == 4
|
|
41
|
+
assert result["time"]["repeated_steps"] == 1
|
|
42
|
+
assert result["time"]["backward_steps"] == 1
|
|
43
|
+
assert not result["time"]["strictly_increasing"]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@pytest.mark.parametrize(
|
|
47
|
+
"text",
|
|
48
|
+
[
|
|
49
|
+
"",
|
|
50
|
+
"# comment only\n",
|
|
51
|
+
"#! FIELDS time cv\n",
|
|
52
|
+
"0 1\n",
|
|
53
|
+
"#! FIELDS\n0\n",
|
|
54
|
+
"#! FIELDS cv cv\n1 2\n",
|
|
55
|
+
"#! FIELDS time cv\n0\n",
|
|
56
|
+
"#! FIELDS time cv\n0 nope\n",
|
|
57
|
+
"#! FIELDS time cv\n0 nan\n",
|
|
58
|
+
"#! FIELDS time cv\n0 inf\n",
|
|
59
|
+
"#! FIELDS time cv\n0 1\n#! FIELDS time other\n1 2\n",
|
|
60
|
+
],
|
|
61
|
+
)
|
|
62
|
+
def test_malformed_inputs_refuse(source, text):
|
|
63
|
+
source.write_text(text)
|
|
64
|
+
with pytest.raises(ValueError):
|
|
65
|
+
list(iter_colvar(source))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_summary_refuses_numerical_overflow(source):
|
|
69
|
+
source.write_text("#! FIELDS cv\n1e308\n-1e308\n")
|
|
70
|
+
with pytest.raises(ValueError, match="precision"):
|
|
71
|
+
summarize(source)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.parametrize("columns", [["absent"], ["cv", "cv"], []])
|
|
75
|
+
def test_invalid_selection_refuses(source, columns):
|
|
76
|
+
with pytest.raises(ValueError):
|
|
77
|
+
summarize(source, columns=columns)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def test_no_time_is_distinct_from_wrong_explicit_time(source):
|
|
81
|
+
source.write_text("#! FIELDS cv\n1\n2\n")
|
|
82
|
+
assert summarize(source)["time"] is None
|
|
83
|
+
with pytest.raises(ValueError, match="Unknown time"):
|
|
84
|
+
summarize(source, time_column="clock")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_csv_preserves_selection_order_and_refuses_overwrite(source, tmp_path):
|
|
88
|
+
target = tmp_path / "selected.csv"
|
|
89
|
+
export_csv(source, target, ["bias", "time"])
|
|
90
|
+
with target.open(newline="") as stream:
|
|
91
|
+
rows = list(csv.reader(stream))
|
|
92
|
+
assert rows == [["bias", "time"], ["-1.0", "0.0"], ["-2.0", "1.0"], ["-3.0", "2.0"]]
|
|
93
|
+
before = target.read_bytes()
|
|
94
|
+
with pytest.raises(FileExistsError):
|
|
95
|
+
export_csv(source, target)
|
|
96
|
+
assert target.read_bytes() == before
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_late_parse_failure_publishes_no_partial_export(source, tmp_path):
|
|
100
|
+
source.write_text("#! FIELDS time cv\n0 1\n1 broken\n")
|
|
101
|
+
target = tmp_path / "incomplete.csv"
|
|
102
|
+
with pytest.raises(ValueError, match="Line 3"):
|
|
103
|
+
export_csv(source, target)
|
|
104
|
+
assert not target.exists()
|
|
105
|
+
assert not list(tmp_path.glob(".reactra-*"))
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def test_fingerprint_is_exact_and_detects_corruption(source):
|
|
109
|
+
expected = hashlib.sha256(source.read_bytes()).hexdigest()
|
|
110
|
+
assert fingerprint(source, expected_sha256=expected)["matches"]
|
|
111
|
+
source.write_text(source.read_text() + "3 4 -4\n")
|
|
112
|
+
assert not fingerprint(source, expected_sha256=expected)["matches"]
|
|
113
|
+
with pytest.raises(ValueError, match="64"):
|
|
114
|
+
fingerprint(source, expected_sha256="invalid")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_cli_json_and_exit_status(source, capsys):
|
|
118
|
+
assert main(["summary", str(source)]) == 0
|
|
119
|
+
assert json.loads(capsys.readouterr().out)["rows"] == 3
|
|
120
|
+
assert main(["fingerprint", str(source), "--sha256", "0" * 64]) == 1
|
|
121
|
+
assert json.loads(capsys.readouterr().out)["matches"] is False
|
|
122
|
+
assert main(["summary", str(source.parent / "absent")]) == 2
|
|
123
|
+
assert "error" in json.loads(capsys.readouterr().err)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_plot_generates_readable_output_and_validates_all_rows(source, tmp_path):
|
|
127
|
+
pytest.importorskip("matplotlib")
|
|
128
|
+
target = tmp_path / "trace.png"
|
|
129
|
+
report = plot_trace(source, target, "time", ["cv", "bias"], stride=2)
|
|
130
|
+
assert target.read_bytes().startswith(b"\x89PNG\r\n\x1a\n")
|
|
131
|
+
assert report["plotted_rows"] == 2
|
|
132
|
+
with pytest.raises(FileExistsError):
|
|
133
|
+
plot_trace(source, target, "time", ["cv"])
|
|
134
|
+
source.write_text("#! FIELDS time cv\n0 1\n1 nan\n2 3\n")
|
|
135
|
+
with pytest.raises(ValueError, match="nonfinite"):
|
|
136
|
+
plot_trace(source, tmp_path / "bad.png", "time", ["cv"], stride=2)
|
|
137
|
+
assert not (tmp_path / "bad.png").exists()
|