ekwan16 commited on
Commit
fefb9a6
·
verified ·
1 Parent(s): 04b5a3d

Add files using upload-large-folder tool

Browse files
Files changed (50) hide show
  1. .gitattributes +3 -34
  2. .gitignore +37 -0
  3. LICENSE +21 -0
  4. README.md +212 -0
  5. build_api_docs.py +50 -0
  6. conftest.py +48 -0
  7. data/applications/applications_experimental.xlsx +0 -0
  8. data/applications/applications_reader.py +260 -0
  9. data/applications/test_applications.py +211 -0
  10. data/carbon_monoxide/co_bond_lengths_histogram.csv +101 -0
  11. data/carbon_monoxide/co_magnet_vs_gaussian.csv +82 -0
  12. data/cp3/goodman2009_cp3.xlsx +0 -0
  13. data/delta22/delta22_experimental.xlsx +0 -0
  14. data/delta22/delta22_reader.py +1047 -0
  15. data/delta22/test_delta22.py +260 -0
  16. data/delta50/decode_delta50.py +118 -0
  17. data/delta50/delta50.hdf5 +0 -0
  18. data/delta50/test_delta50.py +103 -0
  19. data/dft8k/dft8k_reader.py +236 -0
  20. data/dft8k/test_dft8k.py +224 -0
  21. data/gdb_qcd/decode_gdb_qcd.py +136 -0
  22. data/gdb_qcd/test_gdb_qcd.py +112 -0
  23. data/magnet_benchmark/performance_results.csv +17 -0
  24. data/magnet_test_predictions/build_magnet_test_predictions.py +132 -0
  25. data/magnet_test_predictions/magnet_test_predictions_reader.py +229 -0
  26. data/magnet_test_predictions/test_magnet_test_predictions.py +175 -0
  27. data/scaling_factors/scaling_factors_reader.py +36 -0
  28. data/scaling_factors/scaling_factors_symmetrized_C.csv +13 -0
  29. data/sigma-concentrate/decode_sigma_concentrate.py +113 -0
  30. data/sigma-concentrate/test_sigma_concentrate.py +162 -0
  31. data/sigma-pepper/decode_sigma_pepper.py +134 -0
  32. data/sigma-pepper/test_sigma_pepper.py +197 -0
  33. data/sigma-shake/decode_sigma_shake.py +123 -0
  34. data/sigma-shake/test_sigma_shake.py +161 -0
  35. data/supertestset_magnet_x/decode_supertest.py +148 -0
  36. data/supertestset_magnet_x/test_supertest.py +110 -0
  37. magnet/__init__.py +52 -0
  38. magnet/api.py +309 -0
  39. magnet/inference.py +154 -0
  40. magnet/model.py +236 -0
  41. magnet/pyproject.toml +49 -0
  42. magnet/requirements.txt +32 -0
  43. magnet/run_magnet.py +221 -0
  44. magnet/scaling.py +78 -0
  45. magnet/test_api.py +463 -0
  46. magnet/test_magnet.py +262 -0
  47. pdoc_templates/module.html.jinja2 +9 -0
  48. reproduce.py +63 -0
  49. requirements.txt +38 -0
  50. test_reproduce.py +31 -0
.gitattributes CHANGED
@@ -1,35 +1,4 @@
1
- *.7z filter=lfs diff=lfs merge=lfs -text
2
- *.arrow filter=lfs diff=lfs merge=lfs -text
3
- *.bin filter=lfs diff=lfs merge=lfs -text
4
- *.bz2 filter=lfs diff=lfs merge=lfs -text
5
- *.ckpt filter=lfs diff=lfs merge=lfs -text
6
- *.ftz filter=lfs diff=lfs merge=lfs -text
7
- *.gz filter=lfs diff=lfs merge=lfs -text
8
  *.h5 filter=lfs diff=lfs merge=lfs -text
9
- *.joblib filter=lfs diff=lfs merge=lfs -text
10
- *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
- *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
- *.model filter=lfs diff=lfs merge=lfs -text
13
- *.msgpack filter=lfs diff=lfs merge=lfs -text
14
- *.npy filter=lfs diff=lfs merge=lfs -text
15
- *.npz filter=lfs diff=lfs merge=lfs -text
16
- *.onnx filter=lfs diff=lfs merge=lfs -text
17
- *.ot filter=lfs diff=lfs merge=lfs -text
18
- *.parquet filter=lfs diff=lfs merge=lfs -text
19
- *.pb filter=lfs diff=lfs merge=lfs -text
20
- *.pickle filter=lfs diff=lfs merge=lfs -text
21
- *.pkl filter=lfs diff=lfs merge=lfs -text
22
- *.pt filter=lfs diff=lfs merge=lfs -text
23
- *.pth filter=lfs diff=lfs merge=lfs -text
24
- *.rar filter=lfs diff=lfs merge=lfs -text
25
- *.safetensors filter=lfs diff=lfs merge=lfs -text
26
- saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
- *.tar.* filter=lfs diff=lfs merge=lfs -text
28
- *.tar filter=lfs diff=lfs merge=lfs -text
29
- *.tflite filter=lfs diff=lfs merge=lfs -text
30
- *.tgz filter=lfs diff=lfs merge=lfs -text
31
- *.wasm filter=lfs diff=lfs merge=lfs -text
32
- *.xz filter=lfs diff=lfs merge=lfs -text
33
- *.zip filter=lfs diff=lfs merge=lfs -text
34
- *.zst filter=lfs diff=lfs merge=lfs -text
35
- *tfevents* filter=lfs diff=lfs merge=lfs -text
 
1
+ *.hdf5 filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
2
  *.h5 filter=lfs diff=lfs merge=lfs -text
3
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
4
+ *.mnova filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.gitignore ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Large files live on the Hugging Face copy of this repo (ekwan16/MagNET), never committed here.
2
+ *.hdf5
3
+ *.h5
4
+ *.npy
5
+ *.npz
6
+ *.pkl
7
+ *.pickle
8
+ *.ckpt
9
+ *.mnova
10
+ model_checkpoints/
11
+ # Exception: the tiny (54 KB) natural-products reference used by magnet/test_magnet.py is a committed
12
+ # test fixture, so those tests are self-contained (they need only the released checkpoints, not a
13
+ # separate reference download).
14
+ !magnet/test_data/magnet_zero_and_pcm_shieldings.h5
15
+ # Exception: delta50 is tiny (33 KB), so it ships with the repo instead of a Hugging Face download.
16
+ !data/delta50/delta50.hdf5
17
+
18
+ # Regenerated figure outputs (the notebook redraws them and embeds them in its own cells).
19
+ analysis/**/figures/
20
+ analysis/**/documents/
21
+
22
+ # macOS
23
+ .DS_Store
24
+
25
+ # Python
26
+ __pycache__/
27
+ *.py[cod]
28
+ .pytest_cache/
29
+ .ipynb_checkpoints/
30
+ *.egg-info/
31
+ .venv/
32
+ venv/
33
+ dist/
34
+ build/
35
+
36
+ # Rendered API docs (users regenerate with `pdoc magnet -t pdoc_templates -o api_docs`).
37
+ api_docs/
LICENSE ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ MIT License
2
+
3
+ Copyright (c) 2026 The MagNET authors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
README.md CHANGED
@@ -1,3 +1,215 @@
1
  ---
2
  license: mit
 
 
 
 
 
 
 
3
  ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
  license: mit
3
+ tags:
4
+ - chemistry
5
+ - nmr
6
+ - chemical-shift-prediction
7
+ - molecular-property-prediction
8
+ - equiformer
9
+
10
  ---
11
+
12
+ # MagNET
13
+
14
+ MagNET is a family of neural networks for predicting NMR chemical shifts. This repository contains all models, datasets, and code to reproduce the data in the MagNET paper.
15
+
16
+ ### Contents
17
+
18
+ | item | contents |
19
+ |---|---|
20
+ | `magnet/` | the importable model package |
21
+ | `data/` | the *sigma* datasets and other auxiliary datasets |
22
+ | `analysis/` | scripts for reproducing figures and tables |
23
+ | `model_checkpoints/` | weights for the four MagNET models |
24
+
25
+ ### MagNET Models
26
+
27
+ | Model | Description | Details |
28
+ |---|---|---|
29
+ | **MagNET** | foundation model | - for near-equilibrium geometries <br /> - trained on PBE0/pcSseg-1/gas shieldings |
30
+ | **MagNET-Zero** | high-quality gas-phase solute shieldings | - use AIMNet2-optimized geometries <br /> - WP04/pcSseg-2 (<sup>1</sup>H shieldings) <br /> - ωB97X-D/pcSseg-2 (<sup>13</sup>C shieldings) |
31
+ | **MagNET-PCM** | implicit solvent corrections | - shielding(PCM) - shielding(gas) <br />- use AIMNet2-optimized geometries <br /> - computed at B3LYP/pcSseg-2/chloroform |
32
+ | **MagNET-x** | explicit solvent corrections | - shielding(solute+solvent) - shielding(solute) <br /> - supports chloroform, benzene, methanol, and water <br /> - use classical MD geometries <br /> - trained at PBE0/pcSseg-1 |
33
+
34
+ All models use Equiformer-V2 and have approximately 10M weights. Separate weights are given for <sup>1</sup>H and <sup>13</sup>C prediction. Input structures can contain H, C, N, O, F, Cl, and S (inference should not be performed on structures with unsupported elements).
35
+
36
+ ### *sigma* Datasets
37
+
38
+ | Dataset | Details |
39
+ |---|---|
40
+ | **_sigma_-shake** | - 4.4M solutes from GDB-13/17 with functional group augmentation <br /> - stationary and quasiclassically perturbed structures at B3LYP-D3(BJ)/6-31G\* <br /> - PBE0/pcSseg-1/gas shieldings |
41
+ | **_sigma_-fresh** | - 10K representative natural product, drug-like, sugar, and peptide solutes dissolved in benzene, chloroform, methanol, and water <br /> - ~10 computed poses/solute with many more solvated geometries available <br /> - 462K poses have computed PBE0/pcSseg-1 shieldings |
42
+ | **_sigma_-pepper** (part 1) | - GDB molecules with ≤ 10 heavy atoms <br /> - AIMNet2 stationary structures <br /> - PBE0/pcSseg-1 shieldings |
43
+ | **_sigma_-pepper** (part 2) | - GDB molecules with ≤ 9 heavy atoms <br /> - AIMNet2 stationary structures <br /> - WP04/pcSseg-2/gas (<sup>1</sup>H shieldings) <br /> - ωB97X-D/pcSseg-2/gas (<sup>13</sup>C shieldings) |
44
+ | **_sigma_-concentrate** | - 50K random structures from *sigma*-shake <br /> - PCM(chloroform) corrections at B3LYP/pcSseg-2 |
45
+
46
+ ### Installing MagNET
47
+
48
+ 1. Install MagNET and its dependencies.
49
+
50
+ **Option A: Model Only via PyPI (won't work until publication)**
51
+
52
+ ```bash
53
+ pip install torch==2.5.0 --index-url https://download.pytorch.org/whl/cpu
54
+ pip install magnet-nmr
55
+ pip install torch_scatter torch_cluster --find-links https://data.pyg.org/whl/torch-2.5.0+cpu.html
56
+ ```
57
+
58
+ **Option B: Full Installation**
59
+
60
+ ```bash
61
+ git clone https://github.com/ekwan/MagNET
62
+ cd MagNET
63
+ pip install torch==2.5.0 --index-url https://download.pytorch.org/whl/cpu
64
+ pip install -r magnet/requirements.txt
65
+ pip install --no-deps ./magnet
66
+ ```
67
+
68
+ **<i>During the review process</i>**, the GitHub repository is private, so use a read-only token for cloning:
69
+
70
+ `git clone https://<token>@github.com/ekwan/MagNET`
71
+
72
+ 2. **Fetch Model Weights**
73
+
74
+ ```bash
75
+ pip install "huggingface_hub[cli]"
76
+ hf download ekwan16/MagNET --local-dir . --include "model_checkpoints/*"
77
+ ```
78
+
79
+ The model weights will be downloaded into the working directory. No repository cloning is needed, as MagNET will automatically check the current directory for weights. Alternatively, you may pass the `checkpoints_dir` parameter to the inference methods.
80
+
81
+ **<i>During the review process</i>**, the HuggingFace repository is private, so use a read-only token for downloading:
82
+
83
+ `hf download ekwan16/MagNET --local-dir . --include "model_checkpoints/*" --token hf_xxx`
84
+
85
+ 3. **Check the Installation**
86
+
87
+ `python -c "import magnet"`
88
+
89
+ This should work with no errors.
90
+
91
+ ### Your First Prediction
92
+
93
+ Let's predict the <sup>1</sup>H and <sup>13</sup>C shifts of acetone in chloroform. `predict_shifts`
94
+ takes an AIMNet2-optimized geometry and runs MagNET-Zero, MagNET-PCM, and the paper's scaling for you.
95
+
96
+ ```python
97
+ import numpy as np
98
+ import magnet
99
+
100
+ # acetone, (CH3)2C=O, on an AIMNet2-optimized geometry (Angstrom)
101
+ atomic_numbers = np.array([6, 6, 8, 6, 1, 1, 1, 1, 1, 1]) # supported elements: H C N O F Cl S
102
+ geometry = np.array([
103
+ [ 1.2913, -0.5947, -0.0016], # C methyl
104
+ [ 0.0029, 0.1931, -0.0010], # C carbonyl
105
+ [-0.0174, 1.3994, -0.0003], # O
106
+ [-1.2743, -0.6189, 0.0004], # C methyl
107
+ [-1.0822, -1.6899, 0.0014], # H
108
+ [-1.8597, -0.3513, 0.8791], # H
109
+ [-1.8605, -0.3530, -0.8783], # H
110
+ [ 1.3415, -1.2208, 0.8916], # H
111
+ [ 1.3170, -1.2669, -0.8614], # H
112
+ [ 2.1415, 0.0788, -0.0298], # H
113
+ ])
114
+
115
+ shifts = magnet.predict_shifts(atomic_numbers, geometry, solvent="chloroform") # per-atom ppm
116
+ ```
117
+
118
+ `shifts` has one value per atom (NaN where the atom is neither <sup>1</sup>H nor <sup>13</sup>C).
119
+ Averaging acetone's chemically-equivalent atoms by index:
120
+
121
+ ```python
122
+ sites = {
123
+ "carbonyl 13C": [1],
124
+ "methyl 13C": [0, 3],
125
+ "methyl 1H": [4, 5, 6, 7, 8, 9],
126
+ }
127
+ for name, indices in sites.items():
128
+ print(f"{name:<13} {shifts[indices].mean():6.2f} ppm")
129
+ ```
130
+
131
+ The predictions land on the measured shifts (experiment is acetone in chloroform, from the delta22
132
+ dataset):
133
+
134
+ | site | MagNET | experiment (CDCl<sub>3</sub>) |
135
+ |---|---|---|
136
+ | carbonyl <sup>13</sup>C | 207.6 | 207.07 |
137
+ | methyl <sup>13</sup>C | 30.7 | 30.92 |
138
+ | methyl <sup>1</sup>H | 2.20 | 2.17 |
139
+
140
+ **Notes:**
141
+
142
+ - **Passes and symmetry.** `n_passes` (default 10) averages out equivariance error over the specified number of forward passes. If `symmetrize` (default True) is set, an additional `n_passes` are also performed on the mirror image of the input geometry (for a total of `2*n_passes`).
143
+ - **Geometry.** MagNET-Zero and MagNET-PCM require AIMNet2-optimized geometries. Do not use other geometries.
144
+ - **Components.** Pass `return_components=True` to also get the MagNET-Zero shielding, the MagNET-PCM correction, and the scaling coefficients behind each shift, as a `dict`.
145
+ - **Other models.** For raw shieldings and solvent corrections, see the [API documentation](#api-documentation).
146
+
147
+ ### Getting the datasets
148
+
149
+ The supporting datasets for this paper are large (42 GB) and are [archived on Hugging Face](https://huggingface.co/ekwan16/MagNET). To fetch everything:
150
+
151
+ hf download ekwan16/MagNET --local-dir .
152
+
153
+ If you only want to download a single dataset, add
154
+ `--include "data/<name>/*"` with one of the names below.
155
+
156
+ | dataset | size | contents |
157
+ |---|---|---|
158
+ | `sigma-shake` | 2.2 GB | main training set: 4.4M GDB solutes, perturbed geometries, PBE0/pcSseg-1 shieldings |
159
+ | `sigma-fresh` | 30 GB | explicit-solvent poses: 10K solutes in benzene, chloroform, methanol, and water |
160
+ | `sigma-pepper` | 501 MB | AIMNet2 GDB structures used to train MagNET-Zero and MagNET-PCM |
161
+ | `sigma-concentrate` | 17 MB | PCM corrections on 50K *sigma*-shake structures |
162
+ | `delta22` | 2.5 GB | experimental <sup>1</sup>H/<sup>13</sup>C shifts with matched DFT and MagNET predictions |
163
+ | `dft8k` | 12 MB | external benchmark of 7111 organics (Guan and Paton), two levels of theory |
164
+ | `gdb_qcd` | 291 MB | quasiclassical-dynamics benchmark of 2461 GDB molecules, for rovibrational corrections |
165
+ | `magnet_test_predictions` | 822 MB | MagNET's raw per-atom predictions and DFT targets on the test sets |
166
+ | `applications` | 5.1 GB | natural-product evaluation set, with MD geometries and experimental shifts |
167
+ | `supertestset_magnet_x` | 1.2 MB | explicit-solvent test set for MagNET-x: isolated vs solvated DFT shieldings |
168
+
169
+ ### Reproducing Figures and Tables
170
+
171
+ Each figure and table in the paper is produced by a notebook under `analysis/`.
172
+
173
+ 1. Use the **Option B** checkout.
174
+ 2. [Download](#getting-the-datasets) the datasets.
175
+ 3. Install the analysis dependencies: `pip install -r requirements.txt`
176
+ 4. Run the analysis scripts: `python reproduce.py`
177
+
178
+ If you want to reproduce specific items: `python reproduce.py fig3 s10`
179
+
180
+ The notebooks are stored without outputs. Running a notebook outputs its figures into `figures/` and its tables into `documents/`.
181
+
182
+ ### Running the Tests
183
+
184
+ ```bash
185
+ pip install -r requirements.txt "pytest>=7"
186
+ pytest
187
+ ```
188
+
189
+ The tests use small synthetic fixtures and do not need the downloaded files. Any test that does require the large model weights or datasets is skipped automatically when they are absent, and runs once they have been downloaded.
190
+
191
+ ### API Documentation
192
+
193
+ You can render the API docs with [pdoc](https://pdoc.dev):
194
+
195
+ pip install pdoc
196
+ python build_api_docs.py
197
+
198
+ Open `api_docs/index.html`. This documents the `magnet` package (`magnet.html` is the public
199
+ API: `predict_shifts`, `predict_shieldings`, `implicit_solvent_correction`,
200
+ `explicit_solvent_correction`) and every analysis module under `analysis/code`.
201
+
202
+ ### How to Cite
203
+
204
+ "Chemical Shift Prediction Beyond the Electronic Structure Limit."
205
+
206
+ Adams, K.; Wagen, C.C.W.; Wolford, J.; Sak, M.H.; Saurí, J.; Feng, Z.; Bhadauria, A.S.; Bailey, M.A.; Downs, J.S.; Li, S.Z.; Liu, A.I.; Smidt, T.; Paton, R.S.; Liu, R.Y.; Coley, C.W.\*; Kwan, E.E.\*
207
+
208
+ *submitted*, July 2026.
209
+
210
+ ### License
211
+
212
+ - The original code, model weights, and datasets in this repository are released under the MIT License (see [`LICENSE`](LICENSE)).
213
+ - Third-party literature data redistributed here remains subject to its original publications' terms and should be
214
+ cited accordingly: the Goodman CP3 spreadsheet (`data/cp3/`), the Schattenberg and Kaupp NS372
215
+ spreadsheet (`data/ns372/`), and the DFT8K benchmark (`data/dft8k/`, from Guan and Paton).
build_api_docs.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render the MagNET API docs into api_docs/ with pdoc.
2
+
3
+ python build_api_docs.py
4
+
5
+ Documents the magnet package's public modules and every analysis module under analysis/code,
6
+ skipping test files. The analysis modules import their siblings and the dataset readers by bare
7
+ name, so their directories are added to sys.path first.
8
+ """
9
+ import glob
10
+ import os
11
+ import sys
12
+
13
+ import pdoc
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+
17
+
18
+ def _import_dirs():
19
+ """Directories the analysis modules import from: their own folder, shared/, and each
20
+ data/<dataset>/ folder that holds a reader module."""
21
+ dirs = [os.path.join(HERE, "analysis", "code"),
22
+ os.path.join(HERE, "analysis", "code", "shared")]
23
+ dirs += sorted(os.path.dirname(p) for p in glob.glob(os.path.join(HERE, "data", "*", "*_reader.py")))
24
+ return dirs
25
+
26
+
27
+ def _modules():
28
+ """The .py files to document: the six public magnet modules, then the analysis modules."""
29
+ mods = [os.path.join(HERE, "magnet", name + ".py")
30
+ for name in ("__init__", "api", "inference", "model", "run_magnet", "scaling")]
31
+ analysis = glob.glob(os.path.join(HERE, "analysis", "code", "*.py"))
32
+ analysis += glob.glob(os.path.join(HERE, "analysis", "code", "shared", "*.py"))
33
+ for path in sorted(analysis):
34
+ name = os.path.basename(path)
35
+ if name.startswith("test_"):
36
+ continue
37
+ mods.append(path)
38
+ return mods
39
+
40
+
41
+ def main():
42
+ for d in _import_dirs():
43
+ sys.path.insert(0, d)
44
+ pdoc.render.configure(template_directory=os.path.join(HERE, "pdoc_templates"))
45
+ pdoc.pdoc(*_modules(), output_directory=os.path.join(HERE, "api_docs"))
46
+ print("api docs written to", os.path.join(HERE, "api_docs", "index.html"))
47
+
48
+
49
+ if __name__ == "__main__":
50
+ main()
conftest.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # The magnet/ model package needs the heavy PyTorch / PyTorch Geometric stack
2
+ # (see magnet/requirements.txt), which the default dataset-reader test environment does not
3
+ # install. When torch is absent, skip collecting the magnet tests entirely so the lightweight
4
+ # CI stays green. They run locally once the torch stack from magnet/requirements.txt is present.
5
+ #
6
+ # This is needed (not just the importorskip guards inside test_magnet.py) because that test file
7
+ # lives inside the magnet package, so pytest imports magnet/__init__.py during collection, which
8
+ # imports torch before any in-test skip can run.
9
+ import os
10
+ import sys
11
+
12
+ # The analysis code lives in analysis/code/ (importable modules) and analysis/code/shared/ (the
13
+ # shared utilities). Put both on sys.path so tests anywhere resolve bare imports like `import
14
+ # delta22` and `from stats import rmse`, no matter which folder the test file itself sits in.
15
+ _HERE = os.path.dirname(os.path.abspath(__file__))
16
+ for _p in (os.path.join(_HERE, "analysis", "code"),
17
+ os.path.join(_HERE, "analysis", "code", "shared")):
18
+ if _p not in sys.path:
19
+ sys.path.insert(0, _p)
20
+
21
+ collect_ignore = []
22
+ collect_ignore_glob = []
23
+ try:
24
+ import torch # noqa: F401
25
+ except ImportError:
26
+ collect_ignore = ["magnet"]
27
+ collect_ignore_glob = ["magnet/*"]
28
+
29
+
30
+ # The reproduce tests (test_reproduce.py) execute every notebook and take minutes; they are opt-in so
31
+ # a plain `pytest` stays fast. Run them with `pytest --run-notebooks` (or `pytest -m reproduce`).
32
+ def pytest_addoption(parser):
33
+ parser.addoption("--run-notebooks", action="store_true", default=False,
34
+ help="run the reproduce tests that execute every analysis notebook")
35
+
36
+
37
+ def pytest_configure(config):
38
+ config.addinivalue_line("markers", "reproduce: executes a full analysis notebook (slow, opt-in)")
39
+
40
+
41
+ def pytest_collection_modifyitems(config, items):
42
+ import pytest
43
+ if config.getoption("--run-notebooks"):
44
+ return
45
+ skip = pytest.mark.skip(reason="notebook reproduce test; pass --run-notebooks to run")
46
+ for item in items:
47
+ if "reproduce" in item.keywords:
48
+ item.add_marker(skip)
data/applications/applications_experimental.xlsx ADDED
Binary file (24.8 kB). View file
 
data/applications/applications_reader.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Loader for the natural-products ("applications") release.
2
+
3
+ applications.hdf5 stores shieldings and coordinates as whole numbers equal to the real value
4
+ times 10,000 (scale 1e-4), with -2147483648 marking values that were never computed. Energies
5
+ are stored as ordinary floating-point numbers. This module turns the stored numbers back into
6
+ real values (leaving blanks where data is missing) and exposes one accessor per data layer, so
7
+ notebooks never touch the raw file format. It is the single place where decoding happens.
8
+
9
+ Solute names: vomicine, prednisone, peptide, flavone, dihydrotanshinone_I, and
10
+ isomer_1E/1Z/2E/2Z/3E/3Z/4N/4O.
11
+ Solvents: TIP4P (water), benzene, chloroform, methanol.
12
+ """
13
+ import h5py
14
+ import io
15
+ import os
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+
20
+ _SCALE = 1e4
21
+ _MISSING_MARKER = -2147483648
22
+
23
+ SOLVENTS = ["TIP4P", "benzene", "chloroform", "methanol"]
24
+ # shell label = approximate number of heavy (non-hydrogen) atoms in the solute-plus-solvent cluster
25
+ SHELL_SIZES = [50, 150, 250, 350, 450, 550, 650]
26
+
27
+
28
+ def _decode(values):
29
+ """Turn stored whole numbers back into real values; blanks where data is missing."""
30
+ values = np.asarray(values)
31
+ if values.dtype == np.int32:
32
+ out = values.astype(np.float64) / _SCALE
33
+ out[values == _MISSING_MARKER] = np.nan
34
+ return out
35
+ return np.asarray(values, dtype=np.float64)
36
+
37
+
38
+ class Applications:
39
+ """Read-only accessor for applications.hdf5 (+ the experimental spreadsheet)."""
40
+
41
+ def __init__(self, hdf5_path, experimental_xlsx=None, md_geometries_path=None):
42
+ self.hdf5_path = str(hdf5_path)
43
+ self.experimental_xlsx = str(experimental_xlsx) if experimental_xlsx else None
44
+ # the large MD-geometry companion is optional; default to the sibling file if present
45
+ if md_geometries_path is None:
46
+ sibling = self.hdf5_path.replace("applications.hdf5", "applications_md_geometries.hdf5")
47
+ md_geometries_path = sibling if (sibling != self.hdf5_path and os.path.exists(sibling)) else None
48
+ self.md_geometries_path = str(md_geometries_path) if md_geometries_path else None
49
+
50
+ def _open(self):
51
+ return h5py.File(self.hdf5_path, "r")
52
+
53
+ def _open_md(self):
54
+ if not self.md_geometries_path:
55
+ raise ValueError(
56
+ "MD-geometry companion not available. Download applications_md_geometries.hdf5 "
57
+ "next to applications.hdf5, or pass md_geometries_path=...")
58
+ return h5py.File(self.md_geometries_path, "r")
59
+
60
+ # ---- structure ----
61
+ _NON_SOLUTE_GROUPS = ("solvents", "composite_model")
62
+
63
+ def solutes(self):
64
+ """List the solute names stored in the release."""
65
+ with self._open() as f:
66
+ return [k for k in f.keys() if k not in self._NON_SOLUTE_GROUPS]
67
+
68
+ def atomic_numbers(self, solute):
69
+ """Return a solute's per-atom atomic numbers."""
70
+ with self._open() as f:
71
+ return np.asarray(f[solute]["atomic_numbers"][()])
72
+
73
+ # ---- MagNET-Zero (gas) + implicit-solvent (PCM) ----
74
+ def magnet_zero(self, solute):
75
+ """Boltzmann-weighted MagNET-Zero stationary shieldings and the PCM correction."""
76
+ with self._open() as f:
77
+ g = f[solute]["magnet_zero"]
78
+ return {
79
+ "stationary": _decode(g["stationary"][()]),
80
+ "pcm_correction": _decode(g["pcm_correction"][()]),
81
+ }
82
+
83
+ def magnet_zero_conformers(self, solute):
84
+ """Per-conformer dict: {conformer_name: {energy, geometry, magnet_zero, pcm}}."""
85
+ out = {}
86
+ with self._open() as f:
87
+ cg = f[solute]["magnet_zero"]["conformers"]
88
+ for ck in cg.keys():
89
+ c = cg[ck]
90
+ out[ck] = {
91
+ "energy": float(c["energy"][()]),
92
+ "geometry": _decode(c["geometry"][()]),
93
+ "atomic_numbers": np.asarray(c["atomic_numbers"][()]),
94
+ "magnet_zero": _decode(c["magnet_zero"][()]),
95
+ "pcm": _decode(c["pcm"][()]),
96
+ }
97
+ return out
98
+
99
+ # ---- QCD (rovibrational) ----
100
+ def qcd(self, solute, shielding_only=False):
101
+ """qcd stationary (n_atoms,4)=[x,y,z,shielding] and trajectories (replica,frame,n_atoms,4).
102
+ With shielding_only=True returns just the shielding channel (column 3)."""
103
+ with self._open() as f:
104
+ g = f[solute]["qcd"]
105
+ stat = _decode(g["stationary"][()])
106
+ traj = _decode(g["trajectories"][()])
107
+ if shielding_only:
108
+ return {"stationary": stat[..., 3], "trajectories": traj[..., 3]}
109
+ return {"stationary": stat, "trajectories": traj}
110
+
111
+ # ---- MagNET-X (explicit solvent) ----
112
+ def magnet_x(self, solute, solvent, shell):
113
+ """Explicit-solvent shieldings (frames, n_atoms, 2)=[isolated, solvated] for one shell."""
114
+ with self._open() as f:
115
+ return _decode(f[solute]["magnet_x"][solvent][f"shell_{shell}"][()])
116
+
117
+ def magnet_x_stationary(self, solute, solvent):
118
+ """Explicit-solvent stationary-geometry shieldings for one solute/solvent pair."""
119
+ with self._open() as f:
120
+ return _decode(f[solute]["magnet_x"][solvent]["stationary"][()])
121
+
122
+ def available_shells(self, solute, solvent):
123
+ """List the shell sizes stored for one solute/solvent pair."""
124
+ with self._open() as f:
125
+ return sorted(
126
+ int(k.split("_")[1])
127
+ for k in f[solute]["magnet_x"][solvent].keys()
128
+ if k.startswith("shell_")
129
+ )
130
+
131
+ # ---- DFT reference (provided-only; PBE0(PBE1PBE)/pcSseg-3 GIAO, PCM per solvent) ----
132
+ def dft_reference(self, solute, solvent):
133
+ """Reference DFT shieldings for one solvent. Solvent keys here are 'gas', 'water',
134
+ 'benzene', 'chloroform', 'methanol' -- note 'water' is the CPCM continuum-water model
135
+ (not the explicit 'TIP4P' key used by magnet_x), and 'gas' has no MagNET-x counterpart."""
136
+ with self._open() as f:
137
+ return _decode(f[solute]["dft_reference"][solvent]["shieldings"][()])
138
+
139
+ def dft_reference_geometries(self, solute):
140
+ """The DFT conformer geometries the reference shieldings were computed on, as a dict
141
+ {conformer_name: {coordinates (n_atoms, 3), atomic_numbers (n_atoms,), energy}}."""
142
+ out = {}
143
+ with self._open() as f:
144
+ g = f[solute]["dft_reference"].get("geometries")
145
+ if g is None:
146
+ return out
147
+ for ck in g.keys():
148
+ c = g[ck]
149
+ out[ck] = {
150
+ "coordinates": _decode(c["coordinates"][()]),
151
+ "atomic_numbers": np.asarray(c["atomic_numbers"][()]),
152
+ "energy": float(c["energy"][()]),
153
+ }
154
+ return out
155
+
156
+ # ---- composite-model coefficients (fit on delta-22, applied to the natural products) ----
157
+ def _composite_csv(self, *path):
158
+ key = "composite_model/" + "/".join(path)
159
+ with self._open() as f:
160
+ if key not in f:
161
+ raise KeyError(f"{key} not in {self.hdf5_path}")
162
+ txt = f[key][()]
163
+ if isinstance(txt, bytes):
164
+ txt = txt.decode("utf-8")
165
+ return pd.read_csv(io.StringIO(txt))
166
+
167
+ def ols_coefficients(self, formula, nucleus):
168
+ """OLS composite-model coefficients (parameter rows x solvent columns) for one formula/nucleus."""
169
+ return self._composite_csv("ols_coefficients", formula, nucleus)
170
+
171
+ def bootstrap_coefficients(self, formula, nucleus):
172
+ """Bootstrap-resampled composite-model coefficients (one row per solvent x seed)."""
173
+ return self._composite_csv("bootstrap_coefficients", formula, nucleus)
174
+
175
+ def rmse_distribution(self, nucleus):
176
+ """delta-22 bootstrap RMSE distributions (solvent, nucleus, formula, seed, Bootstrap_RMSE)."""
177
+ return self._composite_csv("rmse_distributions", nucleus)
178
+
179
+ def pcm_conversion_factors(self, nucleus):
180
+ """Per-solvent scale factors applied to the chloroform-only PCM correction."""
181
+ return self._composite_csv("pcm_conversion_factors", nucleus)
182
+
183
+ def composite_formulas(self, kind="ols_coefficients"):
184
+ """List the formula keys available under a composite_model subgroup."""
185
+ with self._open() as f:
186
+ g = f.get(f"composite_model/{kind}")
187
+ return sorted(g.keys()) if g is not None else []
188
+
189
+ # ---- canonical solvent atom ordering ----
190
+ def solvent_atomic_numbers(self, solvent):
191
+ """Return one solvent molecule's atomic numbers, in the canonical atom order."""
192
+ with self._open() as f:
193
+ return np.asarray(f["solvents"][solvent]["atomic_numbers"][()])
194
+
195
+ # ---- MD solvent-shell geometries (the large companion file) ----
196
+ def available_md_shells(self, solute, solvent):
197
+ """List the shell sizes with MD geometries for one solute/solvent pair."""
198
+ with self._open_md() as f:
199
+ return sorted(int(k.split("_")[1]) for k in f[solute][solvent].keys()
200
+ if k.startswith("geometries_"))
201
+
202
+ def md_geometry(self, solute, solvent, shell):
203
+ """Solvent-shell snapshot coordinates (frames, n_shell_atoms, 3) for one shell. The frame
204
+ axis aligns 1:1 with magnet_x(solute, solvent, shell)."""
205
+ with self._open_md() as f:
206
+ return _decode(f[solute][solvent][f"geometries_{shell}"][()])
207
+
208
+ def md_geometry_atomic_numbers(self, solute, solvent, shell):
209
+ """Atomic numbers for one MD solvent-shell snapshot's atoms."""
210
+ with self._open_md() as f:
211
+ return np.asarray(f[solute][solvent][f"atomic_numbers_{shell}"][()])
212
+
213
+ def md_stationary_geometry(self, solute):
214
+ """The solute's stationary-geometry coordinates from the OpenMM setup."""
215
+ with self._open_md() as f:
216
+ return _decode(f[solute]["openMM_stationary_geometry"][()])
217
+
218
+ # ---- experiment ----
219
+ @staticmethod
220
+ def _prefix_h_sites(df):
221
+ """Give each proton site a unique label per solute. The spreadsheet labels every H
222
+ site simply "H"; this prepends an incrementing two-digit number in row order per
223
+ solute ("01 H", "02 H", ...), matching the figure notebooks so per-site H rows are
224
+ distinguishable. Carbon sites already carry distinct labels and are left untouched."""
225
+ df = df.copy()
226
+ for solute in df["solute"].unique():
227
+ n = 1
228
+ mask = (df["solute"] == solute) & (df["nucleus"] == "H")
229
+ for idx in df[mask].index:
230
+ df.at[idx, "site"] = f"{n:02d} {df.at[idx, 'site']}"
231
+ n += 1
232
+ return df
233
+
234
+ def experiment(self, rename_water_to_tip4p=True, uuid_prefix_h_sites=False):
235
+ """Flat long table from the per-solute-sheet xlsx: one row per (solute, site).
236
+ With uuid_prefix_h_sites=True, proton sites are made unique per solute (see
237
+ _prefix_h_sites) as the figure notebooks require."""
238
+ if not self.experimental_xlsx:
239
+ raise ValueError("experimental_xlsx path not provided")
240
+ sheets = pd.read_excel(self.experimental_xlsx, sheet_name=None)
241
+ frames = []
242
+ for solute, df in sheets.items():
243
+ df = df.copy()
244
+ df.insert(0, "solute", solute)
245
+ frames.append(df)
246
+ out = pd.concat(frames, ignore_index=True)
247
+ if rename_water_to_tip4p and "water" in out.columns:
248
+ out = out.rename(columns={"water": "TIP4P"})
249
+ if uuid_prefix_h_sites:
250
+ out = self._prefix_h_sites(out)
251
+ return out
252
+
253
+ def site_atom_table(self, uuid_prefix_h_sites=False):
254
+ """Mapping of each (solute, nucleus, site) to its atom_numbers, used to aggregate
255
+ per-atom shieldings up to NMR sites. `atom_numbers` is returned as the raw string from
256
+ the spreadsheet (e.g. "17,19" or "21"); callers split/offset it themselves (atoms are
257
+ 1-based in the spreadsheet, so subtract 1 to index the shielding arrays)."""
258
+ exp = self.experiment(rename_water_to_tip4p=False, uuid_prefix_h_sites=uuid_prefix_h_sites)
259
+ cols = ["solute", "site", "nucleus", "atom_numbers"]
260
+ return exp[cols].drop_duplicates().reset_index(drop=True)
data/applications/test_applications.py ADDED
@@ -0,0 +1,211 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the applications (natural-products) loader.
2
+
3
+ These build a tiny synthetic applications.hdf5 + experimental spreadsheet in a temp dir that
4
+ mimic the real schema, so the loader and its decoding can be checked in CI without the multi-
5
+ hundred-megabyte release files.
6
+ """
7
+ import numpy as np
8
+ import pandas as pd
9
+ import h5py
10
+ import pytest
11
+
12
+ from applications_reader import Applications, _decode, SHELL_SIZES, SOLVENTS
13
+
14
+ SCALE = 1e4
15
+ MISS = np.int32(-2147483648)
16
+
17
+
18
+ def _enc(a):
19
+ a = np.asarray(a, dtype=np.float64)
20
+ nan = np.isnan(a)
21
+ out = np.where(nan, 0.0, a)
22
+ out = np.round(out * SCALE).astype(np.int32)
23
+ out[nan] = MISS
24
+ return out
25
+
26
+
27
+ @pytest.fixture
28
+ def fake_release(tmp_path):
29
+ """A 2-solute synthetic release: vomicine (with a missing shell) + isomer_1E."""
30
+ h5 = tmp_path / "applications.hdf5"
31
+ xlsx = tmp_path / "applications_experimental.xlsx"
32
+ rng = np.random.default_rng(0)
33
+
34
+ def mk_solute(g, n_atoms, atomic_numbers, shells):
35
+ g.create_dataset("atomic_numbers", data=np.asarray(atomic_numbers, dtype=np.int32))
36
+ mz = g.create_group("magnet_zero")
37
+ # MagNET predicts H/C only -> heteroatoms read 0 (mirror the real data)
38
+ hetero = ~np.isin(np.asarray(atomic_numbers), [1, 6])
39
+ stat = rng.normal(100, 30, n_atoms); stat[hetero] = 0.0
40
+ pcm = rng.normal(0, 2, n_atoms); pcm[hetero] = 0.0
41
+ mz.create_dataset("stationary", data=_enc(stat))
42
+ mz.create_dataset("pcm_correction", data=_enc(pcm))
43
+ cf = mz.create_group("conformers").create_group("conformer_0")
44
+ cf.create_dataset("energy", data=np.float64(-1234.5)) # float, not encoded
45
+ cf.create_dataset("geometry", data=_enc(rng.normal(0, 5, (n_atoms, 3))))
46
+ cf.create_dataset("atomic_numbers", data=np.asarray(atomic_numbers, dtype=np.int32))
47
+ cf.create_dataset("magnet_zero", data=_enc(rng.normal(100, 30, n_atoms)))
48
+ cf.create_dataset("pcm", data=_enc(rng.normal(0, 2, n_atoms)))
49
+ q = g.create_group("qcd")
50
+ q.create_dataset("stationary", data=_enc(rng.normal(0, 50, (n_atoms, 4))))
51
+ q.create_dataset("trajectories", data=_enc(rng.normal(0, 50, (3, 4, n_atoms, 4))))
52
+ mx = g.create_group("magnet_x")
53
+ for sv in SOLVENTS:
54
+ svg = mx.create_group(sv)
55
+ for sh in shells:
56
+ svg.create_dataset(f"shell_{sh}", data=_enc(rng.normal(100, 30, (5, n_atoms, 2))))
57
+ svg.create_dataset("stationary", data=_enc(rng.normal(100, 30, n_atoms)))
58
+ dr = g.create_group("dft_reference")
59
+ # dft_reference uses gas + CPCM-continuum 'water' (not the explicit 'TIP4P'), like the real data
60
+ for sv in ["gas", "water", "benzene", "chloroform", "methanol"]:
61
+ dr.create_group(sv).create_dataset("shieldings", data=_enc(rng.normal(100, 40, n_atoms)))
62
+ gg = dr.create_group("geometries").create_group("conformer_001")
63
+ gg.create_dataset("coordinates", data=_enc(rng.normal(0, 5, (n_atoms, 3))))
64
+ gg.create_dataset("atomic_numbers", data=np.asarray(atomic_numbers, dtype=np.int32))
65
+ gg.create_dataset("energy", data=np.float64(-100.0))
66
+
67
+ with h5py.File(h5, "w") as f:
68
+ sg = f.create_group("solvents")
69
+ for sv in SOLVENTS:
70
+ sg.create_group(sv).create_dataset("atomic_numbers", data=np.array([6, 1, 1], dtype=np.int32))
71
+ # vomicine: drop one shell (mimic the isomer_1Z/chloroform/250 corruption)
72
+ mk_solute(f.create_group("vomicine"), 4, [8, 7, 6, 1], [s for s in SHELL_SIZES if s != 250])
73
+ mk_solute(f.create_group("isomer_1E"), 3, [6, 1, 1], SHELL_SIZES)
74
+ # composite_model group: fit tables stored verbatim as CSV text
75
+ cm = f.create_group("composite_model")
76
+ cm.create_group("pcm_conversion_factors").create_dataset(
77
+ "H", data="solvent,pcm_conversion_factor\nchloroform,1.0\nbenzene,0.6\n")
78
+ cm.create_group("ols_coefficients").create_group("stationary_plus_pcm").create_dataset(
79
+ "H", data="parameter,chloroform,benzene\nintercept,31.2,31.3\nstationary,-0.98,-0.99\n")
80
+ cm.create_group("bootstrap_coefficients").create_group("stationary_plus_pcm").create_dataset(
81
+ "H", data="solvent,seed,Intercept,stationary,pcm\nchloroform,0,31.2,-0.98,-1.0\nbenzene,0,31.3,-0.99,-0.6\n")
82
+ cm.create_group("rmse_distributions").create_dataset(
83
+ "H", data="solvent,nucleus,formula,seed,Bootstrap_RMSE\nchloroform,H,pcm2,0,0.07\nbenzene,H,pcm2,0,0.21\n")
84
+
85
+ # experiment xlsx: one sheet per solute
86
+ with pd.ExcelWriter(xlsx) as w:
87
+ for sol in ["vomicine", "isomer_1E"]:
88
+ pd.DataFrame({
89
+ "site": ["a_C", "b_H"], "nucleus": ["C", "H"], "atom_numbers": ["3", "4"],
90
+ "chloroform": [1.0, 2.0], "benzene": [1.1, 2.1],
91
+ "methanol": [1.2, 2.2], "water": [1.3, 2.3],
92
+ }).to_excel(w, sheet_name=sol, index=False)
93
+ return Applications(h5, xlsx)
94
+
95
+
96
+ def test_decode_roundtrip_and_missing():
97
+ vals = np.array([1.2345, -50.0, 0.0], dtype=np.float64)
98
+ enc = _enc(vals)
99
+ out = _decode(enc)
100
+ assert np.allclose(out, vals, atol=5e-5)
101
+ enc_with_miss = np.append(enc, MISS).astype(np.int32)
102
+ assert np.isnan(_decode(enc_with_miss)[-1])
103
+
104
+
105
+ def test_solutes_and_solvents_group_excluded(fake_release):
106
+ assert set(fake_release.solutes()) == {"vomicine", "isomer_1E"}
107
+
108
+
109
+ def test_magnet_zero_heteroatoms_read_zero(fake_release):
110
+ # vomicine atomic_numbers [8, 7, 6, 1]: the O and N positions must decode to 0 (MagNET does H/C only)
111
+ mz = fake_release.magnet_zero("vomicine")
112
+ hetero = ~np.isin(fake_release.atomic_numbers("vomicine"), [1, 6])
113
+ assert np.allclose(mz["stationary"][hetero], 0.0)
114
+ assert np.allclose(mz["pcm_correction"][hetero], 0.0)
115
+ assert np.any(mz["stationary"][~hetero] != 0.0) # H/C carry real values
116
+
117
+
118
+ def test_qcd_shielding_only_drops_xyz(fake_release):
119
+ full = fake_release.qcd("isomer_1E")
120
+ just = fake_release.qcd("isomer_1E", shielding_only=True)
121
+ assert full["stationary"].shape == (3, 4)
122
+ assert just["stationary"].shape == (3,)
123
+ assert just["trajectories"].shape == (3, 4, 3)
124
+ assert np.allclose(just["stationary"], full["stationary"][..., 3], atol=5e-5)
125
+
126
+
127
+ def test_magnet_x_and_shell_guard(fake_release):
128
+ mx = fake_release.magnet_x("isomer_1E", "benzene", 650)
129
+ assert mx.shape == (5, 3, 2)
130
+ # vomicine is missing shell 250 -> available_shells must exclude it, not crash
131
+ assert 250 not in fake_release.available_shells("vomicine", "chloroform")
132
+ assert fake_release.available_shells("isomer_1E", "chloroform") == SHELL_SIZES
133
+
134
+
135
+ def test_dft_reference_and_conformers(fake_release):
136
+ assert fake_release.dft_reference("vomicine", "gas").shape == (4,)
137
+ conf = fake_release.magnet_zero_conformers("vomicine")
138
+ assert conf["conformer_0"]["energy"] == pytest.approx(-1234.5)
139
+ assert conf["conformer_0"]["geometry"].shape == (4, 3)
140
+
141
+
142
+ def test_experiment_water_rename(fake_release):
143
+ df = fake_release.experiment()
144
+ assert "TIP4P" in df.columns and "water" not in df.columns
145
+ assert set(df["solute"].unique()) == {"vomicine", "isomer_1E"}
146
+
147
+
148
+ def test_site_atom_table(fake_release):
149
+ tbl = fake_release.site_atom_table()
150
+ assert list(tbl.columns) == ["solute", "site", "nucleus", "atom_numbers"]
151
+ assert set(tbl["solute"].unique()) == {"vomicine", "isomer_1E"}
152
+ assert set(str(v) for v in tbl["atom_numbers"]) == {"3", "4"}
153
+
154
+
155
+ def test_composite_model_accessors(fake_release):
156
+ conv = fake_release.pcm_conversion_factors("H")
157
+ assert list(conv.columns) == ["solvent", "pcm_conversion_factor"]
158
+ assert conv.set_index("solvent").loc["chloroform", "pcm_conversion_factor"] == 1.0
159
+ ols = fake_release.ols_coefficients("stationary_plus_pcm", "H")
160
+ assert "parameter" in ols.columns and "chloroform" in ols.columns
161
+ assert fake_release.composite_formulas("ols_coefficients") == ["stationary_plus_pcm"]
162
+ boot = fake_release.bootstrap_coefficients("stationary_plus_pcm", "H")
163
+ assert {"solvent", "seed", "Intercept", "stationary", "pcm"}.issubset(boot.columns)
164
+ rd = fake_release.rmse_distribution("H")
165
+ assert {"solvent", "formula", "Bootstrap_RMSE"}.issubset(rd.columns)
166
+
167
+
168
+ def test_dft_reference_uses_water_not_tip4p(fake_release):
169
+ # the DFT reference is CPCM-continuum 'water'; the explicit 'TIP4P' key does not exist there
170
+ assert fake_release.dft_reference("vomicine", "water").shape == (4,)
171
+ with pytest.raises(KeyError):
172
+ fake_release.dft_reference("vomicine", "TIP4P")
173
+
174
+
175
+ def test_dft_reference_geometries(fake_release):
176
+ geo = fake_release.dft_reference_geometries("vomicine")
177
+ assert "conformer_001" in geo
178
+ c = geo["conformer_001"]
179
+ assert c["coordinates"].shape == (4, 3)
180
+ assert c["energy"] == pytest.approx(-100.0)
181
+ assert list(c["atomic_numbers"]) == [8, 7, 6, 1]
182
+
183
+
184
+ def test_md_geometry_companion(tmp_path):
185
+ md = tmp_path / "applications_md_geometries.hdf5"
186
+ with h5py.File(md, "w") as f:
187
+ g = f.create_group("vomicine")
188
+ g.create_dataset("openMM_stationary_geometry", data=_enc(np.zeros((4, 3))))
189
+ sv = g.create_group("chloroform")
190
+ sv.create_dataset("geometries_50", data=_enc(np.ones((5, 7, 3))))
191
+ sv.create_dataset("atomic_numbers_50", data=np.arange(7, dtype=np.int32))
192
+ app = Applications(tmp_path / "applications.hdf5", md_geometries_path=md)
193
+ assert app.available_md_shells("vomicine", "chloroform") == [50]
194
+ assert app.md_geometry("vomicine", "chloroform", 50).shape == (5, 7, 3)
195
+ assert app.md_geometry_atomic_numbers("vomicine", "chloroform", 50).shape == (7,)
196
+ assert app.md_stationary_geometry("vomicine").shape == (4, 3)
197
+
198
+
199
+ def test_md_geometry_absent_raises_clearly(fake_release):
200
+ # the big companion is optional; accessing it without it gives a clear, actionable error
201
+ with pytest.raises(ValueError, match="companion"):
202
+ fake_release.available_md_shells("vomicine", "chloroform")
203
+
204
+
205
+ def test_uuid_prefix_h_sites(fake_release):
206
+ # each solute's single H site (fixture site "b_H") becomes "01 b_H"; C sites untouched
207
+ tbl = fake_release.site_atom_table(uuid_prefix_h_sites=True)
208
+ h_sites = tbl[tbl["nucleus"] == "H"]["site"].tolist()
209
+ assert all(s.startswith("01 ") for s in h_sites)
210
+ c_sites = tbl[tbl["nucleus"] == "C"]["site"].tolist()
211
+ assert all(not s[:2].isdigit() for s in c_sites)
data/carbon_monoxide/co_bond_lengths_histogram.csv ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ bin_left_angstrom,count
2
+ 0.6975547075,1
3
+ 0.7077355623,0
4
+ 0.7179164171,0
5
+ 0.7280972719,0
6
+ 0.7382781267,0
7
+ 0.7484589815,0
8
+ 0.7586398363,0
9
+ 0.7688206911,0
10
+ 0.7790015459,0
11
+ 0.7891824007,0
12
+ 0.7993632555,0
13
+ 0.8095441103,0
14
+ 0.8197249651,0
15
+ 0.8299058199,0
16
+ 0.8400866747,0
17
+ 0.8502675295,0
18
+ 0.8604483843,0
19
+ 0.8706292391,0
20
+ 0.8808100939,0
21
+ 0.8909909487,0
22
+ 0.9011718035,0
23
+ 0.9113526583,0
24
+ 0.9215335131,0
25
+ 0.9317143679,0
26
+ 0.9418952227,1
27
+ 0.9520760775,0
28
+ 0.9622569323,0
29
+ 0.9724377871,0
30
+ 0.9826186419,0
31
+ 0.9927994967,0
32
+ 1.002980351,0
33
+ 1.013161206,1
34
+ 1.023342061,0
35
+ 1.033522916,1
36
+ 1.043703771,0
37
+ 1.053884625,1
38
+ 1.06406548,0
39
+ 1.074246335,0
40
+ 1.08442719,5
41
+ 1.094608045,8
42
+ 1.104788899,2
43
+ 1.114969754,10
44
+ 1.125150609,34
45
+ 1.135331464,151
46
+ 1.145512319,768
47
+ 1.155693173,2689
48
+ 1.165874028,5616
49
+ 1.176054883,8403
50
+ 1.186235738,29687
51
+ 1.196416593,164521
52
+ 1.206597447,512713
53
+ 1.216778302,313754
54
+ 1.226959157,59816
55
+ 1.237140012,13908
56
+ 1.247320867,8563
57
+ 1.257501721,5611
58
+ 1.267682576,3237
59
+ 1.277863431,1824
60
+ 1.288044286,1890
61
+ 1.298225141,2919
62
+ 1.308405995,5201
63
+ 1.31858685,12030
64
+ 1.328767705,37567
65
+ 1.33894856,101935
66
+ 1.349129415,186377
67
+ 1.359310269,257009
68
+ 1.369491124,222621
69
+ 1.379671979,170158
70
+ 1.389852834,196816
71
+ 1.400033689,323785
72
+ 1.410214543,689662
73
+ 1.420395398,1028853
74
+ 1.430576253,790563
75
+ 1.440757108,503514
76
+ 1.450937963,247803
77
+ 1.461118817,124073
78
+ 1.471299672,59557
79
+ 1.481480527,28576
80
+ 1.491661382,15571
81
+ 1.501842237,9137
82
+ 1.512023091,5441
83
+ 1.522203946,3237
84
+ 1.532384801,1882
85
+ 1.542565656,1043
86
+ 1.552746511,552
87
+ 1.562927365,323
88
+ 1.57310822,170
89
+ 1.583289075,105
90
+ 1.59346993,45
91
+ 1.603650784,27
92
+ 1.613831639,20
93
+ 1.624012494,16
94
+ 1.634193349,1
95
+ 1.644374204,6
96
+ 1.654555058,5
97
+ 1.664735913,3
98
+ 1.674916768,3
99
+ 1.685097623,1
100
+ 1.695278478,4
101
+ 1.705459332,2
data/carbon_monoxide/co_magnet_vs_gaussian.csv ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ bond_length_angstrom,bond_length_delta,gaussian_shielding_c_ppm,magnet_shielding_c_ppm,delta_c_ppm,gaussian_shielding_o_ppm,magnet_shielding_o_ppm,delta_o_ppm
2
+ 0.7000000476837158,0.0,100.323,-98.84709167480469,-199.17009167480467,118.92,0.0,-118.92
3
+ 0.7100000381469727,0.0,100.6646,-101.11131286621094,-201.77591286621094,119.3498,0.0,-119.3498
4
+ 0.7200000286102295,0.0,100.7627,-106.29923248291016,-207.06193248291015,119.4879,0.0,-119.4879
5
+ 0.7300000190734863,0.0,100.6302,-110.37581634521484,-211.00601634521485,119.3424,0.0,-119.3424
6
+ 0.7400000095367432,0.0,100.2785,-105.22541809082031,-205.50391809082032,118.9203,0.0,-118.9203
7
+ 0.75,0.0,99.7179,-82.99818420410156,-182.71608420410155,118.2274,0.0,-118.2274
8
+ 0.7599999904632568,0.0,98.9574,-58.89897537231445,-157.85637537231446,117.2682,0.0,-117.2682
9
+ 0.7699999809265137,0.0,98.0051,-41.90826416015625,-139.91336416015625,116.0464,0.0,-116.0464
10
+ 0.7799999713897705,0.0,96.8686,-30.977083206176758,-127.84568320617676,114.5651,0.0,-114.5651
11
+ 0.7900000214576721,0.0,95.5546,-24.81533432006836,-120.36993432006835,112.8267,0.0,-112.8267
12
+ 0.800000011920929,0.0,94.0699,-20.98507308959961,-115.05497308959961,110.8334,0.0,-110.8334
13
+ 0.8100000023841858,0.0,92.4205,-17.495792388916016,-109.91629238891602,108.5869,0.0,-108.5869
14
+ 0.8199999928474426,0.0,90.6124,-13.717061042785645,-104.32946104278564,106.089,0.0,-106.089
15
+ 0.8299999833106995,0.0,88.6517,-10.725614547729492,-99.3773145477295,103.3413,0.0,-103.3413
16
+ 0.8400000333786011,0.0,86.5441,-9.816015243530273,-96.36011524353027,100.3454,0.0,-100.3454
17
+ 0.8500000238418579,0.0,84.2955,-10.453042030334473,-94.74854203033448,97.103,0.0,-97.103
18
+ 0.8600000143051147,0.0,81.9118,-11.056601524353027,-92.96840152435303,93.6158,0.0,-93.6158
19
+ 0.8700000047683716,0.0,79.3986,-10.437362670898438,-89.83596267089844,89.8856,0.0,-89.8856
20
+ 0.8799999952316284,0.0,76.7615,-8.59598159790039,-85.35748159790039,85.9141,0.0,-85.9141
21
+ 0.8899999856948853,0.0,74.0062,-6.2477288246154785,-80.25392882461549,81.7031,0.0,-81.7031
22
+ 0.8999999761581421,0.0,71.1378,-3.5964534282684326,-74.73425342826843,77.2543,0.0,-77.2543
23
+ 0.9099999666213989,0.0,68.1616,-0.3521101176738739,-68.51371011767388,72.5691,0.0,-72.5691
24
+ 0.9199999570846558,0.0,65.0822,3.5139496326446533,-61.56825036735535,67.649,0.0,-67.649
25
+ 0.9300000071525574,0.0,61.9043,7.585774898529053,-54.31852510147095,62.495,0.0,-62.495
26
+ 0.9399999976158142,0.0,58.632,11.563019752502441,-47.06898024749756,57.1079,0.0,-57.1079
27
+ 0.949999988079071,0.0,55.269,15.541687965393066,-39.72731203460693,51.4883,0.0,-51.4883
28
+ 0.9599999785423279,0.0,51.8188,19.621856689453125,-32.19694331054688,45.6364,0.0,-45.6364
29
+ 0.9700000286102295,0.0,48.2845,23.571359634399414,-24.713140365600587,39.552,0.0,-39.552
30
+ 0.9800000190734863,0.0,44.6688,26.864168167114258,-17.80463183288574,33.2348,0.0,-33.2348
31
+ 0.9900000095367432,0.0,40.9739,29.0251407623291,-11.948759237670899,26.6838,0.0,-26.6838
32
+ 1.0,0.0,37.2017,30.12110710144043,-7.080592898559573,19.8976,0.0,-19.8976
33
+ 1.0099999904632568,0.0,33.3541,30.643943786621094,-2.7101562133789088,12.8748,0.0,-12.8748
34
+ 1.0199999809265137,0.0,29.4321,30.884471893310547,1.4523718933105485,5.6134,0.0,-5.6134
35
+ 1.0299999713897705,0.0,25.437,30.741764068603516,5.3047640686035145,-1.8888,0.0,1.8888
36
+ 1.0399999618530273,0.0,21.3696,30.09064292907715,8.72104292907715,-9.6345,0.0,9.6345
37
+ 1.0499999523162842,0.0,17.2304,29.05397605895996,11.823576058959961,-17.6265,0.0,17.6265
38
+ 1.0600000619888306,0.0,13.0196,27.87270736694336,14.853107366943359,-25.8681,0.0,25.8681
39
+ 1.0699999332427979,0.0,8.7375,26.578834533691406,17.841334533691406,-34.3628,0.0,34.3628
40
+ 1.0799999237060547,0.0,4.384,24.647645950317383,20.263645950317382,-43.1141,0.0,43.1141
41
+ 1.0899999141693115,0.0,-0.0411,21.391674041748047,21.432774041748047,-52.1261,0.0,52.1261
42
+ 1.0999999046325684,0.0,-4.5382,16.754995346069336,21.293195346069336,-61.403,0.0,61.403
43
+ 1.1100000143051147,0.0,-9.1078,11.06360149383545,20.171401493835447,-70.9492,0.0,70.9492
44
+ 1.1200000047683716,0.0,-13.7508,4.679224491119385,18.430024491119383,-80.7696,0.0,80.7696
45
+ 1.1299999952316284,0.0,-18.4679,-2.118180990219116,16.349719009780884,-90.8691,0.0,90.8691
46
+ 1.1399999856948853,0.0,-23.2601,-9.029415130615234,14.230684869384767,-101.253,0.0,101.253
47
+ 1.149999976158142,0.0,-28.1288,-15.528891563415527,12.599908436584471,-111.9268,0.0,111.9268
48
+ 1.159999966621399,0.0,-33.0753,-21.46068000793457,11.614619992065428,-122.8962,0.0,122.8962
49
+ 1.1700000762939453,0.0,-38.101,-27.291627883911133,10.809372116088866,-134.1672,0.0,134.1672
50
+ 1.1800000667572021,0.0,-43.2076,-33.76377868652344,9.443821313476562,-145.746,0.0,145.746
51
+ 1.190000057220459,0.0,-48.3971,-41.44232940673828,6.9547705932617205,-157.6393,0.0,157.6393
52
+ 1.2000000476837158,0.0,-53.6713,-50.427207946777344,3.2440920532226585,-169.8537,0.0,169.8537
53
+ 1.2100000381469727,0.0,-59.0325,-60.3585205078125,-1.3260205078125011,-182.3964,0.0,182.3964
54
+ 1.2200000286102295,0.0,-64.4831,-71.33944702148438,-6.856347021484382,-195.2749,0.0,195.2749
55
+ 1.2300000190734863,0.0,-70.0258,-83.5546875,-13.528887499999996,-208.4969,0.0,208.4969
56
+ 1.2400000095367432,0.0,-75.6632,-96.37044525146484,-20.70724525146484,-222.0705,0.0,222.0705
57
+ 1.25,0.0,-81.3984,-109.45909118652344,-28.060691186523442,-236.0042,0.0,236.0042
58
+ 1.2599999904632568,0.0,-87.2346,-123.06512451171875,-35.83052451171875,-250.3065,0.0,250.3065
59
+ 1.2699999809265137,0.0,-93.1752,-137.39169311523438,-44.21649311523437,-264.9868,0.0,264.9868
60
+ 1.2799999713897705,0.0,-99.2239,-152.3052215576172,-53.08132155761719,-280.0545,0.0,280.0545
61
+ 1.2899999618530273,0.0,-105.3845,-168.0139617919922,-62.629461791992185,-295.5198,0.0,295.5198
62
+ 1.2999999523162842,0.0,-111.6611,-185.2198944091797,-73.55879440917968,-311.3932,0.0,311.3932
63
+ 1.309999942779541,0.0,-118.0581,-204.2111053466797,-86.15300534667969,-327.6854,0.0,327.6854
64
+ 1.3199999332427979,0.0,-124.5801,-224.82327270507812,-100.24317270507812,-344.4081,0.0,344.4081
65
+ 1.3299999237060547,0.0,-131.2321,-247.2126007080078,-115.98050070800781,-361.5731,0.0,361.5731
66
+ 1.3399999141693115,0.0,-138.019,-271.37847900390625,-133.35947900390624,-379.1926,0.0,379.1926
67
+ 1.350000023841858,0.0,-144.9464,-297.06243896484375,-152.11603896484374,-397.2797,0.0,397.2797
68
+ 1.3600000143051147,0.0,-152.0198,-324.2068176269531,-172.18701762695312,-415.8481,0.0,415.8481
69
+ 1.3700000047683716,0.0,-159.2452,-352.2054748535156,-192.9602748535156,-434.9119,0.0,434.9119
70
+ 1.3799999952316284,0.0,-166.6289,-380.4760437011719,-213.8471437011719,-454.4861,0.0,454.4861
71
+ 1.3899999856948853,0.0,-174.1774,-408.8212585449219,-234.64385854492187,-474.5864,0.0,474.5864
72
+ 1.4000000953674316,0.0,-181.8975,-436.86309814453125,-254.96559814453124,-495.2291,0.0,495.2291
73
+ 1.4100000858306885,0.0,-189.7964,-463.40863037109375,-273.61223037109374,-516.4312,0.0,516.4312
74
+ 1.4200000762939453,0.0,-197.8817,-486.49700927734375,-288.6153092773437,-538.2106,0.0,538.2106
75
+ 1.4300000667572021,0.0,-206.1613,-504.5982360839844,-298.4369360839844,-560.5865,0.0,560.5865
76
+ 1.440000057220459,0.0,-214.6433,-517.5965576171875,-302.9532576171875,-583.5779,0.0,583.5779
77
+ 1.4500000476837158,0.0,-223.3363,-526.4462280273438,-303.10992802734376,-607.2056,0.0,607.2056
78
+ 1.4600000381469727,0.0,-232.2493,-532.2877807617188,-300.03848076171874,-631.4914,0.0,631.4914
79
+ 1.4700000286102295,0.0,-241.3916,-535.899169921875,-294.50756992187496,-656.4581,0.0,656.4581
80
+ 1.4800000190734863,0.0,-250.7732,-537.3216552734375,-286.5484552734375,-682.1295,0.0,682.1295
81
+ 1.4900000095367432,0.0,-260.4042,-535.8610229492188,-275.45682294921875,-708.5308,0.0,708.5308
82
+ 1.5,0.0,-270.2954,-530.5790405273438,-260.2836405273438,-735.6883,0.0,735.6883
data/cp3/goodman2009_cp3.xlsx ADDED
Binary file (25.1 kB). View file
 
data/delta22/delta22_experimental.xlsx ADDED
Binary file (18.8 kB). View file
 
data/delta22/delta22_reader.py ADDED
@@ -0,0 +1,1047 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data loading functions for delta22 DFT and NN data.
3
+
4
+ Loads and processes the delta22 HDF5 file (DFT and MagNET shieldings, timings, experimental
5
+ shifts) into the flat tables the analysis consumes. Read through analysis/code/delta22.py and
6
+ the delta-22 figure notebooks (fig3, fig4, si_figure_s08_panelsBCD, ...).
7
+ """
8
+
9
+ import csv
10
+ import os
11
+ import pandas as pd
12
+ import numpy as np
13
+ import h5py
14
+ from tqdm import tqdm # plain tqdm, not tqdm.auto: no ipywidgets/IProgress warning headless or in a notebook
15
+
16
+
17
+ GEOMETRY_TYPES = ["aimnet2", "pbe0_tz"]
18
+ IMPLICIT_SOLVATION_MODELS = ["pcm", "smd"]
19
+ EXPLICIT_SOLVATION_MODELS = ["desmond", "openMM"]
20
+
21
+ DEFAULT_PCM_REFERENCE_METHOD = "b3lyp_d3bj"
22
+ DEFAULT_PCM_REFERENCE_BASIS = "pcSseg2"
23
+
24
+ INDEX_COLUMNS = [
25
+ "solute",
26
+ "sap_geometry_type",
27
+ "sap_nmr_method",
28
+ "sap_basis",
29
+ "nucleus",
30
+ "site",
31
+ "solvent"
32
+ ]
33
+
34
+ # This dataset stores shieldings and coordinates as whole numbers equal to the real
35
+ # value times 10,000, with a reserved marker for values that were never computed.
36
+ # These helpers turn them back into real values, leaving blanks where data is missing.
37
+ _FIXED_POINT_SCALE = 1e4
38
+ _MISSING_MARKER = -2147483648
39
+
40
+ def _decode_fixed_point(values):
41
+ values = np.asarray(values)
42
+ if values.dtype == np.int32:
43
+ out = values.astype(np.float64) / _FIXED_POINT_SCALE
44
+ out[values == _MISSING_MARKER] = np.nan
45
+ return out
46
+ return np.asarray(values, dtype=np.float64)
47
+
48
+
49
+
50
+ def _parse_method_name(s: str) -> tuple[str, str, str, str]:
51
+ """
52
+ Helper for parsing tuples with method details into a method name, basis,
53
+ solvent model, and solvent name.
54
+ """
55
+ method, basis, solvent_model, solvent_name = next(csv.reader([s]))
56
+ return method, basis, solvent_model, solvent_name
57
+
58
+
59
+ def _sort_and_parse_raw_shieldings(shield_df):
60
+ """
61
+ Helper for sorting the raw shieldings DataFrame to put 'none' solvents first
62
+ and for parsing the shielding lists into true lists from strings.
63
+ """
64
+ def _custom_sort(value):
65
+ """Helper function for sorting solvents with 'none' first."""
66
+ if pd.isna(value) or str(value).lower() == 'none':
67
+ return ('', str(value))
68
+ return (str(value).lower(), str(value))
69
+
70
+
71
+ def _parse_to_list(x):
72
+ """Parse shielding arrays from string format to float lists."""
73
+ value_list = x.strip("[]").replace('\n','').split(" ")
74
+ value_list = [v for v in value_list if v]
75
+ try:
76
+ return [float(i) for i in value_list]
77
+ except:
78
+ return []
79
+
80
+ def _to_float_list(x):
81
+ """Return the shieldings as a plain list of floats. Values loaded from the HDF5 are already
82
+ numeric arrays and are converted directly; the string form is only parsed when a caller hands
83
+ one in (e.g. a live-inference override), since converting an array through str() truncates to
84
+ print precision (~8 digits) instead of keeping full float64 precision."""
85
+ if isinstance(x, str):
86
+ return _parse_to_list(x)
87
+ return np.asarray(x, dtype=float).tolist()
88
+
89
+ sort_key = lambda x: x.map(_custom_sort) if x.name == "solvent" else x
90
+ shield_df.sort_values(["solute","solvent"],
91
+ key=sort_key,
92
+ inplace=True)
93
+ shield_df["shieldings"] = shield_df["shieldings"].apply(_to_float_list)
94
+ return shield_df
95
+
96
+
97
+ def _fix_atom_numbers(atom_numbers, prefix="atom_", coerce_to=str, offset=0):
98
+ """Utility function for converting strings and ints to a common list of strings format."""
99
+ if isinstance(atom_numbers, int):
100
+ atom_numbers = [f"{prefix}{int(atom_numbers)+offset}"]
101
+ else:
102
+ atom_numbers = [f"{prefix}{int(i)+offset}" for i in atom_numbers.split(",")]
103
+ atom_numbers = [coerce_to(i) for i in atom_numbers]
104
+ return atom_numbers
105
+
106
+
107
+ def _aggregate_shieldings_by_site(shieldings_data_df:pd.DataFrame,
108
+ site_atoms_df:pd.DataFrame,
109
+ verbose:bool=True):
110
+ """Aggregate DFT/NN shieldings by site."""
111
+
112
+ site_solutes = site_atoms_df.reset_index().solute.unique()
113
+
114
+ if verbose:
115
+ print("\033[93m", end="")
116
+ solute_iter = tqdm(site_solutes,
117
+ desc="Aggregating solute shieldings by site")
118
+ else:
119
+ solute_iter = site_solutes
120
+
121
+ rows = []
122
+ for solute in solute_iter:
123
+ sites_df = site_atoms_df.query(f"solute == '{solute}'")
124
+ data_df = shieldings_data_df.query(f"solute == '{solute}'").copy()
125
+
126
+ # Building the frame from the stacked arrays (every row of this solute has the same number of
127
+ # atoms) is far faster than .apply(pd.Series), which allocates a Series per row.
128
+ shieldings_columns = pd.DataFrame(np.stack(data_df.shieldings.to_numpy()),
129
+ index=data_df.index)
130
+ shieldings_columns.columns = [f"atom_{i}" for i in range(1, len(shieldings_columns.columns)+1)]
131
+ data_df = data_df.iloc[:,:-1]
132
+ data_df = data_df.join(shieldings_columns)
133
+ data_df.reset_index(inplace=True, drop=True) # otherwise the index over the for loop won't work
134
+
135
+ # Metadata columns (geometry type, NMR method, basis, solvation model, solvent) are the same
136
+ # for every site of this solute; pulled out once as a plain object array so the per-site loop
137
+ # does not rebuild a pandas Series per row.
138
+ meta = data_df.iloc[:, 1:6].to_numpy(dtype=object)
139
+ # the first row is always the stationary (unsolvated) one
140
+ assert data_df.iloc[0, 5] == "none", \
141
+ f"Invalid first row: {data_df.iloc[0, 2]} for solute {solute}"
142
+
143
+ for (_, nucl, site), site_row in sites_df.iterrows():
144
+ atom_numbers = _fix_atom_numbers(site_row.iloc[0])
145
+ mean = data_df[atom_numbers].mean(axis=1).to_numpy()
146
+ for i in range(len(meta)):
147
+ geo_type, nmr_method, basis, implicit_solvation_model, solvent = meta[i]
148
+ rows.append([solute, geo_type, nmr_method, basis, implicit_solvation_model,
149
+ solvent, nucl, site, mean[i]])
150
+
151
+ result_df = pd.DataFrame(rows, columns=[
152
+ "solute",
153
+ "sap_geometry_type",
154
+ "sap_nmr_method",
155
+ "sap_basis",
156
+ "implicit_solvation_model",
157
+ "solvent",
158
+ "nucleus",
159
+ "site",
160
+ "shielding"
161
+ ])
162
+
163
+ if verbose:
164
+ print(f"\033[92mRows of site-aggregated shieldings data: {len(result_df)}\033[0m")
165
+
166
+ return result_df
167
+
168
+ def _create_stationary_df(shieldings_df, solvents):
169
+ """Create a dataframe for the stationary shieldings, tiled across."""
170
+ stationary_df = shieldings_df.query("solvent=='none'")
171
+ rows = []
172
+ for _, row in stationary_df.iterrows():
173
+ solute, geo_type, nmr_method, basis, _, _, nucl, site, stationary = row
174
+ for solvent in solvents:
175
+ row = [solute, geo_type, nmr_method, basis, nucl, site, solvent, stationary]
176
+ rows.append(row)
177
+
178
+ stationary_df = pd.DataFrame(rows, columns=[*INDEX_COLUMNS, "stationary"])
179
+ stationary_df.set_index(INDEX_COLUMNS, inplace=True)
180
+ stationary_df = stationary_df.sort_index()
181
+ return stationary_df
182
+
183
+ def _create_dft_pcm_correction_df(sap_shieldings_df:pd.DataFrame,
184
+ stationary_df:pd.DataFrame,
185
+ pcm_reference_method:str=DEFAULT_PCM_REFERENCE_METHOD,
186
+ pcm_reference_basis:str=DEFAULT_PCM_REFERENCE_BASIS,
187
+ verbose:bool=True):
188
+ """
189
+ Create a dataframe for the DFT PCM corrections.
190
+ """
191
+ pcm_dft_shieldings_df = sap_shieldings_df.query("solvent != 'none'")
192
+
193
+ pcm_dft_shieldings_df = pd.pivot_table(pcm_dft_shieldings_df,
194
+ index=INDEX_COLUMNS,
195
+ columns="implicit_solvation_model",
196
+ values="shielding")
197
+
198
+ pcm_dft_shieldings_df = pcm_dft_shieldings_df.merge(stationary_df,
199
+ on=INDEX_COLUMNS,
200
+ how='left')
201
+
202
+ pcm_dft_corrections_df = pcm_dft_shieldings_df.copy()
203
+ pcm_dft_corrections_df["pcm"] = pcm_dft_corrections_df["pcm"] - \
204
+ pcm_dft_shieldings_df["stationary"]
205
+ pcm_dft_corrections_df.drop(columns=["stationary"], inplace=True)
206
+ pcm_dft_corrections_df.reset_index(inplace=True)
207
+
208
+ # methods with no PCM shielding data of their own (e.g. double hybrids) borrow the reference
209
+ # method's PCM correction below
210
+ missing_methods = set(sap_shieldings_df['sap_nmr_method'].unique()) - \
211
+ set(pcm_dft_corrections_df['sap_nmr_method'].unique())
212
+
213
+ if verbose:
214
+ print("Methods without pcm shielding data:")
215
+ for method in missing_methods:
216
+ print(f" - {method}")
217
+
218
+ print("\nThe following reference PCM correction data will be used:")
219
+ print(f" Reference Method: {pcm_reference_method}")
220
+ print(f" Reference Basis: {pcm_reference_basis}")
221
+
222
+ # Get the data for the reference pcm corrections
223
+ reference_corrections = pcm_dft_corrections_df.query(
224
+ f"sap_nmr_method == '{pcm_reference_method}' and "
225
+ f"sap_basis == '{pcm_reference_basis}'"
226
+ ).copy().drop(columns=["sap_nmr_method","sap_basis"])
227
+
228
+ for method in missing_methods:
229
+ method_df = sap_shieldings_df.query(f"sap_nmr_method == '{method}'").copy()
230
+ method_df.drop(
231
+ columns=[
232
+ "implicit_solvation_model",
233
+ "solvent",
234
+ "shielding"
235
+ ],
236
+ inplace=True
237
+ )
238
+
239
+ method_pcm_df = method_df.merge(reference_corrections,
240
+ on=[
241
+ "solute",
242
+ "sap_geometry_type",
243
+ "nucleus",
244
+ "site"
245
+ ],
246
+ how='left')
247
+
248
+ pcm_dft_corrections_df = pd.concat([pcm_dft_corrections_df,
249
+ method_pcm_df],
250
+ ignore_index=True)
251
+
252
+ pcm_dft_corrections_df.set_index(INDEX_COLUMNS, inplace=True)
253
+
254
+ if verbose:
255
+ print("\nTotal rows in implicit corrections dataframe:",
256
+ pcm_dft_corrections_df.shape[0])
257
+
258
+ return pcm_dft_corrections_df
259
+
260
+ def _create_nn_pcm_correction_df(zap_shieldings_df: pd.DataFrame,
261
+ solvents:list,
262
+ verbose: bool = True):
263
+ """
264
+ Create a DataFrame for NN PCM corrections.
265
+ """
266
+ pcm_corrections_df = zap_shieldings_df.query("solvent != 'none'").copy()
267
+
268
+ # MagNET only computes a PCM correction for chloroform; copy it unchanged to every other
269
+ # solvent (no per-solvent correction is computed here).
270
+ chloroform_df = pcm_corrections_df[pcm_corrections_df["solvent"] == "chloroform"]
271
+ new_rows = []
272
+ for nucleus in chloroform_df["nucleus"].unique():
273
+ nucleus_base = chloroform_df[chloroform_df["nucleus"] == nucleus]
274
+ for solvent in solvents:
275
+ if solvent == "chloroform":
276
+ continue
277
+ new_row = nucleus_base.copy()
278
+ new_row["solvent"] = solvent
279
+ new_rows.append(new_row)
280
+
281
+ if new_rows:
282
+ pcm_corrections_df = pd.concat([pcm_corrections_df, *new_rows], ignore_index=True)
283
+
284
+ pcm_corrections_df = pd.pivot_table(
285
+ pcm_corrections_df,
286
+ index=INDEX_COLUMNS,
287
+ columns="implicit_solvation_model",
288
+ values="shielding"
289
+ )
290
+
291
+ # renamed to match the DFT implicit-corrections dataframe's column name
292
+ pcm_corrections_df.columns = ["pcm"]
293
+
294
+ return pcm_corrections_df
295
+
296
+ def _aggregate_qcd_by_site(qcd_raw_df:pd.DataFrame,
297
+ site_atoms_df:pd.DataFrame,
298
+ verbose:bool=True):
299
+ """Aggregate QCD corrections by site."""
300
+
301
+ if verbose:
302
+ print("\033[93m", end="")
303
+ solute_iter = tqdm(site_atoms_df.iterrows(),
304
+ desc="Aggregating solute QCD corrections by site",
305
+ total=len(site_atoms_df))
306
+ else:
307
+ solute_iter = site_atoms_df.iterrows()
308
+
309
+ rows = []
310
+ for (solute, nucl, site), row in solute_iter:
311
+ atom_numbers, *_ = row
312
+ atom_numbers = _fix_atom_numbers(atom_numbers, prefix="", coerce_to=int)
313
+ query = qcd_raw_df.query(f"solute == '{solute}' and atom_number.isin({atom_numbers})")
314
+ correction = query.qcd_correction.mean()
315
+ stderr = query.stderr.mean()
316
+ rows.append([solute, nucl, site, correction, stderr])
317
+
318
+ result_df = pd.DataFrame(rows, columns=["solute", "nucleus", "site", "qcd", "qcd_stderr"])
319
+ if verbose:
320
+ print(f"\033[92mRows of site-aggregated QCD corrections: {result_df.shape[0]}\033[0m")
321
+ return result_df
322
+
323
+
324
+ def _tile_qcd_corrections(qcd_df, solvents):
325
+ """Tile QCD corrections across solvents."""
326
+ rows = []
327
+ for _, row in qcd_df.iterrows():
328
+ solute, nucl, site, qcd, _ = row
329
+ for solvent in solvents:
330
+ rows.append([solute, nucl, site, solvent, qcd])
331
+ df = pd.DataFrame(rows, columns=["solute", "nucleus", "site", "solvent", "qcd"])
332
+ df.set_index(["solute", "nucleus", "site", "solvent"], inplace=True)
333
+ return df
334
+
335
+
336
+ def _collate_explicit_corrections(df):
337
+ """
338
+ Collate desmond and openMM corrections for each (solute, nucleus, site, solvent).
339
+ Returns a DataFrame with columns ['desmond', 'openMM'].
340
+ """
341
+ def extract_corrections(group):
342
+ desmond = group.loc[group['explicit_solvation_model'] == 'desmond', 'correction']
343
+ openmm = group.loc[group['explicit_solvation_model'] == 'openMM', 'correction']
344
+ return pd.Series({
345
+ 'desmond': desmond.iloc[0] if not desmond.empty else np.nan,
346
+ 'openMM': openmm.iloc[0] if not openmm.empty else np.nan
347
+ })
348
+
349
+ result_df = df.groupby(["solute", "nucleus", "site", "solvent"]).apply(extract_corrections, include_groups=False)
350
+ result_df = pd.DataFrame(result_df)
351
+ return result_df
352
+
353
+
354
+ def _build_combined_df(
355
+ experimental_df,
356
+ qcd_corrections_df,
357
+ explicit_corrections_df,
358
+ classical_df,
359
+ stationary_df,
360
+ implicit_corrections_df,
361
+ index_columns,
362
+ ):
363
+ """Build the final combined dataframe with all corrections."""
364
+ combined_corrections_df = pd.concat(
365
+ [experimental_df, qcd_corrections_df, explicit_corrections_df, classical_df], axis=1
366
+ )
367
+
368
+ stationary_and_pcm_df = pd.concat([stationary_df, implicit_corrections_df], axis=1)
369
+
370
+ # merged (not concatenated) so the non-nmr-specific data gets tiled across every nmr method
371
+ combined_corrections_df.reset_index(inplace=True)
372
+ stationary_and_pcm_df.reset_index(inplace=True)
373
+ combined_df = combined_corrections_df.merge(
374
+ stationary_and_pcm_df,
375
+ on=["solute", "nucleus", "site", "solvent"],
376
+ how='outer'
377
+ )
378
+ combined_df.set_index(index_columns, inplace=True)
379
+ combined_df = combined_df[
380
+ [
381
+ 'experimental',
382
+ 'stationary',
383
+ 'qcd',
384
+ 'pcm',
385
+ 'desmond',
386
+ 'openMM',
387
+ 'desmond_vib',
388
+ 'openMM_vib'
389
+ ]
390
+ ]
391
+ return combined_df
392
+
393
+
394
+ # Importable data-loading functions
395
+
396
+ def load_experiment_data(filepath: str, verbose: bool = True) -> pd.DataFrame:
397
+ """
398
+ Reads the experimental data from an excel file at the specified path.
399
+ """
400
+ if verbose:
401
+ print("\033[93mLoading experimental data...\033[0m", end="", flush=True)
402
+ experimental_df = pd.read_excel(filepath)
403
+ experimental_df["nucleus"] = experimental_df.site.str.split("_").str[-1]
404
+ experimental_df.set_index(["solute","nucleus","site"], inplace=True)
405
+
406
+ # stack into column vectors indexed by solute/site/solvent, for vector regressions like
407
+ # y = c0 + c1 * x1 + ...
408
+ experimental_df = experimental_df.drop("atom_numbers", axis=1)
409
+ experimental_df.reset_index(inplace=True)
410
+ experimental_df.set_index(["solute","nucleus","site"], inplace=True)
411
+ experimental_df = pd.DataFrame(experimental_df.stack(future_stack=True))
412
+ experimental_df.columns = ["experimental"]
413
+ experimental_df.index.names = ["solute", "nucleus", "site", "solvent"]
414
+ experimental_df = experimental_df.sort_index()
415
+
416
+ if verbose:
417
+ print("\r\033[92mLoaded experimental data. \033[0m")
418
+
419
+ return experimental_df
420
+
421
+ def load_site_atom_data(filepath: str, verbose: bool = True) -> pd.DataFrame:
422
+ """
423
+ Loads atom number information for each solute/nucleus/site combination from
424
+ the experimental Excel file.
425
+
426
+ Returns a DataFrame indexed by solute, nucleus, and site, with a column
427
+ 'atom_numbers'.
428
+ """
429
+ if verbose:
430
+ print("\033[93mLoading site atom data...\033[0m", end="", flush=True)
431
+ experimental_df = pd.read_excel(filepath)
432
+ experimental_df["nucleus"] = experimental_df.site.str.split("_").str[-1]
433
+ result_df = experimental_df[["solute", "nucleus", "site", "atom_numbers"]]
434
+ result_df = result_df.drop_duplicates()
435
+ result_df.set_index(["solute", "nucleus", "site"], inplace=True)
436
+ if verbose:
437
+ print("\r\033[92mLoaded site atom data. \033[0m")
438
+ return result_df
439
+
440
+ def load_nmr_method_names(delta22_file_path:str) -> list[str]:
441
+ """
442
+ Returns the names of the nmr methods stored in the specified hdf5 file.
443
+ """
444
+ with h5py.File(delta22_file_path, 'r') as hdf5_file:
445
+ names_data = np.array(hdf5_file["conventional_nmr_method_names"])
446
+
447
+ nmr_method_tuples = [name.decode("ascii") for name in names_data]
448
+ parsed_methods = [_parse_method_name(t) for t in nmr_method_tuples]
449
+ columns = ["sap_nmr_method", "sap_basis", "solvent_model", "solvent_name"]
450
+ return pd.DataFrame(parsed_methods, columns=columns)
451
+
452
+ def load_solvents_from_methods(delta22_file_path:str) -> list[str]:
453
+ """
454
+ Returns the list of solvents used in the nmr methods stored in the specified hdf5 file.
455
+ """
456
+ methods_df = load_nmr_method_names(delta22_file_path)
457
+ solvents = methods_df['solvent_name'].unique().tolist()
458
+ return [s for s in solvents if s.lower() != 'none']
459
+
460
+
461
+ # Geometry and topology accessors.
462
+ #
463
+ # These return the raw stored arrays (atom coordinates and solvent atom orderings) that the
464
+ # shielding loaders above do not expose. Coordinates are stored in the same fixed-point form as
465
+ # the shieldings (whole numbers equal to the real value in angstroms times 10,000) and are decoded
466
+ # back to angstroms here; the solvent atom orderings are plain atomic numbers and need no decoding.
467
+
468
+ def load_solutes(hdf5_filepath: str) -> list[str]:
469
+ """
470
+ Returns the names of the solutes stored in the file, in file order.
471
+ """
472
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
473
+ return list(hdf5_file["solutes"].keys())
474
+
475
+
476
+ def load_solvent_atomic_numbers(hdf5_filepath: str,
477
+ solvent: str,
478
+ explicit_solvation_model: str = "openMM") -> np.ndarray:
479
+ """
480
+ Returns the atomic numbers of one solvent molecule, in the atom order used by the explicit
481
+ solvent geometries. explicit_solvation_model is "openMM" (default) or "desmond"; the two
482
+ orderings are stored separately. This is the reference topology for reading the solvent atoms
483
+ in the solvated geometries from load_explicit_ensemble_geometries.
484
+ """
485
+ key = f"{explicit_solvation_model}_atomic_numbers"
486
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
487
+ return np.array(hdf5_file["solvents"][solvent][key])
488
+
489
+
490
+ def load_stationary_geometries(hdf5_filepath: str, solute: str) -> dict:
491
+ """
492
+ Returns the two resting geometries the conventional (stationary and PCM) shieldings were
493
+ computed on, as a dict {geometry_type: coordinates (n_atoms, 3) in angstroms}. The geometry
494
+ types are "aimnet2" and "pbe0_tz", matching GEOMETRY_TYPES.
495
+ """
496
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
497
+ coords = _decode_fixed_point(hdf5_file["solutes"][solute]["stationary_and_pcm"]["geometries"][()])
498
+ return {geometry_type: coords[i] for i, geometry_type in enumerate(GEOMETRY_TYPES)}
499
+
500
+
501
+ def load_qcd_geometries(hdf5_filepath: str, solute: str) -> dict:
502
+ """
503
+ Returns the gas-phase quasiclassical-dynamics geometries the QCD corrections were computed on,
504
+ as a dict with "unperturbed" (n_atoms, 3) = the resting geometry and "perturbed"
505
+ (n_trajectories, n_frames, n_atoms, 3) = the jiggled snapshots. Coordinates are in angstroms.
506
+ The perturbed frame axes align with the perturbed shieldings used by load_average_qcd_corrections.
507
+ """
508
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
509
+ qcd = hdf5_file["solutes"][solute]["qcd"]["gas_phase"]
510
+ return {
511
+ "unperturbed": _decode_fixed_point(qcd["unperturbed_geometry"][()]),
512
+ "perturbed": _decode_fixed_point(qcd["perturbed_geometries"][()]),
513
+ }
514
+
515
+
516
+ def load_explicit_ensemble_geometries(hdf5_filepath: str,
517
+ solute: str,
518
+ solvent: str,
519
+ explicit_solvation_model: str = "openMM") -> np.ndarray:
520
+ """
521
+ Returns the explicit-solvent snapshot coordinates (n_frames, n_cluster_atoms, 3) in angstroms
522
+ for one solute/solvent under one engine ("openMM" or "desmond"). The solute atoms come first
523
+ (load_solutes order), then whole solvent molecules in the load_solvent_atomic_numbers order.
524
+ The frame axis aligns with the perturbed shieldings used by the explicit-correction loaders.
525
+ """
526
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
527
+ node = hdf5_file["solutes"][solute][explicit_solvation_model][solvent]
528
+ return _decode_fixed_point(node["perturbed_ensemble_geometries"][()])
529
+
530
+
531
+ def load_explicit_solute_geometry(hdf5_filepath: str,
532
+ solute: str,
533
+ explicit_solvation_model: str = "openMM") -> np.ndarray:
534
+ """
535
+ Returns the isolated resting solute geometry (n_atoms, 3) in angstroms used as the gas-phase
536
+ reference for the explicit-solvent corrections under one engine ("openMM" or "desmond").
537
+ """
538
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
539
+ node = hdf5_file["solutes"][solute][explicit_solvation_model]["gas_phase"]
540
+ return _decode_fixed_point(node["unperturbed_solute_geometry"][()])
541
+
542
+
543
+ def load_perturbed_shieldings(hdf5_filepath, solute, solvent,
544
+ explicit_solvation_model="openMM", shield_type="dft"):
545
+ """The raw per-frame explicit-solvent shieldings (n_frames, n_atoms, 2) for one solute/solvent
546
+ under one engine ("openMM" or "desmond"). shield_type is "dft" or "nn". The last axis is
547
+ [isolated, solvated], so the per-frame solvent correction is solvated minus isolated. This is the
548
+ frame-resolved data behind the convergence and autocorrelation panels of Figure S8; the
549
+ site-aggregated correction over all frames comes from load_site_aggregated_explicit_shieldings.
550
+ Frames that were not computed read as NaN."""
551
+ key = f"perturbed_{shield_type}_shieldings"
552
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
553
+ node = hdf5_file["solutes"][solute][explicit_solvation_model][solvent]
554
+ return _decode_fixed_point(node[key][()])
555
+
556
+
557
+ def load_stationary_and_pcm_dft_shieldings(hdf5_filepath:str,
558
+ verbose:bool=True):
559
+ """Load raw stationary and PCM DFT shieldings, one row per (solute, geometry, nmr method)."""
560
+ if verbose:
561
+ print("\033[93mLoading stationary and PCM DFT shieldings...\033[0m", end="", flush=True)
562
+ with h5py.File(hdf5_filepath, 'r') as hdf5_file:
563
+ nmr_method_names = np.array(hdf5_file["conventional_nmr_method_names"])
564
+ nmr_method_names = [i.decode("utf-8") for i in nmr_method_names]
565
+
566
+ dft_rows = []
567
+ nan_dft_rows = 0
568
+ for solute in hdf5_file["solutes"]:
569
+ solute_group = hdf5_file["solutes"][solute]["stationary_and_pcm"]
570
+
571
+ geometry_optimization_timings = np.array(solute_group["geometry_optimization_timings"])
572
+ dft_shieldings = _decode_fixed_point(solute_group["conventional_shieldings"][()])
573
+ dft_timings = np.array(solute_group["conventional_nmr_timings"])
574
+
575
+ for nmr_method_index, nmr_method_name in enumerate(nmr_method_names):
576
+ nmr_method, basis, solvent_model, solvent_name = _parse_method_name(nmr_method_name)
577
+
578
+ for geometry_index, geometry_type in enumerate(GEOMETRY_TYPES):
579
+ method_geometry_time = geometry_optimization_timings[geometry_index]
580
+ method_nmr_time = dft_timings[nmr_method_index, geometry_index]
581
+ method_shieldings = dft_shieldings[nmr_method_index, geometry_index]
582
+ if np.isnan(method_shieldings).any():
583
+ nan_dft_rows += 1
584
+ dft_row = [
585
+ solute,
586
+ geometry_type,
587
+ nmr_method,
588
+ basis,
589
+ solvent_model,
590
+ solvent_name,
591
+ method_shieldings,
592
+ method_geometry_time,
593
+ method_nmr_time
594
+ ]
595
+ dft_rows.append(dft_row)
596
+
597
+ if nan_dft_rows > 0 and verbose:
598
+ print(f"Found {nan_dft_rows} total DFT rows with NaNs!")
599
+
600
+ columns = [
601
+ "solute",
602
+ "sap_geometry_type",
603
+ "sap_nmr_method",
604
+ "sap_basis",
605
+ "solvent_model",
606
+ "solvent",
607
+ "shieldings",
608
+ "geometry_time",
609
+ "nmr_time"
610
+ ]
611
+ dft_shieldings_df = pd.DataFrame(dft_rows, columns=columns)
612
+ dft_shieldings_df = _sort_and_parse_raw_shieldings(dft_shieldings_df)
613
+ if verbose:
614
+ print("\r\033[92mLoaded stationary and PCM DFT shieldings. \033[0m")
615
+ return dft_shieldings_df
616
+
617
+ def load_stationary_and_pcm_nn_shieldings(hdf5_filepath:str,
618
+ verbose:bool=True):
619
+ """Load raw stationary and PCM NN (MagNET) shieldings, one row per (solute, geometry, nmr method)."""
620
+ with h5py.File(hdf5_filepath, 'r') as hdf5_file:
621
+ nn_rows = []
622
+ nan_nn_rows = 0
623
+ for solute in hdf5_file["solutes"]:
624
+ atomic_numbers = np.array(hdf5_file["solutes"][solute]["atomic_numbers"])
625
+ ch_mask = np.where((atomic_numbers == 1) | (atomic_numbers == 6))[0]
626
+
627
+ solute_group = hdf5_file["solutes"][solute]["stationary_and_pcm"]
628
+ geometry_optimization_timings = np.array(solute_group["geometry_optimization_timings"])
629
+ nn_gas_shieldings = _decode_fixed_point(solute_group["nn_gas_shieldings"][()])
630
+ nn_pcm_corrections = _decode_fixed_point(solute_group["nn_pcm_corrections"][()])
631
+ nn_nmr_timings = np.array(solute_group["nn_nmr_timings"])
632
+
633
+ if np.isnan(nn_gas_shieldings[ch_mask]).any():
634
+ nan_nn_rows += 1
635
+ if np.isnan(nn_pcm_corrections[ch_mask]).any():
636
+ nan_nn_rows += 1
637
+
638
+ nn_row_gas = [
639
+ solute,
640
+ "aimnet2",
641
+ "MagNET",
642
+ "N/A",
643
+ "gas",
644
+ "none",
645
+ nn_gas_shieldings,
646
+ geometry_optimization_timings[0], # AimNet2 timing
647
+ nn_nmr_timings[0] # MagNET-Zero timing
648
+ ]
649
+ nn_row_pcm = [
650
+ solute,
651
+ "aimnet2",
652
+ "MagNET",
653
+ "N/A",
654
+ "pcm_correction",
655
+ "chloroform",
656
+ nn_pcm_corrections,
657
+ geometry_optimization_timings[0], # AimNet2 timing
658
+ nn_nmr_timings[1] # MagNET-PCM timing
659
+ ]
660
+ nn_rows.append(nn_row_gas)
661
+ nn_rows.append(nn_row_pcm)
662
+
663
+ if nan_nn_rows > 0 and verbose:
664
+ print(f"Found {nan_nn_rows} total NN rows with NaNs!")
665
+
666
+ columns = [
667
+ "solute",
668
+ "sap_geometry_type",
669
+ "sap_nmr_method",
670
+ "sap_basis",
671
+ "solvent_model",
672
+ "solvent",
673
+ "shieldings",
674
+ "geometry_time",
675
+ "nmr_time"
676
+ ]
677
+ nn_shieldings_df = pd.DataFrame(nn_rows, columns=columns)
678
+ nn_shieldings_df = _sort_and_parse_raw_shieldings(nn_shieldings_df)
679
+ return nn_shieldings_df
680
+
681
+ def load_average_qcd_corrections(hdf5_filepath,
682
+ shield_type="dft",
683
+ verbose:bool=True):
684
+ """Loads and computes the average QCD corrections."""
685
+ if verbose:
686
+ print("\033[93mLoading QCD data...\033[0m", end="", flush=True)
687
+ rows = []
688
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
689
+ solutes = hdf5_file["solutes"].keys()
690
+ for solute in solutes:
691
+ qcd_group = hdf5_file["solutes"][solute]["qcd"]["gas_phase"]
692
+ if shield_type == "dft":
693
+ unperturbed = _decode_fixed_point(qcd_group["unperturbed_dft_shieldings"][()])
694
+ perturbed = _decode_fixed_point(qcd_group["perturbed_dft_shieldings"][()])
695
+ elif shield_type == "nn":
696
+ unperturbed = _decode_fixed_point(qcd_group["unperturbed_nn_shieldings"][()])
697
+ perturbed = _decode_fixed_point(qcd_group["perturbed_nn_shieldings"][()])
698
+ else:
699
+ raise ValueError("shield_type must be 'dft' or 'nn'")
700
+ means = np.mean(perturbed, axis=1)
701
+ n = means.shape[0]
702
+ stdevs = np.std(means, axis=0)
703
+ means = np.mean(means, axis=0)
704
+ corrections = means - unperturbed
705
+ stderrs = stdevs / np.sqrt(n)
706
+ for i, (correction, error) in enumerate(zip(corrections, stderrs)):
707
+ row = [solute, i + 1, correction, error]
708
+ rows.append(row)
709
+ df = pd.DataFrame(rows, columns=["solute", "atom_number", "qcd_correction", "stderr"])
710
+ if verbose:
711
+ print("\r\033[92mLoaded QCD data. \033[0m")
712
+ return df
713
+
714
+ def load_site_aggregated_explicit_shieldings(hdf5_filepath: str,
715
+ site_atoms_df: pd.DataFrame,
716
+ shield_type: str = "dft",
717
+ verbose: bool = True):
718
+ """Compute, per site/solvent/explicit-solvation-engine, the mean explicit-solvent correction
719
+ (solvated minus isolated) over all frames, with its standard error."""
720
+ if verbose:
721
+ print("\033[93mLoading explicit shieldings data...\033[0m", end="", flush=True)
722
+
723
+ new_rows = []
724
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
725
+ for (solute, nucl, site), row in site_atoms_df.iterrows():
726
+ atom_numbers, *_ = row
727
+ atom_indices = _fix_atom_numbers(atom_numbers, prefix="", coerce_to=int, offset=-1)
728
+
729
+ # desmond and openMM may not cover the same solvents; take the union
730
+ solvents = set()
731
+ for explicit_solvation_model in EXPLICIT_SOLVATION_MODELS:
732
+ group = hdf5_file[f"solutes/{solute}/{explicit_solvation_model}"]
733
+ solvents.update([
734
+ k for k in group.keys()
735
+ if k != "gas_phase" and f"perturbed_{shield_type}_shieldings" in group[k]
736
+ ])
737
+ solvents = sorted(solvents)
738
+
739
+ for explicit_solvation_model in EXPLICIT_SOLVATION_MODELS:
740
+ group = hdf5_file[f"solutes/{solute}/{explicit_solvation_model}"]
741
+ for solvent in solvents:
742
+ dataset = group.get(f"{solvent}/perturbed_{shield_type}_shieldings")
743
+ if dataset is None:
744
+ new_row = [solute, solvent, explicit_solvation_model, nucl, site, np.nan, 0, np.nan]
745
+ new_rows.append(new_row)
746
+ continue
747
+ dataset = _decode_fixed_point(dataset[()])
748
+
749
+ dataset = dataset[:,atom_indices,:]
750
+ dataset = np.mean(dataset, axis=1)
751
+ corrections = dataset[:,1] - dataset[:,0]
752
+ corrections = corrections[~np.isnan(corrections)]
753
+ correction = np.mean(corrections)
754
+ n = len(corrections)
755
+ stderr = np.std(corrections)/np.sqrt(n)
756
+
757
+ new_row = [solute, solvent, explicit_solvation_model, nucl, site, correction, n, stderr]
758
+ new_rows.append(new_row)
759
+
760
+ explicit_df = pd.DataFrame(new_rows)
761
+ explicit_df.columns = [
762
+ "solute",
763
+ "solvent",
764
+ "explicit_solvation_model",
765
+ "nucleus",
766
+ "site",
767
+ "correction",
768
+ "n",
769
+ "stderr"
770
+ ]
771
+
772
+ if verbose:
773
+ print("\r\033[92mLoaded explicit shieldings data. \033[0m")
774
+
775
+ return explicit_df
776
+
777
+ def load_classical_vibrational_corrections(hdf5_filepath: str,
778
+ site_atoms_df: pd.DataFrame,
779
+ shield_type: str = "dft",
780
+ verbose: bool = True):
781
+ """Compute classical vibrational corrections for both DFT and NN shielding values."""
782
+ rows = []
783
+ with h5py.File(hdf5_filepath, "r") as hdf5_file:
784
+ solutes = hdf5_file["solutes"].keys()
785
+ for solute in solutes:
786
+ sites_df = site_atoms_df.query(f"solute == '{solute}'")
787
+ for (solute, nucl, site), site_row in sites_df.iterrows():
788
+ atom_numbers, *_ = site_row
789
+ atom_numbers = _fix_atom_numbers(atom_numbers, prefix="", coerce_to=int, offset=-1)
790
+
791
+ # desmond and openMM may not cover the same solvents; take the union
792
+ solvents = set()
793
+ for explicit_solvation_model in EXPLICIT_SOLVATION_MODELS:
794
+ group = hdf5_file[f"solutes/{solute}/{explicit_solvation_model}"]
795
+ solvents.update([
796
+ k for k in group.keys()
797
+ if k != "gas_phase" and f"perturbed_{shield_type}_shieldings" in group[k]
798
+ ])
799
+ solvents = sorted(solvents)
800
+
801
+ for explicit_solvation_model in EXPLICIT_SOLVATION_MODELS:
802
+ stationary_path = f"solutes/{solute}/{explicit_solvation_model}/gas_phase/unperturbed_{shield_type}_shieldings"
803
+ shieldings = hdf5_file[stationary_path]
804
+ shieldings = _decode_fixed_point(shieldings[()])
805
+ classical_stationary = np.mean(shieldings[atom_numbers])
806
+
807
+ for solvent in solvents:
808
+ path = f"solutes/{solute}/{explicit_solvation_model}/{solvent}/perturbed_{shield_type}_shieldings"
809
+ added = False
810
+ if path in hdf5_file:
811
+ shieldings = hdf5_file[path]
812
+ shieldings = _decode_fixed_point(shieldings[()])
813
+ shieldings = shieldings[:, atom_numbers, 0] # solute shieldings
814
+
815
+ if np.all(np.isnan(shieldings)):
816
+ print(f"Found all NaNs in {path} for solute {solute}, nucleus {nucl}, site {site}, solvent {solvent}")
817
+ shieldings = shieldings[~np.isnan(shieldings)]
818
+
819
+ if len(shieldings) > 0:
820
+ explicit_mean = np.mean(shieldings)
821
+ correction = explicit_mean - classical_stationary
822
+ new_row = [solute, nucl, site, solvent, explicit_solvation_model, correction]
823
+ added = True
824
+ if not added:
825
+ new_row = [solute, nucl, site, solvent, explicit_solvation_model, np.nan]
826
+ rows.append(new_row)
827
+
828
+ columns = ["solute", "nucleus", "site", "solvent", "flavor", "correction"]
829
+ df = pd.DataFrame(rows, columns=columns)
830
+ df = pd.pivot_table(df, values="correction", columns="flavor", index=["solute", "nucleus", "site", "solvent"])
831
+ df.columns = [f"{col}_vib" for col in df.columns]
832
+ return df
833
+
834
+ def load_delta22_dft_data(delta22_file_path: str,
835
+ experimental_data_path: str,
836
+ pcm_reference_method: str = DEFAULT_PCM_REFERENCE_METHOD,
837
+ pcm_reference_basis: str = DEFAULT_PCM_REFERENCE_BASIS,
838
+ verbose: bool = True) -> pd.DataFrame:
839
+ """
840
+ Load and process DFT shielding data from the delta22 HDF5 file into one combined DataFrame.
841
+
842
+ Returns a DataFrame indexed by solute, sap_geometry_type, sap_nmr_method, sap_basis, nucleus,
843
+ site, solvent, with columns experimental, stationary, qcd, pcm, desmond, openMM, desmond_vib,
844
+ openMM_vib.
845
+ """
846
+ experimental_df = load_experiment_data(experimental_data_path, verbose)
847
+ site_atoms_df = load_site_atom_data(experimental_data_path, verbose)
848
+ dft_solvents = load_solvents_from_methods(delta22_file_path)
849
+ sap_shieldings_df = load_stationary_and_pcm_dft_shieldings(delta22_file_path, verbose)
850
+ sap_shieldings_df = _aggregate_shieldings_by_site(sap_shieldings_df,
851
+ site_atoms_df)
852
+
853
+ stationary_df = _create_stationary_df(sap_shieldings_df, dft_solvents)
854
+ pcm_dft_corrections_df = _create_dft_pcm_correction_df(sap_shieldings_df,
855
+ stationary_df,
856
+ pcm_reference_method,
857
+ pcm_reference_basis,
858
+ verbose=False)
859
+
860
+ qcd_data_df = load_average_qcd_corrections(delta22_file_path,
861
+ "dft",
862
+ verbose)
863
+ qcd_data_df = _aggregate_qcd_by_site(qcd_data_df, site_atoms_df, verbose)
864
+ qcd_corrections_df = _tile_qcd_corrections(qcd_data_df, dft_solvents)
865
+
866
+ explicit_data_df = load_site_aggregated_explicit_shieldings(
867
+ delta22_file_path,
868
+ site_atoms_df,
869
+ shield_type="dft",
870
+ verbose=verbose
871
+ )
872
+ explicit_corrections_df = _collate_explicit_corrections(explicit_data_df)
873
+
874
+ classical_df = load_classical_vibrational_corrections(
875
+ delta22_file_path,
876
+ site_atoms_df,
877
+ shield_type="dft",
878
+ verbose=verbose
879
+ )
880
+
881
+ combined_df = _build_combined_df(
882
+ experimental_df,
883
+ qcd_corrections_df,
884
+ explicit_corrections_df,
885
+ classical_df,
886
+ stationary_df,
887
+ pcm_dft_corrections_df,
888
+ INDEX_COLUMNS
889
+ )
890
+
891
+ return combined_df
892
+
893
+
894
+ def load_delta22_nn_data(delta22_file_path: str,
895
+ experimental_data_path: str,
896
+ exclude_solutes=None,
897
+ verbose: bool = True,
898
+ nn_shieldings_override_df: pd.DataFrame = None) -> pd.DataFrame:
899
+ """
900
+ Load and process NN (MagNET) shielding data from the delta22 HDF5 file into one combined
901
+ DataFrame, indexed by solute, sap_geometry_type, sap_nmr_method, sap_basis, nucleus, site,
902
+ solvent, with columns experimental, stationary, qcd, pcm, desmond, openMM, desmond_vib,
903
+ openMM_vib.
904
+
905
+ MagNET only computes a PCM correction for chloroform; that value is copied unchanged to every
906
+ other solvent.
907
+
908
+ exclude_solutes drops solute names from the result, e.g. ["nitromethane"]. The composite-model
909
+ fits exclude nitromethane because it is a stark outlier for MagNET (its rovibrational/QCD
910
+ correction is anomalous).
911
+
912
+ nn_shieldings_override_df, if given, is used in place of
913
+ load_stationary_and_pcm_nn_shieldings(delta22_file_path) and must have the same columns/shape
914
+ (one "gas" row and one "pcm_correction" row per solute). This lets a caller substitute
915
+ freshly-computed shieldings (e.g. from live, symmetrized inference) for the ones stored in the
916
+ HDF5, without touching the rest of the pipeline (QCD, explicit-solvent, and
917
+ classical-vibrational corrections still come from the file).
918
+ """
919
+ experimental_df = load_experiment_data(experimental_data_path, verbose)
920
+ site_atoms_df = load_site_atom_data(experimental_data_path, verbose)
921
+
922
+ if nn_shieldings_override_df is not None:
923
+ # same row-sorting / shieldings-parsing load_stationary_and_pcm_nn_shieldings applies, so
924
+ # downstream code (_aggregate_shieldings_by_site) sees an identical shape regardless of
925
+ # whether the shieldings came from the HDF5 or from a live override
926
+ nn_shieldings_df = _sort_and_parse_raw_shieldings(nn_shieldings_override_df.copy())
927
+ else:
928
+ nn_shieldings_df = load_stationary_and_pcm_nn_shieldings(delta22_file_path,
929
+ verbose)
930
+
931
+ nn_shieldings_df = _aggregate_shieldings_by_site(nn_shieldings_df,
932
+ site_atoms_df,
933
+ verbose)
934
+
935
+ nn_solvents = load_solvents_from_methods(delta22_file_path)
936
+ stationary_df = _create_stationary_df(nn_shieldings_df, nn_solvents)
937
+ pcm_nn_corrections_df = _create_nn_pcm_correction_df(nn_shieldings_df,
938
+ nn_solvents,
939
+ verbose=True)
940
+
941
+ qcd_data_df = load_average_qcd_corrections(delta22_file_path, "nn", verbose)
942
+ qcd_data_df = _aggregate_qcd_by_site(qcd_data_df, site_atoms_df, verbose)
943
+ qcd_corrections_df = _tile_qcd_corrections(qcd_data_df, nn_solvents)
944
+
945
+ explicit_data_df = load_site_aggregated_explicit_shieldings(
946
+ delta22_file_path,
947
+ site_atoms_df,
948
+ shield_type="nn",
949
+ verbose=verbose
950
+ )
951
+ explicit_corrections_df = _collate_explicit_corrections(explicit_data_df)
952
+
953
+ classical_df = load_classical_vibrational_corrections(
954
+ delta22_file_path,
955
+ site_atoms_df,
956
+ shield_type="nn",
957
+ verbose=verbose
958
+ )
959
+
960
+ combined_df = _build_combined_df(
961
+ experimental_df,
962
+ qcd_corrections_df,
963
+ explicit_corrections_df,
964
+ classical_df,
965
+ stationary_df,
966
+ pcm_nn_corrections_df,
967
+ INDEX_COLUMNS
968
+ )
969
+
970
+ if exclude_solutes:
971
+ solute_index = combined_df.index.get_level_values("solute")
972
+ combined_df = combined_df[~solute_index.isin(set(exclude_solutes))]
973
+
974
+ return combined_df
975
+
976
+ def _compute_timings(df: pd.DataFrame) -> pd.DataFrame:
977
+ """Sum a grouped shielding dataframe's geometry and NMR timing columns and return
978
+ geometry_time, nmr_time, and their total as a Series."""
979
+ geometry_time = df["geometry_time"].sum()
980
+ nmr_time = df["nmr_time"].sum()
981
+ total_time = geometry_time + nmr_time
982
+ return pd.Series({"geometry_time":geometry_time,
983
+ "nmr_time":nmr_time,
984
+ "total_time":total_time})
985
+
986
+ def load_delta22_dft_timings(delta22_file_path: str) -> pd.DataFrame:
987
+ """
988
+ Sums DFT geometry-optimization and NMR computation times for every combination of geometry
989
+ type, NMR method, basis, solvent model, and solvent name.
990
+ """
991
+ sap_dft_shieldings = load_stationary_and_pcm_dft_shieldings(delta22_file_path)
992
+
993
+ groupings = [
994
+ "sap_geometry_type",
995
+ "sap_nmr_method",
996
+ "sap_basis",
997
+ "solvent_model",
998
+ "solvent",
999
+ ]
1000
+ return sap_dft_shieldings.groupby(groupings).apply(_compute_timings,
1001
+ include_groups=False)
1002
+
1003
+ def load_delta22_nn_timings(delta22_file_path: str) -> pd.DataFrame:
1004
+ """
1005
+ Sums NN (MagNET) geometry-optimization and NMR computation times for every combination of
1006
+ geometry type, NMR method, basis, solvent model, and solvent name.
1007
+ """
1008
+ sap_nn_shieldings = load_stationary_and_pcm_nn_shieldings(delta22_file_path)
1009
+
1010
+ groupings = [
1011
+ "sap_geometry_type",
1012
+ "sap_nmr_method",
1013
+ "sap_basis",
1014
+ "solvent_model",
1015
+ "solvent"
1016
+ ]
1017
+ return sap_nn_shieldings.groupby(groupings).apply(_compute_timings,
1018
+ include_groups=False)
1019
+
1020
+
1021
+ if __name__ == "__main__":
1022
+ current_file_path = os.path.abspath(__file__)
1023
+ here = os.path.dirname(current_file_path)
1024
+ hdf5_filepath = os.path.join(here, "delta22.hdf5")
1025
+ experiment_data_filepath = os.path.join(here, "delta22_experimental.xlsx")
1026
+
1027
+ print("LOADING DFT DATA:")
1028
+ print("="*60)
1029
+ dft_data = load_delta22_dft_data(hdf5_filepath, experiment_data_filepath)
1030
+
1031
+ dft_data_display = dft_data.query(
1032
+ "sap_nmr_method == 'wp04' and "
1033
+ "sap_basis == 'pcSseg2' and "
1034
+ "sap_geometry_type == 'aimnet2'"
1035
+ ).copy().reset_index()
1036
+ dft_data_display.drop(columns=["sap_nmr_method", "sap_basis", "sap_geometry_type"], inplace=True)
1037
+ print("DFT Data Display:")
1038
+ print(dft_data_display.head())
1039
+ print("Solvents:")
1040
+ print(dft_data_display.reset_index().solvent.unique())
1041
+
1042
+ print("LOADING NN DATA:")
1043
+ print("="*60)
1044
+ nn_data = load_delta22_nn_data(hdf5_filepath, experiment_data_filepath)
1045
+ print(nn_data.head())
1046
+
1047
+
data/delta22/test_delta22.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the delta22 reader (delta22_reader.py).
3
+
4
+ These build a tiny synthetic dataset in the *exact* on-disk format (so the fixture also
5
+ serves as an executable spec of the layout) plus a matching experimental spreadsheet, then
6
+ run the public loaders against them. No dependency on the multi-GB real file. One opt-in
7
+ test runs against the real `delta22.hdf5` if it is present next to this file.
8
+
9
+ Run: pytest test_delta22.py -q
10
+ Requires: pytest, numpy, h5py, pandas, openpyxl, tqdm.
11
+ """
12
+ import os
13
+ import numpy as np
14
+ import pandas as pd
15
+ import h5py
16
+ import pytest
17
+
18
+ import delta22_reader as R
19
+ from delta22_reader import (
20
+ _decode_fixed_point,
21
+ _MISSING_MARKER,
22
+ load_delta22_dft_data,
23
+ load_delta22_nn_data,
24
+ load_delta22_dft_timings,
25
+ load_delta22_nn_timings,
26
+ )
27
+
28
+ SCALE = 1e4
29
+
30
+
31
+ def _enc(arr):
32
+ """Encode floats the way the real file does: int32 = round(value * 1e4), NaN -> marker."""
33
+ a = np.asarray(arr, dtype=np.float64)
34
+ mask = np.isnan(a)
35
+ out = np.zeros(a.shape, dtype=np.int32)
36
+ out[~mask] = np.rint(a[~mask] * SCALE).astype(np.int32)
37
+ out[mask] = _MISSING_MARKER
38
+ return out
39
+
40
+
41
+ # ---- known synthetic content -------------------------------------------------
42
+ # One solute "moltest", 4 atoms (H, C, H, C); one solvent (chloroform); one method
43
+ # (b3lyp_d3bj/pcSseg2, which is also the default PCM reference) present as a gas entry
44
+ # and a chloroform-PCM entry; two geometries (aimnet2, pbe0_tz).
45
+ SOLUTE = "moltest"
46
+ Z = np.array([1, 6, 1, 6], np.uint8) # 1-based H at 1,3 ; C at 2,4
47
+ METHOD_NAMES = [
48
+ 'b3lyp_d3bj,pcSseg2,gas,"none"',
49
+ 'b3lyp_d3bj,pcSseg2,pcm,"chloroform"',
50
+ ]
51
+ # conventional_shieldings[method, geometry, atom]
52
+ GAS = np.array([[30.0, 150.0, 31.0, 151.0], # aimnet2
53
+ [30.2, 150.4, 31.2, 151.4]]) # pbe0_tz
54
+ PCM = np.array([[30.5, 151.0, 31.5, 152.0], # aimnet2 (+0.5 H, +1.0 C vs gas)
55
+ [30.7, 151.4, 31.7, 152.4]]) # pbe0_tz
56
+ CONV = np.stack([GAS, PCM]) # (2 methods, 2 geom, 4 atoms)
57
+
58
+ NN_GAS = np.array([30.1, 150.1, 31.1, 151.1])
59
+ NN_PCM = np.array([0.4, 0.9, 0.4, 0.9]) # chloroform - gas
60
+
61
+ # Expected stationary shift for site Ha_H (atoms 1,3) on the aimnet2 geometry:
62
+ EXP_STATIONARY_HA_AIMNET2 = np.mean([30.0, 31.0]) # 30.5
63
+ # Expected PCM correction for Ha_H aimnet2 = mean(PCM 1,3) - mean(GAS 1,3):
64
+ EXP_PCM_HA_AIMNET2 = np.mean([30.5, 31.5]) - np.mean([30.0, 31.0]) # 0.5
65
+
66
+
67
+ def _write_solute(grp):
68
+ grp.create_dataset("atomic_numbers", data=Z)
69
+
70
+ sap = grp.create_group("stationary_and_pcm")
71
+ sap.create_dataset("conventional_shieldings", data=_enc(CONV))
72
+ sap.create_dataset("geometries", data=_enc(np.zeros((2, 4, 3))))
73
+ sap.create_dataset("conventional_nmr_timings", data=np.ones((2, 2)) * 1.5)
74
+ sap.create_dataset("geometry_optimization_timings", data=np.array([2.0, 9.0]))
75
+ sap.create_dataset("nn_gas_shieldings", data=_enc(NN_GAS)) # (4,) flat
76
+ sap.create_dataset("nn_pcm_corrections", data=_enc(NN_PCM)) # (4,) flat
77
+ sap.create_dataset("nn_nmr_timings", data=np.array([0.1, 0.2]))
78
+
79
+ qcd = grp.create_group("qcd").create_group("gas_phase")
80
+ qcd.create_dataset("unperturbed_dft_shieldings", data=_enc(GAS[0]))
81
+ qcd.create_dataset("unperturbed_nn_shieldings", data=_enc(NN_GAS))
82
+ # (n_trajectories=2, n_frames=2, n_atoms=4); means equal the unperturbed -> 0 correction
83
+ qcd.create_dataset("perturbed_dft_shieldings",
84
+ data=_enc(np.broadcast_to(GAS[0], (2, 2, 4)).copy()))
85
+ qcd.create_dataset("perturbed_nn_shieldings",
86
+ data=_enc(np.broadcast_to(NN_GAS, (2, 2, 4)).copy()))
87
+ # geometry companions (coordinates, never consumed by the shielding loaders)
88
+ qcd.create_dataset("unperturbed_geometry", data=_enc(np.full((4, 3), 0.5)))
89
+ qcd.create_dataset("perturbed_geometries", data=_enc(np.full((2, 2, 4, 3), 0.5)))
90
+
91
+ for engine in ("desmond", "openMM"):
92
+ eg = grp.create_group(engine)
93
+ gp = eg.create_group("gas_phase")
94
+ gp.create_dataset("unperturbed_solute_geometry", data=_enc(np.zeros((4, 3))))
95
+ gp.create_dataset("unperturbed_dft_shieldings", data=_enc(GAS[0]))
96
+ gp.create_dataset("unperturbed_nn_shieldings", data=_enc(NN_GAS))
97
+ sv = eg.create_group("chloroform")
98
+ # (n_frames=3, n_atoms=4, 2) -> [:, :, 0] isolated, [:, :, 1] solvated
99
+ iso = np.broadcast_to(GAS[0], (3, 4)).copy()
100
+ solv = iso + 0.2 # +0.2 ppm solvent shift
101
+ solv[2, 0] = np.nan # exercise the missing-value path
102
+ dft = np.stack([iso, solv], axis=-1)
103
+ nn = np.stack([np.broadcast_to(NN_GAS, (3, 4)).copy(),
104
+ np.broadcast_to(NN_GAS, (3, 4)).copy() + 0.2], axis=-1)
105
+ sv.create_dataset("perturbed_dft_shieldings", data=_enc(dft))
106
+ sv.create_dataset("perturbed_nn_shieldings", data=_enc(nn))
107
+ # solvated-cluster coordinates: 4 solute atoms + one 3-atom solvent block = 7
108
+ sv.create_dataset("perturbed_ensemble_geometries", data=_enc(np.full((3, 7, 3), 1.25)))
109
+
110
+
111
+ @pytest.fixture(scope="module")
112
+ def fixture(tmp_path_factory):
113
+ d = tmp_path_factory.mktemp("d22")
114
+ hdf5 = str(d / "mini.hdf5")
115
+ xlsx = str(d / "mini_experimental.xlsx")
116
+ with h5py.File(hdf5, "w") as f:
117
+ f.create_dataset("conventional_nmr_method_names",
118
+ data=np.array(METHOD_NAMES, dtype=h5py.string_dtype()))
119
+ _write_solute(f.create_group("solutes").create_group(SOLUTE))
120
+ # top-level solvent topology: one 3-atom solvent block per engine ordering
121
+ cf = f.create_group("solvents").create_group("chloroform")
122
+ cf.create_dataset("desmond_atomic_numbers", data=np.array([6, 1, 17], np.uint8))
123
+ cf.create_dataset("openMM_atomic_numbers", data=np.array([6, 1, 17], np.uint8))
124
+ pd.DataFrame({
125
+ "solute": [SOLUTE, SOLUTE],
126
+ "site": ["Ha_H", "Ca_C"],
127
+ "atom_numbers": ["1,3", "2,4"],
128
+ "chloroform": [2.10, 30.0],
129
+ }).to_excel(xlsx, index=False)
130
+ return hdf5, xlsx
131
+
132
+
133
+ # ---- the encode/decode round-trip (the one new piece of logic) ----
134
+ def test_decode_scaling_and_missing():
135
+ enc = np.array([2070700, _MISSING_MARKER, -150000], np.int32)
136
+ out = _decode_fixed_point(enc)
137
+ assert out[0] == pytest.approx(207.07)
138
+ assert np.isnan(out[1])
139
+ assert out[2] == pytest.approx(-15.0)
140
+
141
+
142
+ def test_decode_passes_floats_through():
143
+ f = np.array([1.5, 2.5], np.float64)
144
+ np.testing.assert_array_equal(_decode_fixed_point(f), f)
145
+
146
+
147
+ # ---- the full reader against the synthetic file ----
148
+ EXPECTED_COLUMNS = ["experimental", "stationary", "qcd", "pcm",
149
+ "desmond", "openMM", "desmond_vib", "openMM_vib"]
150
+ EXPECTED_INDEX = ["solute", "sap_geometry_type", "sap_nmr_method",
151
+ "sap_basis", "nucleus", "site", "solvent"]
152
+
153
+
154
+ def test_dft_loader_shape_and_decode(fixture):
155
+ hdf5, xlsx = fixture
156
+ df = load_delta22_dft_data(hdf5, xlsx, verbose=False)
157
+ assert list(df.index.names) == EXPECTED_INDEX
158
+ assert list(df.columns) == EXPECTED_COLUMNS
159
+ assert len(df) > 0
160
+ row = df.reset_index().query(
161
+ "site == 'Ha_H' and solvent == 'chloroform' and sap_geometry_type == 'aimnet2'"
162
+ )
163
+ assert len(row) == 1
164
+ assert row["stationary"].iloc[0] == pytest.approx(EXP_STATIONARY_HA_AIMNET2, abs=1e-3)
165
+ assert row["pcm"].iloc[0] == pytest.approx(EXP_PCM_HA_AIMNET2, abs=1e-3)
166
+ # explicit correction is +0.2 ppm; the seeded missing frame must not break it
167
+ assert row["desmond"].iloc[0] == pytest.approx(0.2, abs=1e-3)
168
+ assert np.isfinite(row["desmond"].iloc[0])
169
+
170
+
171
+ def test_nn_loader_shape(fixture):
172
+ hdf5, xlsx = fixture
173
+ df = load_delta22_nn_data(hdf5, xlsx, verbose=False)
174
+ assert list(df.columns) == EXPECTED_COLUMNS
175
+ assert len(df) > 0
176
+ assert df.index.get_level_values("sap_nmr_method").unique().tolist() == ["MagNET"]
177
+
178
+
179
+ def test_load_perturbed_shieldings(fixture):
180
+ hdf5, _ = fixture
181
+ raw = R.load_perturbed_shieldings(hdf5, SOLUTE, "chloroform", "openMM", "dft")
182
+ assert raw.shape == (3, 4, 2) # (n_frames, n_atoms, [isolated, solvated])
183
+ # the fixture set solvated = isolated + 0.2 with one missing value seeded
184
+ corr = raw[:, :, 1] - raw[:, :, 0]
185
+ assert np.nanmax(np.abs(corr - 0.2)) < 1e-3
186
+ assert np.isnan(raw[2, 0, 1]) # the seeded missing solvated value
187
+
188
+
189
+ def test_nn_loader_exclude_solutes(fixture):
190
+ hdf5, xlsx = fixture
191
+ full = load_delta22_nn_data(hdf5, xlsx, verbose=False)
192
+ assert SOLUTE in full.index.get_level_values("solute")
193
+ excluded = load_delta22_nn_data(hdf5, xlsx, exclude_solutes=[SOLUTE], verbose=False)
194
+ assert SOLUTE not in excluded.index.get_level_values("solute")
195
+ assert len(excluded) == 0 # the fixture has only the one solute
196
+
197
+
198
+ def test_timings_loaders(fixture):
199
+ hdf5, _ = fixture
200
+ dft = load_delta22_dft_timings(hdf5)
201
+ nn = load_delta22_nn_timings(hdf5)
202
+ for t in (dft, nn):
203
+ assert list(t.columns) == ["geometry_time", "nmr_time", "total_time"]
204
+ assert (t["total_time"] >= 0).all()
205
+
206
+
207
+ # ---- geometry + topology accessors (the raw stored arrays the loaders don't expose) ----
208
+ def test_solutes_listing(fixture):
209
+ hdf5, _ = fixture
210
+ assert R.load_solutes(hdf5) == [SOLUTE]
211
+
212
+
213
+ def test_geometry_accessors_shape_and_decode(fixture):
214
+ hdf5, _ = fixture
215
+ sg = R.load_stationary_geometries(hdf5, SOLUTE)
216
+ assert set(sg) == {"aimnet2", "pbe0_tz"}
217
+ assert sg["aimnet2"].shape == (4, 3)
218
+ q = R.load_qcd_geometries(hdf5, SOLUTE)
219
+ assert q["unperturbed"].shape == (4, 3)
220
+ assert q["perturbed"].shape == (2, 2, 4, 3)
221
+ assert q["unperturbed"][0, 0] == pytest.approx(0.5) # coordinates decode from fixed point
222
+ assert q["perturbed"][0, 0, 0, 0] == pytest.approx(0.5)
223
+ e = R.load_explicit_ensemble_geometries(hdf5, SOLUTE, "chloroform", "openMM")
224
+ assert e.shape == (3, 7, 3) # 4 solute + 3 solvent atoms
225
+ assert e[0, 0, 0] == pytest.approx(1.25)
226
+ gs = R.load_explicit_solute_geometry(hdf5, SOLUTE, "desmond")
227
+ assert gs.shape == (4, 3)
228
+
229
+
230
+ def test_solvent_atomic_numbers(fixture):
231
+ hdf5, _ = fixture
232
+ for engine in ("desmond", "openMM"):
233
+ an = R.load_solvent_atomic_numbers(hdf5, "chloroform", engine)
234
+ assert list(an) == [6, 1, 17]
235
+
236
+
237
+ # ---------- opt-in: real file, if present ----------
238
+ REAL = os.path.join(os.path.dirname(__file__), "delta22.hdf5")
239
+ REAL_XLSX = os.path.join(os.path.dirname(__file__), "delta22_experimental.xlsx")
240
+
241
+
242
+ @pytest.mark.skipif(not (os.path.exists(REAL) and os.path.exists(REAL_XLSX)),
243
+ reason="real delta22.hdf5 / experimental xlsx not present")
244
+ def test_real_file_smoke():
245
+ with h5py.File(REAL, "r") as f:
246
+ assert len(f["solutes"].keys()) == 22
247
+ assert len(f["conventional_nmr_method_names"]) == 473
248
+ any_solute = next(iter(f["solutes"].keys()))
249
+ desmond = [k for k in f["solutes"][any_solute]["desmond"].keys() if k != "gas_phase"]
250
+ assert len(desmond) == 12
251
+ df = load_delta22_dft_data(REAL, REAL_XLSX, verbose=False)
252
+ assert len(df) > 0
253
+ assert list(df.columns) == EXPECTED_COLUMNS
254
+ # the geometry + topology accessors reach the real stored arrays and decode to angstroms
255
+ sol = R.load_solutes(REAL)[0]
256
+ sg = R.load_stationary_geometries(REAL, sol)
257
+ assert sg["aimnet2"].ndim == 2 and sg["aimnet2"].shape[1] == 3
258
+ assert np.nanmax(np.abs(sg["aimnet2"])) < 100.0
259
+ an = R.load_solvent_atomic_numbers(REAL, "chloroform", "openMM")
260
+ assert an.ndim == 1 and len(an) > 0
data/delta50/decode_delta50.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_delta50.py - reader/decoder for the delta50 HDF5 dataset.
3
+
4
+ DELTA50 is a small benchmark of 50 common organic molecules (reference 47 in the paper). This
5
+ file holds MagNET's predicted NMR shieldings for each molecule on its AIMNet2 geometry, at three
6
+ model chemistries:
7
+
8
+ nn_magnet_zero MagNET-Zero (WP04 for 1H, wB97X-D for 13C; pcSseg-2; 20-pass), the gas-phase
9
+ triple-zeta-quality prediction
10
+ nn_b3lyp the gas-phase component of the MagNET-PCM correction (B3LYP/pcSseg-2)
11
+ nn_b3lyp_pcm the chloroform component of the MagNET-PCM correction (B3LYP/pcSseg-2 + PCM)
12
+
13
+ The MagNET-PCM implicit-solvent correction for a molecule is nn_b3lyp_pcm minus nn_b3lyp. The
14
+ paper uses this set to probe outliers (nitromethane, nitroethane, and t-butyl nitrate are the
15
+ notable ones).
16
+
17
+ Storage format:
18
+ - Per-atom arrays are concatenated across molecules. Molecule i has `n_atoms[i]` atoms and owns
19
+ the rows [start[i], start[i+1]) where start is the cumulative sum of `n_atoms`.
20
+ - coordinates and shieldings are int32 fixed point: physical = stored * 1e-4, so reconstruction
21
+ error is <= 5e-5 (Angstrom or ppm). A missing value would be the marker -2147483648 -> NaN.
22
+ - molecules are identified by name (`molecule_names`).
23
+
24
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
25
+ """
26
+ from __future__ import annotations
27
+ import numpy as np
28
+ import h5py
29
+
30
+ _SCALE = 1e-4
31
+ _MARKER = -2147483648
32
+
33
+
34
+ def _decode(values: np.ndarray) -> np.ndarray:
35
+ """int32 fixed-point -> float64 physical units; marker -> NaN. No-op on float input."""
36
+ values = np.asarray(values)
37
+ if not np.issubdtype(values.dtype, np.integer):
38
+ return np.asarray(values, dtype=np.float64)
39
+ out = values.astype(np.float64) * _SCALE
40
+ out[values == _MARKER] = np.nan
41
+ return out
42
+
43
+
44
+ class Delta50:
45
+ """Reader for delta50.hdf5. Use as a context manager; index molecules by position or by name."""
46
+
47
+ def __init__(self, path: str):
48
+ self.f = h5py.File(path, "r")
49
+ self.molecule_names = [str(n) for n in self.f["molecule_names"].asstr()[:]]
50
+ self.n_molecules = int(self.f.attrs["n_molecules"])
51
+ self._n_atoms = self.f["n_atoms"][:].astype(np.int64)
52
+ self._start = np.empty(self.n_molecules + 1, np.int64)
53
+ self._start[0] = 0
54
+ np.cumsum(self._n_atoms, out=self._start[1:])
55
+ self._name_to_index = {n: i for i, n in enumerate(self.molecule_names)}
56
+
57
+ def _check(self, index: int):
58
+ if not 0 <= index < self.n_molecules:
59
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
60
+
61
+ def index_of(self, name: str) -> int:
62
+ """Position of the molecule named `name`; raises KeyError if absent."""
63
+ if name not in self._name_to_index:
64
+ raise KeyError(f"molecule {name!r} not in delta50")
65
+ return self._name_to_index[name]
66
+
67
+ def molecule(self, index: int) -> dict:
68
+ """Return one molecule's data (physical units) by position.
69
+
70
+ Keys: name; atomic_numbers (n,); coordinates (n, 3 Angstrom); and the three predicted
71
+ shieldings (nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm), each (n,) ppm.
72
+ """
73
+ self._check(index)
74
+ sl = slice(int(self._start[index]), int(self._start[index + 1]))
75
+ return {
76
+ "name": self.molecule_names[index],
77
+ "atomic_numbers": self.f["atomic_numbers"][sl],
78
+ "coordinates": _decode(self.f["coordinates"][sl]),
79
+ "nn_magnet_zero": _decode(self.f["nn_magnet_zero"][sl]),
80
+ "nn_b3lyp": _decode(self.f["nn_b3lyp"][sl]),
81
+ "nn_b3lyp_pcm": _decode(self.f["nn_b3lyp_pcm"][sl]),
82
+ }
83
+
84
+ def molecule_by_name(self, name: str) -> dict:
85
+ """Return one molecule's data (see `molecule`) by name."""
86
+ return self.molecule(self.index_of(name))
87
+
88
+ def pcm_correction(self, index: int) -> np.ndarray:
89
+ """The MagNET-PCM implicit-solvent correction (nn_b3lyp_pcm minus nn_b3lyp), (n,) ppm."""
90
+ m = self.molecule(index)
91
+ return m["nn_b3lyp_pcm"] - m["nn_b3lyp"]
92
+
93
+ def close(self):
94
+ """Close the underlying HDF5 file."""
95
+ self.f.close()
96
+
97
+ def __enter__(self):
98
+ return self
99
+
100
+ def __exit__(self, *a):
101
+ self.close()
102
+
103
+ def __len__(self):
104
+ return self.n_molecules
105
+
106
+ def __repr__(self):
107
+ return f"<Delta50: {self.n_molecules} molecules>"
108
+
109
+
110
+ if __name__ == "__main__":
111
+ import sys
112
+ path = sys.argv[1] if len(sys.argv) > 1 else "delta50.hdf5"
113
+ with Delta50(path) as ds:
114
+ print(ds)
115
+ m = ds.molecule(ds.index_of("nitromethane") if "nitromethane" in ds.molecule_names else 0)
116
+ c = m["atomic_numbers"] == 6
117
+ print(f" {m['name']}: {len(m['atomic_numbers'])} atoms")
118
+ print(f" 13C MagNET-Zero[:3] = {np.round(m['nn_magnet_zero'][c][:3], 3)} ppm")
data/delta50/delta50.hdf5 ADDED
Binary file (33.2 kB). View file
 
data/delta50/test_delta50.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for data/delta50/decode_delta50.py.
2
+
3
+ A tiny synthetic file in the stored int32 format exercises the reader, the per-molecule slicing,
4
+ the fixed-point decode, and the PCM-correction helper, without the real file. An opt-in smoke test
5
+ runs against the real file when present.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+ import numpy as np
11
+ import pytest
12
+ import h5py
13
+
14
+ HERE = os.path.dirname(os.path.abspath(__file__))
15
+ sys.path.insert(0, HERE)
16
+
17
+ import decode_delta50 as D # noqa: E402
18
+
19
+ SCALE = 1e-4
20
+ MARKER = -2147483648
21
+ REAL = os.path.join(HERE, "delta50.hdf5")
22
+
23
+
24
+ def _encode(values):
25
+ v = np.asarray(values, dtype=np.float64)
26
+ out = np.round(v / SCALE).astype(np.int64)
27
+ out[~np.isfinite(v)] = MARKER
28
+ return out.astype(np.int32)
29
+
30
+
31
+ def _build_synthetic(path):
32
+ """Two molecules (3 and 5 atoms) in the stored int32 format, plus the decoded truth to check against."""
33
+ rng = np.random.default_rng(0)
34
+ names = ["alpha", "beta"]
35
+ specs = [3, 5] # n_atoms
36
+ n_atoms = np.array(specs, dtype=np.int32)
37
+ an, coords, zero, b3, pcm, truth = [], [], [], [], [], []
38
+ for na in specs:
39
+ a = rng.integers(1, 9, size=na).astype(np.uint8)
40
+ c = rng.normal(0, 2, size=(na, 3))
41
+ z = rng.normal(100, 30, size=na)
42
+ g = rng.normal(100, 30, size=na)
43
+ p = g + rng.normal(0, 0.2, size=na)
44
+ an.append(a); coords.append(_encode(c)); zero.append(_encode(z))
45
+ b3.append(_encode(g)); pcm.append(_encode(p))
46
+ truth.append((a, c, z, g, p))
47
+ opts = dict(compression="gzip", shuffle=True)
48
+ with h5py.File(path, "w") as f:
49
+ f.attrs["n_molecules"] = 2
50
+ f.attrs["scale"] = SCALE
51
+ f.attrs["missing_value_marker"] = MARKER
52
+ f.create_dataset("molecule_names", data=np.array(names, dtype=object),
53
+ dtype=h5py.string_dtype("utf-8"))
54
+ f.create_dataset("n_atoms", data=n_atoms, **opts)
55
+ f.create_dataset("atomic_numbers", data=np.concatenate(an), **opts)
56
+ f.create_dataset("coordinates", data=np.concatenate(coords), **opts)
57
+ f.create_dataset("nn_magnet_zero", data=np.concatenate(zero), **opts)
58
+ f.create_dataset("nn_b3lyp", data=np.concatenate(b3), **opts)
59
+ f.create_dataset("nn_b3lyp_pcm", data=np.concatenate(pcm), **opts)
60
+ return names, specs, truth
61
+
62
+
63
+ def test_reader_and_decode(tmp_path):
64
+ path = str(tmp_path / "tiny.hdf5")
65
+ names, specs, truth = _build_synthetic(path)
66
+ with D.Delta50(path) as ds:
67
+ assert len(ds) == 2
68
+ assert ds.molecule_names == names
69
+ for i, na in enumerate(specs):
70
+ m = ds.molecule(i)
71
+ a, c, z, g, p = truth[i]
72
+ assert m["name"] == names[i]
73
+ assert np.array_equal(m["atomic_numbers"], a)
74
+ assert m["coordinates"].shape == (na, 3)
75
+ assert np.abs(m["coordinates"] - c).max() <= 5e-5
76
+ assert np.abs(m["nn_magnet_zero"] - z).max() <= 5e-5
77
+ assert np.allclose(ds.pcm_correction(i), p - g, atol=1e-4)
78
+
79
+
80
+ def test_lookup_and_errors(tmp_path):
81
+ path = str(tmp_path / "tiny.hdf5")
82
+ names, _, _ = _build_synthetic(path)
83
+ with D.Delta50(path) as ds:
84
+ assert ds.molecule_by_name("beta")["name"] == "beta"
85
+ with pytest.raises(KeyError):
86
+ ds.index_of("missing")
87
+ with pytest.raises(IndexError):
88
+ ds.molecule(9)
89
+
90
+
91
+ def test_decode_marker_to_nan():
92
+ out = D._decode(np.array([0, 10000, MARKER], dtype=np.int32))
93
+ assert out[1] == pytest.approx(1.0) and np.isnan(out[2])
94
+
95
+
96
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
97
+ def test_real_file_smoke():
98
+ with D.Delta50(REAL) as ds:
99
+ assert len(ds) == 50
100
+ assert "nitromethane" in ds.molecule_names
101
+ m = ds.molecule_by_name("nitromethane")
102
+ assert np.isfinite(m["nn_magnet_zero"]).all()
103
+ assert ds.pcm_correction(ds.index_of("nitromethane")).shape == m["atomic_numbers"].shape
data/dft8k/dft8k_reader.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ dft8k_reader.py - reader/decoder for the dft8k HDF5 dataset.
3
+
4
+ DFT8k is an external NMR shielding benchmark of 7111 organic molecules (Guan, Paton and
5
+ co-workers; reference 14 in the paper), used to evaluate the MagNET models out of sample.
6
+ The file has TWO groups, one per level of theory. Both groups cover the SAME 7111 molecules
7
+ and use the SAME integer molecule ids, but in different row order, so match a molecule across
8
+ the two groups by id (use `molecule_by_id`), not by row position.
9
+
10
+ /aimnet2_wp04_wb97xd_pcsseg2 AIMNet2 geometries; per atom, three DFT shieldings plus the
11
+ two MagNET predictions. This is the file behind the paper's
12
+ DFT8k panels.
13
+ /b3lyp_pbe0_pcsseg1 B3LYP/pcSseg-1 geometries; three geometries per molecule with
14
+ one PBE0/pcSseg-1 shielding each, plus symmetry-equivalence
15
+ groups and per-atom averaging weights. This is the format the
16
+ model's own training/eval loader consumes.
17
+
18
+ Storage format:
19
+ - Per-atom arrays are concatenated across the molecules in a group. Each molecule's atom
20
+ count is in `n_atoms`; molecule i owns the rows [start[i], start[i+1]) where start is the
21
+ cumulative sum of `n_atoms` (reconstructed once, on open).
22
+ - coordinates and shieldings are int32 fixed-point: physical = stored * scale (scale = 1e-4),
23
+ so reconstruction error is <= 5e-5 (Angstrom or ppm). A missing value (an atom a model does
24
+ not predict, or an absent shielding) is the marker -2147483648 and decodes to NaN.
25
+ - The molecule's integer `molecule_ids` entry is its identifier.
26
+
27
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
28
+ """
29
+ from __future__ import annotations
30
+ import numpy as np
31
+ import h5py
32
+
33
+ _SCALE = 1e-4
34
+ _MARKER = -2147483648
35
+
36
+
37
+ def _decode(values: np.ndarray) -> np.ndarray:
38
+ """int32 fixed-point -> float64 physical units; marker -> NaN. No-op on float input."""
39
+ values = np.asarray(values)
40
+ if not np.issubdtype(values.dtype, np.integer):
41
+ return np.asarray(values, dtype=np.float64)
42
+ out = values.astype(np.float64) * _SCALE
43
+ out[values == _MARKER] = np.nan
44
+ return out
45
+
46
+
47
+ class _Group:
48
+ """Common molecule-indexing logic shared by both groups."""
49
+
50
+ def __init__(self, grp: h5py.Group):
51
+ self.g = grp
52
+ self.name = grp.name.lstrip("/")
53
+ self.molecule_ids = grp["molecule_ids"][:]
54
+ self.n_molecules = int(grp.attrs["n_molecules"])
55
+ self.n_atoms = int(grp.attrs["n_atoms"])
56
+ self.level_of_theory = str(grp.attrs["level_of_theory"])
57
+ self.geometry = str(grp.attrs["geometry"])
58
+ na = grp["n_atoms"][:].astype(np.int64)
59
+ self._start = np.empty(self.n_molecules + 1, np.int64)
60
+ self._start[0] = 0
61
+ np.cumsum(na, out=self._start[1:])
62
+ self._id_to_index = {int(m): i for i, m in enumerate(self.molecule_ids)}
63
+
64
+ def _check(self, index: int):
65
+ if not 0 <= index < self.n_molecules:
66
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
67
+
68
+ def _rows(self, index: int) -> slice:
69
+ return slice(int(self._start[index]), int(self._start[index + 1]))
70
+
71
+ def index_of(self, molecule_id: int) -> int:
72
+ """Return the row position of a molecule id within this group."""
73
+ if int(molecule_id) not in self._id_to_index:
74
+ raise KeyError(f"molecule id {molecule_id} not in group {self.name}")
75
+ return self._id_to_index[int(molecule_id)]
76
+
77
+ def molecule_by_id(self, molecule_id: int) -> dict:
78
+ """Look up a molecule by its id instead of its row position."""
79
+ return self.molecule(self.index_of(molecule_id))
80
+
81
+ def __len__(self):
82
+ return self.n_molecules
83
+
84
+
85
+ class AIMNet2Group(_Group):
86
+ """/aimnet2_wp04_wb97xd_pcsseg2 - AIMNet2 geometries, DFT and MagNET shieldings per atom."""
87
+
88
+ def __init__(self, grp: h5py.Group):
89
+ super().__init__(grp)
90
+ # one SMILES per molecule. 6780 of 7111 are real; the rest are the literal "none".
91
+ # Older files may lack the field, so guard for it.
92
+ self._smiles = grp["smiles"].asstr()[:] if "smiles" in grp else None
93
+
94
+ def smiles(self, index: int) -> str:
95
+ """Return molecule i's SMILES string (the literal "none" if it was not provided)."""
96
+ self._check(index)
97
+ if self._smiles is None:
98
+ return "none"
99
+ return str(self._smiles[index])
100
+
101
+ def molecule(self, index: int) -> dict:
102
+ """Return one molecule's atom data (physical units) by position.
103
+
104
+ Keys: id, smiles; atomic_numbers, coordinates (n,3 Angstrom); the three DFT shieldings
105
+ (pbe0_pcSseg1, wp04_pcSseg2, wb97xd_pcSseg2; each (n,) ppm); and the two MagNET
106
+ predictions (nn_wp04_pcSseg2, nn_wb97xd_pcSseg2; (n,) ppm). A MagNET prediction is
107
+ NaN at atoms that model does not predict (WP04 is the 1H model, wB97X-D the 13C).
108
+ """
109
+ self._check(index)
110
+ sl = self._rows(index)
111
+ return {
112
+ "id": int(self.molecule_ids[index]),
113
+ "smiles": self.smiles(index),
114
+ "atomic_numbers": self.g["atomic_numbers"][sl],
115
+ "coordinates": _decode(self.g["coordinates"][sl]),
116
+ "pbe0_pcSseg1": _decode(self.g["shielding_pbe0_pcSseg1"][sl]),
117
+ "wp04_pcSseg2": _decode(self.g["shielding_wp04_pcSseg2"][sl]),
118
+ "wb97xd_pcSseg2": _decode(self.g["shielding_wb97xd_pcSseg2"][sl]),
119
+ "nn_wp04_pcSseg2": _decode(self.g["nn_shielding_wp04_pcSseg2"][sl]),
120
+ "nn_wb97xd_pcSseg2": _decode(self.g["nn_shielding_wb97xd_pcSseg2"][sl]),
121
+ }
122
+
123
+ def all_shieldings(self) -> dict:
124
+ """Every atom in the dataset as flat arrays (physical units), for whole-dataset analyses
125
+ like the MagNET-vs-DFT residual histograms.
126
+
127
+ Keys: atomic_numbers (n_atoms_total,); the three DFT shieldings (pbe0_pcSseg1,
128
+ wp04_pcSseg2, wb97xd_pcSseg2) and the two MagNET predictions (nn_wp04_pcSseg2,
129
+ nn_wb97xd_pcSseg2), each (n_atoms_total,) ppm. A MagNET prediction is NaN at atoms that
130
+ model does not predict (WP04 is the 1H model, wB97X-D the 13C). To get one nucleus' residual,
131
+ difference the DFT and MagNET column for that model at that element and drop the NaNs.
132
+ """
133
+ return {
134
+ "atomic_numbers": self.g["atomic_numbers"][:],
135
+ "pbe0_pcSseg1": _decode(self.g["shielding_pbe0_pcSseg1"][:]),
136
+ "wp04_pcSseg2": _decode(self.g["shielding_wp04_pcSseg2"][:]),
137
+ "wb97xd_pcSseg2": _decode(self.g["shielding_wb97xd_pcSseg2"][:]),
138
+ "nn_wp04_pcSseg2": _decode(self.g["nn_shielding_wp04_pcSseg2"][:]),
139
+ "nn_wb97xd_pcSseg2": _decode(self.g["nn_shielding_wb97xd_pcSseg2"][:]),
140
+ }
141
+
142
+ def __repr__(self):
143
+ return (f"<AIMNet2Group /{self.name}: {self.n_molecules:,} molecules, "
144
+ f"{self.n_atoms:,} atoms>")
145
+
146
+
147
+ class B3LYPGroup(_Group):
148
+ """/b3lyp_pbe0_pcsseg1 - three geometries per molecule, symmetry groups and weights."""
149
+
150
+ def __init__(self, grp: h5py.Group):
151
+ super().__init__(grp)
152
+ self.n_geometries = int(grp.attrs["n_geometries"])
153
+ # symmetry groups are stored flat: n_groups per molecule, each group's length, then
154
+ # all member atom indices. Reconstruct the per-molecule offsets once, on open.
155
+ self._sym_n = grp["symmetric_atoms_n_groups"][:].astype(np.int64)
156
+ self._sym_len = grp["symmetric_atoms_group_lengths"][:].astype(np.int64)
157
+ self._sym_mem = grp["symmetric_atoms_members"][:]
158
+ self._group_start = np.empty(self.n_molecules + 1, np.int64)
159
+ self._group_start[0] = 0
160
+ np.cumsum(self._sym_n, out=self._group_start[1:])
161
+ self._member_start = np.empty(self._sym_len.shape[0] + 1, np.int64)
162
+ self._member_start[0] = 0
163
+ np.cumsum(self._sym_len, out=self._member_start[1:])
164
+
165
+ def symmetric_atoms(self, index: int) -> list:
166
+ """Return molecule i's symmetry-equivalence groups as a list of lists of atom indices.
167
+
168
+ The indices are 0-based positions into this molecule's atom arrays (atomic_numbers,
169
+ coordinates), so a group can be used to index those arrays directly.
170
+ """
171
+ self._check(index)
172
+ groups = []
173
+ for gi in range(int(self._group_start[index]), int(self._group_start[index + 1])):
174
+ a, b = int(self._member_start[gi]), int(self._member_start[gi + 1])
175
+ groups.append([int(x) for x in self._sym_mem[a:b]])
176
+ return groups
177
+
178
+ def molecule(self, index: int) -> dict:
179
+ """Return one molecule's data (physical units) by position.
180
+
181
+ Keys: id, atomic_numbers; coordinates (n_geometries, n, 3 Angstrom); shielding
182
+ (n_geometries, n, ppm), where geometry 0 is a reference and 1, 2 are perturbations;
183
+ weights (n,) per-atom symmetry-averaging weight; symmetric_atoms (list of lists of
184
+ equivalent atoms, each a 0-based index into this molecule's atom arrays).
185
+ """
186
+ self._check(index)
187
+ sl = self._rows(index)
188
+ return {
189
+ "id": int(self.molecule_ids[index]),
190
+ "atomic_numbers": self.g["atomic_numbers"][sl],
191
+ "coordinates": _decode(self.g["coordinates"][:, sl, :]),
192
+ "shielding": _decode(self.g["shielding"][:, sl]),
193
+ "weights": self.g["weights"][sl].astype(np.float64),
194
+ "symmetric_atoms": self.symmetric_atoms(index),
195
+ }
196
+
197
+ def __repr__(self):
198
+ return (f"<B3LYPGroup /{self.name}: {self.n_molecules:,} molecules, "
199
+ f"{self.n_atoms:,} atoms, {self.n_geometries} geometries>")
200
+
201
+
202
+ class DFT8k:
203
+ """Reader for dft8k.hdf5. Use as a context manager; access the two groups by attribute."""
204
+
205
+ def __init__(self, path: str):
206
+ self.f = h5py.File(path, "r")
207
+ self.aimnet2 = AIMNet2Group(self.f["aimnet2_wp04_wb97xd_pcsseg2"])
208
+ self.b3lyp = B3LYPGroup(self.f["b3lyp_pbe0_pcsseg1"])
209
+
210
+ def close(self):
211
+ """Close the underlying HDF5 file."""
212
+ self.f.close()
213
+
214
+ def __enter__(self):
215
+ return self
216
+
217
+ def __exit__(self, *a):
218
+ self.close()
219
+
220
+ def __repr__(self):
221
+ return f"<DFT8k: {self.aimnet2!r}, {self.b3lyp!r}>"
222
+
223
+
224
+ if __name__ == "__main__":
225
+ import sys
226
+ path = sys.argv[1] if len(sys.argv) > 1 else "dft8k.hdf5"
227
+ with DFT8k(path) as ds:
228
+ print(ds)
229
+ m = ds.aimnet2.molecule(0)
230
+ h = m["atomic_numbers"] == 1
231
+ print(f" aimnet2 mol id={m['id']}: {len(m['atomic_numbers'])} atoms smiles={m['smiles']}")
232
+ print(f" 1H WP04 DFT[:3]={np.round(m['wp04_pcSseg2'][h][:3], 3)} "
233
+ f"MagNET[:3]={np.round(m['nn_wp04_pcSseg2'][h][:3], 3)}")
234
+ b = ds.b3lyp.molecule(0)
235
+ print(f" b3lyp mol id={b['id']}: coordinates {b['coordinates'].shape}, "
236
+ f"{len(b['symmetric_atoms'])} symmetry groups")
data/dft8k/test_dft8k.py ADDED
@@ -0,0 +1,224 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the dft8k reader (dft8k_reader.py).
3
+
4
+ Most tests build a tiny synthetic dft8k.hdf5 in the exact on-disk format (so the fixture
5
+ doubles as a spec of what the reader expects) and need no large data. One opt-in test runs
6
+ against the real dft8k.hdf5 if it is present (skipped otherwise, e.g. in CI).
7
+
8
+ Run: pytest test_dft8k.py -q
9
+ Requires: pytest, numpy, h5py.
10
+ """
11
+ import os
12
+ import numpy as np
13
+ import h5py
14
+ import pytest
15
+
16
+ from dft8k_reader import DFT8k, _decode, _MARKER
17
+
18
+ SCALE = 1e4
19
+
20
+
21
+ def _enc(arr):
22
+ """Encode floats the way the build script does: int32 = round(value * 1e4), NaN -> marker."""
23
+ a = np.asarray(arr, dtype=np.float64)
24
+ nan = np.isnan(a)
25
+ out = np.rint(np.where(nan, 0.0, a) * SCALE).astype(np.int32)
26
+ out[nan] = _MARKER
27
+ return out
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # fixed-point decoding
32
+ # ---------------------------------------------------------------------------
33
+ def test_decode_round_trip_and_marker():
34
+ values = np.array([0.0, 207.07, -355.5, np.nan, 875.0], dtype=np.float64)
35
+ decoded = _decode(_enc(values))
36
+ assert np.isnan(decoded[3])
37
+ np.testing.assert_allclose(decoded[~np.isnan(decoded)],
38
+ values[~np.isnan(values)], atol=5e-5)
39
+
40
+
41
+ def test_decode_passes_through_float():
42
+ # a plain-decimal array must read back unchanged (decode is a no-op on floats)
43
+ values = np.array([1.0, 2.5, -7.25], dtype=np.float64)
44
+ np.testing.assert_array_equal(_decode(values), values)
45
+
46
+
47
+ def test_decode_handles_int64():
48
+ # any integer dtype decodes the same way as int32
49
+ vals = np.array([10000, -3550000, _MARKER], dtype=np.int64)
50
+ out = _decode(vals)
51
+ np.testing.assert_allclose(out[:2], [1.0, -355.0])
52
+ assert np.isnan(out[2])
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # synthetic dft8k.hdf5 in the real two-group layout
57
+ # ---------------------------------------------------------------------------
58
+ # two molecules: ids 100 and 200, with 2 and 3 atoms (atoms are H, C, ...)
59
+ A_IDS = [100, 200]
60
+ A_COUNTS = [2, 3]
61
+ A_Z = [1, 6, 6, 1, 7] # mol100: H C ; mol200: C H N
62
+ # per-atom DFT shieldings (concatenated): pbe0, wp04, wb97xd
63
+ A_PBE0 = [30.0, 150.0, 151.0, 31.0, 200.0]
64
+ A_WP04 = [30.5, 150.5, 151.5, 31.5, 200.5] # the 1H reference
65
+ A_WB97 = [30.7, 150.7, 151.7, 31.7, 200.7] # the 13C reference
66
+ # MagNET predicts H with the WP04 model and C with the wB97X-D model; other atoms blank
67
+ A_NN_WP04 = [30.6, np.nan, np.nan, 31.6, np.nan] # only at H atoms (index 0, 3)
68
+ A_NN_WB97 = [np.nan, 150.6, 151.6, np.nan, np.nan] # only at C atoms (index 1, 2)
69
+
70
+
71
+ def _make_synthetic_dft8k(path):
72
+ with h5py.File(path, "w") as f:
73
+ g = f.create_group("aimnet2_wp04_wb97xd_pcsseg2")
74
+ g.attrs["geometry"] = "AIMNet2"
75
+ g.attrs["level_of_theory"] = "PBE0/pcSseg-1; WP04/pcSseg-2; wB97X-D/pcSseg-2"
76
+ g.attrs["n_molecules"] = len(A_IDS)
77
+ g.attrs["n_atoms"] = sum(A_COUNTS)
78
+ g.attrs["scale"] = 1e-4
79
+ g.create_dataset("molecule_ids", data=np.array(A_IDS, np.uint32))
80
+ g.create_dataset("n_atoms", data=np.array(A_COUNTS, np.uint8))
81
+ # one SMILES per molecule; mol100 has a real one, mol200 carries the literal "none"
82
+ g.create_dataset("smiles", data=np.array(["CC", "none"], dtype=object),
83
+ dtype=h5py.string_dtype(encoding="utf-8"))
84
+ g.create_dataset("atomic_numbers", data=np.array(A_Z, np.int8))
85
+ coords = np.arange(sum(A_COUNTS) * 3, dtype=np.float64).reshape(-1, 3) * 0.1
86
+ g.create_dataset("coordinates", data=_enc(coords))
87
+ g.create_dataset("shielding_pbe0_pcSseg1", data=_enc(A_PBE0))
88
+ g.create_dataset("shielding_wp04_pcSseg2", data=_enc(A_WP04))
89
+ g.create_dataset("shielding_wb97xd_pcSseg2", data=_enc(A_WB97))
90
+ g.create_dataset("nn_shielding_wp04_pcSseg2", data=_enc(A_NN_WP04))
91
+ g.create_dataset("nn_shielding_wb97xd_pcSseg2", data=_enc(A_NN_WB97))
92
+
93
+ # second group, SAME ids but reversed row order, three geometries per molecule
94
+ b = f.create_group("b3lyp_pbe0_pcsseg1")
95
+ b.attrs["geometry"] = "B3LYP/pcSseg-1"
96
+ b.attrs["level_of_theory"] = "PBE0/pcSseg-1 on B3LYP/pcSseg-1 geometries"
97
+ b.attrs["n_molecules"] = 2
98
+ b.attrs["n_atoms"] = 5
99
+ b.attrs["n_geometries"] = 3
100
+ b.attrs["scale"] = 1e-4
101
+ b.create_dataset("molecule_ids", data=np.array([200, 100], np.uint32)) # reversed
102
+ b.create_dataset("n_atoms", data=np.array([3, 2], np.uint8))
103
+ b.create_dataset("atomic_numbers", data=np.array([6, 1, 7, 1, 6], np.int8))
104
+ bcoords = np.arange(3 * 5 * 3, dtype=np.float64).reshape(3, 5, 3) * 0.01
105
+ b.create_dataset("coordinates", data=_enc(bcoords))
106
+ bshield = np.arange(3 * 5, dtype=np.float64).reshape(3, 5) + 100.0
107
+ b.create_dataset("shielding", data=_enc(bshield))
108
+ b.create_dataset("weights", data=np.array([1, 1, 1, 0.5, 0.5], np.float32))
109
+ # symmetry groups are 0-based atom indices, local to each molecule.
110
+ # mol200 (first row, 3 atoms): two groups [[1, 2]] and [[0]];
111
+ # mol100 (second row, 2 atoms): one group [[0, 1]].
112
+ b.create_dataset("symmetric_atoms_n_groups", data=np.array([2, 1], np.int32))
113
+ b.create_dataset("symmetric_atoms_group_lengths", data=np.array([2, 1, 2], np.int32))
114
+ b.create_dataset("symmetric_atoms_members", data=np.array([1, 2, 0, 0, 1], np.int32))
115
+
116
+
117
+ def test_aimnet2_group(tmp_path):
118
+ p = tmp_path / "dft8k.hdf5"
119
+ _make_synthetic_dft8k(str(p))
120
+ with DFT8k(str(p)) as ds:
121
+ assert len(ds.aimnet2) == 2
122
+ m = ds.aimnet2.molecule(0)
123
+ assert m["id"] == 100
124
+ assert m["smiles"] == "CC"
125
+ assert ds.aimnet2.smiles(1) == "none" # mol200 has no SMILES
126
+ np.testing.assert_array_equal(m["atomic_numbers"], [1, 6])
127
+ np.testing.assert_allclose(m["wp04_pcSseg2"], [30.5, 150.5], atol=5e-5)
128
+ # H atom (index 0) has a WP04 prediction; C atom (index 1) does not
129
+ assert not np.isnan(m["nn_wp04_pcSseg2"][0])
130
+ assert np.isnan(m["nn_wp04_pcSseg2"][1])
131
+ # C atom has a wB97X-D prediction; H atom does not
132
+ assert np.isnan(m["nn_wb97xd_pcSseg2"][0])
133
+ assert not np.isnan(m["nn_wb97xd_pcSseg2"][1])
134
+ np.testing.assert_allclose(m["nn_wb97xd_pcSseg2"][1], 150.6, atol=5e-5)
135
+
136
+
137
+ def test_all_shieldings_flat(tmp_path):
138
+ p = tmp_path / "dft8k.hdf5"
139
+ _make_synthetic_dft8k(str(p))
140
+ with DFT8k(str(p)) as ds:
141
+ flat = ds.aimnet2.all_shieldings()
142
+ # one entry per atom across both molecules, in stored order
143
+ np.testing.assert_array_equal(flat["atomic_numbers"], [1, 6, 6, 1, 7])
144
+ np.testing.assert_allclose(flat["wp04_pcSseg2"], A_WP04, atol=5e-5)
145
+ # MagNET prediction is present only at the atoms its model covers
146
+ assert np.isnan(flat["nn_wp04_pcSseg2"]).tolist() == [False, True, True, False, True]
147
+ # the 1H residual (DFT minus MagNET) at the two H atoms is -0.1
148
+ Z = flat["atomic_numbers"]
149
+ h = (Z == 1) & np.isfinite(flat["nn_wp04_pcSseg2"])
150
+ np.testing.assert_allclose(flat["wp04_pcSseg2"][h] - flat["nn_wp04_pcSseg2"][h],
151
+ [-0.1, -0.1], atol=5e-5)
152
+
153
+
154
+ def test_b3lyp_group_and_symmetry(tmp_path):
155
+ p = tmp_path / "dft8k.hdf5"
156
+ _make_synthetic_dft8k(str(p))
157
+ with DFT8k(str(p)) as ds:
158
+ b = ds.b3lyp
159
+ assert b.n_geometries == 3
160
+ m = b.molecule(0) # first row is id 200
161
+ assert m["id"] == 200
162
+ assert m["coordinates"].shape == (3, 3, 3) # (geometries, atoms, xyz)
163
+ assert m["shielding"].shape == (3, 3)
164
+ assert m["symmetric_atoms"] == [[1, 2], [0]]
165
+ m2 = b.molecule(1) # id 100
166
+ assert m2["symmetric_atoms"] == [[0, 1]]
167
+ np.testing.assert_allclose(m2["weights"], [0.5, 0.5])
168
+ # the symmetry indices are valid 0-based positions into this molecule's atoms
169
+ for group in m["symmetric_atoms"] + m2["symmetric_atoms"]:
170
+ for atom_index in group:
171
+ assert 0 <= atom_index < len(m["atomic_numbers"]) or \
172
+ 0 <= atom_index < len(m2["atomic_numbers"])
173
+
174
+
175
+ def test_match_across_groups_by_id(tmp_path):
176
+ p = tmp_path / "dft8k.hdf5"
177
+ _make_synthetic_dft8k(str(p))
178
+ with DFT8k(str(p)) as ds:
179
+ # rows are ordered differently in the two groups; matching by id must still work
180
+ a = ds.aimnet2.molecule_by_id(200)
181
+ b = ds.b3lyp.molecule_by_id(200)
182
+ assert a["id"] == b["id"] == 200
183
+ np.testing.assert_array_equal(a["atomic_numbers"], b["atomic_numbers"])
184
+
185
+
186
+ # ---------------------------------------------------------------------------
187
+ # opt-in test against the real file (skipped when absent, e.g. in CI)
188
+ # ---------------------------------------------------------------------------
189
+ _HERE = os.path.dirname(os.path.abspath(__file__))
190
+ _REAL = os.path.join(_HERE, "dft8k.hdf5")
191
+
192
+
193
+ @pytest.mark.skipif(not os.path.exists(_REAL), reason="real dft8k.hdf5 not present")
194
+ def test_real_dft8k():
195
+ with DFT8k(_REAL) as ds:
196
+ assert len(ds.aimnet2) == 7111
197
+ assert len(ds.b3lyp) == 7111
198
+ assert ds.b3lyp.n_geometries == 3
199
+ # the two groups carry the same id set
200
+ assert set(int(x) for x in ds.aimnet2.molecule_ids) == \
201
+ set(int(x) for x in ds.b3lyp.molecule_ids)
202
+ m = ds.aimnet2.molecule(0)
203
+ # SMILES: 6780 of the 7111 molecules have one; the rest carry the literal "none"
204
+ n_real_smiles = sum(1 for i in range(len(ds.aimnet2)) if ds.aimnet2.smiles(i) != "none")
205
+ assert n_real_smiles == 6780
206
+ assert isinstance(m["smiles"], str) and m["smiles"] != "none"
207
+ z = m["atomic_numbers"]
208
+ h = z == 1
209
+ c = z == 6
210
+ other = ~(h | c)
211
+ # the WP04 model predicts every hydrogen and nothing else; the wB97X-D model
212
+ # predicts every carbon and nothing else; non-target atoms read back blank
213
+ assert not np.isnan(m["nn_wp04_pcSseg2"][h]).any()
214
+ assert np.isnan(m["nn_wp04_pcSseg2"][~h]).all()
215
+ assert not np.isnan(m["nn_wb97xd_pcSseg2"][c]).any()
216
+ assert np.isnan(m["nn_wb97xd_pcSseg2"][~c]).all()
217
+ assert other.sum() == 0 or np.isnan(m["nn_wp04_pcSseg2"][other]).all()
218
+ # the same molecule resolves in the other group by id
219
+ b = ds.b3lyp.molecule_by_id(m["id"])
220
+ np.testing.assert_array_equal(m["atomic_numbers"], b["atomic_numbers"])
221
+ # every symmetry index is a valid 0-based position into this molecule's atoms
222
+ for group in b["symmetric_atoms"]:
223
+ for atom_index in group:
224
+ assert 0 <= atom_index < len(b["atomic_numbers"])
data/gdb_qcd/decode_gdb_qcd.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_gdb_qcd.py - reader/decoder for the gdb_qcd HDF5 dataset.
3
+
4
+ gdb_qcd is a quasiclassical-dynamics benchmark: replicate molecular-dynamics trajectories for
5
+ 2461 small molecules drawn from GDB (the MagNET / sigma-shake test set). It is used to benchmark
6
+ rovibrational corrections, which are computed by comparing NMR shieldings on the moving
7
+ (displaced) geometries against the resting (stationary) geometry.
8
+
9
+ For each molecule there are several trajectories; each trajectory has 33 frames; each frame gives
10
+ every atom's position and its PBE0/pcSseg-1 NMR shielding. Trajectory counts vary by molecule
11
+ (about 23 on average); the frame count is always 33. The resting (stationary) reference shieldings
12
+ are not included here: they are the test-set entries of the MagNET training data (sigma-shake),
13
+ matched by the same molecule id.
14
+
15
+ Storage format:
16
+ - Per-atom arrays are concatenated across molecules. Molecule i has `n_atoms[i]` atoms and
17
+ `n_trajectories[i]` trajectories. Its atomic numbers occupy `n_atoms[i]` rows; its coordinates
18
+ and shieldings occupy `n_trajectories[i] * 33 * n_atoms[i]` rows, which reshape to
19
+ (n_trajectories, 33, n_atoms, ...). Offsets are reconstructed once, on open.
20
+ - coordinates and shieldings are int32 fixed point: physical = stored * 1e-4, so reconstruction
21
+ error is <= 5e-5 (Angstrom or ppm). A missing value would be the marker -2147483648 -> NaN
22
+ (none occur in this dataset).
23
+ - `molecule_ids` are integers and match the sigma-shake test-set ids.
24
+
25
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
26
+ """
27
+ from __future__ import annotations
28
+ import numpy as np
29
+ import h5py
30
+
31
+ _SCALE = 1e-4
32
+ _MARKER = -2147483648
33
+
34
+
35
+ def _decode(values: np.ndarray) -> np.ndarray:
36
+ """int32 fixed-point -> float64 physical units; marker -> NaN. No-op on float input."""
37
+ values = np.asarray(values)
38
+ if not np.issubdtype(values.dtype, np.integer):
39
+ return np.asarray(values, dtype=np.float64)
40
+ out = values.astype(np.float64) * _SCALE
41
+ out[values == _MARKER] = np.nan
42
+ return out
43
+
44
+
45
+ class GDBQCD:
46
+ """Reader for gdb_qcd.hdf5. Use as a context manager; index molecules by position or by id."""
47
+
48
+ def __init__(self, path: str):
49
+ self.f = h5py.File(path, "r")
50
+ self.molecule_ids = self.f["molecule_ids"][:]
51
+ self.n_molecules = int(self.f.attrs["n_molecules"])
52
+ self.n_frames = int(self.f.attrs["n_frames"])
53
+ self.nmr_level_of_theory = str(self.f.attrs["nmr_level_of_theory"])
54
+ self._n_atoms = self.f["n_atoms"][:].astype(np.int64)
55
+ self._n_traj = self.f["n_trajectories"][:].astype(np.int64)
56
+ # atom-row offsets (atomic_numbers) and coordinate/shielding-row offsets
57
+ self._astart = np.empty(self.n_molecules + 1, np.int64)
58
+ self._astart[0] = 0
59
+ np.cumsum(self._n_atoms, out=self._astart[1:])
60
+ block = self._n_traj * self.n_frames * self._n_atoms
61
+ self._cstart = np.empty(self.n_molecules + 1, np.int64)
62
+ self._cstart[0] = 0
63
+ np.cumsum(block, out=self._cstart[1:])
64
+ self._id_to_index = {int(m): i for i, m in enumerate(self.molecule_ids)}
65
+
66
+ def _check(self, index: int):
67
+ if not 0 <= index < self.n_molecules:
68
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
69
+
70
+ def index_of(self, molecule_id: int) -> int:
71
+ """Position of the molecule with this id; raises KeyError if absent."""
72
+ if int(molecule_id) not in self._id_to_index:
73
+ raise KeyError(f"molecule id {molecule_id} not in gdb_qcd")
74
+ return self._id_to_index[int(molecule_id)]
75
+
76
+ def n_trajectories(self, index: int) -> int:
77
+ """How many trajectories molecule i has."""
78
+ self._check(index)
79
+ return int(self._n_traj[index])
80
+
81
+ def molecule(self, index: int) -> dict:
82
+ """Return one molecule's trajectory data (physical units) by position.
83
+
84
+ Keys:
85
+ id - integer molecule id (matches the sigma-shake test set)
86
+ atomic_numbers - (n_atoms,) uint8
87
+ coordinates - (n_trajectories, 33, n_atoms, 3) Angstrom
88
+ shieldings - (n_trajectories, 33, n_atoms) ppm, PBE0/pcSseg-1
89
+ """
90
+ self._check(index)
91
+ na = int(self._n_atoms[index])
92
+ nt = int(self._n_traj[index])
93
+ asl = slice(int(self._astart[index]), int(self._astart[index + 1]))
94
+ csl = slice(int(self._cstart[index]), int(self._cstart[index + 1]))
95
+ coords = _decode(self.f["coordinates"][csl]).reshape(nt, self.n_frames, na, 3)
96
+ shields = _decode(self.f["shieldings"][csl]).reshape(nt, self.n_frames, na)
97
+ return {
98
+ "id": int(self.molecule_ids[index]),
99
+ "atomic_numbers": self.f["atomic_numbers"][asl],
100
+ "coordinates": coords,
101
+ "shieldings": shields,
102
+ }
103
+
104
+ def molecule_by_id(self, molecule_id: int) -> dict:
105
+ """Return one molecule's trajectory data (see `molecule`) by id."""
106
+ return self.molecule(self.index_of(molecule_id))
107
+
108
+ def close(self):
109
+ """Close the underlying HDF5 file."""
110
+ self.f.close()
111
+
112
+ def __enter__(self):
113
+ return self
114
+
115
+ def __exit__(self, *a):
116
+ self.close()
117
+
118
+ def __len__(self):
119
+ return self.n_molecules
120
+
121
+ def __repr__(self):
122
+ return (f"<GDBQCD: {self.n_molecules:,} molecules, "
123
+ f"{int(self.f.attrs['n_trajectories']):,} trajectories, "
124
+ f"{self.n_frames} frames each, {self.nmr_level_of_theory}>")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ import sys
129
+ path = sys.argv[1] if len(sys.argv) > 1 else "gdb_qcd.hdf5"
130
+ with GDBQCD(path) as ds:
131
+ print(ds)
132
+ m = ds.molecule(0)
133
+ print(f" molecule id={m['id']}: {len(m['atomic_numbers'])} atoms, "
134
+ f"coordinates {m['coordinates'].shape}, shieldings {m['shieldings'].shape}")
135
+ print(f" trajectory 0, frame 0, atom 0: xyz={np.round(m['coordinates'][0,0,0], 4)} "
136
+ f"shielding={m['shieldings'][0,0,0]:.4f} ppm")
data/gdb_qcd/test_gdb_qcd.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for data/gdb_qcd/decode_gdb_qcd.py.
2
+
3
+ A tiny synthetic file in the stored int32 format exercises the reader, the per-molecule block
4
+ reconstruction (variable atoms and trajectories, fixed 33 frames), and the fixed-point decode,
5
+ all without the multi-hundred-MB real file. An opt-in smoke test runs against the real file when
6
+ it is present.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ import numpy as np
12
+ import pytest
13
+ import h5py
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+ sys.path.insert(0, HERE)
17
+
18
+ import decode_gdb_qcd as D # noqa: E402
19
+
20
+ SCALE = 1e-4
21
+ MARKER = -2147483648
22
+ N_FRAMES = 33
23
+ REAL = os.path.join(HERE, "gdb_qcd.hdf5")
24
+
25
+
26
+ def _encode(values):
27
+ out = np.round(np.asarray(values, dtype=np.float64) / SCALE).astype(np.int64)
28
+ out[~np.isfinite(values)] = MARKER
29
+ return out.astype(np.int32)
30
+
31
+
32
+ def _build_synthetic(path):
33
+ """Two molecules: 3 atoms / 2 trajectories, and 4 atoms / 1 trajectory."""
34
+ rng = np.random.default_rng(0)
35
+ specs = [(3, 2), (4, 1)] # (n_atoms, n_trajectories)
36
+ mol_ids = np.array([11, 22], dtype=np.int64)
37
+ n_atoms = np.array([s[0] for s in specs], dtype=np.int32)
38
+ n_traj = np.array([s[1] for s in specs], dtype=np.int32)
39
+ atomic, coord_blocks, shield_blocks, truth = [], [], [], []
40
+ for na, nt in specs:
41
+ an = rng.integers(1, 9, size=na).astype(np.uint8)
42
+ coords = rng.normal(0, 2, size=(nt, N_FRAMES, na, 3))
43
+ shields = rng.normal(100, 30, size=(nt, N_FRAMES, na))
44
+ atomic.append(an)
45
+ coord_blocks.append(_encode(coords.reshape(-1, 3)))
46
+ shield_blocks.append(_encode(shields.reshape(-1)))
47
+ truth.append((an, coords, shields))
48
+ opts = dict(compression="gzip", shuffle=True)
49
+ with h5py.File(path, "w") as f:
50
+ f.attrs["n_molecules"] = 2
51
+ f.attrs["n_trajectories"] = int(n_traj.sum())
52
+ f.attrs["n_frames"] = N_FRAMES
53
+ f.attrs["nmr_level_of_theory"] = "PBE0/pcSseg-1"
54
+ f.attrs["scale"] = SCALE
55
+ f.attrs["missing_value_marker"] = MARKER
56
+ f.create_dataset("molecule_ids", data=mol_ids, **opts)
57
+ f.create_dataset("n_atoms", data=n_atoms, **opts)
58
+ f.create_dataset("n_trajectories", data=n_traj, **opts)
59
+ f.create_dataset("atomic_numbers", data=np.concatenate(atomic), **opts)
60
+ f.create_dataset("coordinates", data=np.concatenate(coord_blocks), **opts)
61
+ f.create_dataset("shieldings", data=np.concatenate(shield_blocks), **opts)
62
+ return mol_ids, specs, truth
63
+
64
+
65
+ def test_reader_reconstructs_blocks(tmp_path):
66
+ path = str(tmp_path / "tiny.hdf5")
67
+ mol_ids, specs, truth = _build_synthetic(path)
68
+ with D.GDBQCD(path) as ds:
69
+ assert len(ds) == 2
70
+ assert ds.n_frames == 33
71
+ assert list(ds.molecule_ids) == list(mol_ids)
72
+ for i, (na, nt) in enumerate(specs):
73
+ m = ds.molecule(i)
74
+ an, coords, shields = truth[i]
75
+ assert m["id"] == int(mol_ids[i])
76
+ assert ds.n_trajectories(i) == nt
77
+ assert np.array_equal(m["atomic_numbers"], an)
78
+ assert m["coordinates"].shape == (nt, 33, na, 3)
79
+ assert m["shieldings"].shape == (nt, 33, na)
80
+ # fixed-point round-trip within the 5e-5 floor
81
+ assert np.abs(m["coordinates"] - coords).max() <= 5e-5
82
+ assert np.abs(m["shieldings"] - shields).max() <= 5e-5
83
+
84
+
85
+ def test_lookup_by_id_and_errors(tmp_path):
86
+ path = str(tmp_path / "tiny.hdf5")
87
+ mol_ids, _, _ = _build_synthetic(path)
88
+ with D.GDBQCD(path) as ds:
89
+ assert ds.molecule_by_id(22)["id"] == 22
90
+ assert ds.index_of(11) == 0
91
+ with pytest.raises(KeyError):
92
+ ds.index_of(999)
93
+ with pytest.raises(IndexError):
94
+ ds.molecule(5)
95
+
96
+
97
+ def test_decode_marker_to_nan():
98
+ vals = np.array([0, 10000, MARKER, -25000], dtype=np.int32)
99
+ out = D._decode(vals)
100
+ assert out[0] == 0.0 and out[1] == pytest.approx(1.0) and out[3] == pytest.approx(-2.5)
101
+ assert np.isnan(out[2])
102
+
103
+
104
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real gdb_qcd.hdf5 not present")
105
+ def test_real_file_smoke():
106
+ with D.GDBQCD(REAL) as ds:
107
+ assert len(ds) == 2461
108
+ assert int(ds.f.attrs["n_trajectories"]) == 56702
109
+ m = ds.molecule(0)
110
+ assert m["coordinates"].shape[1] == 33
111
+ assert m["coordinates"].shape[0] == ds.n_trajectories(0)
112
+ assert np.isfinite(m["shieldings"]).all()
data/magnet_benchmark/performance_results.csv ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ model,test_set,nucleus,median_ae,mae,rmse,n
2
+ MagNET,stationary_internal,13C,0.23667734375001714,0.37584171551096685,0.7389307200428491,5259
3
+ MagNET,stationary_internal,1H,0.020697677612304233,0.0311367929561522,0.054721767847735706,7655
4
+ MagNET,vibrated_internal,13C,0.3584277770996209,0.5341938983186548,0.896683642076138,4264
5
+ MagNET,vibrated_internal,1H,0.03247062377929666,0.047286319514361755,0.10046215637564167,6780
6
+ MagNET,vibrated_external,13C,0.350236883544925,0.5368293357258397,1.088311121228106,12854
7
+ MagNET,vibrated_external,1H,0.029520549011230912,0.04531780213565488,0.08093539176210796,15506
8
+ MagNET,isolated_chloroform,13C,0.3621,0.596,1.2658,88550
9
+ MagNET,isolated_chloroform,1H,0.0322,0.0745,0.3076,112460
10
+ MagNET-x,stationary_internal,13C,0.2431782714843962,0.3895146201411134,0.7565858139938165,5259
11
+ MagNET-x,stationary_internal,1H,0.020831005859374585,0.03165713338242734,0.05425704945428728,7655
12
+ MagNET-x,vibrated_internal,13C,0.3772723861694356,0.5585515375031819,0.900760237056921,4264
13
+ MagNET-x,vibrated_internal,1H,0.03227110290527335,0.046615542751976255,0.09691115895578069,6780
14
+ MagNET-x,vibrated_external,13C,0.2872504028320293,0.43605742698970223,0.8808960500245364,12854
15
+ MagNET-x,vibrated_external,1H,0.020489848327637006,0.03226036506553448,0.07018272591671143,15506
16
+ MagNET-x,isolated_chloroform,13C,0.3062,0.4375,0.7232,88550
17
+ MagNET-x,isolated_chloroform,1H,0.0224,0.0311,0.0462,112460
data/magnet_test_predictions/build_magnet_test_predictions.py ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build magnet_test_predictions.hdf5 from the raw MagNET inference pickles.
2
+
3
+ This regenerates the released archive so the packaging is reproducible end to end. It reads Keir
4
+ Adams' per-test-set result pickles (each a {molecule_key: tuple} dict) and writes one canonical HDF5
5
+ using the release's int32 fixed-point scheme (round(value * 1e4), NaN sentinel) with content-addressed
6
+ dedup: any stored block that is byte-identical to one already written (the DFT targets are identical
7
+ across a test set's model variants) is stored once and referenced.
8
+
9
+ Usage:
10
+ python build_magnet_test_predictions.py <source_dir_of_pickles> <output.hdf5>
11
+
12
+ The reader `magnet_test_predictions_reader.py` inverts this exactly (round-trip error <= 5e-5 ppm).
13
+ """
14
+ import os
15
+ import glob
16
+ import pickle
17
+ import hashlib
18
+ import numpy as np
19
+ import h5py
20
+
21
+ SCALE = 1e4
22
+ NAN_SENTINEL = np.iinfo(np.int32).min
23
+
24
+
25
+ def _enc_float(a):
26
+ a = np.asarray(a, dtype=np.float64)
27
+ return np.where(np.isfinite(a), np.round(a * SCALE), NAN_SENTINEL).astype(np.int32)
28
+
29
+
30
+ def _kind(elem):
31
+ if isinstance(elem, (str, np.str_)):
32
+ return "str"
33
+ a = np.asarray(elem)
34
+ if a.dtype == bool:
35
+ return "bool"
36
+ if np.issubdtype(a.dtype, np.floating):
37
+ return "float"
38
+ if np.issubdtype(a.dtype, np.integer):
39
+ return "int"
40
+ if a.dtype.kind in ("U", "S"):
41
+ return "str"
42
+ raise ValueError(f"unhandled element dtype {a.dtype}")
43
+
44
+
45
+ class _Writer:
46
+ """Writes groups into an open h5py.File, sharing byte-identical blocks."""
47
+
48
+ def __init__(self, h):
49
+ self.h = h
50
+ self.store = {}
51
+ self.n_dedup = 0
52
+
53
+ def _put(self, grp, name, arr, **kw):
54
+ b = np.ascontiguousarray(arr)
55
+ key = hashlib.sha1(b.tobytes()).hexdigest() + f":{b.dtype.str}:{b.shape}"
56
+ if key in self.store:
57
+ grp.attrs[name + "_ref"] = self.store[key]
58
+ self.n_dedup += 1
59
+ return
60
+ ds = grp.create_dataset(name, data=b, **kw)
61
+ self.store[key] = ds.name
62
+
63
+ def encode_pickle(self, grp, data):
64
+ """Write one result pickle's {molecule_key: tuple} dict into `grp`, column by column."""
65
+ keys = list(data.keys())
66
+ n = len(keys)
67
+ tuples = [data[k] for k in keys]
68
+ tlen = len(tuples[0])
69
+ grp.attrs["n"] = n
70
+ grp.attrs["tuple_len"] = tlen
71
+ k0 = keys[0]
72
+ if isinstance(k0, tuple):
73
+ grp.attrs["key_kind"] = "tuple"
74
+ self._put(grp, "keys", np.asarray(keys, dtype=np.int64), compression="gzip", compression_opts=4)
75
+ elif isinstance(k0, (str, np.str_)):
76
+ grp.attrs["key_kind"] = "str"
77
+ grp.create_dataset("keys", data=np.asarray(keys, dtype=object),
78
+ dtype=h5py.string_dtype(), compression="gzip", compression_opts=4)
79
+ else:
80
+ grp.attrs["key_kind"] = "int"
81
+ self._put(grp, "keys", np.asarray(keys, dtype=np.int64), compression="gzip", compression_opts=4)
82
+ kinds = [_kind(tuples[0][p]) for p in range(tlen)]
83
+ grp.attrs["pos_kinds"] = np.asarray(kinds, dtype=h5py.string_dtype())
84
+ for p in range(tlen):
85
+ kind = kinds[p]
86
+ grp.attrs[f"p{p}_kind"] = kind
87
+ if kind == "str":
88
+ vals = np.asarray([str(t[p]) for t in tuples], dtype=object)
89
+ grp.create_dataset(f"p{p}_str", data=vals, dtype=h5py.string_dtype(),
90
+ compression="gzip", compression_opts=4)
91
+ continue
92
+ arrs = [np.asarray(t[p]) for t in tuples]
93
+ shapes = np.asarray([a.shape for a in arrs], dtype=np.int32)
94
+ sizes = np.asarray([a.size for a in arrs], dtype=np.int64)
95
+ offsets = np.zeros(n + 1, dtype=np.int64)
96
+ offsets[1:] = np.cumsum(sizes)
97
+ flat = np.concatenate([a.ravel() for a in arrs]) if n else np.array([])
98
+ self._put(grp, f"p{p}_shapes", shapes, compression="gzip", compression_opts=4)
99
+ self._put(grp, f"p{p}_offsets", offsets, compression="gzip", compression_opts=4)
100
+ if kind == "float":
101
+ self._put(grp, f"p{p}_data", _enc_float(flat), compression="gzip", compression_opts=4, shuffle=True)
102
+ elif kind == "int":
103
+ self._put(grp, f"p{p}_data", flat.astype(np.int64), compression="gzip", compression_opts=4, shuffle=True)
104
+ elif kind == "bool":
105
+ self._put(grp, f"p{p}_data", flat.astype(np.int8), compression="gzip", compression_opts=4)
106
+
107
+
108
+ def build_file(source_dir, outfile):
109
+ """Encode every *.pickle result file in `source_dir` into `outfile` as one HDF5 archive.
110
+
111
+ Returns (n_files_read, n_blocks_deduplicated).
112
+ """
113
+ files = sorted(glob.glob(os.path.join(source_dir, "*.pickle")))
114
+ with h5py.File(outfile, "w") as h:
115
+ h.attrs["format"] = "magnet_test_predictions v2 (dedup)"
116
+ h.attrs["scale"] = SCALE
117
+ h.attrs["nan_sentinel"] = NAN_SENTINEL
118
+ w = _Writer(h)
119
+ for f in files:
120
+ name = os.path.basename(f)[:-7]
121
+ if name.startswith("results_"):
122
+ name = name[len("results_"):]
123
+ w.encode_pickle(h.create_group(name), pickle.load(open(f, "rb")))
124
+ return len(files), w.n_dedup
125
+
126
+
127
+ if __name__ == "__main__":
128
+ import sys
129
+ src, out = sys.argv[1], sys.argv[2]
130
+ nfiles, ndedup = build_file(src, out)
131
+ print(f"wrote {out} from {nfiles} pickles ({ndedup} shared blocks), "
132
+ f"{os.path.getsize(out) / 1e9:.3f} GB")
data/magnet_test_predictions/magnet_test_predictions_reader.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reader for magnet_test_predictions.hdf5 - MagNET's raw per-atom predictions and DFT targets
2
+ on every benchmark test set.
3
+
4
+ This is the inference behind SI Tables S3 and S4 (proton and carbon performance of MagNET) and
5
+ SI Table S5 (quasi-classical-dynamics corrections). Each test set was run through several models and
6
+ geometries; this file stores, for every molecule, the predicted shieldings and the DFT reference so
7
+ the accuracy statistics can be recomputed exactly.
8
+
9
+ The file is a set of groups, one per original result file. List them with `available()`. Group names
10
+ carry a solvent field only for the solvated MD sets:
11
+ <testset>_<nucleus>_<model>_<extra> (gas-phase and QCD sets)
12
+ <testset>_<solvent>_<nucleus>_<model>_<extra> (solutesmd500 / solutesmd500isolated)
13
+ for example gasphaseinternal_C_pretrained_C_vib1 or solutesmd500isolated_chloroform_H_chloroform_H.
14
+
15
+ Test sets:
16
+ gasphaseinternal internal GDB test set, gas phase
17
+ gasphasedft8k external DFT8K benchmark (Guan/Paton), gas phase
18
+ qcdtraj2500 ~2500 molecules with quasi-classical dynamics trajectories
19
+ solutesmd500 500 solutes, solvated poses from classical MD
20
+ solutesmd500isolated the same 500 solutes, isolated (single-molecule) poses
21
+ solutesmd50super 50 "super" test solutes (solvent-correction outputs)
22
+ solutesmd8superopenmm 8 "super" solutes (OpenMM)
23
+
24
+ Models:
25
+ pretrained MagNET foundation model (trained on vibrated geometries)
26
+ pretrained_stationary MagNET trained only on stationary geometries (the Figure S10 ablation)
27
+ chloroform/benzene/methanol/tip4p MagNET-x solvent-specific models
28
+
29
+ Geometry suffix on the gas-phase sets:
30
+ vib0 stationary (equilibrium) test geometry
31
+ vib1 vibrated test geometry
32
+
33
+ Each group loads back with `load()` to the original {molecule_key: tuple} object. The tuple layout
34
+ differs by family (this matters for computing errors correctly):
35
+ gasphaseinternal, gasphasedft8k : (prediction, target, atomic_numbers, mask) mask selects the
36
+ scored nucleus; the error is over prediction[mask]-target[mask].
37
+ solutesmd500, solutesmd500isolated : (target, prediction) already one nucleus, no mask.
38
+ solutesmd50super : (molecule_name, smiles, prediction, target) these are solvent CORRECTIONS.
39
+ solutesmd8superopenmm : four float arrays of shape (n_frames, n_atoms); pairing is not documented,
40
+ so use load() and interpret them yourself.
41
+ qcdtraj2500 : (stationary_prediction, stationary_target, trajectory_prediction, trajectory_target)
42
+ where the trajectory arrays are (n_replicates, n_frames, n_scored).
43
+ The QCD *correction* (SI Table S5) is the trajectory mean minus the
44
+ stationary value; use qcd_correction_stats(), NOT stats().
45
+
46
+ `stats(group)` gives the (median, MAE, RMSE, n) of the per-atom errors for the gas-phase and MD-solute
47
+ families (SI Tables S3/S4). For the QCD family use `qcd_correction_stats(group)` (SI Table S5). Both
48
+ raise on a family they do not handle rather than return a wrong number.
49
+ """
50
+ import numpy as np
51
+ import h5py
52
+
53
+ SCALE = 1e4
54
+ NAN_SENTINEL = np.iinfo(np.int32).min
55
+
56
+
57
+ def _dec_float(a):
58
+ a = np.asarray(a)
59
+ return np.where(a == NAN_SENTINEL, np.nan, a / SCALE).astype(np.float32)
60
+
61
+
62
+ def available(path):
63
+ """Return the sorted list of test-set group names in the file."""
64
+ with h5py.File(path, "r") as h:
65
+ return sorted(h.keys())
66
+
67
+
68
+ def _fetch(h, g, name):
69
+ """Read dataset g/name, following a content-addressed dedup reference if present."""
70
+ if name in g:
71
+ return g[name][()]
72
+ ref = g.attrs.get(name + "_ref")
73
+ if ref is not None:
74
+ return h[ref][()]
75
+ raise KeyError(f"{name} missing in {g.name}")
76
+
77
+
78
+ def _decode_group(h, g):
79
+ n = int(g.attrs["n"])
80
+ tlen = int(g.attrs["tuple_len"])
81
+ kk = g.attrs["key_kind"]
82
+ if kk == "str":
83
+ raw = g["keys"][()]
84
+ keys = [k.decode() if isinstance(k, bytes) else str(k) for k in raw]
85
+ else:
86
+ raw = _fetch(h, g, "keys")
87
+ if kk == "tuple":
88
+ keys = [tuple(int(x) for x in row) for row in raw]
89
+ else:
90
+ keys = [int(x) for x in raw]
91
+
92
+ cols = []
93
+ for p in range(tlen):
94
+ if f"p{p}_str" in g:
95
+ s = g[f"p{p}_str"][()]
96
+ cols.append(("str", [x.decode() if isinstance(x, bytes) else str(x) for x in s]))
97
+ continue
98
+ kind = g.attrs[f"p{p}_kind"]
99
+ data = _fetch(h, g, f"p{p}_data")
100
+ shapes = _fetch(h, g, f"p{p}_shapes")
101
+ offs = _fetch(h, g, f"p{p}_offsets")
102
+ if kind == "float":
103
+ data = _dec_float(data)
104
+ elif kind == "bool":
105
+ data = data.astype(bool)
106
+ else:
107
+ data = data.astype(np.int64)
108
+ vals = [data[offs[i]:offs[i + 1]].reshape(tuple(int(x) for x in shapes[i])) for i in range(n)]
109
+ cols.append((kind, vals))
110
+ return {keys[i]: tuple(cols[p][1][i] for p in range(tlen)) for i in range(n)}
111
+
112
+
113
+ def load(path, group):
114
+ """Load one test-set group back to its original {molecule_key: tuple} dict."""
115
+ with h5py.File(path, "r") as h:
116
+ if group not in h:
117
+ raise KeyError(f"{group!r} not in file; see available()")
118
+ return _decode_group(h, h[group])
119
+
120
+
121
+ def _require_aligned(h, g, group, *positions):
122
+ """The flat computation below pairs elements across columns by position, which is only equivalent
123
+ to the per-molecule form when the columns share the same per-molecule boundaries. Every column
124
+ stores those boundaries as `p{p}_offsets`; require them to match so malformed input (a molecule
125
+ whose prediction/target/mask arrays are not the same length) is refused loudly instead of pairing
126
+ the wrong atoms silently."""
127
+ offs = [_fetch(h, g, f"p{p}_offsets") for p in positions]
128
+ if not all(np.array_equal(offs[0], o) for o in offs[1:]):
129
+ raise ValueError(f"{group} has misaligned per-molecule lengths across columns "
130
+ f"{positions}; the stored arrays are inconsistent. Use load() and inspect.")
131
+
132
+
133
+ def _flat_error_array(h, g, group):
134
+ """Per-atom |prediction - target| for every scored atom in a group, computed straight from the
135
+ flat column arrays without rebuilding the per-molecule dict. Each column is stored concatenated in
136
+ molecule order with matching per-molecule boundaries (checked by _require_aligned), so a
137
+ whole-column subtraction (plus the stored mask, for the gas-phase family) gives the per-atom errors
138
+ for every recognized tuple layout. Returns None for an unrecognized layout, so the caller can
139
+ refuse rather than guess."""
140
+ tlen = int(g.attrs["tuple_len"])
141
+ if tlen == 2:
142
+ # (target, prediction), already one nucleus, no mask
143
+ _require_aligned(h, g, group, 0, 1)
144
+ target = _dec_float(_fetch(h, g, "p0_data"))
145
+ pred = _dec_float(_fetch(h, g, "p1_data"))
146
+ return np.abs(pred.ravel() - target.ravel())
147
+ if tlen == 4 and "p3_str" not in g and g.attrs.get("p3_kind") == "bool":
148
+ # (prediction, target, atomic_numbers, mask); the mask selects the scored nucleus
149
+ _require_aligned(h, g, group, 0, 1, 3)
150
+ pred = _dec_float(_fetch(h, g, "p0_data"))
151
+ target = _dec_float(_fetch(h, g, "p1_data"))
152
+ mask = _fetch(h, g, "p3_data").astype(bool)
153
+ return np.abs(pred[mask] - target[mask])
154
+ if tlen == 4 and "p0_str" in g:
155
+ # (name, smiles, prediction, target) - solvent-correction predictions
156
+ _require_aligned(h, g, group, 2, 3)
157
+ pred = _dec_float(_fetch(h, g, "p2_data"))
158
+ target = _dec_float(_fetch(h, g, "p3_data"))
159
+ return np.abs(pred.ravel() - target.ravel())
160
+ return None
161
+
162
+
163
+ def errors(path, group):
164
+ """Flat array of per-atom absolute errors |prediction - target| for a per-atom group
165
+ (gas-phase and MD-solute families - SI Tables S3/S4).
166
+
167
+ Refuses the QCD family (raise), whose accuracy is a trajectory *correction*, not a per-atom
168
+ shielding error - use qcd_correction_errors() for that. Also refuses any tuple layout it does not
169
+ recognize (for example solutesmd8superopenmm, whose four float arrays have no documented pairing);
170
+ load() that group and interpret it yourself.
171
+ """
172
+ if group.startswith("qcdtraj"):
173
+ raise ValueError(f"{group} is a QCD trajectory set; its accuracy is a correction "
174
+ f"(SI Table S5). Use qcd_correction_errors()/qcd_correction_stats().")
175
+ with h5py.File(path, "r") as h:
176
+ if group not in h:
177
+ raise KeyError(f"{group!r} not in file; see available()")
178
+ e = _flat_error_array(h, h[group], group)
179
+ if e is None:
180
+ raise ValueError(f"{group} has an unrecognized tuple layout for per-atom errors; "
181
+ f"use load() and compute directly.")
182
+ return e
183
+
184
+
185
+ def stats(path, group):
186
+ """(median_ae, mae, rmse, n) over all scored atoms in a per-atom group - the SI Table S3/S4
187
+ quantities. Raises for the QCD family (use qcd_correction_stats)."""
188
+ d = errors(path, group)
189
+ return float(np.median(d)), float(d.mean()), float((d ** 2).mean() ** 0.5), int(d.size)
190
+
191
+
192
+ def qcd_correction_errors(path, group):
193
+ """Flat per-atom absolute errors in the QCD *correction* for a qcdtraj2500 group - the quantity
194
+ behind SI Table S5. The correction is the trajectory-averaged shielding minus the stationary
195
+ shielding; the error is |predicted_correction - DFT_correction|, per scored atom.
196
+
197
+ Each molecule stores (stationary_prediction, stationary_target, trajectory_prediction,
198
+ trajectory_target); the trajectory arrays are (n_replicates, n_frames, n_scored).
199
+ """
200
+ if not group.startswith("qcdtraj"):
201
+ raise ValueError(f"{group} is not a QCD trajectory set; use errors()/stats().")
202
+ d = load(path, group)
203
+ parts = []
204
+ for stat_pred, stat_target, traj_pred, traj_target in d.values():
205
+ pred_corr = np.asarray(traj_pred).mean(axis=(0, 1)) - np.asarray(stat_pred)
206
+ dft_corr = np.asarray(traj_target).mean(axis=(0, 1)) - np.asarray(stat_target)
207
+ parts.append(np.abs(pred_corr - dft_corr))
208
+ return np.concatenate(parts)
209
+
210
+
211
+ def qcd_correction_stats(path, group):
212
+ """(median_ae, mae, rmse, n) of the QCD correction errors for a qcdtraj2500 group - SI Table S5."""
213
+ e = qcd_correction_errors(path, group)
214
+ return float(np.median(e)), float(e.mean()), float((e ** 2).mean() ** 0.5), int(e.size)
215
+
216
+
217
+ if __name__ == "__main__":
218
+ import sys
219
+ p = sys.argv[1] if len(sys.argv) > 1 else "magnet_test_predictions.hdf5"
220
+ names = available(p)
221
+ print(f"{len(names)} groups.")
222
+ print("Tables S3/S4 (per-atom error):")
223
+ for g in ("gasphaseinternal_C_pretrained_C_vib1", "solutesmd500isolated_chloroform_H_chloroform_H"):
224
+ if g in names:
225
+ print(f" {g}: median/MAE/RMSE/n =", stats(p, g))
226
+ print("Table S5 (QCD correction):")
227
+ for g in ("qcdtraj2500_H_pretrained_H", "qcdtraj2500_C_pretrained_C"):
228
+ if g in names:
229
+ print(f" {g}: median/MAE/RMSE/n =", qcd_correction_stats(p, g))
data/magnet_test_predictions/test_magnet_test_predictions.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the magnet_test_predictions reader.
2
+
3
+ The fixture builds a tiny archive with the real encoder (`build_magnet_test_predictions.build_file`)
4
+ covering every tuple family, both key kinds, string columns, multi-dimensional trajectory arrays, and
5
+ the content-addressed dedup path - so it is a faithful spec of the on-disk format, and the reader is
6
+ tested end to end against it. Optional tests run against the real file when it is present.
7
+ """
8
+ import os
9
+ import pickle
10
+ import numpy as np
11
+ import pytest
12
+
13
+ import magnet_test_predictions_reader as R
14
+ import build_magnet_test_predictions as B
15
+
16
+
17
+ def _make_source(dirpath):
18
+ """Write tiny pickles, one per family, mimicking the real result files (names drive the reader)."""
19
+ files = {}
20
+
21
+ # gas-phase 4-tuple (prediction, target, atomic_numbers, mask); NaN in a prediction
22
+ files["results_gasphaseinternal_H_pretrained_H_vib1.pickle"] = {
23
+ 10: (np.array([1.0, 2.0, 3.0], np.float32), np.array([1.05, 2.10, 2.90], np.float32),
24
+ np.array([1, 1, 6]), np.array([True, True, False])),
25
+ 11: (np.array([4.0, np.nan], np.float32), np.array([4.20, 5.00], np.float32),
26
+ np.array([1, 8]), np.array([True, False])),
27
+ }
28
+ # a SECOND gas-phase model on the SAME test set: identical target/atoms/mask -> must dedup
29
+ files["results_gasphaseinternal_H_chloroform_H_vib1.pickle"] = {
30
+ 10: (np.array([1.1, 2.1, 3.1], np.float32), np.array([1.05, 2.10, 2.90], np.float32),
31
+ np.array([1, 1, 6]), np.array([True, True, False])),
32
+ 11: (np.array([4.1, np.nan], np.float32), np.array([4.20, 5.00], np.float32),
33
+ np.array([1, 8]), np.array([True, False])),
34
+ }
35
+ # MD-solute 2-tuple (target, prediction), tuple keys
36
+ files["results_solutesmd500isolated_chloroform_C_chloroform_C.pickle"] = {
37
+ (1, 1): (np.array([10.0, 20.0], np.float32), np.array([10.1, 19.8], np.float32)),
38
+ (1, 2): (np.array([30.0], np.float32), np.array([30.5], np.float32)),
39
+ }
40
+ # super-corrections 4-tuple (name, smiles, prediction, target), 2-D arrays, int keys
41
+ files["results_solutesmd50super_corrections_TIP4P_C_tip4p_C.pickle"] = {
42
+ 7: ("MOL1", "CCO", np.array([[-1.0, -2.0]], np.float32), np.array([[-1.2, -1.9]], np.float32)),
43
+ }
44
+ # QCD 4-tuple (stat_pred, stat_target, traj_pred(reps,frames,scored), traj_target)
45
+ tp = np.zeros((2, 3, 2), np.float32); tt = np.zeros((2, 3, 2), np.float32)
46
+ tp[:] = np.array([100.0, 50.0]); tt[:] = np.array([100.0, 50.0])
47
+ tp += 0.4 # predicted trajectory shifted +0.4 from stationary
48
+ tt += 0.3 # DFT trajectory shifted +0.3 -> correction error 0.1 per atom
49
+ files["results_qcdtraj2500_C_pretrained_C.pickle"] = {
50
+ 100: (np.array([100.0, 50.0], np.float32), np.array([100.0, 50.0], np.float32), tp, tt),
51
+ }
52
+ # str-key family
53
+ files["results_solutesmd8superopenmm_TIP4P_C_tip4p_C.pickle"] = {
54
+ "MOLX": tuple(np.full((2, 2), v, np.float32) for v in (1.0, 1.1, 2.0, 2.1)),
55
+ }
56
+ for fn, d in files.items():
57
+ pickle.dump(d, open(os.path.join(dirpath, fn), "wb"))
58
+
59
+
60
+ @pytest.fixture
61
+ def archive(tmp_path):
62
+ src = tmp_path / "src"; src.mkdir()
63
+ _make_source(str(src))
64
+ out = str(tmp_path / "magnet_test_predictions.hdf5")
65
+ nfiles, ndedup = B.build_file(str(src), out)
66
+ assert nfiles == 6
67
+ assert ndedup > 0, "the two gas-phase models share a target/atoms/mask; dedup must trigger"
68
+ return out
69
+
70
+
71
+ def test_available(archive):
72
+ got = R.available(archive)
73
+ assert "gasphaseinternal_H_pretrained_H_vib1" in got
74
+ assert "qcdtraj2500_C_pretrained_C" in got
75
+ assert len(got) == 6
76
+
77
+
78
+ def test_roundtrip_all_families(archive):
79
+ """Every family decodes back to its source pickle exactly (floats within a quantum)."""
80
+ src = os.path.join(os.path.dirname(archive), "src")
81
+ for fn in os.listdir(src):
82
+ group = fn[len("results_"):-len(".pickle")]
83
+ orig = pickle.load(open(os.path.join(src, fn), "rb"))
84
+ dec = R.load(archive, group)
85
+ assert set(dec) == set(orig)
86
+ for k in orig:
87
+ for a, b in zip(orig[k], dec[k]):
88
+ if isinstance(a, str):
89
+ assert a == b
90
+ else:
91
+ a = np.asarray(a); b = np.asarray(b)
92
+ assert a.shape == b.shape
93
+ if a.dtype == bool:
94
+ assert np.array_equal(a, b.astype(bool))
95
+ elif np.issubdtype(a.dtype, np.integer):
96
+ assert np.array_equal(a, b)
97
+ else:
98
+ m = np.isfinite(a)
99
+ assert np.array_equal(np.isnan(a), np.isnan(np.asarray(b, float)))
100
+ if m.any():
101
+ assert np.abs(a[m] - np.asarray(b)[m]).max() < 1e-4
102
+
103
+
104
+ def test_dedup_reference_resolves_to_identical_bytes(archive):
105
+ """The deduped target of the second model must equal the first model's target exactly."""
106
+ import h5py
107
+ with h5py.File(archive, "r") as h:
108
+ # exactly one of the two same-target groups stores p1_data; the other references it
109
+ g1 = h["gasphaseinternal_H_pretrained_H_vib1"]
110
+ g2 = h["gasphaseinternal_H_chloroform_H_vib1"]
111
+ has = [("p1_data" in g) for g in (g1, g2)]
112
+ ref = [("p1_data_ref" in g.attrs) for g in (g1, g2)]
113
+ assert sum(has) == 1 and sum(ref) == 1 and has != ref
114
+ a = R.load(archive, "gasphaseinternal_H_pretrained_H_vib1")
115
+ b = R.load(archive, "gasphaseinternal_H_chloroform_H_vib1")
116
+ for k in a:
117
+ assert np.array_equal(np.asarray(a[k][1]), np.asarray(b[k][1])) # identical DFT target
118
+ assert not np.array_equal(np.asarray(a[k][0]), np.asarray(b[k][0])) # different predictions
119
+
120
+
121
+ def test_errors_apply_mask_and_stats(archive):
122
+ e = R.errors(archive, "gasphaseinternal_H_pretrained_H_vib1")
123
+ assert np.allclose(np.sort(e), [0.05, 0.10, 0.20], atol=1e-4) # mol10 two atoms + mol11 one
124
+ med, mae, rmse, n = R.stats(archive, "gasphaseinternal_H_pretrained_H_vib1")
125
+ assert n == 3 and abs(mae - 0.35 / 3) < 1e-4
126
+
127
+
128
+ def test_tuple_keys_roundtrip(archive):
129
+ d = R.load(archive, "solutesmd500isolated_chloroform_C_chloroform_C")
130
+ assert set(d) == {(1, 1), (1, 2)}
131
+
132
+
133
+ def test_qcd_correction(archive):
134
+ # constructed so every atom's correction error is 0.1
135
+ med, mae, rmse, n = R.qcd_correction_stats(archive, "qcdtraj2500_C_pretrained_C")
136
+ assert abs(mae - 0.1) < 1e-3 and abs(rmse - 0.1) < 1e-3
137
+
138
+
139
+ def test_stats_refuses_qcd(archive):
140
+ with pytest.raises(ValueError):
141
+ R.stats(archive, "qcdtraj2500_C_pretrained_C")
142
+
143
+
144
+ def test_qcd_stats_refuses_nonqcd(archive):
145
+ with pytest.raises(ValueError):
146
+ R.qcd_correction_stats(archive, "gasphaseinternal_H_pretrained_H_vib1")
147
+
148
+
149
+ def test_errors_refuses_unpaired_family(archive):
150
+ # solutesmd8superopenmm is four float arrays with no documented pairing -> must refuse, not guess
151
+ with pytest.raises(ValueError):
152
+ R.errors(archive, "solutesmd8superopenmm_TIP4P_C_tip4p_C")
153
+
154
+
155
+ # ----- optional real-file checks (run locally when the archive is downloaded) -----
156
+ REAL = os.path.join(os.path.dirname(__file__), "magnet_test_predictions.hdf5")
157
+
158
+
159
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real hdf5 not present (runs locally)")
160
+ def test_real_tables_s3_s4():
161
+ # foundation 13C vibrated internal (SI: 0.31917763 / 0.47851510 / 7.13765463)
162
+ med, mae, rmse, n = R.stats(REAL, "gasphaseinternal_C_pretrained_C_vib1")
163
+ assert abs(med - 0.3192) < 1e-3 and abs(mae - 0.4785) < 1e-3 and abs(rmse - 7.1377) < 1e-2
164
+ # foundation 1H isolated chloroform (SI: 0.03216 / 0.07175 / 0.29437)
165
+ med, mae, rmse, n = R.stats(REAL, "solutesmd500isolated_chloroform_H_pretrained_H")
166
+ assert abs(med - 0.0322) < 1e-3 and abs(mae - 0.0718) < 1e-3 and abs(rmse - 0.2944) < 1e-2
167
+
168
+
169
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real hdf5 not present (runs locally)")
170
+ def test_real_table_s5_qcd():
171
+ # SI Table S5: MagNET 1H 0.01526165/0.023047674/0.04020983 ; 13C 0.17198181/0.26223338/0.50198762
172
+ med, mae, rmse, n = R.qcd_correction_stats(REAL, "qcdtraj2500_H_pretrained_H")
173
+ assert abs(med - 0.0152617) < 1e-3 and abs(mae - 0.0230477) < 1e-3 and abs(rmse - 0.0402098) < 1e-3
174
+ med, mae, rmse, n = R.qcd_correction_stats(REAL, "qcdtraj2500_C_pretrained_C")
175
+ assert abs(med - 0.1719818) < 2e-3 and abs(mae - 0.2622334) < 1e-3 and abs(rmse - 0.5019876) < 1e-3
data/scaling_factors/scaling_factors_reader.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reader for the symmetrized MagNET-Zero/MagNET-PCM linear-scaling tables in this folder.
2
+
3
+ These two CSV files (one per nucleus) are a deployment-quality export: the per-solvent coefficients
4
+ that turn a MagNET-Zero gas-phase shielding plus a MagNET-PCM chloroform correction into a predicted
5
+ chemical shift, fit with reflection-symmetrized inference (n_passes=10, symmetrize=True). They are
6
+ the "serving on new molecules" variant of Supporting Information Tables S10/S11; the paper's published
7
+ tables use the unsymmetrized shieldings and live in analysis/code/scaling_factors.py.
8
+
9
+ Each row is one solvent; columns are intercept, stationary (the MagNET-Zero slope), and pcm (the
10
+ MagNET-PCM-correction slope). Predicted shift = intercept + stationary * shielding + pcm * correction.
11
+ """
12
+ import os
13
+
14
+ import pandas as pd
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ NUCLEI = ("H", "C")
18
+
19
+
20
+ def _csv_path(nucleus):
21
+ if nucleus not in NUCLEI:
22
+ raise ValueError(f"nucleus must be one of {NUCLEI}, got {nucleus!r}")
23
+ return os.path.join(HERE, f"scaling_factors_symmetrized_{nucleus}.csv")
24
+
25
+
26
+ def load_symmetrized_tables():
27
+ """Returns {"H": DataFrame, "C": DataFrame}, each indexed by solvent with columns
28
+ intercept / stationary / pcm."""
29
+ return {n: pd.read_csv(_csv_path(n)).set_index("solvent") for n in NUCLEI}
30
+
31
+
32
+ def predict_shift(table, solvent, shielding, correction):
33
+ """Chemical shift for one site: intercept + stationary * shielding + pcm * correction, using
34
+ `table` (one nucleus' DataFrame from load_symmetrized_tables) and its `solvent` row."""
35
+ row = table.loc[solvent]
36
+ return float(row["intercept"] + row["stationary"] * shielding + row["pcm"] * correction)
data/scaling_factors/scaling_factors_symmetrized_C.csv ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ solvent,intercept,stationary,pcm
2
+ chloroform,171.726919,-0.924228,-0.936801
3
+ tetrahydrofuran,171.052274,-0.918997,-1.069506
4
+ dichloromethane,171.507154,-0.921165,-1.115013
5
+ acetone,171.305850,-0.919608,-1.239500
6
+ acetonitrile,171.877492,-0.922626,-1.287661
7
+ dimethylsulfoxide,170.596065,-0.918672,-1.296497
8
+ trifluoroethanol,174.235231,-0.939011,-1.290071
9
+ methanol,172.423799,-0.927563,-1.288843
10
+ TIP4P,173.692750,-0.937848,-1.342659
11
+ benzene,171.965715,-0.927029,-0.593714
12
+ toluene,171.689276,-0.924983,-0.618407
13
+ chlorobenzene,171.073820,-0.921235,-0.997638
data/sigma-concentrate/decode_sigma_concentrate.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_sigma_concentrate.py - reader/decoder for the sigma-concentrate HDF5 dataset.
3
+
4
+ sigma-concentrate holds B3LYP/pcSseg-2 NMR shieldings computed both in the gas phase and with
5
+ an implicit chloroform solvent (PCM), on AIMNet2 stationary geometries for 50,000 molecules
6
+ sampled from sigma-shake. It is the data behind the MagNET-PCM implicit-solvent model, which
7
+ learns the solvent correction shielding_b3lyp_pcm - shielding_b3lyp_gas.
8
+
9
+ The file is flat (a single molecule set, one geometry per molecule):
10
+
11
+ shielding_b3lyp_gas isotropic sigma, B3LYP/pcSseg-2, gas phase
12
+ shielding_b3lyp_pcm isotropic sigma, B3LYP/pcSseg-2, PCM, solvent = chloroform
13
+
14
+ Storage format:
15
+ - Per-atom arrays are concatenated across all molecules. Each molecule's atom count is in
16
+ `n_atoms`; the row range for molecule i is [start[i], start[i+1]) where start = cumulative
17
+ sum of n_atoms (reconstructed once, on open).
18
+ - coordinates and shieldings are int32 fixed-point: physical = stored * scale (scale = 1e-4),
19
+ so reconstruction error is <= 5e-5 (Angstrom or ppm). Coordinates are mean-centered per
20
+ molecule (centroid at the origin), a translation only; interatomic distances are unchanged.
21
+ - The molecule's array index IS its identifier (row order is fixed to the source). `sigma_shake_id`
22
+ additionally gives each molecule's id in the sigma-shake dataset (same molecule there, at a
23
+ different level of theory), so the two datasets can be joined.
24
+ - `failed_indices` lists molecules whose shielding calculation failed (none in this release):
25
+ their shieldings would be sentinel zeros and must be ignored. There are no SMILES.
26
+
27
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
28
+ """
29
+ from __future__ import annotations
30
+ import numpy as np
31
+ import h5py
32
+
33
+
34
+ class SigmaConcentrate:
35
+ """Reader for the sigma-concentrate HDF5 file: gas/PCM shieldings on 50,000 sigma-shake molecules."""
36
+
37
+ def __init__(self, path: str):
38
+ self.f = h5py.File(path, "r")
39
+ self.n_molecules = int(self.f.attrs["n_molecules"])
40
+ self.n_atoms_total = int(self.f.attrs["n_atoms"])
41
+ self.level_of_theory = str(self.f.attrs["level_of_theory"])
42
+ self.geometry = str(self.f.attrs["geometry"])
43
+ # atom row offsets: start[i]..start[i+1] for molecule i (one cumsum, cached)
44
+ na = self.f["n_atoms"][:].astype(np.int64)
45
+ self._start = np.empty(self.n_molecules + 1, np.int64)
46
+ self._start[0] = 0
47
+ np.cumsum(na, out=self._start[1:])
48
+ self._sid = self.f["sigma_shake_id"][:]
49
+ self._failed = set(int(x) for x in self.f["failed_indices"][:])
50
+ self.shieldings = sorted(k for k in self.f.keys() if k.startswith("shielding_"))
51
+
52
+ def _check(self, index: int):
53
+ if not 0 <= index < self.n_molecules:
54
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
55
+
56
+ def _scaled(self, name: str, sl: slice) -> np.ndarray:
57
+ d = self.f[name]
58
+ return d[sl].astype(np.float64) * float(d.attrs["scale"])
59
+
60
+ def sigma_shake_id(self, index: int) -> int:
61
+ """Return molecule `index`'s id in the sigma-shake dataset."""
62
+ self._check(index)
63
+ return int(self._sid[index])
64
+
65
+ def molecule(self, index: int) -> dict:
66
+ """Return one molecule's decoded data (physical units) by array index.
67
+
68
+ Keys: index, sigma_shake_id, atomic_numbers, coordinates (n,3 Angstrom),
69
+ shielding_b3lyp_gas (n, ppm), shielding_b3lyp_pcm (n, ppm), and `failed`. If `failed`
70
+ is True the shieldings are sentinel zeros and should be ignored; geometry is still valid.
71
+ """
72
+ self._check(index)
73
+ a, b = int(self._start[index]), int(self._start[index + 1])
74
+ sl = slice(a, b)
75
+ out = {
76
+ "index": index,
77
+ "sigma_shake_id": int(self._sid[index]),
78
+ "atomic_numbers": self.f["atomic_numbers"][sl],
79
+ "coordinates": self._scaled("coordinates", sl), # (n,3) Angstrom
80
+ "failed": index in self._failed,
81
+ }
82
+ for s in self.shieldings:
83
+ out[s] = self._scaled(s, sl) # (n,) ppm
84
+ return out
85
+
86
+ def __len__(self):
87
+ return self.n_molecules
88
+
89
+ def close(self):
90
+ """Close the underlying HDF5 file."""
91
+ self.f.close()
92
+
93
+ def __enter__(self):
94
+ return self
95
+
96
+ def __exit__(self, *a):
97
+ self.close()
98
+
99
+ def __repr__(self):
100
+ return (f"<SigmaConcentrate: {self.n_molecules:,} molecules, "
101
+ f"{self.n_atoms_total:,} atoms, {self.level_of_theory}>")
102
+
103
+
104
+ if __name__ == "__main__":
105
+ import sys
106
+ path = sys.argv[1] if len(sys.argv) > 1 else "sigma-concentrate.hdf5"
107
+ with SigmaConcentrate(path) as ds:
108
+ print(ds)
109
+ m = ds.molecule(0)
110
+ print(f" mol 0: sigma_shake_id={m['sigma_shake_id']} Z={list(m['atomic_numbers'][:6])} "
111
+ f"failed={m['failed']}")
112
+ print(f" gas={np.round(m['shielding_b3lyp_gas'][:3], 4)} "
113
+ f"pcm={np.round(m['shielding_b3lyp_pcm'][:3], 4)}")
data/sigma-concentrate/test_sigma_concentrate.py ADDED
@@ -0,0 +1,162 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the sigma-concentrate reader (decode_sigma_concentrate.SigmaConcentrate).
3
+
4
+ These build a tiny synthetic dataset in the *exact* on-disk format (so the fixture also serves as
5
+ an executable spec of the layout) and exercise the decoder against it. No dependency on the real
6
+ file. One opt-in test runs against the real `sigma-concentrate.hdf5` if it is present next to this
7
+ file.
8
+
9
+ Run: pytest test_sigma_concentrate.py -q
10
+ Requires: pytest, numpy, h5py.
11
+ """
12
+ import os
13
+ import numpy as np
14
+ import h5py
15
+ import pytest
16
+
17
+ from decode_sigma_concentrate import SigmaConcentrate
18
+
19
+ SCALE = 1e-4
20
+
21
+
22
+ def _i32(x):
23
+ return np.round(np.asarray(x, float) / SCALE).astype(np.int32)
24
+
25
+
26
+ def _write(f, *, n_atoms, znums, sids, coords, gas, pcm, failed):
27
+ assert len(znums) == int(np.sum(n_atoms))
28
+ f.attrs["dataset"] = "sigma-concentrate"
29
+ f.attrs["level_of_theory"] = "B3LYP/pcSseg-2"
30
+ f.attrs["geometry"] = "AIMNet2 stationary"
31
+ f.attrs["solvent"] = "gas + PCM chloroform"
32
+ f.attrs["n_molecules"] = len(n_atoms)
33
+ f.attrs["n_atoms"] = int(np.sum(n_atoms))
34
+ f.create_dataset("atomic_numbers", data=np.asarray(znums, np.int8))
35
+ f.create_dataset("n_atoms", data=np.asarray(n_atoms, np.uint8))
36
+ f.create_dataset("sigma_shake_id", data=np.asarray(sids, np.int32))
37
+ d = f.create_dataset("coordinates", data=_i32(coords)); d.attrs["scale"] = SCALE; d.attrs["units"] = "angstrom"
38
+ d = f.create_dataset("shielding_b3lyp_gas", data=_i32(gas)); d.attrs["scale"] = SCALE; d.attrs["units"] = "ppm"
39
+ d = f.create_dataset("shielding_b3lyp_pcm", data=_i32(pcm)); d.attrs["scale"] = SCALE; d.attrs["units"] = "ppm"
40
+ f.create_dataset("failed_indices", data=np.asarray(failed, np.int32))
41
+
42
+
43
+ # 3 molecules (2,1,3 atoms = 6); heteroatoms exercise the passthrough; molecule 1 failed
44
+ NATOMS = [2, 1, 3]
45
+ Z = [6, 1, 8, 6, 7, 1]
46
+ SIDS = [1000265, 42, 999808]
47
+ COORDS = np.array([[0, 0, 0], [1.2345, 0, 0],
48
+ [5, 5, 5],
49
+ [3, 0, 0], [4.1, 0, 0], [3, 1.5, 0]], float)
50
+ GAS = np.array([180.1234, 31.0, 0.0, 150.0, 151.5, 29.9]) # mol 1 (atom 2) = sentinel 0
51
+ PCM = np.array([180.4567, 30.8, 0.0, 149.6, 151.2, 29.7])
52
+
53
+
54
+ @pytest.fixture(scope="module")
55
+ def mini_path(tmp_path_factory):
56
+ p = str(tmp_path_factory.mktemp("sc") / "mini.hdf5")
57
+ with h5py.File(p, "w") as f:
58
+ _write(f, n_atoms=NATOMS, znums=Z, sids=SIDS, coords=COORDS, gas=GAS, pcm=PCM, failed=[1])
59
+ return p
60
+
61
+
62
+ @pytest.fixture
63
+ def ds(mini_path):
64
+ with SigmaConcentrate(mini_path) as d:
65
+ yield d
66
+
67
+
68
+ def test_dimensions(ds):
69
+ assert ds.n_molecules == 3 and ds.n_atoms_total == 6
70
+ assert ds.shieldings == ["shielding_b3lyp_gas", "shielding_b3lyp_pcm"]
71
+ assert len(ds) == 3
72
+
73
+
74
+ def test_offsets_slice_each_molecule(ds):
75
+ assert len(ds.molecule(0)["atomic_numbers"]) == 2
76
+ assert len(ds.molecule(1)["atomic_numbers"]) == 1
77
+ assert len(ds.molecule(2)["atomic_numbers"]) == 3
78
+
79
+
80
+ def test_values_roundtrip_and_id(ds):
81
+ m = ds.molecule(2)
82
+ assert m["sigma_shake_id"] == 999808
83
+ np.testing.assert_array_equal(m["atomic_numbers"], [6, 7, 1]) # heteroatom passthrough
84
+ np.testing.assert_allclose(m["coordinates"], COORDS[3:6], atol=5e-5)
85
+ np.testing.assert_allclose(m["shielding_b3lyp_gas"], GAS[3:6], atol=5e-5)
86
+ np.testing.assert_allclose(m["shielding_b3lyp_pcm"], PCM[3:6], atol=5e-5)
87
+ assert not m["failed"]
88
+
89
+
90
+ def test_both_shieldings_distinct(ds):
91
+ m = ds.molecule(0)
92
+ # gas and pcm differ atom-by-atom (the solvent correction is the signal)
93
+ assert not np.allclose(m["shielding_b3lyp_gas"], m["shielding_b3lyp_pcm"])
94
+
95
+
96
+ def test_sigma_shake_id_accessor(ds):
97
+ assert ds.sigma_shake_id(0) == 1000265
98
+ assert ds.sigma_shake_id(1) == 42
99
+
100
+
101
+ def test_failed_flag(ds):
102
+ assert ds.molecule(1)["failed"] is True
103
+ assert ds.molecule(0)["failed"] is False
104
+ f = ds.molecule(1)
105
+ # failed molecule's shieldings are sentinel zeros; geometry/id still valid
106
+ assert np.all(f["shielding_b3lyp_gas"] == 0) and np.all(f["shielding_b3lyp_pcm"] == 0)
107
+ assert f["sigma_shake_id"] == 42
108
+ np.testing.assert_allclose(f["coordinates"], COORDS[2:3], atol=5e-5)
109
+
110
+
111
+ def test_empty_failed_indices(tmp_path):
112
+ p = str(tmp_path / "nofail.hdf5")
113
+ with h5py.File(p, "w") as f:
114
+ _write(f, n_atoms=[2], znums=[6, 1], sids=[7],
115
+ coords=np.array([[0, 0, 0], [1.0, 0, 0]], float),
116
+ gas=np.array([150.0, 30.0]), pcm=np.array([149.5, 29.8]), failed=[])
117
+ with SigmaConcentrate(p) as d:
118
+ assert d.molecule(0)["failed"] is False
119
+
120
+
121
+ def test_index_out_of_range_raises(ds):
122
+ with pytest.raises(IndexError):
123
+ ds.molecule(-1)
124
+ with pytest.raises(IndexError):
125
+ ds.molecule(3)
126
+
127
+
128
+ def test_context_manager_closes(mini_path):
129
+ d = SigmaConcentrate(mini_path)
130
+ d.close()
131
+ assert not d.f.id.valid
132
+
133
+
134
+ # ---------- opt-in: real file, if present ----------
135
+ REAL = os.path.join(os.path.dirname(__file__), "sigma-concentrate.hdf5")
136
+
137
+
138
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real sigma-concentrate.hdf5 not present")
139
+ def test_real_file():
140
+ with SigmaConcentrate(REAL) as ds:
141
+ assert ds.n_molecules == 50_000 and ds.n_atoms_total == 1_413_134
142
+ assert len(ds.f["failed_indices"]) == 0
143
+ # gas and pcm are distinct columns; PCM differs from gas on carbon
144
+ Z = ds.f["atomic_numbers"][:500_000]
145
+ gas = ds.f["shielding_b3lyp_gas"][:500_000].astype(float) * SCALE
146
+ pcm = ds.f["shielding_b3lyp_pcm"][:500_000].astype(float) * SCALE
147
+ C = Z == 6
148
+ assert gas[C].mean() != pcm[C].mean()
149
+ # sigma_shake_id is populated and plausibly a sigma-shake id (positive)
150
+ assert ds.sigma_shake_id(0) > 0
151
+
152
+
153
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real sigma-concentrate.hdf5 not present")
154
+ def test_real_file_reproduces_table_s8_site_counts():
155
+ """SI Table S8's 'sigma-concentrate' row is this file exactly, and (together with
156
+ sigma-pepper's wp04_wb97xd_pcSseg2 group -- see test_sigma_pepper.py's matching test) these
157
+ 50,000 reused molecules make up the other half of the 'MagNET-Zero (fine-tuning)' row:
158
+ 3,287,698 + 754,437 = 4,042,135 H sites; 2,028,642 + 528,569 = 2,557,211 C sites."""
159
+ with SigmaConcentrate(REAL) as ds:
160
+ z = ds.f["atomic_numbers"][:]
161
+ assert int((z == 1).sum()) == 754_437
162
+ assert int((z == 6).sum()) == 528_569
data/sigma-pepper/decode_sigma_pepper.py ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_sigma_pepper.py - reader/decoder for the sigma-pepper HDF5 dataset.
3
+
4
+ sigma-pepper holds computed gas-phase NMR shieldings on AIMNet2 stationary geometries for
5
+ GDB-derived molecules; it is the data behind the MagNET-Zero model. The file has TWO groups,
6
+ one per level of theory:
7
+
8
+ /pbe0_pcSseg1 GDB 1-10 heavy atoms, PBE0/pcSseg-1, one shielding per atom
9
+ /wp04_wb97xd_pcSseg2 GDB 1-9 heavy atoms, two shieldings per atom; the `shieldings` list is
10
+ sorted alphabetically, so the order is shielding_wb97xd (13C reference)
11
+ then shielding_wp04 (1H reference)
12
+
13
+ Storage format:
14
+ - Per-atom arrays are concatenated across all molecules in a group. Each molecule's atom
15
+ count is in `n_atoms`; the row range for molecule i is [start[i], start[i+1]) where
16
+ start = cumulative sum of n_atoms (reconstructed once, on open).
17
+ - coordinates and shieldings are int32 fixed-point: physical = stored * scale (scale = 1e-4),
18
+ so reconstruction error is <= 5e-5 (Angstrom or ppm). Coordinates are mean-centered per
19
+ molecule (centroid at the origin), a translation only; interatomic distances are unchanged.
20
+ - SMILES are stored as a single concatenated UTF-8 buffer (`smiles_buffer`) plus
21
+ `smiles_offsets`; molecule i's SMILES is buffer[offsets[i]:offsets[i+1]].
22
+ - The molecule's array index IS its identifier (row order is fixed to the source).
23
+ - `failed_indices` lists molecules whose shielding calculation failed: their shieldings are
24
+ sentinel zeros and must be ignored. Geometry and SMILES remain valid.
25
+
26
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
27
+ """
28
+ from __future__ import annotations
29
+ import numpy as np
30
+ import h5py
31
+
32
+
33
+ class Component:
34
+ """One group of sigma-pepper (a single level of theory)."""
35
+
36
+ def __init__(self, grp: h5py.Group):
37
+ self.g = grp
38
+ self.name = grp.name.lstrip("/")
39
+ self.n_molecules = int(grp.attrs["n_molecules"])
40
+ self.n_atoms = int(grp.attrs["n_atoms"])
41
+ self.level_of_theory = str(grp.attrs["level_of_theory"])
42
+ self.geometry = str(grp.attrs["geometry"])
43
+ self.gdb_range = str(grp.attrs["gdb_range"])
44
+ # atom row offsets: start[i]..start[i+1] for molecule i (one cumsum, cached)
45
+ na = grp["n_atoms"][:].astype(np.int64)
46
+ self._start = np.empty(self.n_molecules + 1, np.int64)
47
+ self._start[0] = 0
48
+ np.cumsum(na, out=self._start[1:])
49
+ # smiles buffer (small) + offsets, read once
50
+ self._sbuf = grp["smiles_buffer"][:].tobytes()
51
+ self._soff = grp["smiles_offsets"][:]
52
+ self._failed = set(int(x) for x in grp["failed_indices"][:])
53
+ self.shieldings = sorted(k for k in grp.keys() if k.startswith("shielding_"))
54
+
55
+ def _check(self, index: int):
56
+ if not 0 <= index < self.n_molecules:
57
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
58
+
59
+ def smiles(self, index: int) -> str:
60
+ """Return molecule `index`'s SMILES string."""
61
+ self._check(index)
62
+ return self._sbuf[self._soff[index]:self._soff[index + 1]].decode("utf-8")
63
+
64
+ def _scaled(self, name: str, sl: slice) -> np.ndarray:
65
+ d = self.g[name]
66
+ return d[sl].astype(np.float64) * float(d.attrs["scale"])
67
+
68
+ def molecule(self, index: int) -> dict:
69
+ """Return one molecule's decoded data (physical units) by array index.
70
+
71
+ Keys: index, smiles, atomic_numbers, coordinates (n,3 Angstrom), one entry per
72
+ shielding dataset (n, ppm), and `failed`. If `failed` is True the shieldings are
73
+ sentinel zeros and should be ignored; geometry and SMILES are still valid.
74
+ """
75
+ self._check(index)
76
+ a, b = int(self._start[index]), int(self._start[index + 1])
77
+ sl = slice(a, b)
78
+ out = {
79
+ "index": index,
80
+ "smiles": self.smiles(index),
81
+ "atomic_numbers": self.g["atomic_numbers"][sl],
82
+ "coordinates": self._scaled("coordinates", sl), # (n,3) Angstrom
83
+ "failed": index in self._failed,
84
+ }
85
+ for s in self.shieldings:
86
+ out[s] = self._scaled(s, sl) # (n,) ppm
87
+ return out
88
+
89
+ def __len__(self):
90
+ return self.n_molecules
91
+
92
+ def __repr__(self):
93
+ return (f"<Component /{self.name}: {self.n_molecules:,} molecules, "
94
+ f"{self.n_atoms:,} atoms, {self.level_of_theory}>")
95
+
96
+
97
+ class SigmaPepper:
98
+ """Reader for a sigma-pepper HDF5 file; exposes each level-of-theory group as a Component."""
99
+
100
+ def __init__(self, path: str):
101
+ self.f = h5py.File(path, "r")
102
+ self.components = {name: Component(self.f[name]) for name in self.f.keys()
103
+ if isinstance(self.f[name], h5py.Group)}
104
+
105
+ @property
106
+ def group_names(self):
107
+ """List of level-of-theory group names in the file."""
108
+ return list(self.components)
109
+
110
+ def __getitem__(self, name: str) -> Component:
111
+ return self.components[name]
112
+
113
+ def close(self):
114
+ """Close the underlying HDF5 file."""
115
+ self.f.close()
116
+
117
+ def __enter__(self):
118
+ return self
119
+
120
+ def __exit__(self, *a):
121
+ self.close()
122
+
123
+
124
+ if __name__ == "__main__":
125
+ import sys
126
+ path = sys.argv[1] if len(sys.argv) > 1 else "sigma-pepper.hdf5"
127
+ with SigmaPepper(path) as ds:
128
+ for name in ds.group_names:
129
+ c = ds[name]
130
+ print(c)
131
+ m = c.molecule(0)
132
+ extra = " ".join(f"{s}={np.round(m[s][:3], 4)}" for s in c.shieldings)
133
+ print(f" mol 0: {m['smiles']} Z={list(m['atomic_numbers'][:6])} failed={m['failed']}")
134
+ print(f" {extra}")
data/sigma-pepper/test_sigma_pepper.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the sigma-pepper reader (decode_sigma_pepper.SigmaPepper).
3
+
4
+ These build a tiny synthetic two-group dataset in the *exact* on-disk format (so the fixture
5
+ also serves as an executable spec of the layout) and exercise the decoder against it. No
6
+ dependency on the multi-hundred-MB real file. One opt-in test runs against the real
7
+ `sigma-pepper.hdf5` if it is present next to this file.
8
+
9
+ Run: pytest test_sigma_pepper.py -q
10
+ Requires: pytest, numpy, h5py.
11
+ """
12
+ import os
13
+ import numpy as np
14
+ import h5py
15
+ import pytest
16
+
17
+ from decode_sigma_pepper import SigmaPepper
18
+
19
+ SCALE = 1e-4
20
+
21
+
22
+ def _i32(x):
23
+ return np.round(np.asarray(x, float) / SCALE).astype(np.int32)
24
+
25
+
26
+ def _write_group(f, name, *, n_atoms, znums, smiles, coords, shieldings, failed, attrs):
27
+ """shieldings: dict name->per-atom array; failed: list of molecule indices."""
28
+ g = f.create_group(name)
29
+ g.attrs.update(attrs)
30
+ g.attrs["n_molecules"] = len(n_atoms)
31
+ g.attrs["n_atoms"] = int(np.sum(n_atoms))
32
+ assert len(znums) == int(np.sum(n_atoms))
33
+ g.create_dataset("atomic_numbers", data=np.asarray(znums, np.int8))
34
+ g.create_dataset("n_atoms", data=np.asarray(n_atoms, np.uint8))
35
+ d = g.create_dataset("coordinates", data=_i32(coords)); d.attrs["scale"] = SCALE; d.attrs["units"] = "angstrom"
36
+ for sname, arr in shieldings.items():
37
+ d = g.create_dataset(sname, data=_i32(arr)); d.attrs["scale"] = SCALE; d.attrs["units"] = "ppm"
38
+ g.create_dataset("failed_indices", data=np.asarray(failed, np.int32))
39
+ enc = [s.encode("utf-8") for s in smiles]
40
+ off = np.concatenate([[0], np.cumsum([len(e) for e in enc])]).astype(np.int64)
41
+ g.create_dataset("smiles_buffer", data=np.frombuffer(b"".join(enc), np.uint8))
42
+ g.create_dataset("smiles_offsets", data=off)
43
+
44
+
45
+ # group A: 3 molecules (2,1,3 atoms = 6), one shielding, molecule 1 failed
46
+ A_NATOMS = [2, 1, 3]
47
+ A_Z = [6, 1, 8, 6, 7, 1] # heteroatoms exercise the passthrough
48
+ A_SMILES = ["CC", "O", "CCC"]
49
+ A_COORDS = np.array([[0, 0, 0], [1.2345, 0, 0],
50
+ [5, 5, 5],
51
+ [3, 0, 0], [4.1, 0, 0], [3, 1.5, 0]], float)
52
+ A_PBE0 = np.array([180.1234, 31.0, 0.0, 150.0, 151.5, 29.9]) # mol 1 (atom idx 2) = sentinel 0
53
+ # group B: 2 molecules (3,2 atoms = 5), two shieldings, molecule 0 failed
54
+ B_NATOMS = [3, 2]
55
+ B_Z = [6, 8, 1, 6, 7]
56
+ B_SMILES = ["CCO", "CN"]
57
+ B_COORDS = np.array([[0, 0, 0], [1, 0, 0], [2, 0, 0],
58
+ [9, 9, 9], [9.5, 9, 9]], float)
59
+ B_WB97 = np.array([0.0, 0.0, 0.0, 110.0, 28.0]) # mol 0 failed (all zero)
60
+ B_WP04 = np.array([0.0, 0.0, 0.0, 98.0, 28.3])
61
+
62
+
63
+ @pytest.fixture(scope="module")
64
+ def mini_path(tmp_path_factory):
65
+ p = str(tmp_path_factory.mktemp("sp") / "mini.hdf5")
66
+ with h5py.File(p, "w") as f:
67
+ _write_group(f, "pbe0_pcSseg1", n_atoms=A_NATOMS, znums=A_Z, smiles=A_SMILES, coords=A_COORDS,
68
+ shieldings={"shielding_pbe0": A_PBE0}, failed=[1],
69
+ attrs=dict(level_of_theory="PBE0/pcSseg-1, gas",
70
+ geometry="AIMNet2 stationary (fmax=0.01)", gdb_range="1-10 heavy"))
71
+ _write_group(f, "wp04_wb97xd_pcSseg2", n_atoms=B_NATOMS, znums=B_Z, smiles=B_SMILES, coords=B_COORDS,
72
+ shieldings={"shielding_wb97xd": B_WB97, "shielding_wp04": B_WP04}, failed=[0],
73
+ attrs=dict(level_of_theory="WP04 + wB97X-D /pcSseg-2, gas",
74
+ geometry="AIMNet2 stationary (fmax=1e-4)", gdb_range="1-9 heavy"))
75
+ return p
76
+
77
+
78
+ @pytest.fixture
79
+ def ds(mini_path):
80
+ with SigmaPepper(mini_path) as d:
81
+ yield d
82
+
83
+
84
+ def test_group_names(ds):
85
+ assert set(ds.group_names) == {"pbe0_pcSseg1", "wp04_wb97xd_pcSseg2"}
86
+
87
+
88
+ def test_component_dimensions(ds):
89
+ a = ds["pbe0_pcSseg1"]
90
+ assert a.n_molecules == 3 and a.n_atoms == 6
91
+ b = ds["wp04_wb97xd_pcSseg2"]
92
+ assert b.n_molecules == 2 and b.n_atoms == 5
93
+ assert b.shieldings == ["shielding_wb97xd", "shielding_wp04"]
94
+
95
+
96
+ def test_offsets_slice_each_molecule(ds):
97
+ a = ds["pbe0_pcSseg1"]
98
+ assert len(a.molecule(0)["atomic_numbers"]) == 2
99
+ assert len(a.molecule(1)["atomic_numbers"]) == 1
100
+ assert len(a.molecule(2)["atomic_numbers"]) == 3
101
+
102
+
103
+ def test_values_roundtrip_and_smiles(ds):
104
+ m = ds["pbe0_pcSseg1"].molecule(2)
105
+ assert m["smiles"] == "CCC"
106
+ np.testing.assert_array_equal(m["atomic_numbers"], [6, 7, 1]) # heteroatom passthrough
107
+ np.testing.assert_allclose(m["coordinates"], A_COORDS[3:6], atol=5e-5)
108
+ np.testing.assert_allclose(m["shielding_pbe0"], A_PBE0[3:6], atol=5e-5)
109
+ assert not m["failed"]
110
+
111
+
112
+ def test_two_shieldings_present(ds):
113
+ m = ds["wp04_wb97xd_pcSseg2"].molecule(1)
114
+ assert m["smiles"] == "CN"
115
+ np.testing.assert_allclose(m["shielding_wp04"], B_WP04[3:5], atol=5e-5)
116
+ np.testing.assert_allclose(m["shielding_wb97xd"], B_WB97[3:5], atol=5e-5)
117
+
118
+
119
+ def test_failed_flag(ds):
120
+ assert ds["pbe0_pcSseg1"].molecule(1)["failed"] is True
121
+ assert ds["pbe0_pcSseg1"].molecule(0)["failed"] is False
122
+ b0 = ds["wp04_wb97xd_pcSseg2"].molecule(0)
123
+ assert b0["failed"] is True
124
+ # failed molecule's shieldings are sentinel zeros (the whole point of the flag)
125
+ assert np.all(b0["shielding_wp04"] == 0) and np.all(b0["shielding_wb97xd"] == 0)
126
+ # geometry/smiles still valid for a failed molecule
127
+ assert b0["smiles"] == "CCO"
128
+ np.testing.assert_allclose(b0["coordinates"], B_COORDS[0:3], atol=5e-5)
129
+
130
+
131
+ def test_empty_failed_indices(tmp_path):
132
+ p = str(tmp_path / "nofail.hdf5")
133
+ with h5py.File(p, "w") as f:
134
+ _write_group(f, "pbe0_pcSseg1", n_atoms=[2], znums=[6, 1], smiles=["CO"],
135
+ coords=np.array([[0, 0, 0], [1.0, 0, 0]], float),
136
+ shieldings={"shielding_pbe0": np.array([150.0, 30.0])}, failed=[],
137
+ attrs=dict(level_of_theory="x", geometry="x", gdb_range="x"))
138
+ with SigmaPepper(p) as d:
139
+ assert d["pbe0_pcSseg1"].molecule(0)["failed"] is False
140
+
141
+
142
+ def test_smiles_accessor(ds):
143
+ assert ds["pbe0_pcSseg1"].smiles(0) == "CC"
144
+ assert ds["wp04_wb97xd_pcSseg2"].smiles(1) == "CN"
145
+
146
+
147
+ def test_index_out_of_range_raises(ds):
148
+ with pytest.raises(IndexError):
149
+ ds["pbe0_pcSseg1"].molecule(-1)
150
+ with pytest.raises(IndexError):
151
+ ds["pbe0_pcSseg1"].molecule(3)
152
+
153
+
154
+ def test_context_manager_closes(mini_path):
155
+ d = SigmaPepper(mini_path)
156
+ d.close()
157
+ assert not d.f.id.valid
158
+
159
+
160
+ # ---------- opt-in: real file, if present ----------
161
+ REAL = os.path.join(os.path.dirname(__file__), "sigma-pepper.hdf5")
162
+
163
+
164
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real sigma-pepper.hdf5 not present")
165
+ def test_real_file():
166
+ with SigmaPepper(REAL) as ds:
167
+ a = ds["pbe0_pcSseg1"]
168
+ b = ds["wp04_wb97xd_pcSseg2"]
169
+ assert a.n_molecules == 2_167_210 and a.n_atoms == 45_418_890
170
+ assert b.n_molecules == 320_105 and b.n_atoms == 6_113_101
171
+ assert len(a.g["failed_indices"]) == 1
172
+ assert len(b.g["failed_indices"]) == 472
173
+ # column identity: WP04 must be lower than wB97XD on carbon
174
+ Z = b.g["atomic_numbers"][:2_000_000]
175
+ wp = b.g["shielding_wp04"][:2_000_000].astype(float) * SCALE
176
+ wb = b.g["shielding_wb97xd"][:2_000_000].astype(float) * SCALE
177
+ C = (Z == 6) & (wp != 0) & (wb != 0)
178
+ assert wp[C].mean() < wb[C].mean()
179
+
180
+
181
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real sigma-pepper.hdf5 not present")
182
+ def test_real_file_reproduces_table_s8_site_counts():
183
+ """SI Table S8's 'MagNET-Zero (initial round)' row is this file's pbe0_pcSseg1 group exactly.
184
+ The 'MagNET-Zero (fine-tuning)' row is this group's wp04_wb97xd_pcSseg2 PLUS sigma-concentrate
185
+ (the 50,000 reused molecules) -- see test_sigma_concentrate.py's matching test for that half."""
186
+ with SigmaPepper(REAL) as ds:
187
+ a = ds["pbe0_pcSseg1"]
188
+ z_a = a.g["atomic_numbers"][:]
189
+ assert int((z_a == 1).sum()) == 24_047_999
190
+ assert int((z_a == 6).sum()) == 15_330_361
191
+
192
+ b = ds["wp04_wb97xd_pcSseg2"]
193
+ z_b = b.g["atomic_numbers"][:]
194
+ # this group alone is not a Table S8 row (fine-tuning adds sigma-concentrate's atoms too),
195
+ # but its counts are the fixed half of that sum, so lock them down here too.
196
+ assert int((z_b == 1).sum()) == 3_287_698
197
+ assert int((z_b == 6).sum()) == 2_028_642
data/sigma-shake/decode_sigma_shake.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_sigma_shake.py - reader/decoder for the sigma-shake HDF5 dataset.
3
+
4
+ sigma-shake stores PBE0/pcSseg-1 isotropic NMR shieldings for the stationary and
5
+ vibrationally-perturbed geometries of ~4.79M GDB-derived molecules. It is the training
6
+ data for the MagNET foundation model.
7
+
8
+ Storage format:
9
+ - Per-atom arrays are concatenated across all molecules; molecule i occupies
10
+ rows [atom_start[i], atom_end[i]).
11
+ - Coordinates, perturbation, and shieldings are stored as int32 fixed-point.
12
+ Physical value = stored_integer * scale, where `scale` (= 1e-4) is an attribute
13
+ on each dataset. Reconstruction error is <= 5e-5 (Angstrom or ppm).
14
+ - The perturbed geometry is NOT stored directly; it is
15
+ perturbed_coords = stationary_coords + perturbation
16
+ - Where `incomplete_mask` is True, the molecule has no perturbed calculation:
17
+ `perturbation` and `shielding_perturbed` are sentinel zeros and must be ignored.
18
+
19
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
20
+ """
21
+ from __future__ import annotations
22
+ import numpy as np
23
+ import h5py
24
+
25
+
26
+ class SigmaShake:
27
+ """Reader for the sigma-shake HDF5 file: stationary and vibrationally-perturbed shieldings."""
28
+
29
+ def __init__(self, path: str):
30
+ self.f = h5py.File(path, "r")
31
+ self._id = self.f["molecule_id"][:]
32
+ self._start = self.f["atom_start"][:]
33
+ self._end = self.f["atom_end"][:]
34
+ self._id_to_row = None # built lazily on first by_id()
35
+
36
+ # ---- scalars / metadata ----
37
+ @property
38
+ def n_molecules(self) -> int:
39
+ """Total number of molecules in the file."""
40
+ return self._id.shape[0]
41
+
42
+ @property
43
+ def n_atoms(self) -> int:
44
+ """Total number of atoms across all molecules."""
45
+ return int(self.f.attrs["n_atoms"])
46
+
47
+ def _scaled(self, name: str, sl: slice) -> np.ndarray:
48
+ d = self.f[name]
49
+ return d[sl].astype(np.float64) * float(d.attrs["scale"])
50
+
51
+ # ---- molecule access ----
52
+ def molecule(self, index: int) -> dict:
53
+ """Return one molecule's decoded data by ROW index (0..n_molecules-1).
54
+
55
+ For an incomplete molecule (no perturbed calculation) the returned dict OMITS the
56
+ 'perturbation', 'perturbed_coords', and 'shielding_perturbed' keys.
57
+ """
58
+ if not 0 <= index < self.n_molecules:
59
+ raise IndexError(f"molecule index {index} out of range [0, {self.n_molecules})")
60
+ a, b = int(self._start[index]), int(self._end[index])
61
+ sl = slice(a, b)
62
+ Z = self.f["atomic_numbers"][sl]
63
+ stat = self._scaled("stationary_coords", sl)
64
+ incomplete = bool(self.f["incomplete_mask"][a]) # constant within a molecule
65
+ out = {
66
+ "molecule_id": int(self._id[index]),
67
+ "atomic_numbers": Z,
68
+ "stationary_coords": stat, # (n,3) Angstrom
69
+ "shielding_stationary": self._scaled("shielding_stationary", sl), # (n,) ppm
70
+ "incomplete": incomplete,
71
+ }
72
+ if not incomplete:
73
+ pert = self._scaled("perturbation", sl)
74
+ out["perturbed_coords"] = stat + pert # reconstruct absolute geometry
75
+ out["perturbation"] = pert
76
+ out["shielding_perturbed"] = self._scaled("shielding_perturbed", sl)
77
+ return out
78
+
79
+ def by_id(self, molecule_id: int) -> dict:
80
+ """Return one molecule's decoded data (see `molecule`) looked up by molecule_id."""
81
+ if self._id_to_row is None:
82
+ self._id_to_row = {int(v): i for i, v in enumerate(self._id)}
83
+ try:
84
+ row = self._id_to_row[int(molecule_id)]
85
+ except KeyError:
86
+ raise KeyError(f"unknown molecule_id {molecule_id}") from None
87
+ return self.molecule(row)
88
+
89
+ # ---- splits ----
90
+ def split(self, which: str, effective: bool = True) -> np.ndarray:
91
+ """Return molecule_id values for 'train'/'val'/'test'.
92
+ effective=True drops remove_molecule_ids (the load-time filter), reproducing the
93
+ sizes reported in the SI (train 3,730,572 / val 329,154 / test 329,163)."""
94
+ ids = self.f[f"split_{which}_ids"][:]
95
+ if effective:
96
+ rm = self.f["remove_molecule_ids"][:]
97
+ ids = ids[~np.isin(ids, rm)]
98
+ return ids
99
+
100
+ def close(self):
101
+ """Close the underlying HDF5 file."""
102
+ self.f.close()
103
+
104
+ def __enter__(self): return self
105
+ def __exit__(self, *a): self.close()
106
+
107
+
108
+ if __name__ == "__main__":
109
+ import sys
110
+ path = sys.argv[1] if len(sys.argv) > 1 else "sigma-shake.hdf5"
111
+ with SigmaShake(path) as ds:
112
+ print(f"molecules: {ds.n_molecules:,} atoms: {ds.n_atoms:,}")
113
+ for w in ("train", "val", "test"):
114
+ print(f" effective {w:5s}: {len(ds.split(w)):,}")
115
+ m = ds.molecule(0)
116
+ print(f"\nmolecule 0 (id={m['molecule_id']}): {len(m['atomic_numbers'])} atoms, "
117
+ f"incomplete={m['incomplete']}")
118
+ print(" Z :", m["atomic_numbers"][:8])
119
+ print(" stat xyz :", np.round(m["stationary_coords"][0], 4), "...")
120
+ print(" sigma_stat:", np.round(m["shielding_stationary"][:4], 4), "...")
121
+ if not m["incomplete"]:
122
+ print(" pert xyz :", np.round(m["perturbed_coords"][0], 4), "...")
123
+ print(" sigma_pert:", np.round(m["shielding_perturbed"][:4], 4), "...")
data/sigma-shake/test_sigma_shake.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the sigma-shake reader (decode_sigma_shake.SigmaShake).
3
+
4
+ These build a tiny synthetic dataset in the *exact* on-disk format (so the fixture also
5
+ serves as an executable spec of the layout) and exercise the decoder against it. No
6
+ dependency on the multi-GB real file. One opt-in test runs against the real
7
+ `sigma-shake.hdf5` if it is present next to this file.
8
+
9
+ Run: pytest test_sigma_shake.py -q
10
+ Requires: pytest, numpy, h5py.
11
+ """
12
+ import os
13
+ import numpy as np
14
+ import h5py
15
+ import pytest
16
+
17
+ from decode_sigma_shake import SigmaShake
18
+
19
+ SCALE = 1e-4
20
+
21
+ # ---- known synthetic content: 3 molecules, 9 atoms; molecule 1 is incomplete ----
22
+ MOL_ID = np.array([100, 200, 300], np.int32)
23
+ ATOM_START = np.array([0, 3, 5], np.int32)
24
+ ATOM_END = np.array([3, 5, 9], np.int32)
25
+ Z = np.array([6, 1, 1, 8, 1, 6, 6, 1, 1], np.int8)
26
+ STAT_XYZ = np.array([
27
+ [0.0000, 0.0000, 0.0000], [1.0000, 0.0000, 0.0000], [0.0000, 1.0000, 0.0000], # mol 100
28
+ [2.0000, 2.0000, 2.0000], [2.5000, 2.0000, 2.0000], # mol 200 (incomplete)
29
+ [3.0000, 0.0000, 0.0000], [4.0000, 0.0000, 0.0000], [3.0000, 1.0000, 0.0000], [4.0000, 1.0000, 0.0000],
30
+ ], np.float64)
31
+ STAT_SIG = np.array([150.1234, 30.5678, 31.0000, 200.0000, 25.0000, 140.0000, 141.0000, 29.0000, 29.5000])
32
+ PERT_XYZ = np.array([
33
+ [0.0100, 0.0000, 0.0000], [-0.0050, 0.0000, 0.0000], [0.0000, 0.0200, 0.0000], # mol 100
34
+ [0.0000, 0.0000, 0.0000], [0.0000, 0.0000, 0.0000], # mol 200 (zeroed)
35
+ [0.0030, 0.0000, 0.0000], [-0.0030, 0.0000, 0.0000], [0.0000, 0.0050, 0.0000], [0.0000, -0.0050, 0.0000],
36
+ ], np.float64)
37
+ PERT_SIG = np.array([151.0000, 30.9999, 31.5000, 0.0, 0.0, 140.5000, 141.5000, 29.2000, 29.7000])
38
+ INCOMPLETE_MASK = np.array([0, 0, 0, 1, 1, 0, 0, 0, 0], bool)
39
+ SPLIT = {"train": np.array([100, 200], np.int32), "val": np.array([300], np.int32),
40
+ "test": np.array([], np.int32)}
41
+ REMOVE = np.array([200], np.int32)
42
+ INCOMPLETE_IDS = np.array([200], np.int32)
43
+
44
+
45
+ def _i32(x):
46
+ return np.round(np.asarray(x) / SCALE).astype(np.int32)
47
+
48
+
49
+ @pytest.fixture(scope="module")
50
+ def mini_path(tmp_path_factory):
51
+ p = str(tmp_path_factory.mktemp("ss") / "mini.hdf5")
52
+ with h5py.File(p, "w") as f:
53
+ f.attrs["n_atoms"] = len(Z)
54
+ f.attrs["n_molecules"] = len(MOL_ID)
55
+ for name, arr in [("stationary_coords", STAT_XYZ), ("perturbation", PERT_XYZ),
56
+ ("shielding_stationary", STAT_SIG), ("shielding_perturbed", PERT_SIG)]:
57
+ d = f.create_dataset(name, data=_i32(arr))
58
+ d.attrs["scale"] = SCALE
59
+ f.create_dataset("atomic_numbers", data=Z)
60
+ f.create_dataset("incomplete_mask", data=INCOMPLETE_MASK)
61
+ f.create_dataset("molecule_id", data=MOL_ID)
62
+ f.create_dataset("atom_start", data=ATOM_START)
63
+ f.create_dataset("atom_end", data=ATOM_END)
64
+ f.create_dataset("remove_molecule_ids", data=REMOVE)
65
+ f.create_dataset("incomplete_molecule_ids", data=INCOMPLETE_IDS)
66
+ for k, v in SPLIT.items():
67
+ f.create_dataset(f"split_{k}_ids", data=v)
68
+ return p
69
+
70
+
71
+ @pytest.fixture
72
+ def ds(mini_path):
73
+ with SigmaShake(mini_path) as d:
74
+ yield d
75
+
76
+
77
+ def test_dimensions(ds):
78
+ assert ds.n_molecules == 3
79
+ assert ds.n_atoms == 9
80
+
81
+
82
+ def test_complete_molecule_values(ds):
83
+ m = ds.molecule(0)
84
+ assert m["molecule_id"] == 100
85
+ assert not m["incomplete"]
86
+ np.testing.assert_array_equal(m["atomic_numbers"], [6, 1, 1])
87
+ np.testing.assert_allclose(m["stationary_coords"], STAT_XYZ[0:3], atol=5e-5)
88
+ np.testing.assert_allclose(m["shielding_stationary"], STAT_SIG[0:3], atol=5e-5)
89
+ np.testing.assert_allclose(m["shielding_perturbed"], PERT_SIG[0:3], atol=5e-5)
90
+
91
+
92
+ def test_perturbed_geometry_reconstruction(ds):
93
+ m = ds.molecule(0)
94
+ np.testing.assert_allclose(m["perturbed_coords"], STAT_XYZ[0:3] + PERT_XYZ[0:3], atol=1e-4)
95
+
96
+
97
+ def test_incomplete_molecule_omits_perturbed_keys(ds):
98
+ m = ds.molecule(1)
99
+ assert m["molecule_id"] == 200
100
+ assert m["incomplete"] is True
101
+ for k in ("perturbation", "perturbed_coords", "shielding_perturbed"):
102
+ assert k not in m
103
+ # stationary data still valid
104
+ np.testing.assert_allclose(m["stationary_coords"], STAT_XYZ[3:5], atol=5e-5)
105
+ np.testing.assert_allclose(m["shielding_stationary"], STAT_SIG[3:5], atol=5e-5)
106
+
107
+
108
+ def test_scale_roundtrip_is_within_tolerance(ds):
109
+ # every stored value should reconstruct to <= half the LSB (5e-5)
110
+ m = ds.molecule(2)
111
+ assert np.abs(m["stationary_coords"] - STAT_XYZ[5:9]).max() <= 5e-5
112
+ assert np.abs(m["shielding_stationary"] - STAT_SIG[5:9]).max() <= 5e-5
113
+
114
+
115
+ def test_by_id(ds):
116
+ assert ds.by_id(100)["molecule_id"] == 100
117
+ assert ds.by_id(300)["molecule_id"] == 300
118
+
119
+
120
+ def test_by_id_unknown_raises(ds):
121
+ with pytest.raises(KeyError):
122
+ ds.by_id(999999)
123
+
124
+
125
+ def test_index_out_of_range_raises(ds):
126
+ with pytest.raises(IndexError):
127
+ ds.molecule(-1)
128
+ with pytest.raises(IndexError):
129
+ ds.molecule(3)
130
+
131
+
132
+ def test_split_effective_filters_remove_ids(ds):
133
+ # raw train has 100 and 200; effective drops 200 (in remove_molecule_ids)
134
+ np.testing.assert_array_equal(np.sort(ds.split("train", effective=False)), [100, 200])
135
+ np.testing.assert_array_equal(ds.split("train"), [100])
136
+ np.testing.assert_array_equal(ds.split("val"), [300])
137
+ assert len(ds.split("test")) == 0
138
+
139
+
140
+ def test_context_manager_closes(mini_path):
141
+ d = SigmaShake(mini_path)
142
+ d.close()
143
+ assert not d.f.id.valid
144
+
145
+
146
+ # ---------- opt-in: real file, if present ----------
147
+ REAL = os.path.join(os.path.dirname(__file__), "sigma-shake.hdf5")
148
+
149
+
150
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real sigma-shake.hdf5 not present")
151
+ def test_real_file_matches_si():
152
+ with SigmaShake(REAL) as ds:
153
+ assert ds.n_molecules == 4_787_816
154
+ assert ds.n_atoms == 135_219_803
155
+ assert len(ds.split("train")) == 3_730_572
156
+ assert len(ds.split("val")) == 329_154
157
+ assert len(ds.split("test")) == 329_163
158
+ m = ds.molecule(0)
159
+ assert m["molecule_id"] == 24553
160
+ # spot round-trip: perturbed = stationary + perturbation, bond-length-scale accuracy
161
+ assert m["perturbed_coords"].shape == m["stationary_coords"].shape
data/supertestset_magnet_x/decode_supertest.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ decode_supertest.py - reader/decoder for the supertestset_magnet_x HDF5 dataset.
3
+
4
+ This is the explicit-solvent test set for MagNET-x. It holds DFT (Gaussian) NMR shieldings for
5
+ natural-product solutes computed two ways: with the solute isolated, and with the solute sitting
6
+ in an explicit box of solvent molecules sampled from an OpenMM molecular-dynamics simulation. The
7
+ explicit-solvent correction at each atom is the solvated shielding minus the isolated shielding,
8
+ which is what MagNET-x learns to predict.
9
+
10
+ Each record is one computed structure for a (molecule, solvent, conformer) combination, in either
11
+ the isolated or the solvated state:
12
+
13
+ isolated the full system is just the solute
14
+ solvated the full system is the solute (always the first atoms) followed by solvent molecules
15
+
16
+ The shieldings are stored only for the solute atoms (the solvent carries no NMR signal of interest).
17
+ There are 318 records covering 8 molecules across 4 solvents and 5 conformers. A separate catalog
18
+ lists all 50 candidate molecules of the test set with their SMILES.
19
+
20
+ Storage format:
21
+ - Per-atom arrays are concatenated across records. Record i has `n_full_atoms[i]` atoms in its
22
+ full system and `n_solute_atoms[i]` solute atoms (the leading atoms of the full system). Its
23
+ full atomic numbers and coordinates occupy `n_full_atoms[i]` rows; its solute shieldings occupy
24
+ `n_solute_atoms[i]` rows. Offsets are reconstructed once, on open.
25
+ - coordinates and shieldings are int32 fixed point: physical = stored * 1e-4, reconstruction
26
+ error <= 5e-5. A missing value would be the marker -2147483648 -> NaN.
27
+
28
+ Requires: Python >= 3.7, numpy, h5py. No other dependencies.
29
+ """
30
+ from __future__ import annotations
31
+ import numpy as np
32
+ import h5py
33
+
34
+ _SCALE = 1e-4
35
+ _MARKER = -2147483648
36
+
37
+
38
+ def _decode(values: np.ndarray) -> np.ndarray:
39
+ """int32 fixed-point -> float64 physical units; marker -> NaN. No-op on float input."""
40
+ values = np.asarray(values)
41
+ if not np.issubdtype(values.dtype, np.integer):
42
+ return np.asarray(values, dtype=np.float64)
43
+ out = values.astype(np.float64) * _SCALE
44
+ out[values == _MARKER] = np.nan
45
+ return out
46
+
47
+
48
+ class SuperTestMagNETX:
49
+ """Reader for supertestset_magnet_x.hdf5. Use as a context manager."""
50
+
51
+ def __init__(self, path: str):
52
+ self.f = h5py.File(path, "r")
53
+ self.n_records = int(self.f.attrs["n_records"])
54
+ self.record_names = [str(s) for s in self.f["record_names"].asstr()[:]]
55
+ self.record_solvents = [str(s) for s in self.f["record_solvents"].asstr()[:]]
56
+ self._conf = self.f["conformer"][:]
57
+ self._iso = self.f["is_isolated"][:].astype(bool)
58
+ self._n_sol = self.f["n_solute_atoms"][:].astype(np.int64)
59
+ self._n_full = self.f["n_full_atoms"][:].astype(np.int64)
60
+ self._fstart = np.empty(self.n_records + 1, np.int64)
61
+ self._fstart[0] = 0
62
+ np.cumsum(self._n_full, out=self._fstart[1:])
63
+ self._sstart = np.empty(self.n_records + 1, np.int64)
64
+ self._sstart[0] = 0
65
+ np.cumsum(self._n_sol, out=self._sstart[1:])
66
+
67
+ # ------------------------------------------------------------------ catalog
68
+ def catalog(self) -> dict:
69
+ """The 50-molecule candidate catalog as a {name: SMILES} dict."""
70
+ names = self.f["catalog_names"].asstr()[:]
71
+ smiles = self.f["catalog_smiles"].asstr()[:]
72
+ return {str(n): str(s) for n, s in zip(names, smiles)}
73
+
74
+ # ------------------------------------------------------------------ records
75
+ def _check(self, index: int):
76
+ if not 0 <= index < self.n_records:
77
+ raise IndexError(f"record index {index} out of range [0, {self.n_records})")
78
+
79
+ def record(self, index: int) -> dict:
80
+ """Return one record (physical units) by position.
81
+
82
+ Keys: name, solvent, conformer (int), is_isolated (bool); full_atomic_numbers (n_full,),
83
+ full_coordinates (n_full, 3 Angstrom); solute_atomic_numbers (n_solute,), solute_shieldings
84
+ (n_solute,) ppm. The solute atoms are the leading n_solute atoms of the full system.
85
+ """
86
+ self._check(index)
87
+ fsl = slice(int(self._fstart[index]), int(self._fstart[index + 1]))
88
+ ssl = slice(int(self._sstart[index]), int(self._sstart[index + 1]))
89
+ ns = int(self._n_sol[index])
90
+ full_an = self.f["full_atomic_numbers"][fsl]
91
+ return {
92
+ "name": self.record_names[index],
93
+ "solvent": self.record_solvents[index],
94
+ "conformer": int(self._conf[index]),
95
+ "is_isolated": bool(self._iso[index]),
96
+ "full_atomic_numbers": full_an,
97
+ "full_coordinates": _decode(self.f["full_coordinates"][fsl]),
98
+ "solute_atomic_numbers": full_an[:ns],
99
+ "solute_shieldings": _decode(self.f["solute_shieldings"][ssl]),
100
+ }
101
+
102
+ def find(self, name: str, solvent: str, conformer: int, is_isolated: bool) -> int:
103
+ """Index of the record matching (name, solvent, conformer, is_isolated), or raise KeyError."""
104
+ for i in range(self.n_records):
105
+ if (self.record_names[i] == name and self.record_solvents[i] == solvent
106
+ and int(self._conf[i]) == conformer and bool(self._iso[i]) == is_isolated):
107
+ return i
108
+ raise KeyError(f"no record for {name!r}, {solvent!r}, conformer {conformer}, "
109
+ f"isolated={is_isolated}")
110
+
111
+ def explicit_correction(self, name: str, solvent: str, conformer: int) -> np.ndarray:
112
+ """The explicit-solvent correction (solvated minus isolated solute shieldings), (n_solute,)
113
+ ppm, for one (molecule, solvent, conformer)."""
114
+ solvated = self.record(self.find(name, solvent, conformer, is_isolated=False))
115
+ isolated = self.record(self.find(name, solvent, conformer, is_isolated=True))
116
+ return solvated["solute_shieldings"] - isolated["solute_shieldings"]
117
+
118
+ def close(self):
119
+ """Close the underlying HDF5 file."""
120
+ self.f.close()
121
+
122
+ def __enter__(self):
123
+ return self
124
+
125
+ def __exit__(self, *a):
126
+ self.close()
127
+
128
+ def __len__(self):
129
+ return self.n_records
130
+
131
+ def __repr__(self):
132
+ n_iso = int(self._iso.sum())
133
+ return (f"<SuperTestMagNETX: {self.n_records} records "
134
+ f"({n_iso} isolated, {self.n_records - n_iso} solvated), "
135
+ f"solvents {self.f.attrs['solvents']}>")
136
+
137
+
138
+ if __name__ == "__main__":
139
+ import sys
140
+ path = sys.argv[1] if len(sys.argv) > 1 else "supertestset_magnet_x.hdf5"
141
+ with SuperTestMagNETX(path) as ds:
142
+ print(ds)
143
+ print(f" catalog: {len(ds.catalog())} molecules")
144
+ r = ds.record(ds.find(ds.record_names[0], ds.record_solvents[0], int(ds._conf[0]), bool(ds._iso[0])))
145
+ corr = ds.explicit_correction(r["name"], r["solvent"], r["conformer"])
146
+ print(f" {r['name']} in {r['solvent']} (conformer {r['conformer']}): "
147
+ f"{len(r['solute_atomic_numbers'])} solute atoms")
148
+ print(f" explicit-solvent correction[:3] = {np.round(corr[:3], 4)} ppm")
data/supertestset_magnet_x/test_supertest.py ADDED
@@ -0,0 +1,110 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for data/supertestset_magnet_x/decode_supertest.py.
2
+
3
+ A tiny synthetic file in the stored int32 format exercises the reader, the two-level block
4
+ reconstruction (full system vs solute prefix), the isolated/solvated pairing, and the
5
+ explicit-solvent correction, without the real file. An opt-in smoke test runs against the real
6
+ file when present.
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ import numpy as np
12
+ import pytest
13
+ import h5py
14
+
15
+ HERE = os.path.dirname(os.path.abspath(__file__))
16
+ sys.path.insert(0, HERE)
17
+
18
+ import decode_supertest as D # noqa: E402
19
+
20
+ SCALE = 1e-4
21
+ MARKER = -2147483648
22
+ REAL = os.path.join(HERE, "supertestset_magnet_x.hdf5")
23
+
24
+
25
+ def _encode(values):
26
+ v = np.asarray(values, dtype=np.float64)
27
+ out = np.round(v / SCALE).astype(np.int64)
28
+ out[~np.isfinite(v)] = MARKER
29
+ return out.astype(np.int32)
30
+
31
+
32
+ def _build_synthetic(path):
33
+ """One molecule, one solvent, one conformer: an isolated record (3 solute atoms) and a
34
+ solvated record (3 solute + 4 solvent = 7 full atoms)."""
35
+ rng = np.random.default_rng(0)
36
+ solute_an = np.array([6, 1, 8], dtype=np.uint8)
37
+ iso_sh = np.array([100.0, 30.0, 250.0])
38
+ solv_sh = iso_sh + np.array([-0.3, 0.1, -0.5]) # known correction
39
+ full_an_solv = np.concatenate([solute_an, [1, 1, 1, 1]]).astype(np.uint8)
40
+ records = [
41
+ # name, solvent, conf, is_isolated, full_an, full_xyz, n_solute, solute_sh
42
+ ("MOLX", "benzene", 0, 1, solute_an, rng.normal(0, 2, (3, 3)), 3, iso_sh),
43
+ ("MOLX", "benzene", 0, 0, full_an_solv, rng.normal(0, 2, (7, 3)), 3, solv_sh),
44
+ ]
45
+ str_dt = h5py.string_dtype("utf-8")
46
+ opts = dict(compression="gzip", shuffle=True)
47
+ names, solvents, confs, iso, n_sol, n_full = [], [], [], [], [], []
48
+ an_b, co_b, sh_b = [], [], []
49
+ for name, solv, conf, is_iso, fan, fxyz, ns, ssh in records:
50
+ names.append(name); solvents.append(solv); confs.append(conf); iso.append(is_iso)
51
+ n_sol.append(ns); n_full.append(len(fan))
52
+ an_b.append(fan); co_b.append(_encode(fxyz)); sh_b.append(_encode(ssh))
53
+ with h5py.File(path, "w") as f:
54
+ f.attrs["n_records"] = len(records)
55
+ f.attrs["n_catalog"] = 1
56
+ f.attrs["solvents"] = "benzene"
57
+ f.attrs["scale"] = SCALE
58
+ f.attrs["missing_value_marker"] = MARKER
59
+ f.create_dataset("catalog_names", data=np.array(["MOLX"], dtype=object), dtype=str_dt)
60
+ f.create_dataset("catalog_smiles", data=np.array(["CCO"], dtype=object), dtype=str_dt)
61
+ f.create_dataset("record_names", data=np.array(names, dtype=object), dtype=str_dt)
62
+ f.create_dataset("record_solvents", data=np.array(solvents, dtype=object), dtype=str_dt)
63
+ f.create_dataset("conformer", data=np.asarray(confs, np.int32), **opts)
64
+ f.create_dataset("is_isolated", data=np.asarray(iso, np.uint8), **opts)
65
+ f.create_dataset("n_solute_atoms", data=np.asarray(n_sol, np.int32), **opts)
66
+ f.create_dataset("n_full_atoms", data=np.asarray(n_full, np.int32), **opts)
67
+ f.create_dataset("full_atomic_numbers", data=np.concatenate(an_b), **opts)
68
+ f.create_dataset("full_coordinates", data=np.concatenate(co_b), **opts)
69
+ f.create_dataset("solute_shieldings", data=np.concatenate(sh_b), **opts)
70
+
71
+
72
+ def test_record_reconstruction_and_solute_prefix(tmp_path):
73
+ path = str(tmp_path / "tiny.hdf5")
74
+ _build_synthetic(path)
75
+ with D.SuperTestMagNETX(path) as ds:
76
+ assert len(ds) == 2
77
+ iso = ds.record(0)
78
+ solv = ds.record(1)
79
+ assert iso["is_isolated"] and not solv["is_isolated"]
80
+ # isolated full system is just the solute
81
+ assert len(iso["full_atomic_numbers"]) == 3
82
+ # solvated full system is solute (first 3) + solvent (4)
83
+ assert len(solv["full_atomic_numbers"]) == 7
84
+ assert np.array_equal(solv["solute_atomic_numbers"], solv["full_atomic_numbers"][:3])
85
+ assert solv["solute_shieldings"].shape == (3,)
86
+
87
+
88
+ def test_explicit_correction(tmp_path):
89
+ path = str(tmp_path / "tiny.hdf5")
90
+ _build_synthetic(path)
91
+ with D.SuperTestMagNETX(path) as ds:
92
+ corr = ds.explicit_correction("MOLX", "benzene", 0)
93
+ assert np.allclose(corr, [-0.3, 0.1, -0.5], atol=1e-4)
94
+ assert ds.catalog() == {"MOLX": "CCO"}
95
+ with pytest.raises(KeyError):
96
+ ds.find("MOLX", "water", 0, is_isolated=True)
97
+
98
+
99
+ def test_decode_marker_to_nan():
100
+ out = D._decode(np.array([0, 10000, MARKER], dtype=np.int32))
101
+ assert out[1] == pytest.approx(1.0) and np.isnan(out[2])
102
+
103
+
104
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real supertestset_magnet_x.hdf5 not present")
105
+ def test_real_file_smoke():
106
+ with D.SuperTestMagNETX(REAL) as ds:
107
+ assert len(ds) == 318
108
+ assert len(ds.catalog()) == 50
109
+ corr = ds.explicit_correction("MOL176459", "benzene", 0)
110
+ assert corr.shape[0] == 39 and np.isfinite(corr).all()
magnet/__init__.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ruff: noqa
2
+ """MagNET predicts NMR shieldings using equivariant neural networks.
3
+
4
+ | function | what you get |
5
+ |---|---|
6
+ | `predict_shifts` | <sup>1</sup>H and <sup>13</sup>C chemical shifts (ppm) in a specific solvent using MagNET-Zero/MagNET-PCM |
7
+ | `predict_shieldings` | <sup>1</sup>H and <sup>13</sup>C shieldings (ppm) in the gas phase |
8
+ | `implicit_solvent_correction` | shieldings(PCM=chloroform) - shieldings(gas phase) |
9
+ | `explicit_solvent_correction` | shieldings(solute+solvents) - shieldings(solute) |
10
+
11
+ **Inputs and outputs**
12
+
13
+ - One molecule: array of `atomic_numbers` (e.g. 6 for carbon) and
14
+ `coordinates` (an N-by-3 array of xyz positions in Angstrom, a numpy array or a nested list). You
15
+ get one numpy array back, one value per atom.
16
+ - Many molecules: pass a list of each, and you get a list of arrays back.
17
+
18
+ **Geometries** MagNET-Zero and MagNET-PCM expect AIMNet2-optimized geometries.
19
+
20
+ **Supported Solvents**
21
+
22
+ | solvent | `predict_shifts` | `implicit_solvent_correction` | `explicit_solvent_correction` |
23
+ |---|:---:|:---:|:---:|
24
+ | tetrahydrofuran | ✓ | | |
25
+ | dichloromethane | ✓ | | |
26
+ | chloroform | ✓ | ✓ | ✓ |
27
+ | toluene | ✓ | | |
28
+ | benzene | ✓ | | ✓ |
29
+ | chlorobenzene | ✓ | | |
30
+ | acetone | ✓ | | |
31
+ | dimethylsulfoxide | ✓ | | |
32
+ | acetonitrile | ✓ | | |
33
+ | trifluoroethanol | ✓ | | |
34
+ | methanol | ✓ | | ✓ |
35
+ | water | ✓ | | ✓ |
36
+
37
+ `implicit_solvent_correction` predicts only the chloroform correction; `predict_shifts` linearly scales it to the other solvents.
38
+
39
+ **Shared options** (all four functions):
40
+
41
+ - `n_passes` (default `10`): average out equivariance error over `n_passes` forward passes
42
+ - `symmetrize` (default `True`): if True, average over `n_passes` on the input geometry and `n_passes` on the mirror image of the input geometry
43
+ - `device` (default `None`): where to run, a torch device or a string like `"cpu"` or `"cuda"`; uses GPU if available
44
+
45
+ """
46
+ __docformat__ = "google"
47
+
48
+ from .api import (predict_shifts, predict_shieldings, implicit_solvent_correction,
49
+ explicit_solvent_correction)
50
+
51
+ __all__ = ["predict_shifts", "predict_shieldings", "implicit_solvent_correction",
52
+ "explicit_solvent_correction"]
magnet/api.py ADDED
@@ -0,0 +1,309 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The MagNET user-facing API.
2
+
3
+ Four entry points, each taking one molecule (`atomic_numbers` shaped `(N,)` and `coordinates` shaped
4
+ `(N, 3)`) or a list of molecules, and returning the matching shape:
5
+
6
+ | function | what you get |
7
+ |---|---|
8
+ | `predict_shifts` | <sup>1</sup>H and <sup>13</sup>C chemical shifts (ppm) in a specific solvent using MagNET-Zero/MagNET-PCM |
9
+ | `predict_shieldings` | <sup>1</sup>H and <sup>13</sup>C shieldings (ppm) in the gas phase |
10
+ | `implicit_solvent_correction` | shieldings(PCM=chloroform) - shieldings(gas phase) |
11
+ | `explicit_solvent_correction` | shieldings(solute+solvents) - shieldings(solute) |
12
+
13
+ **Shared options** (all four functions):
14
+
15
+ - `n_passes` (default `10`): average out equivariance error over `n_passes` forward passes
16
+ - `symmetrize` (default `True`): if True, average over `n_passes` on the input geometry and `n_passes` on the mirror image of the input geometry
17
+ - `device` (default `None`): where to run, a torch device or a string like `"cpu"` or `"cuda"`; uses GPU if available
18
+ - `checkpoints_dir` (default `None`): directory holding the released weights, if they are not in the current directory or the repo; may be the `model_checkpoints/` folder or its parent, and takes precedence over both
19
+ """
20
+ __docformat__ = "google"
21
+
22
+ from collections import Counter
23
+
24
+ import numpy as np
25
+
26
+ from . import run_magnet
27
+ from . import scaling
28
+
29
+ _MODELS = {
30
+ "MagNET": run_magnet.compute_MagNET_foundation_shieldings,
31
+ "MagNET-Zero": run_magnet.compute_MagNET_Zero_shieldings,
32
+ }
33
+
34
+ # Atomic-number counts in one molecule of each MagNET-x solvent, used to validate that the
35
+ # non-solute atoms really are a whole number of molecules of the declared solvent.
36
+ _SOLVENT_COMPOSITION = {
37
+ "chloroform": Counter({6: 1, 1: 1, 17: 3}), # CHCl3
38
+ "benzene": Counter({6: 6, 1: 6}), # C6H6
39
+ "methanol": Counter({6: 1, 8: 1, 1: 4}), # CH3OH
40
+ "water": Counter({8: 1, 1: 2}), # H2O (run as TIP4P)
41
+ }
42
+
43
+
44
+ def _check_passes(n_passes):
45
+ if not (isinstance(n_passes, (int, np.integer)) and n_passes >= 1):
46
+ raise ValueError(f"n_passes must be a positive integer; got {n_passes!r}")
47
+
48
+
49
+ def _as_batch(atomic_numbers, coordinates):
50
+ """Normalize a single molecule or a list of molecules to parallel lists. Returns
51
+ (atomic_numbers_list, coordinates_list, was_single). Validates matching atom counts."""
52
+ if len(coordinates) == 0:
53
+ raise ValueError("empty input; pass one molecule ((N,) and (N, 3)) or a non-empty list")
54
+ single = np.ndim(np.asarray(coordinates[0])) == 1 # (N, 3): first row is length-3 -> single
55
+ if single:
56
+ an_list = [np.asarray(atomic_numbers)]
57
+ xyz_list = [np.asarray(coordinates, dtype=float)]
58
+ else:
59
+ an_list = [np.asarray(a) for a in atomic_numbers]
60
+ xyz_list = [np.asarray(c, dtype=float) for c in coordinates]
61
+ if len(an_list) != len(xyz_list):
62
+ raise ValueError(f"got {len(an_list)} atomic_numbers array(s) but {len(xyz_list)} geometry(ies)")
63
+ for a, c in zip(an_list, xyz_list):
64
+ if a.ndim != 1 or c.ndim != 2 or c.shape[1] != 3 or a.shape[0] != c.shape[0]:
65
+ raise ValueError("each molecule needs atomic_numbers shaped (N,) and coordinates shaped "
66
+ f"(N, 3) with the same N; got {a.shape} and {c.shape}")
67
+ return an_list, xyz_list, single
68
+
69
+
70
+ def predict_shifts(atomic_numbers, coordinates, solvent="chloroform", n_passes=10, symmetrize=True,
71
+ device=None, return_components=False, checkpoints_dir=None):
72
+ """Predict the <sup>1</sup>H and <sup>13</sup>C chemical shifts of a molecule in a solvent.
73
+
74
+ This is the main entry point. It runs the MagNET-Zero and MagNET-PCM models and applies the paper's
75
+ per-solvent calibration, so the output is directly comparable to an experimental spectrum. Needs an
76
+ AIMNet2-optimized geometry.
77
+
78
+ Args:
79
+ atomic_numbers: element numbers, shape `(N,)`, for one molecule; or a list of such arrays.
80
+ coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
81
+ solvent: one of the 12 calibrated solvents: `"tetrahydrofuran"`, `"dichloromethane"`,
82
+ `"chloroform"`, `"toluene"`, `"benzene"`, `"chlorobenzene"`, `"acetone"`,
83
+ `"dimethylsulfoxide"`, `"acetonitrile"`, `"trifluoroethanol"`, `"methanol"`, `"water"`.
84
+ n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
85
+ symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
86
+ doubling the passes.
87
+ device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
88
+ uses GPU if available.
89
+ return_components: return the numbers behind each shift (see Returns) instead of just the shifts.
90
+ checkpoints_dir: directory holding the released weights, if they are not in the current
91
+ directory or the repo (default `None`); may be the `model_checkpoints/` folder or its
92
+ parent, and takes precedence over both defaults.
93
+
94
+ Returns:
95
+ One chemical shift in ppm per atom, shape `(N,)`, with NaN at any atom that is not
96
+ <sup>1</sup>H or <sup>13</sup>C. Pass a list of molecules and you get a list of arrays back.
97
+
98
+ With `return_components=True` you get a dict instead (or a list of dicts):
99
+
100
+ - `shifts`: the shifts, as above.
101
+ - `zero_shielding`: the MagNET-Zero gas-phase shielding at each atom.
102
+ - `pcm_correction`: the MagNET-PCM solvent correction at each atom.
103
+ - `coefficients`: the calibration used, as
104
+ `{"H": {"intercept": ..., "stationary": ..., "pcm": ...}, "C": {...}}`, so that
105
+ `shift = intercept + stationary * zero_shielding + pcm * pcm_correction`.
106
+ """
107
+ _check_passes(n_passes)
108
+ tables = scaling.published_scaling_tables()
109
+ key = "TIP4P" if solvent == "water" else solvent
110
+ if key not in tables["C"] or key not in tables["H"]:
111
+ options = ["water" if s == "TIP4P" else s for s in tables["C"]]
112
+ raise ValueError(f"unknown solvent {solvent!r}; choose one of {options}")
113
+
114
+ an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
115
+ zero = run_magnet.compute_MagNET_Zero_shieldings(an_list, xyz_list, n_passes=n_passes,
116
+ symmetrize=symmetrize, device=device,
117
+ checkpoints_dir=checkpoints_dir)
118
+ pcm = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
119
+ symmetrize=symmetrize, device=device,
120
+ checkpoints_dir=checkpoints_dir)
121
+ results = []
122
+ for atoms, sigma, delta in zip(an_list, zero, pcm):
123
+ out = np.full(atoms.shape, np.nan, dtype=float)
124
+ for nucleus, atomic_number in (("H", 1), ("C", 6)):
125
+ mask = atoms == atomic_number
126
+ if mask.any():
127
+ out[mask] = scaling.predict_shift(tables[nucleus], key, sigma[mask], delta[mask])
128
+ if return_components:
129
+ results.append({"shifts": out, "zero_shielding": sigma, "pcm_correction": delta,
130
+ "coefficients": {"H": dict(tables["H"][key]), "C": dict(tables["C"][key])}})
131
+ else:
132
+ results.append(out)
133
+ return results[0] if single else results
134
+
135
+
136
+ def predict_shieldings(atomic_numbers, coordinates, model="MagNET", n_passes=10, symmetrize=True,
137
+ device=None, checkpoints_dir=None):
138
+ """Predict gas-phase NMR shielding constants for a molecule.
139
+
140
+ A shielding constant is what the network outputs directly, before it is calibrated into a chemical
141
+ shift; if you want shifts, use `predict_shifts`. For solvent effects use
142
+ `implicit_solvent_correction` or `explicit_solvent_correction`.
143
+
144
+ Args:
145
+ atomic_numbers: element numbers, shape `(N,)`; or a list of such arrays.
146
+ coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
147
+ model: `"MagNET"`, the general foundation model, or `"MagNET-Zero"`, which is more accurate but
148
+ expects an AIMNet2-optimized geometry.
149
+ n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
150
+ symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
151
+ doubling the passes.
152
+ device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
153
+ uses GPU if available.
154
+ checkpoints_dir: directory holding the released weights, if they are not in the current
155
+ directory or the repo (default `None`); may be the `model_checkpoints/` folder or its
156
+ parent, and takes precedence over both defaults.
157
+
158
+ Returns:
159
+ One shielding constant in ppm per atom, shape `(N,)`. The models are trained on <sup>1</sup>H
160
+ and <sup>13</sup>C, so the values are meaningful only at hydrogen and carbon atoms; atoms of
161
+ other elements come back as `0.0`, not a prediction. (`predict_shifts` instead returns `NaN`
162
+ there.) Pass a list of molecules and you get a list of arrays back.
163
+ """
164
+ redirect = {"MagNET-PCM": "implicit_solvent_correction", "MagNET-x": "explicit_solvent_correction"}
165
+ if model in redirect:
166
+ raise ValueError(f"{model!r} is a correction model; use magnet.{redirect[model]}(...), "
167
+ f"not predict_shieldings().")
168
+ if model not in _MODELS:
169
+ raise ValueError(f"model must be one of {list(_MODELS)}; got {model!r}")
170
+ _check_passes(n_passes)
171
+ an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
172
+ out = _MODELS[model](an_list, xyz_list, n_passes=n_passes, symmetrize=symmetrize, device=device,
173
+ checkpoints_dir=checkpoints_dir)
174
+ return out[0] if single else out
175
+
176
+
177
+ def implicit_solvent_correction(atomic_numbers, coordinates, n_passes=10, symmetrize=True, device=None,
178
+ checkpoints_dir=None):
179
+ """Predict how a solvent changes a molecule's shieldings, using a fast continuum-solvent model.
180
+
181
+ The result is a per-atom shielding change: add it to a `predict_shieldings(..., model="MagNET-Zero")`
182
+ value to get the solvated shielding, or just use `predict_shifts`, which does this for you. Uses
183
+ MagNET-PCM, which only predicts the chloroform correction, so there is no solvent argument here;
184
+ `predict_shifts` is what reuses this one correction for other solvents, through its per-solvent
185
+ calibration. Needs an AIMNet2-optimized geometry.
186
+
187
+ Args:
188
+ atomic_numbers: element numbers, shape `(N,)`; or a list of such arrays.
189
+ coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
190
+ n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
191
+ symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
192
+ doubling the passes.
193
+ device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
194
+ uses GPU if available.
195
+ checkpoints_dir: directory holding the released weights, if they are not in the current
196
+ directory or the repo (default `None`); may be the `model_checkpoints/` folder or its
197
+ parent, and takes precedence over both defaults.
198
+
199
+ Returns:
200
+ One shielding change in ppm per atom, shape `(N,)`. Meaningful only at hydrogen and carbon
201
+ atoms; atoms of other elements come back as `0.0`. Pass a list of molecules and you get a list
202
+ of arrays back.
203
+ """
204
+ _check_passes(n_passes)
205
+ an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
206
+ out = run_magnet.compute_MagNET_PCM_corrections(an_list, xyz_list, n_passes=n_passes,
207
+ symmetrize=symmetrize, device=device,
208
+ checkpoints_dir=checkpoints_dir)
209
+ return out[0] if single else out
210
+
211
+
212
+ def _validate_solvent(solvent, solvent_atomic_numbers):
213
+ """Confirm the non-solute atoms really are whole molecules of `solvent`, one after another.
214
+
215
+ The solvent atoms must be listed one molecule at a time: all atoms of the first molecule, then
216
+ all atoms of the next, and so on. This splits them into consecutive groups of the solvent's
217
+ molecule size and checks each group has the right number of each element (the order of atoms
218
+ within a group does not matter). Listing them this way is required by the model, which finds the
219
+ solvent molecules by splitting the atom list into equal-size groups the same way, and uses that
220
+ to drop solvent molecules that sit far from the solute."""
221
+ if solvent not in _SOLVENT_COMPOSITION:
222
+ raise ValueError(f"unknown solvent {solvent!r}; expected one of {sorted(_SOLVENT_COMPOSITION)}")
223
+ atoms = np.asarray(solvent_atomic_numbers).tolist()
224
+ if not atoms:
225
+ raise ValueError("no solvent atoms; solute_atoms cannot name every atom")
226
+ per_molecule = _SOLVENT_COMPOSITION[solvent]
227
+ size = run_magnet.N_ATOMS_PER_SOLVENT[solvent]
228
+ if len(atoms) % size != 0:
229
+ raise ValueError(f"got {len(atoms)} non-solute atoms, not a whole number of {solvent} "
230
+ f"molecules ({size} atoms each); check solute_atoms and solvent")
231
+ for start in range(0, len(atoms), size):
232
+ group = Counter(atoms[start:start + size])
233
+ if group != per_molecule:
234
+ raise ValueError(f"the non-solute atoms are not whole {solvent} molecules listed one at "
235
+ f"a time: the group of {size} atoms starting at position {start} has "
236
+ f"element counts {dict(group)}, expected {dict(per_molecule)}. List each "
237
+ f"solvent molecule's atoms together.")
238
+
239
+
240
+ def _explicit_one(atoms, xyz, solute_atoms, solvent):
241
+ """Validate solute_atoms and the solvent composition, then reorder one snapshot to
242
+ solute-first-then-solvent. Returns (solute_atomic_numbers, reordered_atomic_numbers,
243
+ reordered_coordinates)."""
244
+ n = len(atoms)
245
+ solute = np.asarray(solute_atoms, dtype=int)
246
+ solute = np.where(solute < 0, solute + n, solute) # normalize negative indices
247
+ if solute.size == 0:
248
+ raise ValueError("solute_atoms is empty; name at least one solute atom")
249
+ if np.any(solute < 0) or np.any(solute >= n):
250
+ raise ValueError(f"solute_atoms has an index out of range for a {n}-atom system")
251
+ if len(set(solute.tolist())) != solute.size:
252
+ raise ValueError("solute_atoms has duplicate indices")
253
+ solute_set = set(solute.tolist())
254
+ solvent_idx = np.array([i for i in range(n) if i not in solute_set], dtype=int)
255
+ _validate_solvent(solvent, atoms[solvent_idx])
256
+ order = np.concatenate([solute, solvent_idx]) # solute first, then solvent blocks
257
+ return atoms[solute], atoms[order], xyz[order]
258
+
259
+
260
+ def explicit_solvent_correction(atomic_numbers, coordinates, solute_atoms, solvent="chloroform",
261
+ n_passes=10, symmetrize=True, device=None,
262
+ solvent_distance_threshold=12.0, checkpoints_dir=None):
263
+ """Predict a solvent's effect on shieldings from an MD snapshot with explicit solvent molecules.
264
+
265
+ Uses MagNET-x. Corrections are returned for solute atoms only. Corrections should be averaged
266
+ over multiple frames.
267
+
268
+ The expected atom ordering is [solute, n x solvents].
269
+
270
+ Args:
271
+ atomic_numbers: element numbers for the whole solute + solvent system, shape `(N,)`; or a list
272
+ of snapshots.
273
+ coordinates: xyz positions in Angstrom, shape `(N, 3)`; or a list of them.
274
+ solute_atoms: which atoms are the solute, as 0-based indices (a list or array of ints;
275
+ negative indices count from the end, like Python lists); the rest are treated as solvent.
276
+ solvent: `"chloroform"`, `"benzene"`, `"methanol"`, or `"water"`, the four MagNET-x supports
277
+ (default `"chloroform"`).
278
+ n_passes: average out equivariance error over `n_passes` forward passes (default `10`).
279
+ symmetrize: if True, also average over the mirror image of the input geometry (default `True`),
280
+ doubling the passes.
281
+ device: where to run, a torch device or a string like `"cpu"` or `"cuda"` (default `None`);
282
+ uses GPU if available.
283
+ solvent_distance_threshold: solvent molecules whose nearest atom is farther than this many
284
+ Angstrom from the solute are dropped automatically (default 12.0), so extra solvent in the
285
+ snapshot is harmless.
286
+ checkpoints_dir: directory holding the released weights, if they are not in the current
287
+ directory or the repo (default `None`); may be the `model_checkpoints/` folder or its
288
+ parent, and takes precedence over both defaults.
289
+
290
+ Returns:
291
+ One correction in ppm per solute atom, in the order you listed them in `solute_atoms`. Pass a
292
+ list of snapshots and you get a list of arrays back.
293
+ """
294
+ if solvent not in run_magnet.N_ATOMS_PER_SOLVENT:
295
+ raise ValueError(f"solvent must be one of {sorted(run_magnet.N_ATOMS_PER_SOLVENT)}; "
296
+ f"got {solvent!r}")
297
+ _check_passes(n_passes)
298
+ an_list, xyz_list, single = _as_batch(atomic_numbers, coordinates)
299
+ solute_an_list, full_an_list, full_xyz_list = [], [], []
300
+ for atoms, xyz in zip(an_list, xyz_list):
301
+ solute_an, full_an, full_xyz = _explicit_one(atoms, xyz, solute_atoms, solvent)
302
+ solute_an_list.append(solute_an)
303
+ full_an_list.append(full_an)
304
+ full_xyz_list.append(full_xyz)
305
+ out = run_magnet.compute_MagNET_x_corrections(
306
+ solvent, solute_an_list, full_an_list, full_xyz_list,
307
+ n_passes=n_passes, symmetrize=symmetrize, device=device,
308
+ solvent_distance_threshold=solvent_distance_threshold, checkpoints_dir=checkpoints_dir)
309
+ return out[0] if single else out
magnet/inference.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import torch
3
+ import torch_geometric
4
+ import torch_scatter
5
+
6
+ # The elements MagNET was trained on (the sigma datasets are GDB molecules plus F, S, and Cl
7
+ # substituents): hydrogen, carbon, nitrogen, oxygen, fluorine, sulfur, chlorine. Predictions for any
8
+ # other element are not meaningful, so predict_shieldings refuses them.
9
+ SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17})
10
+
11
+
12
+ def yield_data(solute_atomic_numbers, geometries, atomic_numbers, shieldings = None, N_atoms_per_solvent = 3, solvent_distance_threshold = None, atom_type = 'H'):
13
+ data = torch_geometric.data.Data(
14
+ x = torch.as_tensor(atomic_numbers, dtype = torch.long),
15
+ )
16
+ if shieldings is None:
17
+ data.shieldings = torch.zeros(data.x.shape[0], dtype = torch.float)
18
+ else:
19
+ data.shieldings = torch.as_tensor(shieldings, dtype = torch.float)
20
+
21
+ data.pos = torch.as_tensor(geometries, dtype = torch.float)
22
+
23
+ solute = np.zeros(len(atomic_numbers), dtype = int)
24
+ solute[0:len(solute_atomic_numbers)] = 1
25
+ data.solute = torch.as_tensor(solute, dtype = torch.long)
26
+
27
+ if atom_type == 'H':
28
+ atom_type_mask = (atomic_numbers == 1) & (solute == 1) # H shieldings
29
+ elif atom_type == 'C':
30
+ atom_type_mask = (atomic_numbers == 6) & (solute == 1) # C shieldings
31
+ data.atom_type_mask = torch.as_tensor(atom_type_mask, dtype = torch.bool)
32
+
33
+ data.batch = torch.zeros(data.x.shape[0], dtype = torch.long)
34
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
35
+
36
+ N_solute_atoms = sum(data.solute).item()
37
+ N_solvent_atoms = data.x.shape[0] - N_solute_atoms
38
+ N_solvent_molecules = N_solvent_atoms // N_atoms_per_solvent
39
+ unique_molecule_batch = np.zeros(N_solute_atoms, dtype = int)
40
+ if N_solvent_molecules > 0:
41
+ unique_molecule_batch = np.concatenate([
42
+ unique_molecule_batch,
43
+ np.concatenate([np.zeros(N_atoms_per_solvent, dtype = int) + 1 + i for i in range(N_solvent_molecules)], axis = 0),
44
+ ], axis = 0)
45
+ data.unique_molecule_batch = torch.as_tensor(unique_molecule_batch, dtype = torch.long)
46
+
47
+ if solvent_distance_threshold is not None:
48
+ min_distance_to_solute_peratom = torch.linalg.norm(data.pos - (data.pos[data.solute > 0])[:, None, ...], dim = -1).min(axis = 0).values
49
+ min_distance_to_solute_permolecule = torch_scatter.scatter_min(min_distance_to_solute_peratom, data.unique_molecule_batch)[0]
50
+ remove_atom = np.array([m in torch.where((min_distance_to_solute_permolecule > solvent_distance_threshold))[0] for m in data.unique_molecule_batch])
51
+
52
+ data.x = data.x[~remove_atom]
53
+ if shieldings is None:
54
+ data.shieldings = data.shieldings[~remove_atom]
55
+ data.pos = data.pos[~remove_atom]
56
+ data.solute = data.solute[~remove_atom]
57
+ data.atom_type_mask = data.atom_type_mask[~remove_atom]
58
+ data.batch = torch.zeros(data.x.shape[0], dtype = torch.long)
59
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
60
+ N_solute_atoms = sum(data.solute).item()
61
+ N_solvent_atoms = data.x.shape[0] - N_solute_atoms
62
+ N_solvent_molecules = N_solvent_atoms // N_atoms_per_solvent
63
+ unique_molecule_batch = np.zeros(N_solute_atoms, dtype = int)
64
+ if N_solvent_molecules > 0:
65
+ unique_molecule_batch = np.concatenate([
66
+ unique_molecule_batch,
67
+ np.concatenate([np.zeros(N_atoms_per_solvent, dtype = int) + 1 + i for i in range(N_solvent_molecules)], axis = 0),
68
+ ], axis = 0)
69
+ data.unique_molecule_batch = torch.as_tensor(unique_molecule_batch, dtype = torch.long)
70
+
71
+ return data
72
+
73
+
74
+ def _predict_once(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers, N_atoms_per_solvent, solvent_distance_threshold, device):
75
+ # one forward pass each for the 1H and 13C heads, combined into a single per-atom array
76
+ for atom_type in ['H', 'C']:
77
+ data = yield_data(
78
+ solute_atomic_numbers = solute_atomic_numbers,
79
+ geometries = geometry,
80
+ atomic_numbers = atomic_numbers,
81
+ shieldings = None,
82
+ N_atoms_per_solvent = N_atoms_per_solvent if N_atoms_per_solvent is not None else 3,
83
+ solvent_distance_threshold = solvent_distance_threshold,
84
+ atom_type = atom_type,
85
+ )
86
+ data = data.to(device)
87
+
88
+ with torch.no_grad():
89
+ if atom_type == 'H':
90
+ y_pred = model_H.forward(data).cpu()
91
+ elif atom_type == 'C':
92
+ y_pred = model_C.forward(data).cpu()
93
+ # the masks live on `device`; move them to CPU to match y_pred (a no-op on CPU, and
94
+ # required on CUDA, where indexing a CPU tensor with a device mask would raise)
95
+ atom_type_mask = data.atom_type_mask.cpu()
96
+ solute = data.solute.cpu()
97
+ y_pred[~atom_type_mask] = 0.
98
+ y_pred = y_pred[solute == 1].numpy()
99
+
100
+ # combine H and C shieldings into one array
101
+ if atom_type == 'H':
102
+ y_pred_combined = y_pred
103
+ elif atom_type == 'C':
104
+ y_pred_combined[y_pred != 0.0] = y_pred[y_pred != 0.0]
105
+
106
+ return y_pred_combined
107
+
108
+
109
+ def predict_shieldings(model_H, model_C, solute_atomic_numbers, geometry, atomic_numbers = None, N_atoms_per_solvent = None, solvent_distance_threshold = None, device = 'cpu', n_passes = 1, symmetrize = False):
110
+ """Predict 1H/13C shieldings for the solute atoms.
111
+
112
+ A single forward pass is NOT deterministic: each edge picks a random local
113
+ reference frame (eqV2/edge_rot_mat.py), so with a finite spherical-harmonic grid
114
+ the output varies by ~0.01 ppm (13C) between passes. Set n_passes (e.g. 20, as in
115
+ the MagNET paper) to average that frame noise away.
116
+
117
+ symmetrize=True also averages the prediction over the molecule and its mirror image.
118
+ Isotropic shielding is parity-even (a molecule and its reflection have identical
119
+ shieldings), but the SO(3)-only model does not enforce this and can disagree by
120
+ ~0.3 ppm on 13C for large molecules; symmetrizing removes that spurious error.
121
+ """
122
+ solute_atomic_numbers = np.asarray(solute_atomic_numbers)
123
+ if atomic_numbers is None:
124
+ atomic_numbers = solute_atomic_numbers
125
+ else:
126
+ # coerce so the element comparisons in yield_data (atomic_numbers == 1) stay array-wise;
127
+ # a bare Python list would compare to a scalar False and silently mis-mask
128
+ atomic_numbers = np.asarray(atomic_numbers)
129
+
130
+ # MagNET was only ever trained on these elements. On anything else it would emit a confident but
131
+ # meaningless prediction, so refuse it rather than let bad numbers through (validate the full
132
+ # system, which for MagNET-x includes the solvent atoms).
133
+ unsupported = sorted(set(np.unique(atomic_numbers).tolist()) - SUPPORTED_ELEMENTS)
134
+ if unsupported:
135
+ raise ValueError(
136
+ f"MagNET supports only the elements {sorted(SUPPORTED_ELEMENTS)} "
137
+ f"(H, C, N, O, F, S, Cl); the input contains unsupported atomic numbers {unsupported}. "
138
+ f"Exclude molecules with these elements before predicting.")
139
+
140
+ geometry = np.asarray(geometry, dtype=float)
141
+ geometries = [geometry]
142
+ if symmetrize:
143
+ reflected = geometry.copy()
144
+ reflected[..., 0] = -reflected[..., 0] # mirror across the yz-plane (an improper rotation)
145
+ geometries.append(reflected)
146
+
147
+ preds = []
148
+ for geom in geometries:
149
+ for _ in range(n_passes):
150
+ preds.append(np.squeeze(_predict_once(model_H, model_C, solute_atomic_numbers, geom,
151
+ atomic_numbers, N_atoms_per_solvent,
152
+ solvent_distance_threshold, device)))
153
+ # atleast_1d keeps a one-atom solute a (1,) array instead of a 0-d scalar after the per-pass squeeze
154
+ return np.atleast_1d(np.mean(preds, axis=0))
magnet/model.py ADDED
@@ -0,0 +1,236 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import pytorch_lightning as pl
3
+ from .eqV2 import EquiformerV2_NMR
4
+
5
+ class MagNET_Lightning(pl.LightningModule):
6
+ def __init__(self, params, precision = 32, filter_solvent_edges = None, solvent_edge_radius = None):
7
+ super().__init__()
8
+
9
+ self.save_hyperparameters()
10
+
11
+ self.precision = precision
12
+
13
+ # if filter_solvent_edges and solvent_edge_radius are not provided as arguments, read from params file
14
+ # this is convenient, because we don't need to constantly provide filter_solvent_edges and solvent_edge_radius
15
+ # as arguments if they're already specified in params, while still allowing us to override their previous
16
+ # values if we need to (e.g., when loading in a pretrained checkpoint)
17
+ if filter_solvent_edges is None:
18
+ assert solvent_edge_radius is None
19
+ filter_solvent_edges = False
20
+ solvent_edge_radius = params['max_radius']
21
+ if 'filter_solvent_edges' in params:
22
+ filter_solvent_edges = params['filter_solvent_edges']
23
+ if 'solvent_edge_radius' in params:
24
+ solvent_edge_radius = params['solvent_edge_radius']
25
+ self.filter_solvent_edges = filter_solvent_edges
26
+ self.solvent_edge_radius = solvent_edge_radius
27
+
28
+ self.lr = params['lr']
29
+
30
+ if 'min_lr' in params:
31
+ self.min_lr = params['min_lr']
32
+ else:
33
+ self.min_lr = self.lr
34
+
35
+ # number of exponential decay steps (= # batches) from self.lr to self.min_lr
36
+ if 'lr_steps' in params:
37
+ self.lr_steps = params['lr_steps']
38
+ else:
39
+ self.lr_steps = 300000
40
+
41
+ self.model_type = params['model_type'] # in ['EquiformerV2']
42
+
43
+ evidential_regression = False
44
+ if 'evidential_regression' in params:
45
+ evidential_regression = params['evidential_regression']
46
+ self.evidential_regression = evidential_regression
47
+
48
+ if self.model_type == 'EquiformerV2':
49
+ assert self.precision == 32
50
+
51
+ self.model = EquiformerV2_NMR(
52
+ None, None, None,
53
+ use_pbc=False, regress_forces=False,
54
+
55
+ otf_graph = True,
56
+ max_neighbors = params['max_neighbors'],
57
+ max_radius = params['max_radius'],
58
+ max_num_elements = params['max_num_elements'],
59
+
60
+ num_layers = params['num_layers'],
61
+ sphere_channels = params['sphere_channels'],
62
+ attn_hidden_channels = params['attn_hidden_channels'],
63
+ num_heads = params['num_heads'],
64
+ attn_alpha_channels = params['attn_alpha_channels'],
65
+ attn_value_channels = params['attn_value_channels'],
66
+ ffn_hidden_channels = params['ffn_hidden_channels'],
67
+
68
+ norm_type='layer_norm_sh',
69
+
70
+ lmax_list = params['lmax_list'],
71
+ mmax_list = params['mmax_list'],
72
+ grid_resolution = params['grid_resolution'],
73
+
74
+ num_sphere_samples=128,
75
+
76
+ edge_channels=128,
77
+ use_atom_edge_embedding=True,
78
+ share_atom_edge_embedding=False,
79
+ use_m_share_rad=False,
80
+ distance_function="gaussian",
81
+ num_distance_basis=512, # not used; hard-coded by Equiformer-V2 to 600
82
+
83
+ attn_activation='silu',
84
+ use_s2_act_attn=False,
85
+ use_attn_renorm=True,
86
+ ffn_activation='silu',
87
+ use_gate_act=False,
88
+ use_grid_mlp=True,
89
+ use_sep_s2_act=True,
90
+
91
+ alpha_drop=0.0,
92
+ drop_path_rate=0.0,
93
+ proj_drop=0.0,
94
+
95
+ weight_init = params['weight_init'],
96
+
97
+ evidential_regression = self.evidential_regression,
98
+ filter_solvent_edges = self.filter_solvent_edges,
99
+ solvent_edge_radius = self.solvent_edge_radius,
100
+ )
101
+
102
+ def forward(self, data):
103
+ if 'atomic_numbers' not in data.keys():
104
+ data.atomic_numbers = data.x
105
+ if 'natoms' not in data.keys():
106
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
107
+
108
+ if self.model_type == 'EquiformerV2':
109
+ if self.evidential_regression:
110
+ mu,v,alpha,beta = self.model(data).squeeze(1).chunk(4, dim = 1)
111
+ v = torch.nn.functional.softplus(v) + 1e-8
112
+ alpha = torch.nn.functional.softplus(alpha) + 1. + 1e-8
113
+ beta = torch.nn.functional.softplus(beta) + 1e-8
114
+ return torch.cat([mu,v,alpha,beta], dim = 1)
115
+ else:
116
+ return self.model(data).squeeze(1)
117
+
118
+ def configure_optimizers(self):
119
+ optimizer = torch.optim.Adam(self.parameters(), lr = self.lr)
120
+
121
+ # exponential lr decay from self.lr to self.min_lr in self.lr_steps steps
122
+ gamma = (self.min_lr / self.lr) ** (1.0 / self.lr_steps)
123
+ func = lambda step: max(gamma**(step), self.min_lr / self.lr)
124
+ scheduler = torch.optim.lr_scheduler.LambdaLR(optimizer, lr_lambda = func)
125
+
126
+ lr_scheduler_config = {
127
+ "scheduler": scheduler,
128
+ "interval": "step",
129
+ "frequency": 1,
130
+ "strict": False,
131
+ "name": None,
132
+ }
133
+
134
+ return {"optimizer": optimizer, "lr_scheduler": lr_scheduler_config}
135
+
136
+ def training_step(self, train_batch, batch_idx):
137
+ data = train_batch
138
+ data.atomic_numbers = data.x
139
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
140
+
141
+ out = self.forward(data)
142
+
143
+ if self.precision == 64:
144
+ y = data.shieldings.double()
145
+ else:
146
+ y = data.shieldings
147
+
148
+ if self.evidential_regression:
149
+ lam = 0.2
150
+ mu,v,alpha,beta = out.chunk(4, dim = 1)
151
+ mu = mu.squeeze(dim=1)[data.atom_type_mask]
152
+ v = v.squeeze(dim=1)[data.atom_type_mask]
153
+ alpha = alpha.squeeze(dim=1)[data.atom_type_mask]
154
+ beta = beta.squeeze(dim=1)[data.atom_type_mask]
155
+
156
+ loss = 0.5 * (torch.log(torch.pi / v)) \
157
+ - alpha * torch.log(2.*beta*(1. + v)) \
158
+ + (alpha + 0.5) * torch.log(v*(y[data.atom_type_mask] - mu)**2. + 2.*beta*(1. + v)) \
159
+ + torch.lgamma(alpha) - torch.lgamma(alpha + 0.5) \
160
+ + lam * (torch.abs(y[data.atom_type_mask] - mu) * (2. * v + alpha) - 1e-4)
161
+ loss = loss.mean()
162
+ else:
163
+ loss = torch.mean(((out.squeeze(1) - y)[data.atom_type_mask])**2.0)
164
+
165
+ self.log('train_loss', loss, batch_size = data.atom_type_mask.sum().item())
166
+
167
+ # does not account for symmetric atoms
168
+ return loss
169
+
170
+ def validation_step(self, val_batch, batch_idx):
171
+ data = val_batch
172
+ data.atomic_numbers = data.x
173
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
174
+
175
+ out = self.forward(data)
176
+
177
+ if self.precision == 64:
178
+ y = data.shieldings.double()
179
+ else:
180
+ y = data.shieldings
181
+
182
+ if self.evidential_regression:
183
+ mu,v,alpha,beta = out.chunk(4, dim = 1)
184
+ mu = mu.squeeze(dim=1)[data.atom_type_mask]
185
+ v = v.squeeze(dim=1)[data.atom_type_mask]
186
+ alpha = alpha.squeeze(dim=1)[data.atom_type_mask]
187
+ beta = beta.squeeze(dim=1)[data.atom_type_mask]
188
+
189
+ lam = 0.2
190
+ loss = 0.5 * (torch.log(torch.pi / v)) \
191
+ - alpha * torch.log(2.*beta*(1. + v)) \
192
+ + (alpha + 0.5) * torch.log(v*(y[data.atom_type_mask] - mu)**2. + 2.*beta*(1. + v)) \
193
+ + torch.lgamma(alpha) - torch.lgamma(alpha + 0.5) \
194
+ + lam * (torch.abs(y[data.atom_type_mask] - mu) * (2. * v + alpha) - 1e-4)
195
+ loss = loss.mean()
196
+ mae = torch.mean(torch.abs(mu - y[data.atom_type_mask]))
197
+ else:
198
+ loss = torch.mean(((out.squeeze(1) - y)[data.atom_type_mask])**2.0)
199
+ mae = torch.mean(torch.abs(((out.squeeze(1) - y)[data.atom_type_mask])))
200
+
201
+ self.log_dict({"val_loss": loss, "val_mae": mae}, batch_size = data.atom_type_mask.sum().item())
202
+ return loss
203
+
204
+ def test_step(self, test_batch, batch_idx):
205
+ data = test_batch
206
+ data.atomic_numbers = data.x
207
+ data.natoms = torch.unique_consecutive(data.batch, return_counts = True)[1]
208
+
209
+ out = self.forward(data)
210
+
211
+ if self.precision == 64:
212
+ y = data.shieldings.double()
213
+ else:
214
+ y = data.shieldings
215
+
216
+ if self.evidential_regression:
217
+ lam = 0.2
218
+ mu,v,alpha,beta = out.chunk(4, dim = 1)
219
+ mu = mu.squeeze(dim=1)[data.atom_type_mask]
220
+ v = v.squeeze(dim=1)[data.atom_type_mask]
221
+ alpha = alpha.squeeze(dim=1)[data.atom_type_mask]
222
+ beta = beta.squeeze(dim=1)[data.atom_type_mask]
223
+
224
+ loss = 0.5 * (torch.log(torch.pi / v)) \
225
+ - alpha * torch.log(2.*beta*(1. + v)) \
226
+ + (alpha + 0.5) * torch.log(v*(y[data.atom_type_mask] - mu)**2. + 2.*beta*(1. + v)) \
227
+ + torch.lgamma(alpha) - torch.lgamma(alpha + 0.5) \
228
+ + lam * (torch.abs(y[data.atom_type_mask] - mu) * (2. * v + alpha) - 1e-4)
229
+ loss = loss.mean()
230
+ mae = torch.mean(torch.abs(mu - y[data.atom_type_mask]))
231
+ else:
232
+ loss = torch.mean(((out.squeeze(1) - y)[data.atom_type_mask])**2.0)
233
+ mae = torch.mean(torch.abs(((out.squeeze(1) - y)[data.atom_type_mask])))
234
+
235
+ self.log_dict({"test_loss": loss, "test_mae": mae}, batch_size = data.atom_type_mask.sum().item())
236
+ return loss
magnet/pyproject.toml ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "magnet-nmr"
7
+ version = "0.1.0"
8
+ description = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction"
9
+ readme = {text = "MagNET: equivariant neural networks for NMR chemical-shift (shielding) prediction. Models, datasets, install instructions, and usage examples are at https://github.com/ekwan/MagNET", content-type = "text/markdown"}
10
+ requires-python = ">=3.9"
11
+ license = "MIT"
12
+
13
+ # torch_geometric is pinned to 2.6.x because 2.7+ requires pyg-lib for radius_graph.
14
+ #
15
+ # torch_scatter and torch_cluster are deliberately NOT listed here. They have no plain-PyPI
16
+ # wheels for our platforms, so pip would try to build them from source and fail; they must come
17
+ # from the PyG wheel index, keyed to the exact torch version:
18
+ # pip install torch_scatter torch_cluster --find-links https://data.pyg.org/whl/torch-2.5.0+cpu.html
19
+ # The `pyg` extra records this so `pip install magnet-nmr[pyg]` documents the two, but the
20
+ # --find-links index is still required for the install to resolve.
21
+ dependencies = [
22
+ "torch==2.5.0",
23
+ "torch_geometric==2.6.1",
24
+ "pytorch_lightning>=2.0",
25
+ "e3nn>=0.5",
26
+ "numpy",
27
+ ]
28
+
29
+ [project.optional-dependencies]
30
+ examples = ["h5py"]
31
+ pyg = ["torch_scatter", "torch_cluster"]
32
+
33
+ # The importable package `magnet` lives in this directory (package-dir maps it to ".").
34
+ [tool.setuptools]
35
+ packages = [
36
+ "magnet",
37
+ "magnet.eqV2",
38
+ "magnet.eqV2.ocpmodels",
39
+ "magnet.eqV2.ocpmodels.common",
40
+ "magnet.eqV2.ocpmodels.models",
41
+ "magnet.eqV2.ocpmodels.models.scn",
42
+ ]
43
+
44
+ [tool.setuptools.package-dir]
45
+ "magnet" = "."
46
+
47
+ # the Wigner-D rotation table that wigner.py loads at import time
48
+ [tool.setuptools.package-data]
49
+ "magnet.eqV2" = ["Jd.pt"]
magnet/requirements.txt ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # MagNET inference dependencies (CPU; tested on Apple Silicon arm64 and Linux x86-64).
2
+ #
3
+ # Two non-obvious points are baked in below:
4
+ # 1. torch_scatter and torch_cluster are NOT on plain PyPI for macOS arm64. They come
5
+ # from the PyG wheel index, which is keyed to the EXACT torch version. The
6
+ # --find-links line points pip at the matching prebuilt wheels for torch 2.5.0 + CPU.
7
+ # 2. torch_geometric is PINNED to 2.6.x. From 2.7 onward, radius_graph requires pyg-lib
8
+ # (which has no Apple-Silicon wheel); 2.6.x still dispatches radius_graph to
9
+ # torch_cluster, which is what the model needs.
10
+ #
11
+ # Install torch first, then this file, or just run this file twice if a fresh
12
+ # environment resolves torch_scatter before torch:
13
+ # pip install torch==2.5.0
14
+ # pip install -r requirements.txt
15
+ #
16
+ # On a Linux machine with CUDA, `pip install torch==2.5.0` defaults to the multi-gigabyte GPU build.
17
+ # This is a CPU stack, so install the CPU build explicitly first:
18
+ # pip install torch==2.5.0 --index-url https://download.pytorch.org/whl/cpu
19
+
20
+ --find-links https://data.pyg.org/whl/torch-2.5.0+cpu.html
21
+
22
+ torch==2.5.0
23
+ torch_scatter
24
+ torch_cluster
25
+ torch_geometric==2.6.1
26
+ pytorch_lightning>=2.0
27
+ e3nn>=0.5
28
+ numpy
29
+
30
+ # Only needed to run the bundled examples/tests that read the .hdf5 datasets
31
+ # (the model itself does not import h5py):
32
+ h5py
magnet/run_magnet.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Worked examples for the MagNET model family.
2
+
3
+ Each helper loads the released checkpoints for one model and runs `predict_shieldings`. All of them
4
+ expose the two prediction options:
5
+
6
+ - `n_passes`: average over this many forward passes to remove the per-pass frame noise (the MagNET
7
+ paper uses 20).
8
+ - `symmetrize`: also average over the molecule and its mirror image. Isotropic shielding is
9
+ parity-even, but the SO(3)-only network does not enforce that, so symmetrizing removes a spurious
10
+ reflection error that grows to ~0.3 ppm on 13C for large molecules. It costs one extra evaluation.
11
+ The released reference shieldings are NOT symmetrized, so leave it False to reproduce them and turn
12
+ it on for best accuracy on large molecules.
13
+
14
+ Geometries are the AIMNet2 (machine-learning-optimized) stationary geometries, in Angstrom.
15
+ """
16
+ import os
17
+
18
+ import numpy as np
19
+ import torch
20
+
21
+ from magnet.model import MagNET_Lightning
22
+ from magnet.inference import predict_shieldings
23
+
24
+ MODEL_CHECKPOINTS = {
25
+ # foundation model (predicts the gas-phase shielding the rovibrational/QCD analysis builds on)
26
+ "MagNET_H": "model_checkpoints/MagNET/MagNET_1H.ckpt",
27
+ "MagNET_C": "model_checkpoints/MagNET/MagNET_13C.ckpt",
28
+ # MagNET-Zero: gas-phase shielding (WP04 for 1H, wB97X-D for 13C, pcSseg-2)
29
+ "MagNET-Zero_H": "model_checkpoints/MagNET-Zero/MagNET-Zero_1H.ckpt",
30
+ "MagNET-Zero_C": "model_checkpoints/MagNET-Zero/MagNET-Zero_13C.ckpt",
31
+ # MagNET-PCM: B3LYP-D3(BJ)/pcSseg-2 gas-phase and chloroform-PCM shieldings; the correction is
32
+ # their difference
33
+ "MagNET-PCM-withPCM_H": "model_checkpoints/MagNET-PCM/MagNET-PCM-withPCM_1H.ckpt",
34
+ "MagNET-PCM-withPCM_C": "model_checkpoints/MagNET-PCM/MagNET-PCM-withPCM_13C.ckpt",
35
+ "MagNET-PCM-withoutPCM_H": "model_checkpoints/MagNET-PCM/MagNET-PCM-withoutPCM_1H.ckpt",
36
+ "MagNET-PCM-withoutPCM_C": "model_checkpoints/MagNET-PCM/MagNET-PCM-withoutPCM_13C.ckpt",
37
+ # MagNET-x: explicit-solvent shieldings, one model per solvent (see compute_MagNET_x_corrections).
38
+ # The released checkpoint folder is "MagNET-x" (lowercase x); paths are case-sensitive on Linux.
39
+ "MagNET-x-chloroform_H": "model_checkpoints/MagNET-x/MagNET-x-chloroform_1H.ckpt",
40
+ "MagNET-x-chloroform_C": "model_checkpoints/MagNET-x/MagNET-x-chloroform_13C.ckpt",
41
+ "MagNET-x-benzene_H": "model_checkpoints/MagNET-x/MagNET-x-benzene_1H.ckpt",
42
+ "MagNET-x-benzene_C": "model_checkpoints/MagNET-x/MagNET-x-benzene_13C.ckpt",
43
+ "MagNET-x-methanol_H": "model_checkpoints/MagNET-x/MagNET-x-methanol_1H.ckpt",
44
+ "MagNET-x-methanol_C": "model_checkpoints/MagNET-x/MagNET-x-methanol_13C.ckpt",
45
+ "MagNET-x-water_H": "model_checkpoints/MagNET-x/MagNET-x-water_1H.ckpt",
46
+ "MagNET-x-water_C": "model_checkpoints/MagNET-x/MagNET-x-water_13C.ckpt",
47
+ }
48
+
49
+
50
+ def _default_device():
51
+ return torch.device("cuda:0" if torch.cuda.is_available() else "cpu")
52
+
53
+
54
+ def _resolve_checkpoint(path, checkpoints_dir=None):
55
+ """Locate a checkpoint file given a MODEL_CHECKPOINTS-relative path like
56
+ "model_checkpoints/MagNET/MagNET_1H.ckpt". Resolution order for a relative path:
57
+ 1. under `checkpoints_dir`, if given (it may be the model_checkpoints/ folder itself, or a
58
+ directory that contains one); this takes precedence over the defaults below,
59
+ 2. as given (relative to the current directory),
60
+ 3. relative to the release repo root (the parent of this magnet/ folder), where
61
+ model_checkpoints/ sits in the integrated Hugging Face checkout.
62
+ Absolute paths pass through unchanged. If nothing is found, return the original so the loader
63
+ raises a clear error on it."""
64
+ if checkpoints_dir:
65
+ # the stored path is "model_checkpoints/<rest>"; accept a checkpoints_dir that IS the
66
+ # model_checkpoints/ folder (join <rest>) or one that CONTAINS it (join the whole path)
67
+ rest = path[len("model_checkpoints/"):] if path.startswith("model_checkpoints/") else path
68
+ for candidate in (os.path.join(checkpoints_dir, rest), os.path.join(checkpoints_dir, path)):
69
+ if os.path.exists(candidate):
70
+ return candidate
71
+ if os.path.exists(path):
72
+ return path
73
+ repo_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # parent of magnet/
74
+ candidate = os.path.join(repo_root, path)
75
+ if os.path.exists(candidate):
76
+ return candidate
77
+ return path
78
+
79
+
80
+ def load_model_to_device(path, device=None, checkpoints_dir=None):
81
+ """Load one checkpoint onto `device`. `checkpoints_dir`, if given, is where to find the released
82
+ weights: it may point at the model_checkpoints/ folder itself or at a directory that contains one,
83
+ and takes precedence over the current directory and the repo root (see _resolve_checkpoint). Pass
84
+ it when the weights live outside the repo; leave it None to use the in-repo/CWD defaults."""
85
+ if device is None:
86
+ device = _default_device()
87
+ path = _resolve_checkpoint(path, checkpoints_dir=checkpoints_dir)
88
+ return MagNET_Lightning.load_from_checkpoint(checkpoint_path=path, map_location=device).to(device).eval()
89
+
90
+
91
+ def _predict_with(key_H, key_C, atomic_numbers_list, geometries_list,
92
+ n_passes=20, symmetrize=False, device=None, checkpoints_dir=None, **predict_kwargs):
93
+ """Load one model pair and run predict_shieldings over a list of molecules. Returns a list of
94
+ per-atom shielding arrays. Extra keyword arguments are forwarded to predict_shieldings (used by
95
+ the explicit-solvent path for the solute/solvent split)."""
96
+ if device is None:
97
+ device = _default_device()
98
+ model_H = load_model_to_device(MODEL_CHECKPOINTS[key_H], device, checkpoints_dir=checkpoints_dir)
99
+ model_C = load_model_to_device(MODEL_CHECKPOINTS[key_C], device, checkpoints_dir=checkpoints_dir)
100
+ out = []
101
+ for entry in zip(atomic_numbers_list, geometries_list):
102
+ atomic_numbers, geometry = entry[0], entry[1]
103
+ out.append(np.atleast_1d(predict_shieldings(
104
+ model_H, model_C, solute_atomic_numbers=atomic_numbers, geometry=geometry,
105
+ device=device, n_passes=n_passes, symmetrize=symmetrize, **predict_kwargs).squeeze()))
106
+ return out
107
+
108
+
109
+ def compute_MagNET_foundation_shieldings(atomic_numbers_list, geometries_list,
110
+ n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
111
+ """MagNET foundation-model gas-phase shieldings (1H and 13C) for AIMNet2 geometries."""
112
+ return _predict_with("MagNET_H", "MagNET_C", atomic_numbers_list, geometries_list,
113
+ n_passes=n_passes, symmetrize=symmetrize, device=device,
114
+ checkpoints_dir=checkpoints_dir)
115
+
116
+
117
+ def compute_MagNET_Zero_shieldings(atomic_numbers_list, geometries_list,
118
+ n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
119
+ """MagNET-Zero gas-phase shieldings (1H and 13C) for a list of AIMNet2 geometries.
120
+
121
+ Args:
122
+ atomic_numbers_list: list of (N,) integer atomic-number arrays.
123
+ geometries_list: list of (N, 3) coordinate arrays, in Angstrom.
124
+ n_passes, symmetrize: prediction options (see module docstring).
125
+ checkpoints_dir: optional directory holding the released weights (see load_model_to_device).
126
+
127
+ Returns a list of (N,) shielding arrays (1H at H atoms, 13C at C atoms).
128
+ """
129
+ return _predict_with("MagNET-Zero_H", "MagNET-Zero_C", atomic_numbers_list, geometries_list,
130
+ n_passes=n_passes, symmetrize=symmetrize, device=device,
131
+ checkpoints_dir=checkpoints_dir)
132
+
133
+
134
+ def compute_MagNET_PCM_corrections(atomic_numbers_list, geometries_list,
135
+ n_passes=20, symmetrize=False, device=None, checkpoints_dir=None):
136
+ """MagNET-PCM implicit-solvent (chloroform) corrections (1H and 13C) for AIMNet2 geometries.
137
+
138
+ The correction is the difference between the PCM-corrected shielding and the gas-phase shielding,
139
+ both from the B3LYP-D3(BJ)/pcSseg-2 MagNET-PCM checkpoints.
140
+
141
+ Returns a list of (N,) correction arrays.
142
+ """
143
+ gas = _predict_with("MagNET-PCM-withoutPCM_H", "MagNET-PCM-withoutPCM_C",
144
+ atomic_numbers_list, geometries_list,
145
+ n_passes=n_passes, symmetrize=symmetrize, device=device,
146
+ checkpoints_dir=checkpoints_dir)
147
+ pcm = _predict_with("MagNET-PCM-withPCM_H", "MagNET-PCM-withPCM_C",
148
+ atomic_numbers_list, geometries_list,
149
+ n_passes=n_passes, symmetrize=symmetrize, device=device,
150
+ checkpoints_dir=checkpoints_dir)
151
+ return [pcm[i] - gas[i] for i in range(len(pcm))]
152
+
153
+
154
+ # Atoms per solvent molecule, for the explicit-solvent split (water is stored/run as TIP4P).
155
+ N_ATOMS_PER_SOLVENT = {"chloroform": 5, "benzene": 12, "methanol": 6, "water": 3}
156
+
157
+
158
+ def compute_MagNET_x_corrections(solvent, solute_atomic_numbers_list, atomic_numbers_list,
159
+ geometries_list, n_passes=20, symmetrize=False, device=None,
160
+ solvent_distance_threshold=12.0, checkpoints_dir=None):
161
+ """MagNET-x explicit-solvent corrections (solvated minus isolated) for one solvent.
162
+
163
+ Args:
164
+ solvent: 'chloroform' | 'benzene' | 'methanol' | 'water' (water uses the TIP4P model).
165
+ solute_atomic_numbers_list: list of (n_solute,) solute atomic-number arrays.
166
+ atomic_numbers_list: list of (n_total,) atomic numbers for the whole solute+solvent system.
167
+ The solute atoms must come first, followed by complete solvent molecules in contiguous
168
+ blocks of N_ATOMS_PER_SOLVENT[solvent] atoms.
169
+ geometries_list: list of (n_total, 3) coordinate arrays (same atom order), in Angstrom.
170
+ n_passes, symmetrize: prediction options (see module docstring).
171
+ solvent_distance_threshold: drop solvent molecules whose nearest atom is beyond this many
172
+ Angstrom of the solute (the released pipeline used 12.0). The isolated pass uses 0.0
173
+ internally, which strips all solvent.
174
+ checkpoints_dir: optional directory holding the released weights (see load_model_to_device).
175
+
176
+ Returns a list of (n_solute,) correction arrays. The published pipeline averages this over MD
177
+ frames; a single-frame value is only an estimate.
178
+ """
179
+ if solvent not in N_ATOMS_PER_SOLVENT:
180
+ raise ValueError(f"solvent must be one of {sorted(N_ATOMS_PER_SOLVENT)}; got {solvent!r}")
181
+ n_per = N_ATOMS_PER_SOLVENT[solvent]
182
+ if device is None:
183
+ device = _default_device()
184
+ model_H = load_model_to_device(MODEL_CHECKPOINTS[f"MagNET-x-{solvent}_H"], device, checkpoints_dir=checkpoints_dir)
185
+ model_C = load_model_to_device(MODEL_CHECKPOINTS[f"MagNET-x-{solvent}_C"], device, checkpoints_dir=checkpoints_dir)
186
+ out = []
187
+ for solute_Z, full_Z, geom in zip(solute_atomic_numbers_list, atomic_numbers_list, geometries_list):
188
+ kw = dict(model_H=model_H, model_C=model_C, solute_atomic_numbers=solute_Z, geometry=geom,
189
+ atomic_numbers=full_Z, N_atoms_per_solvent=n_per, device=device,
190
+ n_passes=n_passes, symmetrize=symmetrize)
191
+ solvated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=solvent_distance_threshold, **kw).squeeze())
192
+ isolated = np.atleast_1d(predict_shieldings(solvent_distance_threshold=0.0, **kw).squeeze())
193
+ out.append(solvated - isolated)
194
+ return out
195
+
196
+
197
+ if __name__ == "__main__":
198
+ atomic_numbers = np.array([6, 6, 6, 8, 8, 6, 1, 1, 1, 1, 1, 1])
199
+ coordinates = np.array(
200
+ [
201
+ [-2.07273936, 0.71782219, -0.27169511],
202
+ [-1.51154256, -0.47834334, -0.05051489],
203
+ [-0.06133485, -0.72471339, 0.10392854],
204
+ [0.38785097, -1.84332836, 0.30459866],
205
+ [0.65684897, 0.41875002, -0.00514369],
206
+ [2.0648613, 0.23182768, 0.13713977],
207
+ [-1.48494101, 1.6266042, -0.3569932],
208
+ [-3.14927745, 0.81001383, -0.37304503],
209
+ [-2.12480974, -1.36963022, 0.03013895],
210
+ [2.44585443, -0.42836204, -0.64841408],
211
+ [2.30088663, -0.16782625, 1.12843716],
212
+ [2.5483427, 1.20718563, 0.03357037],
213
+ ]
214
+ )
215
+
216
+ foundation = compute_MagNET_foundation_shieldings([atomic_numbers], [coordinates])
217
+ zero = compute_MagNET_Zero_shieldings([atomic_numbers], [coordinates])
218
+ pcm = compute_MagNET_PCM_corrections([atomic_numbers], [coordinates])
219
+ print("foundation gas-phase shieldings:", foundation[0])
220
+ print("MagNET-Zero gas-phase shieldings:", zero[0])
221
+ print("MagNET-PCM chloroform corrections:", pcm[0])
magnet/scaling.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Turn MagNET-Zero / MagNET-PCM shieldings into the chemical shifts a chemist measures, using the
2
+ paper's published per-solvent linear scaling (SI Table S10 for proton, S11 for carbon).
3
+
4
+ This is a tiny, self-contained copy (numpy only), so `from magnet import published_scaling_tables,
5
+ predict_shift` works with no data download and no extra path setup. The full derivation of these
6
+ tables from the delta-22 data lives in analysis/code/scaling_factors.py, and a test there checks the
7
+ two copies agree so they cannot drift.
8
+
9
+ The prediction equation, for one nucleus and one solvent, is
10
+
11
+ shift = intercept + stationary * sigma_zero + pcm * delta_pcm
12
+
13
+ where sigma_zero is the MagNET-Zero gas-phase shielding (compute_MagNET_Zero_shieldings) and delta_pcm
14
+ is the MagNET-PCM chloroform correction (compute_MagNET_PCM_corrections). You always pass the
15
+ chloroform correction; the per-solvent scaling is already folded into the `pcm` coefficient.
16
+ """
17
+ import csv
18
+ import io
19
+
20
+ import numpy as np
21
+
22
+ # Published SI Tables S10 (proton) and S11 (carbon), verbatim. Columns: intercept, stationary, pcm.
23
+ _PUBLISHED_TABLE_CSV = {
24
+ "H": """solvent,intercept,stationary,pcm
25
+ tetrahydrofuran,31.321785,-0.980175,-0.786672
26
+ dichloromethane,31.377310,-0.979870,-0.808573
27
+ chloroform,31.294992,-0.975794,-0.852690
28
+ toluene,31.716957,-0.996733,1.944856
29
+ benzene,31.987569,-1.005289,2.236515
30
+ chlorobenzene,31.681438,-0.993846,0.830236
31
+ acetone,31.512157,-0.987261,-1.238414
32
+ dimethylsulfoxide,31.598698,-0.991103,-1.355248
33
+ acetonitrile,31.496110,-0.985716,-0.974465
34
+ trifluoroethanol,30.876004,-0.959997,-0.958852
35
+ methanol,31.256030,-0.976446,-1.250121
36
+ TIP4P,31.359134,-0.978809,-1.478298
37
+ """,
38
+ "C": """solvent,intercept,stationary,pcm
39
+ tetrahydrofuran,171.054488,-0.919000,-1.069509
40
+ dichloromethane,171.509389,-0.921167,-1.115015
41
+ chloroform,171.728797,-0.924231,-0.936804
42
+ toluene,171.690907,-0.924987,-0.618410
43
+ benzene,171.967177,-0.927033,-0.593716
44
+ chlorobenzene,171.075910,-0.921238,-0.997641
45
+ acetone,171.308023,-0.919610,-1.239503
46
+ dimethylsulfoxide,170.598425,-0.918674,-1.296501
47
+ acetonitrile,171.879839,-0.922629,-1.287664
48
+ trifluoroethanol,174.237671,-0.939012,-1.290073
49
+ methanol,172.426161,-0.927566,-1.288847
50
+ TIP4P,173.696379,-0.937854,-1.342668
51
+ """,
52
+ }
53
+
54
+
55
+ def published_scaling_tables():
56
+ """The published scaling tables as {"H": table, "C": table}. Each table maps a solvent name to a
57
+ {"intercept", "stationary", "pcm"} dict of coefficients. Pass one table and a solvent name to
58
+ predict_shift. Water is named "TIP4P"."""
59
+ tables = {}
60
+ for nucleus, text in _PUBLISHED_TABLE_CSV.items():
61
+ table = {}
62
+ for row in csv.DictReader(io.StringIO(text.strip())):
63
+ solvent = row.pop("solvent")
64
+ table[solvent] = {name: float(value) for name, value in row.items()}
65
+ tables[nucleus] = table
66
+ return tables
67
+
68
+
69
+ def predict_shift(table, solvent, magnet_zero_shielding, magnet_pcm_chloroform_correction):
70
+ """Chemical shift(s) from MagNET-Zero / MagNET-PCM outputs, for one nucleus and solvent.
71
+
72
+ table: one nucleus's table from published_scaling_tables(). solvent: a solvent name (water is
73
+ "TIP4P"). magnet_zero_shielding and magnet_pcm_chloroform_correction are scalars or arrays.
74
+ """
75
+ row = table[solvent]
76
+ return (row["intercept"]
77
+ + row["stationary"] * np.asarray(magnet_zero_shielding, dtype=float)
78
+ + row["pcm"] * np.asarray(magnet_pcm_chloroform_correction, dtype=float))
magnet/test_api.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adversarial edge-case tests for the magnet public API (magnet/api.py).
2
+
3
+ Pure-logic tests (single-vs-list detection, validation, solvent checking, solute selection) run
4
+ whenever the torch stack is installed. Tests marked @needs_model load real checkpoints and are
5
+ skipped unless the weights are present; they stay few and fast (n_passes=1, symmetrize=False, tiny
6
+ molecules). Run the whole suite with `pytest`; to include the real-model tests, check out the
7
+ Hugging Face copy so the weights land in model_checkpoints/.
8
+ """
9
+ import os
10
+ import warnings
11
+
12
+ import numpy as np
13
+ import pytest
14
+
15
+ warnings.filterwarnings("ignore")
16
+
17
+ import magnet.api as api
18
+ from magnet import scaling
19
+
20
+ _HERE = os.path.dirname(os.path.abspath(__file__))
21
+ _REPO = os.path.dirname(_HERE) # magnet/ -> repo root
22
+ _CKPT_REL = os.path.join("model_checkpoints", "MagNET-Zero", "MagNET-Zero_1H.ckpt")
23
+ _HAVE_MODELS = os.path.exists(os.path.join(_REPO, _CKPT_REL))
24
+ needs_model = pytest.mark.skipif(not _HAVE_MODELS, reason="checkpoints not available")
25
+
26
+ # ---- tiny geometries (validity irrelevant for API-contract tests) ----
27
+ CH4_Z = np.array([6, 1, 1, 1, 1])
28
+ CH4_XYZ = np.array([[0., 0., 0.],
29
+ [0.629, 0.629, 0.629],
30
+ [-0.629, -0.629, 0.629],
31
+ [-0.629, 0.629, -0.629],
32
+ [0.629, -0.629, -0.629]])
33
+ # formaldehyde CH2O: exercises H, C, and an O (should be NaN in shifts)
34
+ CH2O_Z = np.array([6, 8, 1, 1])
35
+ CH2O_XYZ = np.array([[0., 0., 0.], [1.2, 0., 0.], [-0.5, 0.94, 0.], [-0.5, -0.94, 0.]])
36
+ # one chloroform molecule, atoms in molecule order C H Cl Cl Cl
37
+ CHCL3_Z = np.array([6, 1, 17, 17, 17])
38
+ CHCL3_XYZ = np.array([[0., 0., 0.], [0., 0., 1.1], [1.7, 0., -0.4],
39
+ [-0.85, 1.47, -0.4], [-0.85, -1.47, -0.4]])
40
+
41
+
42
+ # =====================================================================
43
+ # _as_batch: single-vs-list detection and validation
44
+ # =====================================================================
45
+
46
+ def test_single_ndarray_is_single():
47
+ an, xyz, single = api._as_batch(CH4_Z, CH4_XYZ)
48
+ assert single is True and len(an) == 1 and an[0].shape == (5,) and xyz[0].shape == (5, 3)
49
+
50
+ def test_single_pythonlist_is_single():
51
+ an, xyz, single = api._as_batch([6, 1, 1, 1, 1], CH4_XYZ.tolist())
52
+ assert single is True and len(xyz) == 1
53
+
54
+ def test_one_atom_molecule_is_single():
55
+ an, xyz, single = api._as_batch(np.array([1]), np.array([[0., 0., 0.]]))
56
+ assert single is True and xyz[0].shape == (1, 3)
57
+
58
+ def test_list_of_molecules_is_batch():
59
+ an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ])
60
+ assert single is False and len(an) == 2
61
+
62
+ def test_batch_size_one_list_is_batch():
63
+ an, xyz, single = api._as_batch([CH4_Z], [CH4_XYZ])
64
+ assert single is False and len(an) == 1
65
+
66
+ def test_3d_ndarray_is_batch():
67
+ Z = np.stack([CH4_Z, CH4_Z]); X = np.stack([CH4_XYZ, CH4_XYZ]) # (2,5) and (2,5,3)
68
+ an, xyz, single = api._as_batch(Z, X)
69
+ assert single is False and len(xyz) == 2 and xyz[0].shape == (5, 3)
70
+
71
+ def test_ragged_batch_ok():
72
+ an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ])
73
+ assert xyz[0].shape == (5, 3) and xyz[1].shape == (4, 3)
74
+
75
+ def test_empty_list_raises():
76
+ with pytest.raises(ValueError, match="empty input"):
77
+ api._as_batch([], [])
78
+
79
+ def test_empty_ndarray_raises():
80
+ with pytest.raises(ValueError, match="empty input"):
81
+ api._as_batch(np.array([]), np.zeros((0, 3)))
82
+
83
+ def test_mismatched_atom_counts_single_raises():
84
+ with pytest.raises(ValueError):
85
+ api._as_batch(np.array([6, 1, 1]), np.zeros((4, 3)))
86
+
87
+ def test_wrong_last_dim_raises():
88
+ with pytest.raises(ValueError):
89
+ api._as_batch(np.array([6, 1]), np.zeros((2, 2)))
90
+
91
+ def test_mismatched_list_lengths_raises():
92
+ with pytest.raises(ValueError):
93
+ api._as_batch([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ, CH4_XYZ])
94
+
95
+ def test_coords_1d_single_atom_flat_is_misread():
96
+ # coordinates=(3,) flat for one atom: coordinates[0] is a scalar (ndim 0) -> not single ->
97
+ # treated as a 3-molecule batch of 0-d coords -> raises. Documents the (N,3) requirement.
98
+ with pytest.raises(ValueError):
99
+ api._as_batch(np.array([6]), np.array([0., 0., 0.]))
100
+
101
+
102
+ # =====================================================================
103
+ # _check_passes
104
+ # =====================================================================
105
+
106
+ @pytest.mark.parametrize("bad", [0, -1, 1.5, "3", None, 2.0])
107
+ def test_check_passes_rejects(bad):
108
+ with pytest.raises(ValueError, match="n_passes"):
109
+ api._check_passes(bad)
110
+
111
+ @pytest.mark.parametrize("good", [1, 10, np.int64(5)])
112
+ def test_check_passes_accepts(good):
113
+ api._check_passes(good) # no raise
114
+
115
+ def test_check_passes_bool_quirk():
116
+ # bool is a subclass of int in Python; True passes as "1 pass", False is rejected.
117
+ api._check_passes(True) # accepted (quirk, not a real bug)
118
+ with pytest.raises(ValueError):
119
+ api._check_passes(False)
120
+
121
+
122
+ # =====================================================================
123
+ # predict_shieldings argument validation (pure logic, before model load)
124
+ # =====================================================================
125
+
126
+ def test_predict_shieldings_bad_model():
127
+ with pytest.raises(ValueError, match="model must be one of"):
128
+ api.predict_shieldings(CH4_Z, CH4_XYZ, model="bogus")
129
+
130
+ @pytest.mark.parametrize("m,fn", [("MagNET-PCM", "implicit_solvent_correction"),
131
+ ("MagNET-x", "explicit_solvent_correction")])
132
+ def test_predict_shieldings_correction_redirect(m, fn):
133
+ with pytest.raises(ValueError, match=fn):
134
+ api.predict_shieldings(CH4_Z, CH4_XYZ, model=m)
135
+
136
+ def test_predict_shieldings_bad_passes():
137
+ with pytest.raises(ValueError, match="n_passes"):
138
+ api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=0)
139
+
140
+
141
+ # =====================================================================
142
+ # predict_shifts solvent validation (pure logic, before model load)
143
+ # =====================================================================
144
+
145
+ def test_predict_shifts_unknown_solvent():
146
+ with pytest.raises(ValueError, match="unknown solvent"):
147
+ api.predict_shifts(CH4_Z, CH4_XYZ, solvent="ethanol")
148
+
149
+ def test_predict_shifts_solvent_case_sensitive():
150
+ with pytest.raises(ValueError, match="unknown solvent"):
151
+ api.predict_shifts(CH4_Z, CH4_XYZ, solvent="Chloroform")
152
+
153
+ def test_predict_shifts_water_not_rejected_by_validation():
154
+ # "water" must map to TIP4P and NOT trip the unknown-solvent guard.
155
+ tables = scaling.published_scaling_tables()
156
+ assert "TIP4P" in tables["C"] and "TIP4P" in tables["H"]
157
+ # the error message offers "water" (not "TIP4P") as an option:
158
+ options = ["water" if s == "TIP4P" else s for s in tables["C"]]
159
+ assert "water" in options and "TIP4P" not in options
160
+
161
+
162
+ # =====================================================================
163
+ # _validate_solvent: atom-type-count check against a named solvent
164
+ # =====================================================================
165
+
166
+ def test_validate_chloroform():
167
+ api._validate_solvent("chloroform", CHCL3_Z) # no raise
168
+
169
+ def test_validate_two_chloroforms():
170
+ api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z]))
171
+
172
+ def test_validate_water():
173
+ api._validate_solvent("water", np.array([8, 1, 1]))
174
+
175
+ def test_validate_benzene():
176
+ api._validate_solvent("benzene", np.array([6] * 6 + [1] * 6))
177
+
178
+ def test_validate_methanol():
179
+ api._validate_solvent("methanol", np.array([6, 8, 1, 1, 1, 1]))
180
+
181
+ def test_validate_order_within_block_independent():
182
+ # order within one molecule's block does not matter
183
+ api._validate_solvent("water", np.array([1, 8, 1]))
184
+
185
+ def test_validate_empty_raises():
186
+ with pytest.raises(ValueError, match="no solvent atoms"):
187
+ api._validate_solvent("chloroform", np.array([], dtype=int))
188
+
189
+ def test_validate_wrong_count_raises():
190
+ # 7 atoms is not a whole number of chloroforms (5 atoms each)
191
+ with pytest.raises(ValueError, match="not a whole number"):
192
+ api._validate_solvent("chloroform", np.array([6, 6, 1, 1, 17, 17, 17]))
193
+
194
+ def test_validate_wrong_composition_raises():
195
+ # 5 atoms (one chloroform-sized block) but the wrong elements
196
+ with pytest.raises(ValueError, match="not whole chloroform"):
197
+ api._validate_solvent("chloroform", np.array([6, 6, 6, 6, 6]))
198
+
199
+ def test_validate_grouped_by_element_raises():
200
+ # two chloroforms' worth of atoms (correct TOTALS: 2 C, 2 H, 6 Cl) but grouped by element, so the
201
+ # contiguous 5-atom blocks are not whole molecules. Total-count check would miss this; block does not.
202
+ grouped = np.array([6, 6, 1, 1, 17, 17, 17, 17, 17, 17])
203
+ with pytest.raises(ValueError, match="not whole chloroform"):
204
+ api._validate_solvent("chloroform", grouped)
205
+
206
+ def test_validate_two_chloroforms_contiguous_ok():
207
+ # same atoms as above but laid out as two whole contiguous molecules: fine
208
+ api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z]))
209
+
210
+ def test_validate_mismatched_solvent_raises():
211
+ # a benzene block passed as methanol (both divisible cases would still fail composition)
212
+ with pytest.raises(ValueError, match="not whole methanol"):
213
+ api._validate_solvent("methanol", np.array([6, 6, 6, 6, 6, 6]))
214
+
215
+
216
+ # =====================================================================
217
+ # supported-solvent tables stay in sync with the code (validation)
218
+ # =====================================================================
219
+
220
+ EXPLICIT_SOLVENTS = ["chloroform", "benzene", "methanol", "water"]
221
+ PREDICT_SHIFTS_SOLVENTS = [
222
+ "tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene",
223
+ "acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "water",
224
+ ]
225
+
226
+ def _one_molecule(solvent):
227
+ """Build one molecule of `solvent` as an atomic-number array from the composition table."""
228
+ return np.array([element for element, count in api._SOLVENT_COMPOSITION[solvent].items()
229
+ for _ in range(count)])
230
+
231
+ def test_explicit_solvent_set_matches_run_magnet():
232
+ # composition table, the model's block-size table, and the documented list all agree
233
+ from magnet import run_magnet
234
+ assert sorted(api._SOLVENT_COMPOSITION) == sorted(run_magnet.N_ATOMS_PER_SOLVENT)
235
+ assert sorted(api._SOLVENT_COMPOSITION) == sorted(EXPLICIT_SOLVENTS)
236
+
237
+ def test_solvent_composition_sums_to_block_size():
238
+ from magnet import run_magnet
239
+ for solvent, comp in api._SOLVENT_COMPOSITION.items():
240
+ assert sum(comp.values()) == run_magnet.N_ATOMS_PER_SOLVENT[solvent]
241
+
242
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
243
+ def test_validate_accepts_one_molecule(solvent):
244
+ api._validate_solvent(solvent, _one_molecule(solvent)) # must not raise
245
+
246
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
247
+ def test_validate_accepts_two_contiguous_molecules(solvent):
248
+ one = _one_molecule(solvent)
249
+ api._validate_solvent(solvent, np.concatenate([one, one])) # must not raise
250
+
251
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
252
+ def test_validate_rejects_dropping_one_atom(solvent):
253
+ # one atom short of a whole molecule -> wrong count
254
+ short = _one_molecule(solvent)[:-1]
255
+ with pytest.raises(ValueError, match="not a whole number"):
256
+ api._validate_solvent(solvent, short)
257
+
258
+ def test_predict_shifts_supports_the_12_documented_solvents():
259
+ tables = scaling.published_scaling_tables()
260
+ for nucleus in ("H", "C"):
261
+ keys = {"water" if s == "TIP4P" else s for s in tables[nucleus]}
262
+ assert keys == set(PREDICT_SHIFTS_SOLVENTS)
263
+
264
+ def test_explicit_rejects_predict_only_solvent():
265
+ # acetone is valid for predict_shifts but not for explicit corrections
266
+ atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z])
267
+ xyz = np.zeros((len(atoms), 3))
268
+ with pytest.raises(ValueError, match="solvent must be one of"):
269
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="acetone",
270
+ n_passes=1, symmetrize=False)
271
+
272
+
273
+ # =====================================================================
274
+ # _explicit_one: solute selection, reordering, negative/dup/range
275
+ # =====================================================================
276
+
277
+ def _system(): # methane solute (0..3) + one chloroform (4..8)
278
+ atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z])
279
+ xyz = np.concatenate([CH4_XYZ[:4] + 10.0, CHCL3_XYZ]) # solute far, solvent near origin
280
+ return atoms, xyz
281
+
282
+ def test_explicit_one_basic():
283
+ atoms, xyz = _system()
284
+ solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [0, 1, 2, 3], "chloroform")
285
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
286
+ assert np.array_equal(full_an, atoms) # already solute-first
287
+
288
+ def test_explicit_one_reorders_solvent_first_input():
289
+ # input has chloroform first, methane second; solute indices point at the methane
290
+ atoms = np.concatenate([CHCL3_Z, np.array([6, 1, 1, 1])])
291
+ xyz = np.concatenate([CHCL3_XYZ, CH4_XYZ[:4] + 10.0])
292
+ solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [5, 6, 7, 8], "chloroform")
293
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
294
+ # reordered: solute first, then the solvent block
295
+ assert np.array_equal(full_an[:4], np.array([6, 1, 1, 1]))
296
+ assert np.array_equal(np.sort(full_an[4:]), np.sort(CHCL3_Z))
297
+
298
+ def test_explicit_one_output_order_follows_solute_atoms():
299
+ atoms, xyz = _system()
300
+ solute_an, full_an, _ = api._explicit_one(atoms, xyz, [3, 2, 1, 0], "chloroform") # reversed
301
+ assert np.array_equal(solute_an, np.array([1, 1, 1, 6])) # matches requested order
302
+ assert np.array_equal(full_an[:4], np.array([1, 1, 1, 6]))
303
+
304
+ def test_explicit_one_negative_indices():
305
+ atoms, xyz = _system() # n=9
306
+ solute_an, _, _ = api._explicit_one(atoms, xyz, [-9, -8, -7, -6], "chloroform") # -> 0,1,2,3
307
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
308
+
309
+ def test_explicit_one_wrong_solvent_raises():
310
+ atoms, xyz = _system() # solvent is chloroform
311
+ with pytest.raises(ValueError, match="not .* benzene"):
312
+ api._explicit_one(atoms, xyz, [0, 1, 2, 3], "benzene")
313
+
314
+ def test_explicit_one_duplicate_raises():
315
+ atoms, xyz = _system()
316
+ with pytest.raises(ValueError, match="duplicate"):
317
+ api._explicit_one(atoms, xyz, [0, 0, 1, 2], "chloroform")
318
+
319
+ def test_explicit_one_negative_normalizes_to_duplicate_raises():
320
+ atoms, xyz = _system() # n=9; index 2 and -7 both -> 2
321
+ with pytest.raises(ValueError, match="duplicate"):
322
+ api._explicit_one(atoms, xyz, [2, -7], "chloroform")
323
+
324
+ def test_explicit_one_out_of_range_raises():
325
+ atoms, xyz = _system()
326
+ with pytest.raises(ValueError, match="out of range"):
327
+ api._explicit_one(atoms, xyz, [0, 1, 2, 9], "chloroform")
328
+
329
+ def test_explicit_one_very_negative_raises():
330
+ atoms, xyz = _system()
331
+ with pytest.raises(ValueError, match="out of range"):
332
+ api._explicit_one(atoms, xyz, [-100], "chloroform")
333
+
334
+ def test_explicit_one_all_atoms_solute_raises():
335
+ atoms, xyz = _system()
336
+ with pytest.raises(ValueError, match="no solvent atoms"):
337
+ api._explicit_one(atoms, xyz, list(range(len(atoms))), "chloroform")
338
+
339
+ def test_explicit_one_empty_solute_raises():
340
+ atoms, xyz = _system()
341
+ with pytest.raises(ValueError, match="empty"):
342
+ api._explicit_one(atoms, xyz, [], "chloroform")
343
+
344
+
345
+ # =====================================================================
346
+ # explicit_solvent_correction: pure-logic validation before any model call
347
+ # =====================================================================
348
+
349
+ def test_explicit_unknown_solvent_raises():
350
+ atoms, xyz = _system()
351
+ with pytest.raises(ValueError, match="solvent must be one of"):
352
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="ethanol",
353
+ n_passes=1, symmetrize=False)
354
+
355
+ def test_explicit_solvent_mismatch_raises():
356
+ # declare benzene but the snapshot has chloroform -> count check fails before any model load
357
+ atoms, xyz = _system()
358
+ with pytest.raises(ValueError, match="not .* benzene"):
359
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="benzene",
360
+ n_passes=1, symmetrize=False)
361
+
362
+
363
+ # =====================================================================
364
+ # REAL MODEL TESTS (few, tiny, n_passes=1, symmetrize=False)
365
+ # =====================================================================
366
+
367
+ @needs_model
368
+ def test_real_predict_shieldings_single_returns_array():
369
+ out = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False)
370
+ assert isinstance(out, np.ndarray) and out.shape == (5,)
371
+ assert np.all(np.isfinite(out)) # H and C all predicted, none NaN
372
+
373
+ @needs_model
374
+ def test_real_predict_shieldings_list_returns_list():
375
+ out = api.predict_shieldings([CH4_Z], [CH4_XYZ], n_passes=1, symmetrize=False)
376
+ assert isinstance(out, list) and len(out) == 1 and out[0].shape == (5,)
377
+
378
+ @needs_model
379
+ def test_real_predict_shieldings_batch_shapes():
380
+ out = api.predict_shieldings([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ],
381
+ n_passes=1, symmetrize=False)
382
+ assert isinstance(out, list) and out[0].shape == (5,) and out[1].shape == (4,)
383
+
384
+ @needs_model
385
+ def test_real_unsupported_element_rejected():
386
+ # PH3-like: phosphorus (15) is out of vocab
387
+ Z = np.array([15, 1, 1, 1])
388
+ X = np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])
389
+ with pytest.raises(ValueError, match="unsupported atomic numbers"):
390
+ api.predict_shieldings(Z, X, n_passes=1, symmetrize=False)
391
+
392
+ @needs_model
393
+ def test_real_predict_shifts_components_reproduce():
394
+ d = api.predict_shifts(CH2O_Z, CH2O_XYZ, solvent="chloroform", n_passes=1,
395
+ symmetrize=False, return_components=True)
396
+ assert set(d) == {"shifts", "zero_shielding", "pcm_correction", "coefficients"}
397
+ shifts, sigma, delta = d["shifts"], d["zero_shielding"], d["pcm_correction"]
398
+ coef = d["coefficients"]
399
+ # coefficients must match chloroform rows
400
+ tbl = scaling.published_scaling_tables()
401
+ assert coef["H"] == tbl["H"]["chloroform"] and coef["C"] == tbl["C"]["chloroform"]
402
+ # reproduce shift = intercept + stationary*zero + pcm*pcm for H (idx 2,3) and C (idx 0)
403
+ for idx, nuc in [(0, "C"), (2, "H"), (3, "H")]:
404
+ expect = coef[nuc]["intercept"] + coef[nuc]["stationary"] * sigma[idx] \
405
+ + coef[nuc]["pcm"] * delta[idx]
406
+ assert np.isclose(shifts[idx], expect, atol=1e-6), (idx, nuc, shifts[idx], expect)
407
+ # O atom (idx 1) must be NaN, H/C finite
408
+ assert np.isnan(shifts[1])
409
+ assert np.all(np.isfinite([shifts[0], shifts[2], shifts[3]]))
410
+
411
+ @needs_model
412
+ def test_real_predict_shifts_acetone_matches_readme():
413
+ # the README "Your First Prediction" worked example: acetone in chloroform on an AIMNet2
414
+ # geometry, default n_passes/symmetrize. Guards the documented numbers against model drift.
415
+ Z = np.array([6, 6, 8, 6, 1, 1, 1, 1, 1, 1])
416
+ X = np.array([
417
+ [1.2913, -0.5947, -0.0016], [0.0029, 0.1931, -0.0010], [-0.0174, 1.3994, -0.0003],
418
+ [-1.2743, -0.6189, 0.0004], [-1.0822, -1.6899, 0.0014], [-1.8597, -0.3513, 0.8791],
419
+ [-1.8605, -0.3530, -0.8783], [1.3415, -1.2208, 0.8916], [1.3170, -1.2669, -0.8614],
420
+ [2.1415, 0.0788, -0.0298],
421
+ ])
422
+ shifts = api.predict_shifts(Z, X, solvent="chloroform")
423
+ carbonyl = shifts[[1]].mean()
424
+ methyl_C = shifts[[0, 3]].mean()
425
+ methyl_H = shifts[[4, 5, 6, 7, 8, 9]].mean()
426
+ assert np.isclose(carbonyl, 207.6, atol=1.0), carbonyl
427
+ assert np.isclose(methyl_C, 30.7, atol=0.5), methyl_C
428
+ assert np.isclose(methyl_H, 2.20, atol=0.15), methyl_H
429
+
430
+ @needs_model
431
+ def test_real_predict_shifts_water_maps_to_tip4p():
432
+ d = api.predict_shifts(CH4_Z, CH4_XYZ, solvent="water", n_passes=1,
433
+ symmetrize=False, return_components=True)
434
+ tbl = scaling.published_scaling_tables()
435
+ assert d["coefficients"]["H"] == tbl["H"]["TIP4P"]
436
+ assert d["coefficients"]["C"] == tbl["C"]["TIP4P"]
437
+
438
+ @needs_model
439
+ def test_real_single_vs_batch_same_shapes():
440
+ single = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False)
441
+ batch = api.predict_shieldings([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ],
442
+ n_passes=1, symmetrize=False)
443
+ assert single.shape == batch[0].shape == batch[1].shape
444
+ # values are close (stochastic frames -> not identical); documents non-determinism
445
+ assert np.allclose(single, batch[0], atol=0.5)
446
+
447
+ @needs_model
448
+ @pytest.mark.xfail(reason="known: zero-edge graph (lone atom) gives a cryptic torch error, not a "
449
+ "clean ValueError; a lone atom is not a valid NMR input",
450
+ raises=RuntimeError)
451
+ def test_real_single_atom_solute():
452
+ # isolated single atom: no neighbors -> empty edge tensor -> deep torch.min() crash.
453
+ api.predict_shieldings(np.array([6]), np.array([[0., 0., 0.]]),
454
+ n_passes=1, symmetrize=False)
455
+
456
+ @needs_model
457
+ @pytest.mark.xfail(reason="known: atoms all beyond the model cutoff -> zero edges -> torch "
458
+ "RuntimeError, not a clean ValueError",
459
+ raises=RuntimeError)
460
+ def test_real_atoms_beyond_cutoff_crash():
461
+ # two atoms 50 A apart: no edges within cutoff -> same zero-edge crash as one atom.
462
+ api.predict_shieldings(np.array([6, 1]), np.array([[0., 0., 0.], [0., 0., 50.]]),
463
+ n_passes=1, symmetrize=False)
magnet/test_magnet.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the magnet package.
2
+
3
+ The synthetic tests build a tiny EquiformerV2 model (no checkpoint, no dataset) and run a
4
+ forward pass, so they exercise the whole package wiring and run in CI. The tests that need the
5
+ released checkpoints / reference data are gated with skipif and run only when those files are
6
+ present in the repo (model_checkpoints/ and data/<name>/, carried by the Hugging Face checkout).
7
+ """
8
+ import os
9
+ import numpy as np
10
+ import pytest
11
+
12
+ # The model needs the heavy PyTorch / PyG stack (see requirements.txt). When it is not
13
+ # installed (e.g. the dataset-reader CI, which installs only numpy/h5py/pandas), skip the whole
14
+ # module cleanly instead of erroring at import.
15
+ pytest.importorskip("torch")
16
+ pytest.importorskip("torch_scatter")
17
+ pytest.importorskip("torch_cluster")
18
+ pytest.importorskip("torch_geometric")
19
+ pytest.importorskip("magnet")
20
+
21
+ from magnet.model import MagNET_Lightning
22
+ from magnet.inference import predict_shieldings
23
+
24
+ _HERE = os.path.dirname(os.path.abspath(__file__))
25
+ _REPO = os.path.abspath(os.path.join(_HERE, ".."))
26
+ _CKPT_ROOT = os.path.join(_REPO, "model_checkpoints") # absent on GitHub (git-ignored); every skipif below is then False
27
+ CKPT = os.path.join(_CKPT_ROOT, "MagNET-Zero")
28
+ # The natural-products reference shieldings are a tiny (54 KB) committed test fixture (test_data/), so
29
+ # the reproduce/symmetrize tests need only the released checkpoints, not a separate reference download.
30
+ NP_H5 = os.path.join(_HERE, "test_data", "magnet_zero_and_pcm_shieldings.h5")
31
+
32
+
33
+ def _dataset_file(name):
34
+ """Resolve a released dataset hdf5 the way analysis/code/paths.py does, but without importing it
35
+ (this package is standalone): the in-repo data/<name>/<name>.hdf5, carried by the Hugging Face
36
+ checkout via Git LFS."""
37
+ return os.path.join(_REPO, "data", name, name + ".hdf5")
38
+
39
+ TINY_PARAMS = dict(
40
+ model_type="EquiformerV2", max_neighbors=20, max_radius=5.0, max_num_elements=20,
41
+ num_layers=1, sphere_channels=8, attn_hidden_channels=8, num_heads=1,
42
+ attn_alpha_channels=8, attn_value_channels=8, ffn_hidden_channels=16,
43
+ lmax_list=[2], mmax_list=[2], grid_resolution=14, weight_init="uniform", lr=1e-4,
44
+ )
45
+
46
+ # a small tetrahedral-ish CH4 so the graph has edges
47
+ METHANE_Z = np.array([6, 1, 1, 1, 1])
48
+ METHANE_XYZ = np.array([[0, 0, 0], [0.63, 0.63, 0.63], [-0.63, -0.63, 0.63],
49
+ [-0.63, 0.63, -0.63], [0.63, -0.63, -0.63]], dtype=float)
50
+
51
+
52
+ def test_predict_rejects_unsupported_elements():
53
+ """MagNET only knows H, C, N, O, F, S, Cl; predicting on anything else must raise, not guess.
54
+ The check runs before the model, so dummy models are fine."""
55
+ # phosphorus (15) in the solute
56
+ with pytest.raises(ValueError, match="unsupported atomic numbers"):
57
+ predict_shieldings(None, None, np.array([6, 1, 1, 15]), np.zeros((4, 3)))
58
+ # an unsupported atom in the solvent block (the full atomic_numbers, MagNET-x path)
59
+ with pytest.raises(ValueError, match="unsupported"):
60
+ predict_shieldings(None, None, np.array([6, 1]), np.zeros((4, 3)),
61
+ atomic_numbers=np.array([6, 1, 35, 1])) # bromine
62
+ # a supported-only molecule passes the element check (it may fail later for lack of a real
63
+ # model, but not with an "unsupported" element error)
64
+ try:
65
+ predict_shieldings(None, None, METHANE_Z, METHANE_XYZ)
66
+ except ValueError as e:
67
+ assert "unsupported" not in str(e)
68
+ except Exception:
69
+ pass
70
+
71
+
72
+ def test_resolve_checkpoint_honors_checkpoints_dir(tmp_path):
73
+ """checkpoints_dir points at the released weights when they live outside the repo. It may be the
74
+ model_checkpoints/ folder itself or a directory that contains one, and takes precedence."""
75
+ from magnet.run_magnet import _resolve_checkpoint
76
+ rel = "model_checkpoints/MagNET-Zero/MagNET-Zero_1H.ckpt"
77
+
78
+ # (a) checkpoints_dir IS the model_checkpoints/ folder -> the leading path segment is stripped
79
+ mc = tmp_path / "mc"
80
+ (mc / "MagNET-Zero").mkdir(parents=True)
81
+ inside = mc / "MagNET-Zero" / "MagNET-Zero_1H.ckpt"
82
+ inside.write_bytes(b"")
83
+ assert _resolve_checkpoint(rel, checkpoints_dir=str(mc)) == str(inside)
84
+
85
+ # (b) checkpoints_dir CONTAINS a model_checkpoints/ folder (an `hf download --local-dir` target)
86
+ parent = tmp_path / "download"
87
+ contained = parent / "model_checkpoints" / "MagNET-Zero" / "MagNET-Zero_1H.ckpt"
88
+ contained.parent.mkdir(parents=True)
89
+ contained.write_bytes(b"")
90
+ assert _resolve_checkpoint(rel, checkpoints_dir=str(parent)) == str(contained)
91
+
92
+ # (c) a checkpoints_dir without the file is not fabricated (falls through to the defaults)
93
+ empty = tmp_path / "empty"
94
+ empty.mkdir()
95
+ assert not _resolve_checkpoint(rel, checkpoints_dir=str(empty)).startswith(str(empty))
96
+
97
+ # (d) an absolute path resolves regardless of checkpoints_dir
98
+ assert _resolve_checkpoint(str(inside), checkpoints_dir=str(empty)) == str(inside)
99
+
100
+
101
+ @pytest.fixture(scope="module")
102
+ def tiny_model():
103
+ return MagNET_Lightning(TINY_PARAMS).eval()
104
+
105
+
106
+ def test_predict_shape_and_finite(tiny_model):
107
+ out = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
108
+ geometry=METHANE_XYZ, device="cpu")
109
+ out = np.asarray(out)
110
+ assert out.shape == (METHANE_Z.shape[0],)
111
+ assert np.isfinite(out).all()
112
+
113
+
114
+ def test_n_passes_averages(tiny_model):
115
+ out = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
116
+ geometry=METHANE_XYZ, device="cpu", n_passes=3)
117
+ assert np.asarray(out).shape == (METHANE_Z.shape[0],)
118
+ assert np.isfinite(out).all()
119
+
120
+
121
+ def test_symmetrize_runs_and_is_reflection_consistent(tiny_model):
122
+ # the symmetrized prediction averages over G and its mirror image, so evaluating it on G
123
+ # and on reflect(G) must give the same per-atom values (up to per-pass frame noise).
124
+ refl = METHANE_XYZ.copy(); refl[:, 0] = -refl[:, 0]
125
+ a = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
126
+ geometry=METHANE_XYZ, device="cpu", n_passes=8, symmetrize=True)
127
+ b = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
128
+ geometry=refl, device="cpu", n_passes=8, symmetrize=True)
129
+ # both are reflection-symmetrized estimates of the same molecule
130
+ assert np.allclose(np.asarray(a), np.asarray(b), atol=0.5)
131
+
132
+
133
+ def test_solvent_path_runs():
134
+ """Regression test for the explicit-solvent (MagNET-x) code path. That branch runs only when
135
+ filter_solvent_edges=True (the MagNET-x checkpoints set it), so the gas-phase tests never reach
136
+ it; a torch_geometric >= 2.6 change (data.keys became a method) once broke it. Build a tiny
137
+ solvent-aware model and feed a solute plus two solvent molecules."""
138
+ model = MagNET_Lightning(dict(TINY_PARAMS, filter_solvent_edges=True)).eval()
139
+ solute_Z = np.array([6, 1, 1, 1, 1])
140
+ solvent_mol = np.array([[0.0, 0.0, 0.0], [0.0, 0.96, 0.0], [0.93, -0.24, 0.0]]) # 3-atom solvent
141
+ full_Z = np.concatenate([solute_Z, [8, 1, 1], [8, 1, 1]])
142
+ geom = np.concatenate([METHANE_XYZ, solvent_mol + [3.5, 0, 0], solvent_mol + [0, 3.5, 0]])
143
+ out = np.asarray(predict_shieldings(model, model, solute_atomic_numbers=solute_Z, geometry=geom,
144
+ atomic_numbers=full_Z, N_atoms_per_solvent=3,
145
+ solvent_distance_threshold=12.0, device="cpu"))
146
+ assert out.shape == (solute_Z.shape[0],)
147
+ assert np.isfinite(out).all()
148
+
149
+
150
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
151
+ def test_real_magnet_zero_reproduces_reference():
152
+ import h5py
153
+ dev = "cpu"
154
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
155
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
156
+ with h5py.File(NP_H5, "r") as f:
157
+ name = list(f.keys())[0]
158
+ g = f[name]
159
+ z = g["atomic_numbers"][:]; xyz = g["geometry"][:].astype(np.float64)
160
+ # This reference file predates the MagNET casing standardization, so its dataset is stored
161
+ # under the old "MagNet_..." spelling. Read whichever spelling the file actually uses.
162
+ ref_key = "MagNET_Zero_shieldings" if "MagNET_Zero_shieldings" in g else "MagNet_Zero_shieldings"
163
+ ref = g[ref_key][:]
164
+ pred = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=20)
165
+ tgt = (z == 1) | (z == 6)
166
+ # un-symmetrized 20-pass reproduces the (un-symmetrized) released numbers well under 0.1 ppm
167
+ assert np.abs(pred[tgt] - ref[tgt]).mean() < 0.05
168
+
169
+
170
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
171
+ def test_real_symmetrize_changes_13C_on_large_molecule():
172
+ import h5py
173
+ dev = "cpu"
174
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
175
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
176
+ with h5py.File(NP_H5, "r") as f:
177
+ # pick a large molecule (>=50 atoms) where the parity error is significant
178
+ big = [k for k in f.keys() if f[k]["atomic_numbers"].shape[0] >= 50][0]
179
+ g = f[big]
180
+ z = g["atomic_numbers"][:]; xyz = g["geometry"][:].astype(np.float64)
181
+ plain = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=12)
182
+ sym = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=12, symmetrize=True)
183
+ c = z == 6
184
+ # symmetrization removes a real 13C parity error: it should shift carbons by > 0.05 ppm RMS
185
+ assert np.sqrt(((plain[c] - sym[c]) ** 2).mean()) > 0.05
186
+
187
+
188
+ CKPT_FOUNDATION = os.path.join(_CKPT_ROOT, "MagNET")
189
+ CKPT_PCM = os.path.join(_CKPT_ROOT, "MagNET-PCM")
190
+
191
+ # a small, valid molecule (methyl acetate) to smoke-run the foundation and PCM checkpoints
192
+ _SMOKE_Z = np.array([6, 6, 6, 8, 8, 6, 1, 1, 1, 1, 1, 1])
193
+ _SMOKE_XYZ = np.array([
194
+ [-2.07273936, 0.71782219, -0.27169511], [-1.51154256, -0.47834334, -0.05051489],
195
+ [-0.06133485, -0.72471339, 0.10392854], [0.38785097, -1.84332836, 0.30459866],
196
+ [0.65684897, 0.41875002, -0.00514369], [2.0648613, 0.23182768, 0.13713977],
197
+ [-1.48494101, 1.6266042, -0.3569932], [-3.14927745, 0.81001383, -0.37304503],
198
+ [-2.12480974, -1.36963022, 0.03013895], [2.44585443, -0.42836204, -0.64841408],
199
+ [2.30088663, -0.16782625, 1.12843716], [2.5483427, 1.20718563, 0.03357037]])
200
+
201
+
202
+ @pytest.mark.skipif(not os.path.exists(CKPT_FOUNDATION),
203
+ reason="released MagNET (foundation) checkpoints not present")
204
+ def test_real_foundation_loads_and_runs():
205
+ """The foundation checkpoints load and produce finite per-atom shieldings of the right shape.
206
+ Regression for the checkpoint-family load/run path (the MagNET-X case bug was this class)."""
207
+ dev = "cpu"
208
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_FOUNDATION, "MagNET_1H.ckpt"), map_location=dev).eval()
209
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_FOUNDATION, "MagNET_13C.ckpt"), map_location=dev).eval()
210
+ pred = predict_shieldings(mH, mC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=2)
211
+ assert pred.shape == (_SMOKE_Z.shape[0],)
212
+ assert np.isfinite(pred[(_SMOKE_Z == 1) | (_SMOKE_Z == 6)]).all()
213
+
214
+
215
+ @pytest.mark.skipif(not os.path.exists(CKPT_PCM),
216
+ reason="released MagNET-PCM checkpoints not present")
217
+ def test_real_pcm_correction_is_finite_and_nonzero():
218
+ """The MagNET-PCM gas and with-PCM checkpoints load, run, and genuinely differ: the chloroform
219
+ correction (with-PCM minus gas) is finite and not identically zero over the H/C atoms."""
220
+ dev = "cpu"
221
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withoutPCM_1H.ckpt"), map_location=dev).eval()
222
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withoutPCM_13C.ckpt"), map_location=dev).eval()
223
+ pH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withPCM_1H.ckpt"), map_location=dev).eval()
224
+ pC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withPCM_13C.ckpt"), map_location=dev).eval()
225
+ gas = predict_shieldings(mH, mC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=4)
226
+ pcm = predict_shieldings(pH, pC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=4)
227
+ correction = pcm - gas
228
+ hc = (_SMOKE_Z == 1) | (_SMOKE_Z == 6)
229
+ assert np.isfinite(correction[hc]).all()
230
+ assert np.abs(correction[hc]).max() > 1e-3 # the two checkpoints are not the same model
231
+
232
+
233
+ CKPT_X = os.path.join(_CKPT_ROOT, "MagNET-x")
234
+ SIGMA_FRESH = _dataset_file("sigma-fresh") # in-repo data/sigma-fresh/, git-ignored, via the Hugging Face checkout
235
+
236
+
237
+ @pytest.mark.skipif(not (os.path.exists(CKPT_X) and os.path.exists(SIGMA_FRESH)),
238
+ reason="MagNET-x checkpoints or sigma-fresh data not present")
239
+ def test_real_magnet_x_reproduces_dft_correction():
240
+ """The MagNET-x explicit-solvent correction (solvated minus isolated) should approximate the DFT
241
+ correction stored in sigma-fresh for a single solute+solvent pose."""
242
+ import sys
243
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "sigma-fresh"))
244
+ from decode_sigma_fresh import SigmaFresh
245
+ dev = "cpu"
246
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_X, "MagNET-x-chloroform_1H.ckpt"), map_location=dev).eval()
247
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_X, "MagNET-x-chloroform_13C.ckpt"), map_location=dev).eval()
248
+ with SigmaFresh(SIGMA_FRESH) as ds:
249
+ p = ds.pose("chloroform", 3, 1)
250
+ ns = p["n_solute_atoms"]; nper = 5; cut = ns + p["n_solvents_partial"] * nper
251
+ Zf = p["atomic_numbers"][:cut]; geom = p["coordinates"][:cut]; Zs = p["atomic_numbers"][:ns]
252
+ ref = (p["shielding_solvated"] - p["shielding_isolated"])[:ns]
253
+
254
+ def run(threshold):
255
+ return np.asarray(predict_shieldings(
256
+ mH, mC, solute_atomic_numbers=Zs, geometry=geom, atomic_numbers=Zf,
257
+ N_atoms_per_solvent=nper, solvent_distance_threshold=threshold, device=dev, n_passes=8)).squeeze()
258
+
259
+ correction = run(12.0) - run(0.0)
260
+ keep = ((Zs == 1) | (Zs == 6)) & np.isfinite(ref)
261
+ rms = np.sqrt(np.mean((correction[keep] - ref[keep]) ** 2))
262
+ assert rms < 0.3 # measured ~0.085 ppm on this pose; model error, not exact
pdoc_templates/module.html.jinja2 ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ {% extends "default/module.html.jinja2" %}
2
+ {% block style %}
3
+ {{ super() }}
4
+ <style>
5
+ /* Widen the content column so wide tables (e.g. the supported-solvent table) are not clipped. */
6
+ main, header { width: calc(78rem + var(--sidebar-width)); }
7
+ .pdoc table { width: 100%; }
8
+ </style>
9
+ {% endblock %}
reproduce.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Regenerate every figure and table in the paper from the committed data.
3
+
4
+ Runs each analysis notebook headless (no display, no widgets). Every notebook reads the in-repo
5
+ data/<name>/ files and writes its figures to <notebook>/figures/ and its tables to
6
+ <notebook>/documents/. The notebooks are stored without outputs, so this script is how the figures
7
+ and tables are produced. No environment variables and no network are needed.
8
+
9
+ Usage:
10
+ python reproduce.py # run all notebooks
11
+ python reproduce.py fig3 s10 # run only notebooks whose path contains one of these strings
12
+ """
13
+ import argparse
14
+ import glob
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ HERE = os.path.dirname(os.path.abspath(__file__))
20
+
21
+
22
+ def main():
23
+ ap = argparse.ArgumentParser(description=__doc__,
24
+ formatter_class=argparse.RawDescriptionHelpFormatter)
25
+ ap.add_argument("filters", nargs="*", help="only run notebooks whose path contains one of these")
26
+ ap.add_argument("--timeout", type=int, default=900, help="per-cell timeout in seconds (default 900)")
27
+ args = ap.parse_args()
28
+
29
+ try:
30
+ import nbformat
31
+ from nbconvert.preprocessors import ExecutePreprocessor
32
+ except ImportError:
33
+ sys.exit("reproduce.py needs the analysis dependencies: pip install -r requirements.txt")
34
+
35
+ notebooks = sorted(glob.glob(os.path.join(HERE, "analysis", "*", "*.ipynb")))
36
+ if args.filters:
37
+ notebooks = [p for p in notebooks if any(f in p for f in args.filters)]
38
+ if not notebooks:
39
+ sys.exit("no matching notebooks")
40
+
41
+ print("running {} notebooks headless".format(len(notebooks)))
42
+ n_ok = n_fail = 0
43
+ for path in notebooks:
44
+ rel = os.path.relpath(path, HERE)
45
+ start = time.time()
46
+ notebook = nbformat.read(path, as_version=4)
47
+ # run each notebook from its own directory so its in-repo data and figures/ paths resolve
48
+ executor = ExecutePreprocessor(timeout=args.timeout)
49
+ try:
50
+ executor.preprocess(notebook, {"metadata": {"path": os.path.dirname(path)}})
51
+ print(" OK ({:5.0f}s) {}".format(time.time() - start, rel))
52
+ n_ok += 1
53
+ except Exception as error:
54
+ print(" FAIL ({:5.0f}s) {}: {}: {}".format(
55
+ time.time() - start, rel, type(error).__name__, str(error)[:120]))
56
+ n_fail += 1
57
+
58
+ print("\n{} ok, {} failed".format(n_ok, n_fail))
59
+ sys.exit(1 if n_fail else 0)
60
+
61
+
62
+ if __name__ == "__main__":
63
+ main()
requirements.txt ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Dependencies for the dataset readers, the analysis code, and reproducing the figures/tables
2
+ # (reproduce.py). This is NOT the inference stack: `pip install magnet-nmr` and the Option B checkout
3
+ # pull torch from magnet/pyproject.toml and magnet/requirements.txt instead, so nothing here reaches
4
+ # an inference-only install.
5
+ # Loose floors so a future release can't silently break CI; tests also need pytest.
6
+ # numpy + h5py cover the sigma readers; the delta22 reader additionally uses
7
+ # pandas (DataFrame assembly), openpyxl (reads the experimental .xlsx), and tqdm.
8
+ numpy>=1.20
9
+ h5py>=3.0
10
+ pandas>=1.3
11
+ openpyxl>=3.0
12
+ tqdm>=4.0
13
+ # the leveling-effect analysis (analysis/code/leveling.py) and its tests use scikit-learn
14
+ # for the principal-components step. matplotlib + adjustText are needed only to redraw its
15
+ # figures, not to run the tests, so they are not listed here.
16
+ scikit-learn>=1.0
17
+ # the applications analysis (analysis/code/applications.py) fits the composite linear model with
18
+ # statsmodels. Its figure notebooks also draw boxplots with matplotlib + seaborn; those are needed
19
+ # only to redraw the figures (not to run the readers or the tests), and seaborn pulls matplotlib in
20
+ # as a dependency.
21
+ statsmodels>=0.13
22
+ seaborn>=0.12
23
+ # the DFT8K functional-group analysis (analysis/code/dft8k_residuals.py's
24
+ # functional_group_errors/find_extreme_residual, used by fig5b_dft8k.ipynb) parses each molecule's
25
+ # SMILES and matches functional-group substructures with RDKit. Not needed for the rest of the
26
+ # repo's readers or tests.
27
+ rdkit>=2023.9
28
+
29
+ # Reproducing the figures and tables (reproduce.py) executes the analysis notebooks, which draw with
30
+ # matplotlib (adjustText places the leveling-figure labels) and use scipy directly. matplotlib and
31
+ # scipy already arrive transitively via seaborn and scikit-learn; they are listed explicitly because
32
+ # the notebooks import them. nbconvert + ipykernel are the headless notebook-execution stack
33
+ # reproduce.py drives; they are the only reason a plain reader/test run does not need them.
34
+ matplotlib>=3.5
35
+ scipy>=1.7
36
+ adjustText>=1.0
37
+ nbconvert>=7.0
38
+ ipykernel>=6.0
test_reproduce.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproduction check: every analysis notebook executes headless.
2
+
3
+ This follows the repo's real-data test pattern: it SKIPS when the datasets are not present (a fresh
4
+ CI checkout has none of the big `.hdf5` files) and RUNS locally once they are downloaded. So it never
5
+ slows or breaks CI, but a `pytest` on a machine with the data verifies the whole figure/table pipeline.
6
+ The human-facing entrypoint is reproduce.py; this is the pytest wrapper.
7
+ """
8
+ import glob
9
+ import os
10
+
11
+ import pytest
12
+
13
+ HERE = os.path.dirname(os.path.abspath(__file__))
14
+ NOTEBOOKS = sorted(glob.glob(os.path.join(HERE, "analysis", "*", "*.ipynb")))
15
+
16
+ # Sentinel for "the datasets have been downloaded": delta22 is read by many notebooks. os.path.exists
17
+ # follows symlinks, so a working in-repo symlink into the big-file source counts as present, while a
18
+ # fresh CI checkout (no file, or a dangling link) does not.
19
+ _HAVE_DATA = os.path.exists(os.path.join(HERE, "data", "delta22", "delta22.hdf5"))
20
+
21
+
22
+ @pytest.mark.reproduce
23
+ @pytest.mark.skipif(not _HAVE_DATA, reason="datasets not downloaded (hf download ekwan16/MagNET)")
24
+ @pytest.mark.parametrize("notebook", NOTEBOOKS, ids=[os.path.relpath(p, HERE) for p in NOTEBOOKS])
25
+ def test_notebook_reproduces(notebook):
26
+ nbformat = pytest.importorskip("nbformat")
27
+ nbconvert = pytest.importorskip("nbconvert.preprocessors") # requires Jupyter
28
+ executor = nbconvert.ExecutePreprocessor(timeout=900)
29
+ nb = nbformat.read(notebook, as_version=4)
30
+ # run from the notebook's own directory so its in-repo data and figures/ paths resolve
31
+ executor.preprocess(nb, {"metadata": {"path": os.path.dirname(notebook)}})