Add files using upload-large-folder tool
Browse files- .gitattributes +3 -34
- .gitignore +37 -0
- LICENSE +21 -0
- README.md +212 -0
- build_api_docs.py +50 -0
- conftest.py +48 -0
- data/applications/applications_experimental.xlsx +0 -0
- data/applications/applications_reader.py +260 -0
- data/applications/test_applications.py +211 -0
- data/carbon_monoxide/co_bond_lengths_histogram.csv +101 -0
- data/carbon_monoxide/co_magnet_vs_gaussian.csv +82 -0
- data/cp3/goodman2009_cp3.xlsx +0 -0
- data/delta22/delta22_experimental.xlsx +0 -0
- data/delta22/delta22_reader.py +1047 -0
- data/delta22/test_delta22.py +260 -0
- data/delta50/decode_delta50.py +118 -0
- data/delta50/delta50.hdf5 +0 -0
- data/delta50/test_delta50.py +103 -0
- data/dft8k/dft8k_reader.py +236 -0
- data/dft8k/test_dft8k.py +224 -0
- data/gdb_qcd/decode_gdb_qcd.py +136 -0
- data/gdb_qcd/test_gdb_qcd.py +112 -0
- data/magnet_benchmark/performance_results.csv +17 -0
- data/magnet_test_predictions/build_magnet_test_predictions.py +132 -0
- data/magnet_test_predictions/magnet_test_predictions_reader.py +229 -0
- data/magnet_test_predictions/test_magnet_test_predictions.py +175 -0
- data/scaling_factors/scaling_factors_reader.py +36 -0
- data/scaling_factors/scaling_factors_symmetrized_C.csv +13 -0
- data/sigma-concentrate/decode_sigma_concentrate.py +113 -0
- data/sigma-concentrate/test_sigma_concentrate.py +162 -0
- data/sigma-pepper/decode_sigma_pepper.py +134 -0
- data/sigma-pepper/test_sigma_pepper.py +197 -0
- data/sigma-shake/decode_sigma_shake.py +123 -0
- data/sigma-shake/test_sigma_shake.py +161 -0
- data/supertestset_magnet_x/decode_supertest.py +148 -0
- data/supertestset_magnet_x/test_supertest.py +110 -0
- magnet/__init__.py +52 -0
- magnet/api.py +309 -0
- magnet/inference.py +154 -0
- magnet/model.py +236 -0
- magnet/pyproject.toml +49 -0
- magnet/requirements.txt +32 -0
- magnet/run_magnet.py +221 -0
- magnet/scaling.py +78 -0
- magnet/test_api.py +463 -0
- magnet/test_magnet.py +262 -0
- pdoc_templates/module.html.jinja2 +9 -0
- reproduce.py +63 -0
- requirements.txt +38 -0
- test_reproduce.py +31 -0
.gitattributes
CHANGED
|
@@ -1,35 +1,4 @@
|
|
| 1 |
-
*.
|
| 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 |
-
*.
|
| 10 |
-
*.
|
| 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)}})
|