ekwan16 commited on
Commit
23c6f41
·
verified ·
1 Parent(s): fe82c54

sync code from github@6f6c7d83845ee0307e16b2b9bb1b389c5bfc5007

Browse files
README.md CHANGED
@@ -194,9 +194,7 @@ You can render the API docs with [pdoc](https://pdoc.dev):
194
  pip install pdoc
195
  python build_api_docs.py
196
 
197
- Open `api_docs/index.html`. This documents the `magnet` package (`magnet.html` is the public
198
- API: `predict_shifts`, `predict_shieldings`, `implicit_solvent_correction`,
199
- `explicit_solvent_correction`) and every analysis module under `analysis/code`.
200
 
201
  ### How to Cite
202
 
@@ -211,4 +209,4 @@ Adams, K.; Wagen, C.C.; Wolford, J.; Sak, M.H.; Saurí, J.; Feng, Z.; Bhadauria,
211
  - The original code, model weights, and datasets in this repository are released under the MIT License (see [`LICENSE`](LICENSE)).
212
  - Third-party literature data redistributed here remains subject to its original publications' terms and should be
213
  cited accordingly: CP3 (`data/cp3/`, Smith and Goodman), NS372
214
- (`data/ns372/`, Schattenberg and Kaupp), and DFT8K (`data/dft8k/`, Guan and Paton).
 
194
  pip install pdoc
195
  python build_api_docs.py
196
 
197
+ Most people will only want to look at `api_docs/magnet.html` (the public API), though the other functions are documented as well.
 
 
198
 
199
  ### How to Cite
200
 
 
209
  - The original code, model weights, and datasets in this repository are released under the MIT License (see [`LICENSE`](LICENSE)).
210
  - Third-party literature data redistributed here remains subject to its original publications' terms and should be
211
  cited accordingly: CP3 (`data/cp3/`, Smith and Goodman), NS372
212
+ (`data/ns372/`, Schattenberg and Kaupp), DFT8K (`data/dft8k/`, Guan and Paton), and DELTA50 (`data/delta50/`, Cohen *et al.*).
analysis/code/delta50_residuals.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MagNET-Zero vs DFT residuals on the DELTA50 benchmark (50 common organics). No plotting.
2
+
3
+ MagNET-Zero is compared against the DFT level it reproduces, on the same AIMNet2 geometries: protons
4
+ vs WP04/pcSseg-2, carbons vs wB97X-D/pcSseg-2, gas phase; residual = DFT shielding minus MagNET.
5
+ This is the comparison behind the paper's DELTA50 outlier observation (nitromethane, nitroethane, and
6
+ 2-methyl-2-nitropropane are the significant outliers). Read via data/delta50/decode_delta50.py.
7
+ """
8
+ import numpy as np
9
+
10
+ from stats import summarize_errors
11
+
12
+ # the DFT level MagNET-Zero reproduces for each nucleus; MagNET-Zero is a single per-atom array
13
+ NUCLEI = {
14
+ "H": {"atomic_number": 1, "dft": "shielding_wp04_pcSseg2", "nn": "nn_magnet_zero"},
15
+ "C": {"atomic_number": 6, "dft": "shielding_wb97xd_pcSseg2", "nn": "nn_magnet_zero"},
16
+ }
17
+
18
+
19
+ def load_shieldings(delta50_path):
20
+ """Flat per-atom arrays from the released reader: atomic_numbers, the MagNET predictions, the
21
+ DFT reference shieldings, and experimental_shift (all ppm, NaN where absent)."""
22
+ from decode_delta50 import Delta50
23
+ with Delta50(delta50_path) as ds:
24
+ return ds.all_atoms()
25
+
26
+
27
+ def residuals(shieldings, nucleus):
28
+ """DFT-minus-MagNET-Zero shielding residuals (ppm) for one nucleus ("H" or "C"), over that
29
+ element's atoms where both values are present."""
30
+ spec = NUCLEI[nucleus]
31
+ z = np.asarray(shieldings["atomic_numbers"])
32
+ dft = np.asarray(shieldings[spec["dft"]], dtype=float)
33
+ nn = np.asarray(shieldings[spec["nn"]], dtype=float)
34
+ keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
35
+ return dft[keep] - nn[keep]
36
+
37
+
38
+ def residual_stats(errors):
39
+ """count / RMSE / MAE / median AE / max absolute error for a residual array."""
40
+ errors = np.asarray(errors, dtype=float)
41
+ if not errors.size:
42
+ return {"n": 0, "rmse": float("nan"), "mae": float("nan"), "median_ae": float("nan"),
43
+ "max_ae": float("nan")}
44
+ stats = summarize_errors(errors, np.zeros_like(errors))
45
+ stats["max_ae"] = float(np.abs(errors).max())
46
+ return stats
47
+
48
+
49
+ def residual_summary(delta50_path):
50
+ """{nucleus: residual_stats} for both nuclei, from the released file."""
51
+ shieldings = load_shieldings(delta50_path)
52
+ return {nucleus: residual_stats(residuals(shieldings, nucleus)) for nucleus in NUCLEI}
53
+
54
+
55
+ def per_molecule_max_residual(delta50_path):
56
+ """{molecule_name: {"H": max|residual|, "C": max|residual|}} - the DFT-vs-MagNET-Zero outlier
57
+ magnitudes per molecule (nitromethane, nitroethane, 2-methyl-2-nitropropane rank highest for C)."""
58
+ from decode_delta50 import Delta50
59
+ out = {}
60
+ with Delta50(delta50_path) as ds:
61
+ for i in range(len(ds)):
62
+ m = ds.molecule(i)
63
+ z = m["atomic_numbers"]
64
+ ref = np.where(z == 1, m["shielding_wp04_pcSseg2"],
65
+ np.where(z == 6, m["shielding_wb97xd_pcSseg2"], np.nan))
66
+ r = ref - m["nn_magnet_zero"]
67
+ res = {}
68
+ for nucleus, zz in (("H", 1), ("C", 6)):
69
+ sel = (z == zz) & np.isfinite(r)
70
+ res[nucleus] = float(np.abs(r[sel]).max()) if sel.any() else float("nan")
71
+ out[m["name"]] = res
72
+ return out
analysis/code/test_delta50_residuals.py ADDED
@@ -0,0 +1,145 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/delta50_residuals.py.
2
+
3
+ Synthetic tests check residual selection and statistics on a hand-built shieldings dict. Opt-in
4
+ tests run against the real delta50.hdf5 and reproduce the paper's DELTA50 observation: MagNET-Zero
5
+ tracks its DFT reference to well under 0.5 ppm (1H) and a few ppm (13C), with nitromethane,
6
+ nitroethane, and 2-methyl-2-nitropropane the significant 13C outliers (up to ~7 ppm).
7
+ """
8
+ import os
9
+ import sys
10
+
11
+ import numpy as np
12
+ import pytest
13
+
14
+ HERE = os.path.dirname(os.path.abspath(__file__))
15
+ sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta50")) # decode_delta50
16
+ sys.path.insert(0, HERE)
17
+ import paths # noqa: E402
18
+
19
+ import delta50_residuals as D # noqa: E402
20
+
21
+
22
+ def make_shieldings():
23
+ """Flat shieldings dict like the reader's all_atoms: H, C, C, H, N. MagNET-Zero is one array
24
+ (WP04 quality at H, wB97X-D quality at C); the DFT reference is split by nucleus."""
25
+ return {
26
+ "atomic_numbers": np.array([1, 6, 6, 1, 7]),
27
+ "shielding_wp04_pcSseg2": np.array([30.5, 150.5, 151.5, 31.5, 200.5]),
28
+ "shielding_wb97xd_pcSseg2": np.array([30.7, 150.7, 151.7, 31.7, 200.7]),
29
+ "nn_magnet_zero": np.array([30.6, 150.6, 151.6, 31.3, np.nan]),
30
+ }
31
+
32
+
33
+ def test_residuals_select_nucleus_against_the_right_dft_level():
34
+ s = make_shieldings()
35
+ h = D.residuals(s, "H") # DFT WP04 minus MagNET at the two H atoms (0, 3)
36
+ np.testing.assert_allclose(sorted(h), sorted([30.5 - 30.6, 31.5 - 31.3]))
37
+ c = D.residuals(s, "C") # DFT wB97X-D minus MagNET at the two C atoms (1, 2)
38
+ np.testing.assert_allclose(sorted(c), sorted([150.7 - 150.6, 151.7 - 151.6]))
39
+ assert len(h) == 2 and len(c) == 2
40
+
41
+
42
+ def test_residual_stats():
43
+ errors = np.array([-0.1, 0.2, 0.05, -0.05])
44
+ st = D.residual_stats(errors)
45
+ assert st["n"] == 4
46
+ assert st["rmse"] == pytest.approx(np.sqrt(np.mean(errors ** 2)))
47
+ assert st["mae"] == pytest.approx(np.mean(np.abs(errors)))
48
+ assert st["max_ae"] == pytest.approx(0.2)
49
+
50
+
51
+ def test_residual_stats_empty():
52
+ st = D.residual_stats(np.array([]))
53
+ assert st["n"] == 0 and np.isnan(st["rmse"])
54
+
55
+
56
+ # --- opt-in: reproduce the DELTA50 residual numbers from the released data -----------------------
57
+
58
+ REAL = paths.dataset_file("delta50", file=__file__)
59
+
60
+
61
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
62
+ def test_reproduces_delta50_magnet_zero_vs_dft_residuals():
63
+ summary = D.residual_summary(REAL)
64
+ # 1H: MagNET-Zero tracks WP04 to ~0.05 ppm RMSE, max residual ~0.47 ppm ("up to 0.4 ppm")
65
+ assert summary["H"]["rmse"] == pytest.approx(0.055, abs=0.01)
66
+ assert summary["H"]["max_ae"] == pytest.approx(0.469, abs=0.02)
67
+ # 13C: RMSE ~0.61 ppm, max residual ~6.65 ppm ("up to 7 ppm")
68
+ assert summary["C"]["rmse"] == pytest.approx(0.607, abs=0.03)
69
+ assert summary["C"]["max_ae"] == pytest.approx(6.654, abs=0.05)
70
+
71
+
72
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
73
+ def test_named_13c_outliers_are_the_three_nitroalkanes():
74
+ per_mol = D.per_molecule_max_residual(REAL)
75
+ top3 = sorted(per_mol, key=lambda nm: -per_mol[nm]["C"])[:3]
76
+ assert set(top3) == {"nitromethane", "nitroethane", "2-methyl-2-nitropropane"}
77
+ # nitromethane is the single largest 13C outlier, ~6.65 ppm
78
+ assert per_mol["nitromethane"]["C"] == pytest.approx(6.654, abs=0.05)
79
+ # nitrobenzene, by contrast, is accurately predicted (paper's explicit contrast)
80
+ assert per_mol["nitrobenzene"]["C"] < 0.5
81
+
82
+
83
+ # Golden experimental shifts (ppm) for the molecules whose atom order is most likely to be mismapped:
84
+ # the three symmetry-fallback cases and the near-degenerate distinct environments (amide N-methyls,
85
+ # diastereotopic methyls). Values are the published DELTA50 shifts; a mapping regression changes them.
86
+ _GOLDEN_MULTISET = {
87
+ "cyclohexanone": {"C": [25.02, 27.03, 27.03, 42.0, 42.0, 212.15]},
88
+ "methyl acetate": {"C": [20.71, 51.62, 171.55]},
89
+ "2-methyl-2-nitropropane": {"C": [27.87, 27.87, 27.87, 85.06]},
90
+ "n,n-dimethylformamide": {"C": [31.44, 36.48, 162.52]},
91
+ "n,n-dimethylacetamide": {"C": [21.58, 35.2, 38.05, 170.66]},
92
+ "2-methyl-2-butene": {"C": [13.41, 17.32, 25.63, 118.44, 132.1]},
93
+ }
94
+ # (0-based carbon index -> shift): pins each shift to a specific atom, so a swap of two same-value-set
95
+ # environments (e.g. the two DMF N-methyls) fails even though the multiset is unchanged.
96
+ _GOLDEN_ATOM = {
97
+ "n,n-dimethylformamide": {0: 162.52, 3: 31.44, 4: 36.48},
98
+ "n,n-dimethylacetamide": {1: 170.66, 3: 35.2, 4: 38.05, 5: 21.58},
99
+ "2-methyl-2-butene": {0: 17.32, 1: 132.1, 2: 118.44, 3: 13.41, 4: 25.63},
100
+ "cyclohexanone": {1: 212.15, 2: 42.0, 3: 42.0, 4: 27.03, 5: 27.03, 6: 25.02},
101
+ }
102
+
103
+
104
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
105
+ def test_experimental_multiset_matches_published_delta50():
106
+ from decode_delta50 import Delta50
107
+ with Delta50(REAL) as ds:
108
+ for name, expected in _GOLDEN_MULTISET.items():
109
+ m = ds.molecule_by_name(name)
110
+ got = sorted(round(float(x), 2) for x in m["experimental_shift"][m["atomic_numbers"] == 6]
111
+ if np.isfinite(x))
112
+ assert got == sorted(expected["C"]), name
113
+
114
+
115
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
116
+ def test_experimental_swap_sensitive_carbons_pinned_to_atoms():
117
+ from decode_delta50 import Delta50
118
+ with Delta50(REAL) as ds:
119
+ for name, atoms in _GOLDEN_ATOM.items():
120
+ m = ds.molecule_by_name(name)
121
+ for idx, shift in atoms.items():
122
+ assert m["atomic_numbers"][idx] == 6, (name, idx)
123
+ assert m["experimental_shift"][idx] == pytest.approx(shift, abs=1e-3), (name, idx)
124
+
125
+
126
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
127
+ def test_experimental_shift_present_and_agrees_with_dft():
128
+ from decode_delta50 import Delta50
129
+ with Delta50(REAL) as ds:
130
+ atoms = ds.all_atoms()
131
+ z = atoms["atomic_numbers"]
132
+ exp = atoms["experimental_shift"]
133
+ # experimental is present for every H and C atom, absent for heteroatoms
134
+ assert np.isfinite(exp[(z == 1) | (z == 6)]).all()
135
+ assert np.isnan(exp[(z != 1) & (z != 6)]).all()
136
+ # A correct atom mapping makes scaled DFT track experiment; a scrambled one would not. These
137
+ # tolerances are loose (our DFT is gas phase on AIMNet2 geometries, not DELTA50's PCM level) but
138
+ # far below the ppm-scale RMSE a wrong assignment would produce.
139
+ for zz, key, tol in ((1, "shielding_wp04_pcSseg2", 0.5), (6, "shielding_wb97xd_pcSseg2", 5.0)):
140
+ m = (z == zz) & np.isfinite(exp)
141
+ sigma = atoms[key][m]
142
+ A = np.vstack([sigma, np.ones(m.sum())]).T
143
+ coef, *_ = np.linalg.lstsq(A, exp[m], rcond=None)
144
+ rmse = np.sqrt(np.mean((A @ coef - exp[m]) ** 2))
145
+ assert rmse < tol, f"Z={zz} scaled-DFT-vs-exp RMSE {rmse:.3f} too large"
data/delta50/build_delta50.py ADDED
@@ -0,0 +1,299 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Provenance/build script for delta50.hdf5.
2
+
3
+ Regenerates the shipped delta50.hdf5 from three raw sources (none of which are shipped, exactly as
4
+ for dft8k):
5
+
6
+ 1. the previous delta50.hdf5, for the MagNET predictions already packaged into it
7
+ (nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm; these come from MagNET inference and are carried
8
+ forward unchanged), plus the atom order, geometries, and atomic numbers;
9
+ 2. a directory of Gaussian NMR output files, one per molecule, each with two link jobs at the
10
+ MagNET-Zero reference levels (WP04 for 1H, wB97X-D for 13C; pcSseg-2; gas), computed on the
11
+ stored AIMNet2 geometries. Parsed with cctk. These become the per-atom DFT reference
12
+ shieldings shielding_wp04_pcSseg2 and shielding_wb97xd_pcSseg2, so the MagNET-Zero-vs-DFT
13
+ residual (Table S3/S4-style, the DELTA50 outlier check in the paper) reproduces from the file.
14
+ 3. DELTA50_benchmark.xlsx from the DELTA50 Supporting Information (Cohen et al., Molecules 2023,
15
+ 28, 2449; reference 47; CC BY 4.0), for the experimental 1H and 13C chemical shifts.
16
+
17
+ The experimental shifts are mapped onto this file's own atom order and stored per atom as
18
+ experimental_shift. DELTA50 numbers its atoms against independently built structures; those geometries
19
+ were recomputed here, so the numbering only sometimes lines up. Each molecule is mapped by DELTA50's
20
+ own atom numbering when that numbering is element-consistent and covers every H/C atom (47 of 50);
21
+ the three whose atom order differs (cyclohexanone, methyl acetate, 2-methyl-2-nitropropane) are
22
+ mapped by matching RDKit topological-symmetry classes to DELTA50 environments in order of the
23
+ DFT-predicted shift. The mapping is validated: coverage is 100% of H and C atoms, and the large
24
+ scaled-DFT-vs-experiment residuals are all chemically hard sp2 carbons (carbonyl, nitro, aromatic).
25
+
26
+ Usage:
27
+ python3 build_delta50.py [SRC_HDF5] [OUT_DIR] [XLSX] [DST_HDF5]
28
+
29
+ with the defaults below. Requires numpy, h5py, pandas, cctk, and rdkit.
30
+ """
31
+ from __future__ import annotations
32
+ import os
33
+ import re
34
+ import sys
35
+ from collections import defaultdict
36
+
37
+ import numpy as np
38
+ import h5py
39
+
40
+ SCALE = 1e-4
41
+ MARKER = -2147483648
42
+
43
+ _HOME = os.path.expanduser("~")
44
+ _HERE = os.path.dirname(os.path.abspath(__file__))
45
+ DEFAULT_SRC = os.path.join(_HERE, "delta50.hdf5")
46
+ DEFAULT_OUT_DIR = os.path.join(_HOME, "research", "magnet", "delta50", "delta50",
47
+ "output", "wp04_wb97xd_pcSseg2")
48
+ DEFAULT_XLSX = os.path.join(_HOME, "research", "magnet", "delta50", "DELTA50_benchmark.xlsx")
49
+
50
+ # Molecules whose canonical name here differs from the one in the previous file and the raw inputs.
51
+ # DELTA50 (and the old file) mislabel 2-methyl-2-nitropropane, a C-nitro compound, as a nitrate ester.
52
+ RENAMES = {"t-butyl nitrate": "2-methyl-2-nitropropane"}
53
+
54
+ # DELTA50 spreadsheet compound names that differ from our molecule_names. "3,3-dimethyl-1-butene"
55
+ # is split across two cells ("(3,3-Dimethyl-" then "1-butene)"), so both fragments are aliased.
56
+ NAME_ALIASES = {
57
+ "dmf": "n,n-dimethylformamide", "thf": "tetrahydrofuran", "dmac": "n,n-dimethylacetamide",
58
+ "2cyanopropane": "isobutyronitrile", "mtbe": "methyl t-butyl ether",
59
+ "cyclopentenone": "cyclopent-2-en-1-one", "cyclohexenone": "cyclohex-2-en-1-one",
60
+ "pbenzoquinone": "1,4-benzoquinone", "thp": "tetrahydropyran",
61
+ "33dimethyl": "t-butylethylene", "1butene": "t-butylethylene",
62
+ "tbutylnitrate": "2-methyl-2-nitropropane",
63
+ }
64
+
65
+
66
+ def _norm(s):
67
+ return re.sub(r"[^a-z0-9]", "", str(s).lower())
68
+
69
+
70
+ def _encode(values):
71
+ v = np.asarray(values, dtype=np.float64)
72
+ finite = np.isfinite(v)
73
+ out = np.full(v.shape, MARKER, dtype=np.int64)
74
+ out[finite] = np.round(v[finite] / SCALE).astype(np.int64)
75
+ return out.astype(np.int32)
76
+
77
+
78
+ def _parse_atom_labels(s):
79
+ """DELTA50 atom-number strings: comma lists ("5,6,7"), ranges ("7-12"), singles, and mixtures."""
80
+ out = []
81
+ for tok in re.split(r"[,\s]+", str(s).strip()):
82
+ m = re.match(r"^(\d+)-(\d+)$", tok)
83
+ if m:
84
+ out += list(range(int(m.group(1)), int(m.group(2)) + 1))
85
+ elif tok.isdigit():
86
+ out.append(int(tok))
87
+ return out
88
+
89
+
90
+ def load_predictions(src_hdf5):
91
+ with h5py.File(src_hdf5, "r") as f:
92
+ return {
93
+ "molecule_names": [str(n) for n in f["molecule_names"].asstr()[:]],
94
+ "n_atoms": f["n_atoms"][:].astype(np.int32),
95
+ "atomic_numbers": f["atomic_numbers"][:],
96
+ "coordinates": f["coordinates"][:],
97
+ "nn_magnet_zero": f["nn_magnet_zero"][:],
98
+ "nn_b3lyp": f["nn_b3lyp"][:],
99
+ "nn_b3lyp_pcm": f["nn_b3lyp_pcm"][:],
100
+ }
101
+
102
+
103
+ def load_dft_reference(out_dir, file_names, out_names, atomic_numbers, starts):
104
+ """Per-atom WP04 and wB97X-D isotropic shieldings from the Gaussian outputs, aligned to the
105
+ stored atom order (checked). Files are matched by `file_names` (the raw-input naming) but the
106
+ returned per-molecule dict is keyed by `out_names` (the canonical output naming).
107
+ Returns (wp04, wb97xd, per_molecule_shieldings)."""
108
+ import cctk
109
+ files = {_norm(f[len("delta50-"):-len(".out")]): os.path.join(out_dir, f)
110
+ for f in os.listdir(out_dir) if f.endswith(".out")}
111
+ # the Gaussian files keep the pre-rename naming; fall back through RENAMES so re-running the
112
+ # build against its own (already-renamed) output still finds each file.
113
+ reverse = {v: k for k, v in RENAMES.items()}
114
+
115
+ def _find(name):
116
+ for candidate in (name, reverse.get(name, name)):
117
+ if _norm(candidate) in files:
118
+ return files[_norm(candidate)]
119
+ raise KeyError(f"no Gaussian output for {name!r} in {out_dir}")
120
+
121
+ wp04 = np.full(starts[-1], np.nan)
122
+ wb97xd = np.full(starts[-1], np.nan)
123
+ per_mol = {}
124
+ for i, (file_name, out_name) in enumerate(zip(file_names, out_names)):
125
+ objs = cctk.GaussianFile.read_file(_find(file_name))
126
+ by_level = {}
127
+ for g in objs:
128
+ ens = g.ensemble
129
+ iso = np.asarray(ens.get_properties_dict(ens.molecules[-1])["isotropic_shielding"],
130
+ dtype=float)
131
+ z = np.asarray(ens.molecules[-1].atomic_numbers)
132
+ by_level["wb97xd" if "wb97xd" in g.route_card.lower() else "wp04"] = (z, iso)
133
+ sl = slice(int(starts[i]), int(starts[i + 1]))
134
+ z_ref = atomic_numbers[sl]
135
+ for level, (z, iso) in by_level.items():
136
+ if not np.array_equal(z, z_ref):
137
+ raise ValueError(f"{out_name}: {level} atom order does not match the stored geometry")
138
+ wp04[sl] = by_level["wp04"][1]
139
+ wb97xd[sl] = by_level["wb97xd"][1]
140
+ per_mol[out_name] = {"wp04": by_level["wp04"][1], "wb97xd": by_level["wb97xd"][1]}
141
+ return wp04, wb97xd, per_mol
142
+
143
+
144
+ def _parse_experimental_sheets(xlsx, valid_names):
145
+ """{molecule_name: {"H": [(atoms, shift)], "C": [...]}} from the two DELTA50 functional sheets."""
146
+ import pandas as pd
147
+ valid = {_norm(n): n for n in valid_names}
148
+ out = {n: {"H": [], "C": []} for n in valid_names}
149
+ for sheet, nucleus in (("1H Functional", "H"), ("13C Functional", "C")):
150
+ df = pd.read_excel(xlsx, sheet_name=sheet, header=None)
151
+ hdr = next(r for r in range(df.shape[0]) if str(df.iat[r, 0]).strip() == "Compound")
152
+ current = None
153
+ for r in range(hdr + 1, df.shape[0]):
154
+ compound, label, shift = df.iat[r, 0], df.iat[r, 1], df.iat[r, 2]
155
+ if isinstance(compound, str) and compound.strip():
156
+ k = _norm(compound)
157
+ current = valid.get(_norm(NAME_ALIASES.get(k, compound)))
158
+ if current is None or pd.isna(label) or pd.isna(shift):
159
+ continue
160
+ out[current][nucleus].append((_parse_atom_labels(label), float(shift)))
161
+ return out
162
+
163
+
164
+ def _symmetry_classes(atomic_numbers, coordinates):
165
+ """RDKit topological-symmetry classes for one molecule: {rank: [atom indices]}. Bonds are
166
+ perceived from the geometry."""
167
+ from rdkit import Chem
168
+ from rdkit.Chem import rdDetermineBonds
169
+ pt = Chem.GetPeriodicTable()
170
+ lines = [str(len(atomic_numbers)), ""]
171
+ for z, xyz in zip(atomic_numbers, coordinates):
172
+ lines.append(f"{pt.GetElementSymbol(int(z))} {xyz[0]:.6f} {xyz[1]:.6f} {xyz[2]:.6f}")
173
+ mol = Chem.MolFromXYZBlock("\n".join(lines))
174
+ rdDetermineBonds.DetermineBonds(mol, charge=0)
175
+ classes = defaultdict(list)
176
+ for atom_idx, rank in enumerate(Chem.CanonicalRankAtoms(mol, breakTies=False)):
177
+ classes[rank].append(atom_idx)
178
+ return classes
179
+
180
+
181
+ def map_experimental(xlsx, molecule_names, atomic_numbers, coordinates, starts, dft_per_mol):
182
+ """Map DELTA50 experimental shifts onto our atom order; per-atom array (ppm, NaN where absent).
183
+
184
+ Direct: use DELTA50's atom numbering where it is element-consistent and covers every H/C atom.
185
+ Fallback: for molecules whose atom order differs, match RDKit symmetry classes to DELTA50
186
+ environments by DFT-predicted shift order. Asserts full H/C coverage."""
187
+ experimental = np.full(starts[-1], np.nan)
188
+ fallback = []
189
+ exp = _parse_experimental_sheets(xlsx, molecule_names)
190
+ for i, name in enumerate(molecule_names):
191
+ sl = slice(int(starts[i]), int(starts[i + 1]))
192
+ z = atomic_numbers[sl]
193
+ n = len(z)
194
+ envs = {1: exp[name]["H"], 6: exp[name]["C"]}
195
+ consistent = all(1 <= a <= n and z[a - 1] == zz
196
+ for zz in (1, 6) for atoms, _ in envs[zz] for a in atoms)
197
+ # direct: use DELTA50's numbering. conflict = two environments claim one atom with different
198
+ # shifts (a numbering error, e.g. overlapping ranges); route those to the fallback rather
199
+ # than let a later write silently win.
200
+ direct = np.full(n, np.nan)
201
+ conflict = False
202
+ if consistent:
203
+ for zz in (1, 6):
204
+ for atoms, shift in envs[zz]:
205
+ for a in atoms:
206
+ if np.isfinite(direct[a - 1]) and direct[a - 1] != shift:
207
+ conflict = True
208
+ direct[a - 1] = shift
209
+ if consistent and not conflict and np.isfinite(direct[(z == 1) | (z == 6)]).all():
210
+ experimental[sl] = direct
211
+ continue
212
+ # fallback: RDKit symmetry classes matched to DELTA50 environments by DFT-shift order
213
+ fallback.append(name)
214
+ classes = _symmetry_classes(z, coordinates[sl])
215
+ assigned = np.full(n, np.nan)
216
+ sigma = {1: dft_per_mol[name]["wp04"], 6: dft_per_mol[name]["wb97xd"]}
217
+ for zz in (1, 6):
218
+ element_classes = [atoms for atoms in classes.values() if z[atoms[0]] == zz]
219
+ if len(element_classes) != len(envs[zz]):
220
+ raise ValueError(f"{name}: {len(element_classes)} symmetry classes vs "
221
+ f"{len(envs[zz])} DELTA50 environments for Z={zz}")
222
+ by_sigma = sorted(element_classes, key=lambda a: np.mean([sigma[zz][j] for j in a]))
223
+ by_shift = sorted(envs[zz], key=lambda e: -e[1]) # sigma ascending <-> shift descending
224
+ for atoms, (_, shift) in zip(by_sigma, by_shift):
225
+ for j in atoms:
226
+ assigned[j] = shift
227
+ experimental[sl] = assigned
228
+
229
+ hc = (atomic_numbers == 1) | (atomic_numbers == 6)
230
+ missing = int((~np.isfinite(experimental[hc])).sum())
231
+ if missing:
232
+ raise ValueError(f"{missing} H/C atoms have no experimental shift")
233
+ return experimental, fallback
234
+
235
+
236
+ def build(src_hdf5, out_dir, xlsx, dst_hdf5):
237
+ pred = load_predictions(src_hdf5)
238
+ file_names = pred["molecule_names"] # raw-input naming (files, sheets)
239
+ names = [RENAMES.get(n, n) for n in file_names] # canonical output naming
240
+ starts = np.zeros(len(names) + 1, np.int64)
241
+ np.cumsum(pred["n_atoms"], out=starts[1:])
242
+
243
+ coords = pred["coordinates"].astype(np.float64) * SCALE # int32 fixed point -> Angstrom
244
+ wp04, wb97xd, dft_per_mol = load_dft_reference(out_dir, file_names, names,
245
+ pred["atomic_numbers"], starts)
246
+ experimental, fallback = map_experimental(xlsx, names, pred["atomic_numbers"], coords, starts,
247
+ dft_per_mol)
248
+ print(f"experimental mapped by symmetry fallback: {fallback}")
249
+
250
+ string_dt = h5py.string_dtype("utf-8")
251
+ opts = dict(compression="gzip", shuffle=True)
252
+ with h5py.File(dst_hdf5, "w") as f:
253
+ f.attrs["dataset"] = "delta50"
254
+ f.attrs["description"] = (
255
+ "The 50 small molecules of the DELTA50 benchmark (Cohen et al., Molecules 2023, 28, "
256
+ "2449; CC BY 4.0) on AIMNet2 geometries. Per-atom arrays: MagNET predictions, the DFT "
257
+ "reference shieldings MagNET-Zero targets (WP04 for 1H, wB97X-D for 13C, pcSseg-2, gas), "
258
+ "and the published experimental shifts mapped onto this atom order. The MagNET-Zero-vs-"
259
+ "DFT residual and the experiment comparison both reproduce from this file.")
260
+ f.attrs["n_molecules"] = len(names)
261
+ f.attrs["geometry"] = "AIMNet2"
262
+ f.attrs["scale"] = SCALE
263
+ f.attrs["missing_value_marker"] = MARKER
264
+ f.attrs["nn_magnet_zero"] = "MagNET-Zero: WP04 (1H) / wB97X-D (13C), pcSseg-2, 20-pass"
265
+ f.attrs["nn_b3lyp"] = "MagNET-PCM gas-phase component: B3LYP/pcSseg-2"
266
+ f.attrs["nn_b3lyp_pcm"] = "MagNET-PCM chloroform component: B3LYP/pcSseg-2 + chloroform PCM"
267
+ f.attrs["shielding_wp04_pcSseg2"] = "DFT reference: WP04/pcSseg-2 gas (the 1H MagNET-Zero level)"
268
+ f.attrs["shielding_wb97xd_pcSseg2"] = "DFT reference: wB97X-D/pcSseg-2 gas (the 13C MagNET-Zero level)"
269
+ f.attrs["experimental_shift"] = (
270
+ "Experimental 1H/13C shift (ppm, CDCl3, TMS reference) from DELTA50 (Cohen et al., "
271
+ "Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order. NaN at atoms with no "
272
+ "reported shift (heteroatoms).")
273
+
274
+ f.create_dataset("molecule_names", data=np.array(names, dtype=object), dtype=string_dt)
275
+ f.create_dataset("n_atoms", data=pred["n_atoms"], **opts)
276
+ f.create_dataset("atomic_numbers", data=pred["atomic_numbers"], **opts)
277
+ f.create_dataset("coordinates", data=pred["coordinates"], **opts)
278
+ f.create_dataset("nn_magnet_zero", data=pred["nn_magnet_zero"], **opts)
279
+ f.create_dataset("nn_b3lyp", data=pred["nn_b3lyp"], **opts)
280
+ f.create_dataset("nn_b3lyp_pcm", data=pred["nn_b3lyp_pcm"], **opts)
281
+ f.create_dataset("shielding_wp04_pcSseg2", data=_encode(wp04), **opts)
282
+ f.create_dataset("shielding_wb97xd_pcSseg2", data=_encode(wb97xd), **opts)
283
+ f.create_dataset("experimental_shift", data=_encode(experimental), **opts)
284
+
285
+ return dst_hdf5
286
+
287
+
288
+ if __name__ == "__main__":
289
+ args = sys.argv[1:]
290
+ src = args[0] if len(args) > 0 else DEFAULT_SRC
291
+ out_dir = args[1] if len(args) > 1 else DEFAULT_OUT_DIR
292
+ xlsx = args[2] if len(args) > 2 else DEFAULT_XLSX
293
+ dst = args[3] if len(args) > 3 else DEFAULT_SRC
294
+ print(f"src predictions : {src}")
295
+ print(f"DFT outputs : {out_dir}")
296
+ print(f"experimental : {xlsx}")
297
+ print(f"writing : {dst}")
298
+ build(src, out_dir, xlsx, dst)
299
+ print("done.")
data/delta50/decode_delta50.py CHANGED
@@ -2,23 +2,30 @@
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.
@@ -67,8 +74,10 @@ class Delta50:
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]))
@@ -79,8 +88,24 @@ class Delta50:
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))
 
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, per atom on each molecule's AIMNet2 geometry: MagNET's predicted shieldings, the DFT
6
+ reference shieldings MagNET-Zero is trained to reproduce, and the published experimental shifts.
7
+
8
+ nn_magnet_zero MagNET-Zero (WP04 for 1H, wB97X-D for 13C; pcSseg-2; 20-pass), the
9
+ gas-phase 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
+ shielding_wp04_pcSseg2 DFT reference, WP04/pcSseg-2 gas (the level MagNET-Zero targets for 1H)
13
+ shielding_wb97xd_pcSseg2 DFT reference, wB97X-D/pcSseg-2 gas (the level MagNET-Zero targets for 13C)
14
+ experimental_shift experimental 1H/13C shift (ppm, CDCl3, TMS ref) from DELTA50 (Cohen et
15
+ al., Molecules 2023, 28, 2449; CC BY 4.0), mapped onto this atom order
16
 
17
  The MagNET-PCM implicit-solvent correction for a molecule is nn_b3lyp_pcm minus nn_b3lyp. The
18
+ MagNET-Zero-vs-DFT residual (nn_magnet_zero minus shielding_wp04_pcSseg2 at 1H, minus
19
+ shielding_wb97xd_pcSseg2 at 13C) is what the paper uses to probe outliers (nitromethane, nitroethane,
20
+ and 2-methyl-2-nitropropane are the notable ones). experimental_shift is NaN at atoms with no
21
+ reported shift (heteroatoms).
22
 
23
  Storage format:
24
  - Per-atom arrays are concatenated across molecules. Molecule i has `n_atoms[i]` atoms and owns
25
  the rows [start[i], start[i+1]) where start is the cumulative sum of `n_atoms`.
26
+ - coordinates, shieldings, and shifts are int32 fixed point: physical = stored * 1e-4, so
27
+ reconstruction error is <= 5e-5 (Angstrom or ppm). A missing value is the marker
28
+ -2147483648 -> NaN.
29
  - molecules are identified by name (`molecule_names`).
30
 
31
  Requires: Python >= 3.7, numpy, h5py. No other dependencies.
 
74
  def molecule(self, index: int) -> dict:
75
  """Return one molecule's data (physical units) by position.
76
 
77
+ Keys: name; atomic_numbers (n,); coordinates (n, 3 Angstrom); the three predicted shieldings
78
+ (nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm); the two DFT reference shieldings
79
+ (shielding_wp04_pcSseg2, shielding_wb97xd_pcSseg2); and experimental_shift. Every per-atom
80
+ array is (n,) ppm; experimental_shift is NaN at atoms with no reported shift.
81
  """
82
  self._check(index)
83
  sl = slice(int(self._start[index]), int(self._start[index + 1]))
 
88
  "nn_magnet_zero": _decode(self.f["nn_magnet_zero"][sl]),
89
  "nn_b3lyp": _decode(self.f["nn_b3lyp"][sl]),
90
  "nn_b3lyp_pcm": _decode(self.f["nn_b3lyp_pcm"][sl]),
91
+ "shielding_wp04_pcSseg2": _decode(self.f["shielding_wp04_pcSseg2"][sl]),
92
+ "shielding_wb97xd_pcSseg2": _decode(self.f["shielding_wb97xd_pcSseg2"][sl]),
93
+ "experimental_shift": _decode(self.f["experimental_shift"][sl]),
94
  }
95
 
96
+ def all_atoms(self) -> dict:
97
+ """Every atom in the dataset as flat arrays (physical units), for whole-dataset analyses
98
+ such as the MagNET-Zero-vs-DFT residual or DFT-vs-experiment comparisons.
99
+
100
+ Keys: atomic_numbers (n_total,); nn_magnet_zero, nn_b3lyp, nn_b3lyp_pcm,
101
+ shielding_wp04_pcSseg2, shielding_wb97xd_pcSseg2, experimental_shift, each (n_total,) ppm.
102
+ """
103
+ keys = ("nn_magnet_zero", "nn_b3lyp", "nn_b3lyp_pcm", "shielding_wp04_pcSseg2",
104
+ "shielding_wb97xd_pcSseg2", "experimental_shift")
105
+ out = {"atomic_numbers": self.f["atomic_numbers"][:]}
106
+ out.update({k: _decode(self.f[k][:]) for k in keys})
107
+ return out
108
+
109
  def molecule_by_name(self, name: str) -> dict:
110
  """Return one molecule's data (see `molecule`) by name."""
111
  return self.molecule(self.index_of(name))
data/delta50/delta50.hdf5 CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:977bd7d46e5a81f5b1f0a19983a84ec3feb2bf26d2181c75bb25894d64e11c6f
3
- size 33248
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:c4a4b5e42fdd2e1f07d0e98bddf4f22cdab7edd9d5d99c728e2a42f082671e2a
3
+ size 47212
data/delta50/test_delta50.py CHANGED
@@ -23,8 +23,9 @@ REAL = os.path.join(HERE, "delta50.hdf5")
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
 
@@ -34,16 +35,21 @@ def _build_synthetic(path):
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
@@ -57,6 +63,9 @@ def _build_synthetic(path):
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
 
@@ -68,12 +77,17 @@ def test_reader_and_decode(tmp_path):
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
 
@@ -98,6 +112,16 @@ 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
 
 
 
23
 
24
  def _encode(values):
25
  v = np.asarray(values, dtype=np.float64)
26
+ finite = np.isfinite(v)
27
+ out = np.full(v.shape, MARKER, dtype=np.int64)
28
+ out[finite] = np.round(v[finite] / SCALE).astype(np.int64)
29
  return out.astype(np.int32)
30
 
31
 
 
35
  names = ["alpha", "beta"]
36
  specs = [3, 5] # n_atoms
37
  n_atoms = np.array(specs, dtype=np.int32)
38
+ an, coords, zero, b3, pcm, dwp, dwb, exp, truth = [], [], [], [], [], [], [], [], []
39
  for na in specs:
40
  a = rng.integers(1, 9, size=na).astype(np.uint8)
41
  c = rng.normal(0, 2, size=(na, 3))
42
  z = rng.normal(100, 30, size=na)
43
  g = rng.normal(100, 30, size=na)
44
  p = g + rng.normal(0, 0.2, size=na)
45
+ dw = z + rng.normal(0, 0.1, size=na)
46
+ db = z + rng.normal(0, 0.1, size=na)
47
+ e = rng.normal(5, 2, size=na)
48
+ e[a > 6] = np.nan # heteroatoms have no experimental shift
49
  an.append(a); coords.append(_encode(c)); zero.append(_encode(z))
50
  b3.append(_encode(g)); pcm.append(_encode(p))
51
+ dwp.append(_encode(dw)); dwb.append(_encode(db)); exp.append(_encode(e))
52
+ truth.append((a, c, z, g, p, dw, db, e))
53
  opts = dict(compression="gzip", shuffle=True)
54
  with h5py.File(path, "w") as f:
55
  f.attrs["n_molecules"] = 2
 
63
  f.create_dataset("nn_magnet_zero", data=np.concatenate(zero), **opts)
64
  f.create_dataset("nn_b3lyp", data=np.concatenate(b3), **opts)
65
  f.create_dataset("nn_b3lyp_pcm", data=np.concatenate(pcm), **opts)
66
+ f.create_dataset("shielding_wp04_pcSseg2", data=np.concatenate(dwp), **opts)
67
+ f.create_dataset("shielding_wb97xd_pcSseg2", data=np.concatenate(dwb), **opts)
68
+ f.create_dataset("experimental_shift", data=np.concatenate(exp), **opts)
69
  return names, specs, truth
70
 
71
 
 
77
  assert ds.molecule_names == names
78
  for i, na in enumerate(specs):
79
  m = ds.molecule(i)
80
+ a, c, z, g, p, dw, db, e = truth[i]
81
  assert m["name"] == names[i]
82
  assert np.array_equal(m["atomic_numbers"], a)
83
  assert m["coordinates"].shape == (na, 3)
84
  assert np.abs(m["coordinates"] - c).max() <= 5e-5
85
  assert np.abs(m["nn_magnet_zero"] - z).max() <= 5e-5
86
+ assert np.abs(m["shielding_wp04_pcSseg2"] - dw).max() <= 5e-5
87
+ assert np.abs(m["shielding_wb97xd_pcSseg2"] - db).max() <= 5e-5
88
+ finite = np.isfinite(e)
89
+ assert np.abs(m["experimental_shift"][finite] - e[finite]).max() <= 5e-5
90
+ assert np.isnan(m["experimental_shift"][~finite]).all()
91
  assert np.allclose(ds.pcm_correction(i), p - g, atol=1e-4)
92
 
93
 
 
112
  with D.Delta50(REAL) as ds:
113
  assert len(ds) == 50
114
  assert "nitromethane" in ds.molecule_names
115
+ # the DELTA50 nitrate-ester misnomer is corrected to the C-nitro name here
116
+ assert "2-methyl-2-nitropropane" in ds.molecule_names
117
+ assert "t-butyl nitrate" not in ds.molecule_names
118
  m = ds.molecule_by_name("nitromethane")
119
  assert np.isfinite(m["nn_magnet_zero"]).all()
120
+ for key in ("shielding_wp04_pcSseg2", "shielding_wb97xd_pcSseg2", "experimental_shift"):
121
+ assert m[key].shape == m["atomic_numbers"].shape
122
+ # nitromethane: WP04 at H, wB97X-D at C, and an experimental shift on every H and C
123
+ z = m["atomic_numbers"]
124
+ assert np.isfinite(m["experimental_shift"][(z == 1) | (z == 6)]).all()
125
  assert ds.pcm_correction(ds.index_of("nitromethane")).shape == m["atomic_numbers"].shape
126
+ atoms = ds.all_atoms()
127
+ assert atoms["atomic_numbers"].shape == atoms["experimental_shift"].shape