ekwan16 commited on
Commit
ef53368
·
verified ·
1 Parent(s): 64c992d

Add files using upload-large-folder tool

Browse files
Files changed (40) hide show
  1. analysis/code/applications.py +549 -0
  2. analysis/code/applications_plots.py +752 -0
  3. analysis/code/build_composite_model.py +290 -0
  4. analysis/code/build_nb_applications.py +282 -0
  5. analysis/code/build_nb_composite_models.py +124 -0
  6. analysis/code/build_nb_delta22.py +798 -0
  7. analysis/code/build_nb_magnet_benchmark.py +159 -0
  8. analysis/code/build_nb_scaling.py +126 -0
  9. analysis/code/composite_models.py +395 -0
  10. analysis/code/composite_plots.py +41 -0
  11. analysis/code/convergence_plot.py +621 -0
  12. analysis/code/dataset_summary.py +78 -0
  13. analysis/code/delta22.py +927 -0
  14. analysis/code/dft8k_residuals.py +216 -0
  15. analysis/code/diels_alder.py +137 -0
  16. analysis/code/fig2b_plots.py +52 -0
  17. analysis/code/fig3_plots.py +160 -0
  18. analysis/code/fig4_plots.py +71 -0
  19. analysis/code/fig5b_plots.py +76 -0
  20. analysis/code/leveling.py +322 -0
  21. analysis/code/leveling_plots.py +195 -0
  22. analysis/code/magnet_benchmark.py +327 -0
  23. analysis/code/magnet_benchmark_run.py +121 -0
  24. analysis/code/nb_build.py +28 -0
  25. analysis/code/pareto_plot.py +198 -0
  26. analysis/code/paths.py +64 -0
  27. analysis/code/scaling_factors.py +220 -0
  28. analysis/code/scaling_factors_symmetrized.py +113 -0
  29. analysis/code/test_applications_analysis.py +424 -0
  30. analysis/code/test_carbon_monoxide.py +41 -0
  31. analysis/code/test_composite_model.py +124 -0
  32. analysis/code/test_composite_models.py +178 -0
  33. analysis/code/test_delta22_analysis.py +916 -0
  34. analysis/code/test_dft8k_residuals.py +137 -0
  35. analysis/code/test_leveling.py +233 -0
  36. analysis/si_figures/si_figure_s03_leveling.ipynb +386 -0
  37. analysis/si_figures/si_figure_s11.ipynb +139 -0
  38. analysis/si_figures/si_figure_s12_carbon_monoxide.ipynb +126 -0
  39. analysis/si_figures/si_figure_s13.ipynb +173 -0
  40. analysis/si_figures/si_figure_s14.ipynb +157 -0
analysis/code/applications.py ADDED
@@ -0,0 +1,549 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core analysis for the natural-products ("applications") figures (Figures 5C/5D, SI S8, S15). No plotting.
2
+
3
+ Takes an Applications loader (data/applications/applications_reader.py) and computes the numbers the
4
+ figure notebooks and tests share. A "composite model" predicts a shift as an OLS-weighted sum of
5
+ physics terms (gas-phase shielding + implicit correction + rovibrational correction); these figures
6
+ test how well weights fit on delta-22 carry to the natural-products test set.
7
+ """
8
+ import numpy as np
9
+ import pandas as pd
10
+ import statsmodels.formula.api as smf
11
+
12
+ from stats import rmse as _rmse
13
+ from spreadsheet import site_atom_indices
14
+ from applications_reader import SOLVENTS, SHELL_SIZES
15
+
16
+ # composite-model formula sets (column names refer to build_query_df_nn output)
17
+ FITTING_FORMULAS_H = [
18
+ "stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + qcd",
19
+ "stationary_plus_qcd + pcm", "stationary_plus_pcm_plus_qcd", "stationary + openMM",
20
+ "stationary + openMM + qcd", "stationary_plus_qcd + openMM", "stationary_plus_openMM_plus_qcd",
21
+ ]
22
+ FITTING_FORMULAS_C = [
23
+ "stationary", "stationary_plus_pcm", "stationary + pcm", "stationary + pcm + openMM_vib",
24
+ "stationary_plus_op_vib + pcm", "stationary_plus_pcm_plus_op_vib", "stationary + openMM",
25
+ "stationary + openMM + openMM_vib", "stationary_plus_op_vib + openMM", "stationary_plus_openMM_plus_op_vib",
26
+ ]
27
+ FORMULA_REMAP = {
28
+ "stationary_plus_pcm": "pcm2", "stationary + pcm": "pcm3", "stationary + pcm + qcd": "pcm4",
29
+ "stationary + pcm + openMM_vib": "pcm4", "stationary_plus_qcd + pcm": "semi_parsimonious_pcm",
30
+ "stationary_plus_op_vib + pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
31
+ "stationary_plus_pcm_plus_op_vib": "parsimonious_pcm", "stationary + openMM": "expl3",
32
+ "stationary + openMM + qcd": "expl4", "stationary + openMM + openMM_vib": "expl4",
33
+ "stationary_plus_qcd + openMM": "semi_parsimonious_expl", "stationary_plus_op_vib + openMM": "semi_parsimonious_expl",
34
+ "stationary_plus_openMM_plus_qcd": "parsimonious_expl", "stationary_plus_openMM_plus_op_vib": "parsimonious_expl",
35
+ }
36
+ # manuscript test-set membership, keyed by the canonical solute names build_query_df_nn emits
37
+ ALL_IN_ONE_BIN = {"Test Set": [
38
+ "isomer_1E", "isomer_1Z", "isomer_2E", "isomer_2Z", "isomer_3E", "isomer_3Z", "isomer_4N", "isomer_4O",
39
+ "vomicine", "prednisone", "peptide", "flavone", "dihydrotanshinone_I",
40
+ ]}
41
+ # canonical solute -> manuscript display label (used as figure x-tick labels)
42
+ SOLUTE_DISPLAY = {
43
+ "isomer_1E": "Isomer 1 (E)", "isomer_1Z": "Isomer 1 (Z)", "isomer_2E": "Isomer 2 (E)",
44
+ "isomer_2Z": "Isomer 2 (Z)", "isomer_3E": "Isomer 3 (E)", "isomer_3Z": "Isomer 3 (Z)",
45
+ "isomer_4N": "1,4-dimethylpyridin-\n2(1H)one", "isomer_4O": "2-methoxy-\n4-methylpyridine",
46
+ "vomicine": "Vomicine", "prednisone": "Prednisone", "peptide": "Acetyl-L-alanyl-L-\nglutamine",
47
+ "flavone": "Flavone", "dihydrotanshinone_I": "Dihydrotanshinone I",
48
+ }
49
+ # the peptide (and, for carbon, the pyridone isomer_4N) is highlighted green as the high-error outlier
50
+ PEPTIDE_HIGHLIGHT_H = {"peptide": "#B0FF40"}
51
+ PEPTIDE_HIGHLIGHT_C = {"isomer_4N": "#B0FF40", "peptide": "#B0FF40"}
52
+
53
+ # bootstrap-coefficient file token -> remapped formula name, per nucleus
54
+ _BOOTSTRAP_TOKENS = {
55
+ "H": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_qcd": "pcm4",
56
+ "stationary_plus_qcd_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_qcd": "parsimonious_pcm",
57
+ "stationary_AND_openMM_AND_qcd": "expl4", "stationary_plus_qcd_AND_openMM": "semi_parsimonious_expl",
58
+ "stationary_plus_openMM_plus_qcd": "parsimonious_expl"},
59
+ "C": {"stationary_plus_pcm": "pcm2", "stationary_AND_pcm_AND_openMM_vib": "pcm4",
60
+ "stationary_plus_op_vib_AND_pcm": "semi_parsimonious_pcm", "stationary_plus_pcm_plus_op_vib": "parsimonious_pcm",
61
+ "stationary_AND_openMM_AND_openMM_vib": "expl4", "stationary_plus_op_vib_AND_openMM": "semi_parsimonious_expl",
62
+ "stationary_plus_openMM_plus_op_vib": "parsimonious_expl"},
63
+ }
64
+
65
+
66
+ def shell_convergence_corrections(loader, solute, nucleus, solvents=SOLVENTS):
67
+ """MagNET-X explicit-solvent correction (solvated minus isolated shielding) for each NMR
68
+ site, averaged over the site's atoms and over the MD frames, as a function of solvent shell
69
+ size. Backs SI Figure S8 panel A (shown for vomicine, proton sites).
70
+
71
+ Returns a DataFrame indexed by (site, solvent) with one column per shell ("shell_50" ...
72
+ "shell_650"). Shells absent for a given solute/solvent (e.g. the isomer_1Z/chloroform/250
73
+ gap) come back as NaN.
74
+ """
75
+ sites = loader.site_atom_table(uuid_prefix_h_sites=True)
76
+ sites = sites[(sites["solute"] == solute) & (sites["nucleus"] == nucleus)]
77
+ rows = []
78
+ for _, r in sites.iterrows():
79
+ idx = site_atom_indices(r["atom_numbers"])
80
+ for solvent in solvents:
81
+ present = set(loader.available_shells(solute, solvent))
82
+ vals = {"site": r["site"], "solvent": solvent}
83
+ for shell in SHELL_SIZES:
84
+ if shell not in present:
85
+ vals[f"shell_{shell}"] = np.nan
86
+ continue
87
+ mx = loader.magnet_x(solute, solvent, shell) # (frames, atoms, 2)
88
+ corr = mx[:, idx, 1] - mx[:, idx, 0] # solvated - isolated
89
+ corr = np.mean(corr, axis=1) # average over the site's atoms
90
+ corr = corr[~np.isnan(corr)] # drop frames with missing data
91
+ vals[f"shell_{shell}"] = float(np.mean(corr)) if len(corr) else np.nan
92
+ rows.append(vals)
93
+ return pd.DataFrame(rows).set_index(["site", "solvent"])
94
+
95
+
96
+ def experimental_stack(loader):
97
+ """Experimental shifts as a long DataFrame indexed (solute, nucleus, site, solvent)."""
98
+ exp = loader.experiment(uuid_prefix_h_sites=True).drop(columns=["atom_numbers"])
99
+ exp = exp.set_index(["solute", "nucleus", "site"])
100
+ stack = exp.stack(future_stack=True)
101
+ stack = pd.DataFrame(stack, columns=["experimental"])
102
+ stack.index.names = ["solute", "nucleus", "site", "solvent"]
103
+ return stack.sort_index()
104
+
105
+
106
+ def _site_mean(arr, idx):
107
+ return float(np.asarray(arr)[idx].mean())
108
+
109
+
110
+ def build_query_df_nn(loader):
111
+ """Assemble the per-(solute, nucleus, site, solvent) NN feature table that the composite-model
112
+ figures fit and plot (Figure 5C/5D, SI S15). Columns: experimental, stationary, pcm, qcd,
113
+ openMM, openMM_vib, plus the composite columns added by add_composite_columns
114
+ (stationary_plus_pcm/_qcd/_op_vib and their pcm+/openMM+ combinations). All inputs (MagNET
115
+ shieldings and the PCM conversion factors) are read through the loader.
116
+ """
117
+ sites = loader.site_atom_table(uuid_prefix_h_sites=True)
118
+ solvents = [c for c in loader.experiment().columns
119
+ if c not in ("solute", "site", "nucleus", "atom_numbers")]
120
+
121
+ # Cache the per-solute / per-(solute, solvent) loader reads. Each accessor opens the HDF5 file
122
+ # fresh, and the loops below touch only ~13 solutes (or ~52 solute-solvent pairs) across
123
+ # hundreds of sites, so without caching this re-opens the file thousands of times (~12x slower).
124
+ _mz_cache, _qcd_cache, _shell_cache, _mx_cache, _mxs_cache = {}, {}, {}, {}, {}
125
+ def get_mz(solute):
126
+ if solute not in _mz_cache: _mz_cache[solute] = loader.magnet_zero(solute)
127
+ return _mz_cache[solute]
128
+ def get_qcd(solute):
129
+ if solute not in _qcd_cache: _qcd_cache[solute] = loader.qcd(solute)
130
+ return _qcd_cache[solute]
131
+ def get_shells(solute, solvent):
132
+ k = (solute, solvent)
133
+ if k not in _shell_cache: _shell_cache[k] = loader.available_shells(solute, solvent)
134
+ return _shell_cache[k]
135
+ def get_mx(solute, solvent):
136
+ k = (solute, solvent)
137
+ if k not in _mx_cache: _mx_cache[k] = loader.magnet_x(solute, solvent, 650)
138
+ return _mx_cache[k]
139
+ def get_mxs(solute, solvent):
140
+ k = (solute, solvent)
141
+ if k not in _mxs_cache: _mxs_cache[k] = loader.magnet_x_stationary(solute, solvent)
142
+ return _mxs_cache[k]
143
+
144
+ # --- MagNET-Zero stationary + PCM (PCM predicted for chloroform, broadcast to all solvents) ---
145
+ mz_rows = []
146
+ for _, r in sites.iterrows():
147
+ idx = site_atom_indices(r["atom_numbers"])
148
+ mz = get_mz(r["solute"])
149
+ mz_rows.append([r["solute"], r["nucleus"], r["site"],
150
+ _site_mean(mz["stationary"], idx), _site_mean(mz["pcm_correction"], idx)])
151
+ mz = pd.DataFrame(mz_rows, columns=["solute", "nucleus", "site", "stationary", "pcm"])
152
+ mz = pd.concat([mz.assign(solvent=s) for s in solvents], ignore_index=True)
153
+ mz = mz.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
154
+
155
+ # --- QCD correction (solvent-independent, tiled across solvents) ---
156
+ qcd_rows = []
157
+ for _, r in sites.iterrows():
158
+ idx = site_atom_indices(r["atom_numbers"])
159
+ q = get_qcd(r["solute"])
160
+ corr = q["trajectories"].mean(0).mean(0)[idx, 3] - q["stationary"][idx, 3]
161
+ qcd_rows.append([r["solute"], r["nucleus"], r["site"],
162
+ float(corr.mean()) if len(corr) else np.nan])
163
+ qcd = pd.DataFrame(qcd_rows, columns=["solute", "nucleus", "site", "qcd"])
164
+ qcd = pd.concat([qcd.assign(solvent=s) for s in solvents], ignore_index=True)
165
+ qcd = qcd.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
166
+
167
+ # --- MagNET-X explicit-solvent correction + vibrational correction (per solvent, 650-atom shell) ---
168
+ ex_rows = []
169
+ for _, r in sites.iterrows():
170
+ idx = site_atom_indices(r["atom_numbers"])
171
+ for solvent in solvents:
172
+ shells = get_shells(r["solute"], solvent)
173
+ if 650 not in shells:
174
+ ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, np.nan, np.nan]); continue
175
+ mx = get_mx(r["solute"], solvent) # (frames, atoms, [isolated, solvated])
176
+ corr = mx[:, idx, 1] - mx[:, idx, 0]
177
+ corr = np.mean(corr, axis=1)
178
+ corr = corr[~np.isnan(corr)]
179
+ openmm = float(np.mean(corr)) if len(corr) else np.nan
180
+ stat = get_mxs(r["solute"], solvent)
181
+ vib = float(np.mean(mx.mean(0)[idx, 0] - np.asarray(stat)[idx]))
182
+ ex_rows.append([r["solute"], r["nucleus"], r["site"], solvent, openmm, vib])
183
+ ex = pd.DataFrame(ex_rows, columns=["solute", "nucleus", "site", "solvent", "openMM", "openMM_vib"])
184
+ ex = ex.set_index(["solute", "nucleus", "site", "solvent"]).sort_index()
185
+
186
+ # --- combine + composite columns ---
187
+ comb = pd.concat([experimental_stack(loader), mz, ex, qcd], axis=1).reset_index()
188
+ comb = comb.set_index(["solute", "nucleus", "site", "solvent"])
189
+ # stationary_plus_pcm: scale the chloroform PCM correction to each solvent (factors from the loader,
190
+ # which renames water -> TIP4P to match the explicit-solvent naming)
191
+ conv = {}
192
+ for nuc in ["H", "C"]:
193
+ s = loader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
194
+ conv[nuc] = s.rename({"water": "TIP4P"})
195
+ nn = comb.reset_index()
196
+ factors = nn.apply(lambda r: conv[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
197
+ nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * factors
198
+ nn = nn.set_index(comb.index.names)
199
+ comb["stationary_plus_pcm"] = nn["stationary_plus_pcm"]
200
+ comb["stationary_plus_qcd"] = comb["stationary"] + comb["qcd"]
201
+ comb["stationary_plus_op_vib"] = comb["stationary"] + comb["openMM_vib"]
202
+ comb["stationary_plus_pcm_plus_qcd"] = comb["stationary_plus_pcm"] + comb["qcd"]
203
+ comb["stationary_plus_pcm_plus_op_vib"] = comb["stationary_plus_pcm"] + comb["openMM_vib"]
204
+ comb["stationary_plus_openMM_plus_qcd"] = comb["stationary"] + comb["openMM"] + comb["qcd"]
205
+ comb["stationary_plus_openMM_plus_op_vib"] = comb["stationary"] + comb["openMM"] + comb["openMM_vib"]
206
+ return comb.reset_index()
207
+
208
+
209
+ # ----------------------------------------------------------------------------
210
+ # Composite-model fitting (Figure 5C/5D, SI S15). The coefficients applied at scale come from the
211
+ # loader's composite_model group (fit on delta-22).
212
+ # ----------------------------------------------------------------------------
213
+
214
+ def fit(fit_df, formula):
215
+ """OLS of experimental shift on a composite formula; returns (RMSE, params).
216
+
217
+ params is a pandas Series indexed by "Intercept" and the predictor names (matching statsmodels'
218
+ naming, which the composite-model coefficient code downstream relies on). This does the ordinary
219
+ least squares directly with numpy (np.linalg.lstsq), which is identical to the notebooks'
220
+ statsmodels OLS but far faster in this fitting loop: statsmodels re-parses the formula with patsy
221
+ on every call (~1000x slower per call). The `fit`-matches-`_fit_statsmodels` test guards the
222
+ equivalence.
223
+
224
+ Rows with a missing experimental value or a missing predictor are dropped before fitting and are
225
+ not scored: statsmodels drops those rows when fitting too, and its `predict` returns NaN for a row
226
+ with a missing predictor, so a single missing value never turns the whole RMSE into NaN.
227
+ """
228
+ terms = [term.strip() for term in formula.split("+") if term.strip()]
229
+ response = fit_df["experimental"].to_numpy(dtype=float)
230
+ columns = [fit_df[term].to_numpy(dtype=float) for term in terms]
231
+ design = np.column_stack([np.ones(len(fit_df))] + columns)
232
+ keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
233
+ design, response = design[keep], response[keep]
234
+ beta, *_ = np.linalg.lstsq(design, response, rcond=None)
235
+ params = pd.Series(beta, index=["Intercept"] + terms)
236
+ return _rmse(design @ beta, response), params
237
+
238
+
239
+ def _fit_statsmodels(fit_df, formula):
240
+ """The original statsmodels OLS, kept only as a test oracle for `fit` (see the equivalence test).
241
+ Slow because statsmodels re-parses the formula with patsy on every call; do not use in the harness."""
242
+ result = smf.ols(formula=f"experimental ~ {formula}", data=fit_df).fit()
243
+ fit_df = fit_df.copy()
244
+ fit_df["predicted"] = result.predict(fit_df)
245
+ scored = fit_df[fit_df["experimental"].notna() & fit_df["predicted"].notna()]
246
+ return _rmse(scored["predicted"], scored["experimental"]), result.params
247
+
248
+
249
+ def fit_formulas_per_solvent(df, formulas, solvents, formula_remap):
250
+ """One OLS fit per (formula, solvent), pooled across all solutes; returns rmse and params rows."""
251
+ rows = []
252
+ for formula in formulas:
253
+ for solvent in solvents:
254
+ d = df[df["solvent"] == solvent].copy()
255
+ rmse, params = fit(d, formula)
256
+ rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
257
+ "rmse": rmse, "params": params})
258
+ return pd.DataFrame(rows)
259
+
260
+
261
+ def fit_formulas_per_solvent_and_solute(df, formulas, solvents, solutes, formula_remap):
262
+ """One OLS fit per (formula, solvent, solute); skips a group with under 2 rows or no
263
+ experimental data, and records the exception instead of raising if a fit fails."""
264
+ rows = []
265
+ for formula in formulas:
266
+ for solvent in solvents:
267
+ for solute in solutes:
268
+ d = df[(df["solvent"] == solvent) & (df["solute"] == solute)].copy()
269
+ if len(d) < 2 or d["experimental"].isnull().all():
270
+ continue
271
+ try:
272
+ rmse, params = fit(d, formula)
273
+ rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
274
+ "solute": solute, "rmse": rmse, "params": params})
275
+ except Exception as e: # noqa: BLE001 - mirror source behaviour
276
+ rows.append({"formula": formula_remap.get(formula, formula), "solvent": solvent,
277
+ "solute": solute, "rmse": np.nan, "params": str(e)})
278
+ return pd.DataFrame(rows)
279
+
280
+
281
+ def per_solvent_fits(query_df_nn):
282
+ """all_solute_fitting_results: full per-solvent OLS fits for both nuclei ('scaled to test set')."""
283
+ out = {}
284
+ formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
285
+ solvents = list(query_df_nn["solvent"].unique())
286
+ for nuc in ["H", "C"]:
287
+ d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
288
+ out[nuc] = fit_formulas_per_solvent(d, formulas[nuc], solvents, FORMULA_REMAP)
289
+ return out
290
+
291
+
292
+ def per_solute_fits(query_df_nn):
293
+ """per_solute_fitting_results: per-solvent-and-solute OLS fits ('scaled to solute' baseline)."""
294
+ out = {}
295
+ formulas = {"H": FITTING_FORMULAS_H, "C": FITTING_FORMULAS_C}
296
+ solvents = list(query_df_nn["solvent"].unique())
297
+ solutes = list(query_df_nn["solute"].unique())
298
+ for nuc in ["H", "C"]:
299
+ d = query_df_nn[query_df_nn["nucleus"] == nuc].copy()
300
+ out[nuc] = fit_formulas_per_solvent_and_solute(d, formulas[nuc], solvents, solutes, FORMULA_REMAP)
301
+ return out
302
+
303
+
304
+ def build_bootstrap_seed_coeffs(loader):
305
+ """Per-nucleus DataFrame of bootstrap coefficients with a remapped 'formula' column, read from
306
+ the loader's composite_model group (the delta-22 bootstrap fits applied to the natural products)."""
307
+ out = {}
308
+ for nuc in ["H", "C"]:
309
+ frames = []
310
+ for token, name in _BOOTSTRAP_TOKENS[nuc].items():
311
+ df = loader.bootstrap_coefficients(token, nuc).copy()
312
+ df["formula"] = name
313
+ frames.append(df)
314
+ combined = pd.concat(frames, ignore_index=True, sort=False)
315
+ front = [c for c in ["solvent", "formula", "seed"] if c in combined.columns]
316
+ out[nuc] = combined[front + [c for c in combined.columns if c not in front]]
317
+ return out
318
+
319
+
320
+ def apply_bootstrap_params_to_full_dataset(data_df, params_df, nucleus=None):
321
+ """Apply each bootstrap coefficient set (per formula/seed/solvent) to the full NP dataset,
322
+ returning predictions: solute, nucleus, site, solvent, formula, seed, experimental, predicted."""
323
+ coeffs_df = params_df.copy()
324
+ if nucleus is not None:
325
+ data_df = data_df[data_df["nucleus"] == nucleus]
326
+ keep = ["solute", "nucleus", "site", "solvent", "experimental"]
327
+ keep += [c for c in data_df.columns if c in coeffs_df.columns and c not in keep]
328
+ data_df = data_df[keep]
329
+ merged = coeffs_df.merge(data_df, how="outer", on="solvent", suffixes=("_coeff", "_data"))
330
+ idx = ["solute", "nucleus", "site", "solvent", "formula", "seed"]
331
+ merged = merged[idx + [c for c in merged.columns if c not in idx]]
332
+ # vectorized prediction: intercept (0 if missing) plus, for each parameter, coeff*data where the
333
+ # coeff is present (a missing coeff contributes nothing; a present coeff with missing data
334
+ # propagates NaN).
335
+ pred = merged["Intercept"].astype(float).fillna(0.0) if "Intercept" in merged.columns else 0.0
336
+ for cc in [c for c in merged.columns if c.endswith("_coeff")]:
337
+ dc = cc.replace("_coeff", "_data")
338
+ if dc not in merged.columns:
339
+ continue
340
+ term = merged[cc].astype(float) * merged[dc].astype(float)
341
+ pred = pred + term.where(merged[cc].notna(), 0.0)
342
+ merged["predicted"] = pred
343
+ return merged[idx + ["experimental", "predicted"]]
344
+
345
+
346
+ def compute_solute_rmses(df, solute_col="solute"):
347
+ """Per (solvent, nucleus, formula, seed, solute) RMSE over a solute's sites."""
348
+ valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
349
+ # RMSE is sqrt(mean(squared error)), so a single vectorized groupby-mean over a precomputed
350
+ # squared-error column is identical to grouped.apply(rmse) but ~19x faster on the ~200k groups
351
+ # of a full bootstrap table (stats.rmse is exactly np.sqrt(np.mean(np.square(...)))).
352
+ valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
353
+ result = (valid.groupby(["solvent", "nucleus", "formula", "seed", solute_col])["_squared_error"]
354
+ .mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
355
+ return result.rename(columns={solute_col: "solute"}) if solute_col != "solute" else result
356
+
357
+
358
+ def compute_grouped_rmse(df, solute_groups, solute_col="solute"):
359
+ """Per (solvent, nucleus, formula, seed, solute_group) RMSE over all sites in the group."""
360
+ lookup = {s: g for g, ss in solute_groups.items() for s in ss}
361
+ df = df.copy()
362
+ df["solute_group"] = df[solute_col].map(lookup).fillna("Unknown")
363
+ valid = df[df["experimental"].notnull() & df["predicted"].notnull()].copy()
364
+ # vectorized RMSE, same identity and speedup as compute_solute_rmses above
365
+ valid["_squared_error"] = np.square(valid["predicted"] - valid["experimental"])
366
+ return (valid.groupby(["solvent", "nucleus", "formula", "seed", "solute_group"])["_squared_error"]
367
+ .mean().pow(0.5).reset_index(name="Bootstrap_RMSE"))
368
+
369
+
370
+ # ---------------------------------------------------------------------------------------------
371
+ # SI Figure S15's "Fitting RMSE Comparisons (chloroform, H/C)" bar chart: per test-set solute,
372
+ # three ways of choosing the composite-model coefficients -- "Scaled to Solute" (fit each solute
373
+ # on its own), "Scaled to Test Set" (one fit pooled across all 12 test-set solutes), "Extrapolated
374
+ # from delta22" (delta-22's bootstrap fits applied cold, never seeing the test set). Built from
375
+ # per_solute_fits (Scaled to Solute), per_solvent_fits (Scaled to Test Set, split back out per
376
+ # solute by scaled_to_test_set_per_solute_rmse below), and apply_bootstrap_params_to_full_dataset +
377
+ # compute_solute_rmses (Extrapolated from delta22, the same data Figure 5D's boxplot uses).
378
+
379
+ def scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute_fits, nucleus, solvent, formula):
380
+ """per_solvent_fits' output has one pooled rmse per (formula, solvent): the model is fit once
381
+ across every test-set solute together. To get a comparable per-solute rmse (needed to sit
382
+ "Scaled to Test Set" next to "Scaled to Solute" and "Extrapolated from delta22" in the same bar
383
+ chart), this applies that one pooled fit's coefficients to each solute's own sites and scores
384
+ them individually, reusing apply_bootstrap_params_to_full_dataset's prediction machinery by
385
+ reshaping the fitted statsmodels Params Series into that function's expected wide
386
+ "<term>_coeff" row format."""
387
+ remapped = FORMULA_REMAP.get(formula, formula)
388
+ fits = all_solute_fits[nucleus]
389
+ match = fits[(fits["formula"] == remapped) & (fits["solvent"] == solvent)]
390
+ if match.empty:
391
+ raise KeyError(f"no per_solvent_fits row for formula={remapped!r} solvent={solvent!r}")
392
+ params = match.iloc[0]["params"]
393
+ # bare term names, matching data_df's own raw column names -- apply_bootstrap_params_to_full_
394
+ # dataset's merge(..., suffixes=("_coeff", "_data")) is what appends "_coeff"/"_data" (only
395
+ # triggered by the name COLLIDING with data_df's column of the same name); pre-suffixing here
396
+ # would just create an unmatched "<term>_coeff" column no data_df column collides with.
397
+ wide = {"Intercept": float(params.get("Intercept", 0.0)), "solvent": solvent,
398
+ "formula": remapped, "seed": 0}
399
+ for term in params.index:
400
+ if term != "Intercept":
401
+ wide[term] = float(params[term])
402
+ params_df = pd.DataFrame([wide])
403
+ # Restrict to this one solvent before the merge: apply_bootstrap_params_to_full_dataset's outer
404
+ # join zero-fills a missing coefficient instead of raising, so an unfiltered call would
405
+ # silently score every OTHER solvent's rows against this solvent's plane as if the missing
406
+ # coefficients were all zero.
407
+ solvent_only = query_df_nn[query_df_nn["solvent"] == solvent]
408
+ preds = apply_bootstrap_params_to_full_dataset(solvent_only, params_df, nucleus=nucleus)
409
+ return compute_solute_rmses(preds)
410
+
411
+
412
+ def fitting_rmse_comparison_table(query_df_nn, per_solute_fits_result, per_solvent_fits_result,
413
+ bootstrap_rmses, nucleus, solvent, formula):
414
+ """Assembles SI Figure S15's "Fitting RMSE Comparisons" table: one row per test-set solute,
415
+ columns "Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22" (ppm RMSE), all
416
+ at one nucleus/solvent/formula. bootstrap_rmses is compute_solute_rmses' output for `nucleus`
417
+ (e.g. Figure 5D's bootstrap_rmses_h), averaged here over its bootstrap seeds."""
418
+ remapped = FORMULA_REMAP.get(formula, formula)
419
+
420
+ solute_fit = per_solute_fits_result[nucleus]
421
+ scaled_to_solute = (solute_fit[(solute_fit["formula"] == remapped) & (solute_fit["solvent"] == solvent)]
422
+ .set_index("solute")["rmse"])
423
+
424
+ scaled_to_test_set = (scaled_to_test_set_per_solute_rmse(query_df_nn, per_solvent_fits_result, nucleus, solvent, formula)
425
+ .query("solvent == @solvent and formula == @remapped")
426
+ .set_index("solute")["Bootstrap_RMSE"])
427
+
428
+ boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus) & (bootstrap_rmses["solvent"] == solvent)
429
+ & (bootstrap_rmses["formula"] == remapped)]
430
+ extrapolated = boot.groupby("solute")["Bootstrap_RMSE"].mean()
431
+
432
+ table = pd.DataFrame({"Scaled to Solute": scaled_to_solute, "Scaled to Test Set": scaled_to_test_set,
433
+ "Extrapolated from delta22": extrapolated})
434
+ return table.dropna(how="all")
435
+
436
+
437
+ def distribution_shift_by_solvent_table(per_solute_fits_result, per_solvent_fits_result,
438
+ bootstrap_rmses, nucleus, formula, solvents=None):
439
+ """SI Figure S15's per-solvent "distribution shift is minor" cross-check -- the solvent-averaged
440
+ companion to fitting_rmse_comparison_table (which is per-solute for a single solvent). For one
441
+ nucleus and formula, the mean test-set RMSE under the three ways of choosing the composite-model
442
+ coefficients, one row per solvent: "Extrapolated from delta22" (delta-22's bootstrap
443
+ coefficients applied cold), "Scaled to Test Set" (one fit pooled across the whole test set), and
444
+ "Scaled to Solute" (a separate fit per solute). Where the three sit close, re-optimizing the
445
+ coefficients barely helps, so they transfer and the residual error is physics; the one solvent
446
+ where "Extrapolated" rises well above the refits is TIP4P (water), dominated by the peptide's
447
+ conformer-population sensitivity, which is why S15 reports solvent-averaged performance.
448
+
449
+ bootstrap_rmses is compute_solute_rmses' output for `nucleus` (e.g. the si_figure_s15 notebook's
450
+ bootstrap_rmses["H"]); per_solute_fits_result / per_solvent_fits_result are the per_solute_fits /
451
+ per_solvent_fits dicts keyed by nucleus. Averages over bootstrap seeds and over each solvent's
452
+ solutes."""
453
+ remapped = FORMULA_REMAP.get(formula, formula)
454
+ boot = bootstrap_rmses[(bootstrap_rmses["nucleus"] == nucleus)
455
+ & (bootstrap_rmses["formula"] == remapped)]
456
+ test_set = per_solvent_fits_result[nucleus]
457
+ test_set = test_set[test_set["formula"] == remapped]
458
+ solute = per_solute_fits_result[nucleus]
459
+ solute = solute[solute["formula"] == remapped]
460
+ if solvents is None:
461
+ solvents = sorted(boot["solvent"].unique())
462
+ rows = []
463
+ for solvent in solvents:
464
+ rows.append({
465
+ "solvent": solvent,
466
+ "Extrapolated from delta22": boot[boot["solvent"] == solvent]["Bootstrap_RMSE"].mean(),
467
+ "Scaled to Test Set": test_set[test_set["solvent"] == solvent]["rmse"].mean(),
468
+ "Scaled to Solute": solute[solute["solvent"] == solvent]["rmse"].mean(),
469
+ })
470
+ return pd.DataFrame(rows).set_index("solvent")
471
+
472
+
473
+ # ---------------------------------------------------------------------------------------------
474
+ # SI Figure S15's "Feature Space Coverage by Solvent" and "Residuals for Delta22 Fitting
475
+ # Coefficients" panels: do the test set and delta-22 cover the same region of feature space (so
476
+ # the test set's higher error can't be blamed on extrapolating outside what delta-22's fitting
477
+ # coefficients ever saw)? Both panels combine the test set (this module's query_df_nn) with
478
+ # delta-22's own per-site data at the same two features (delta22.py's query_df_nn, passed in by
479
+ # the caller so this module doesn't import delta22.py directly).
480
+
481
+ # Feature Space Coverage only compares the 4 solvents both datasets have explicit-solvent (OpenMM)
482
+ # coverage for -- delta-22 has Desmond+OpenMM MD for all 12 solvents, but the natural-products test
483
+ # set (sigma-fresh-derived) only has 4.
484
+ FEATURE_SPACE_SOLVENTS = ("chloroform", "benzene", "methanol", "TIP4P")
485
+
486
+
487
+ def _feature_space_config(nucleus):
488
+ if nucleus == "H":
489
+ return {"x_feature": "stationary_plus_qcd", "y_feature": "openMM"}
490
+ if nucleus == "C":
491
+ return {"x_feature": "stationary_plus_op_vib", "y_feature": "openMM"}
492
+ raise ValueError(f"unsupported nucleus {nucleus!r}, expected 'H' or 'C'")
493
+
494
+
495
+ def _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, cols):
496
+ """The shared setup both panels below need: the test set's own rows (excludes any row
497
+ literally named "delta22", a defensive filter) tagged "Test Set", concatenated with
498
+ delta22_query_df_nn's rows (already delta-22-only) tagged "Delta22"."""
499
+ test = query_df_nn[query_df_nn["nucleus"] == nucleus].copy()
500
+ test = test[~test["solute"].astype(str).str.strip().str.lower().eq("delta22")]
501
+ test["dataset"] = "Test Set"
502
+ d22 = delta22_query_df_nn[delta22_query_df_nn["nucleus"] == nucleus].copy()
503
+ d22["dataset"] = "Delta22"
504
+ return pd.concat([test[cols + ["dataset"]], d22[cols + ["dataset"]]], ignore_index=True)
505
+
506
+
507
+ def feature_space_coverage_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
508
+ """SI Figure S15's "Feature Space Coverage by Solvent": test-set and delta-22 per-site feature
509
+ values, mean-centered globally -- one scalar mean per feature, pooled over every solvent and
510
+ both datasets (the notebook's separate fitting cell instead centers per solvent). Returns a
511
+ tidy DataFrame: solvent, dataset ("Test Set"/"Delta22"), x (centered), y (centered)."""
512
+ cfg = _feature_space_config(nucleus)
513
+ x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
514
+ combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus, ["solvent", x_feature, y_feature])
515
+ combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature])
516
+ combined["x"] = combined[x_feature] - combined[x_feature].mean()
517
+ combined["y"] = combined[y_feature] - combined[y_feature].mean()
518
+ return combined[["solvent", "dataset", "x", "y"]].reset_index(drop=True)
519
+
520
+
521
+ def delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus, solvents=FEATURE_SPACE_SOLVENTS):
522
+ """SI Figure S15's "Residuals for Delta22 Fitting Coefficients": fits a 2-feature OLS plane
523
+ (experimental ~ x_feature + y_feature, the same two features feature_space_coverage_table
524
+ plots) to delta-22's data alone, separately per solvent, then applies that one plane to both
525
+ delta-22 and the test set, returning residual = experimental - predicted. For a linear model,
526
+ minimizing RMSE directly (Nelder-Mead) is the same convex problem as ordinary least squares, so
527
+ np.linalg.lstsq gives the identical plane. Returns a tidy DataFrame: solvent, dataset,
528
+ experimental, residual."""
529
+ cfg = _feature_space_config(nucleus)
530
+ x_feature, y_feature = cfg["x_feature"], cfg["y_feature"]
531
+ combined = _test_set_and_delta22(query_df_nn, delta22_query_df_nn, nucleus,
532
+ ["solvent", x_feature, y_feature, "experimental"])
533
+ combined = combined[combined["solvent"].isin(solvents)].dropna(subset=[x_feature, y_feature, "experimental"])
534
+
535
+ rows = []
536
+ for solvent in solvents:
537
+ sdf = combined[combined["solvent"] == solvent]
538
+ d22_sdf = sdf[sdf["dataset"] == "Delta22"]
539
+ if len(d22_sdf) < 3:
540
+ continue
541
+ design = np.column_stack([np.ones(len(d22_sdf)), d22_sdf[x_feature], d22_sdf[y_feature]])
542
+ c0, c1, c2 = np.linalg.lstsq(design, d22_sdf["experimental"].to_numpy(), rcond=None)[0]
543
+ predicted = c0 + c1 * sdf[x_feature] + c2 * sdf[y_feature]
544
+ out = sdf[["solvent", "dataset", "experimental"]].copy()
545
+ out["residual"] = sdf["experimental"] - predicted
546
+ rows.append(out)
547
+ if not rows:
548
+ return pd.DataFrame(columns=["solvent", "dataset", "experimental", "residual"])
549
+ return pd.concat(rows, ignore_index=True).reset_index(drop=True)
analysis/code/applications_plots.py ADDED
@@ -0,0 +1,752 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plotting for the natural-products figures (main-text Figures 5C/5D, SI Figure S15).
2
+
3
+ Holds every plotting engine for the applications (natural-products) notebooks: the delta-22-vs-test-set
4
+ benefit bar chart (Figure 5C), the shared per-solute box-plot engine (Figure 5D and SI Figure S15's
5
+ bootstrap-RMSE panels), and SI Figure S15's fitting-RMSE comparison bars, feature-space/residual scatter
6
+ grids, and distribution-shift bars. Engines are carried over from the source notebooks with notebook
7
+ globals turned into explicit arguments. The numbers come from applications.py; this module is kept
8
+ separate so applications.py stays plotting-free.
9
+ """
10
+ import os
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import seaborn as sns
15
+ import matplotlib.pyplot as plt
16
+
17
+
18
+ def plot_nps_on_boxplot_delta22_simplified(
19
+ regression_ss_df,
20
+ nps_rmse_df,
21
+ best_possible_df, # expects a 'rmse' column
22
+ nucleus,
23
+ formulas,
24
+ colors,
25
+ site_counts,
26
+ solvents_filter=None,
27
+ box_width=0.20,
28
+ box_gap=0.05,
29
+ formula_gap=0.15,
30
+ figsize=(14, 8),
31
+ formula_remap=None,
32
+ solute_remap=None,
33
+ solute_order=None,
34
+ title=None,
35
+ save_path=None,
36
+ solute_color_remap=None,
37
+ max_bar_height=0.05,
38
+
39
+ # --- SELF-SCALING BASELINE CONTROLS ---
40
+ show_baseline=True,
41
+ baseline_annotation_text='Self-scaling baseline (dotted)',
42
+ baseline_annotation_x=0.985,
43
+ baseline_annotation_y=0.93,
44
+ baseline_annotation_fontsize=9,
45
+
46
+ # --- FULL FIT LINE CONTROLS ---
47
+ show_full_fit_line=False,
48
+ all_solute_fitting_results=None, # {nucleus: per-solvent fit df}; required when show_full_fit_line
49
+ full_fit_line_label='Full Fit RMSE',
50
+ full_fit_line_label_x=0.99,
51
+
52
+ # --- INSET AXIS CONTROLS ---
53
+ site_count_axis_mode='attached',
54
+ site_count_inset_axis_offset=0.0,
55
+ site_count_inset_area_frac=0.1,
56
+ site_count_inset_axis_label_pad=0.04,
57
+ site_count_alpha=0.25,
58
+ site_count_label='Site count',
59
+ site_count_preserve_visual_height=True,
60
+
61
+ # ---- font sizes ----
62
+ xtick_fontsize=10,
63
+ delta22_fontsize=10,
64
+ ylabel_fontsize=13,
65
+ title_fontsize=16,
66
+
67
+ # ---- SAVE SVG ----
68
+ save_svg_path=None,
69
+ default_svg_name="Figure_5C_extras.svg",
70
+ svg_transparent=True,
71
+ ):
72
+ """
73
+ Simplified box and whisker plot:
74
+ - Plots all solutes separately (no grouping)
75
+ - Averages RMSE across solvents for each bootstrap seed
76
+ - Shows "best possible" RMSE as dashed baseline for each solute
77
+
78
+ Parameters:
79
+ - regression_ss_df: Delta-22 regression samples (Bootstrap_RMSE)
80
+ - nps_rmse_df: Should have columns: solvent, nucleus, formula, seed, solute, Bootstrap_RMSE
81
+ - best_possible_df: DataFrame with best possible RMSEs per solute/solvent/formula
82
+ Should have columns: formula, solvent, solute, rmse, params
83
+ - nucleus: Which nucleus to filter for
84
+ - formulas: List of formulas to compare
85
+ - colors: Colors for each formula
86
+ - site_counts: DataFrame indexed by solute with nucleus count columns (e.g., 'H', 'C')
87
+ - solvents_filter: Optional list of solvents to include in averaging
88
+ - show_baseline: Whether to show the 'best possible df' baseline
89
+ - show_full_fit_line: Whether to draw a line representing the full-dataset fit RMSE across all solutes
90
+ - full_fit_line_label: Label for the full fit line
91
+ - full_fit_line_label_x: X-coordinate for the full fit line label
92
+ - box_width: Width of each box
93
+ - box_gap: Gap between boxes
94
+ - formula_gap: Gap between formulas
95
+ - figsize: Figure size
96
+ - formula_remap: Optional dict to remap formula names
97
+ - max_bar_height: Maximum visual height target for site-count bars relative to RMSE axis
98
+ - site_count_axis_mode: One of {'attached', 'inset', 'off'}
99
+ - site_count_inset_axis_offset: Horizontal inset-axis adjustment in axes coordinates (clipped to stay inside in inset mode)
100
+ - site_count_inset_area_frac: Fraction of total figure area to allocate to inset axis (inset mode only)
101
+ - site_count_alpha: Alpha for site-count bars
102
+ - site_count_label: Label for site-count axis
103
+ - site_count_preserve_visual_height: Keep the old visual bar footprint when using a right axis
104
+ """
105
+ if formula_remap is None:
106
+ formula_remap = {}
107
+
108
+ if solute_remap is None:
109
+ solute_remap = {}
110
+
111
+ if solute_color_remap is None:
112
+ solute_color_remap = {}
113
+
114
+ valid_axis_modes = {'attached', 'inset', 'off'}
115
+ if site_count_axis_mode not in valid_axis_modes:
116
+ raise ValueError(f"site_count_axis_mode must be one of {sorted(valid_axis_modes)}")
117
+
118
+ if site_counts is not None and not isinstance(site_counts, pd.DataFrame):
119
+ raise TypeError("site_counts must be a pandas DataFrame or None")
120
+
121
+ if site_count_inset_area_frac < 0 or site_count_inset_area_frac > 1:
122
+ raise ValueError("site_count_inset_area_frac must be between 0 and 1")
123
+
124
+ def get_rounded_count_ticks(max_count, target_ticks=7):
125
+ if max_count <= 0:
126
+ return np.array([0.0, 1.0]), 1.0
127
+
128
+ rough_step = max_count / max(target_ticks - 1, 1)
129
+ magnitude = 10 ** np.floor(np.log10(rough_step)) if rough_step > 0 else 1.0
130
+ normalized = rough_step / magnitude
131
+ if normalized <= 1:
132
+ nice_base = 1
133
+ elif normalized <= 2:
134
+ nice_base = 2
135
+ elif normalized <= 5:
136
+ nice_base = 5
137
+ else:
138
+ nice_base = 10
139
+
140
+ step = nice_base * magnitude
141
+ rounded_top = np.ceil(max_count / step) * step
142
+ ticks = np.arange(0, rounded_top + 0.5 * step, step)
143
+ return ticks, rounded_top
144
+
145
+ n_formulas = len(formulas)
146
+
147
+ if solvents_filter is not None:
148
+ regression_ss_df = regression_ss_df[regression_ss_df['solvent'].isin(solvents_filter)].copy()
149
+ nps_rmse_df = nps_rmse_df[nps_rmse_df['solvent'].isin(solvents_filter)].copy()
150
+ best_possible_df = best_possible_df[best_possible_df['solvent'].isin(solvents_filter)].copy()
151
+
152
+ if regression_ss_df.empty:
153
+ raise ValueError(f"No regression data found for solvents: {solvents_filter}")
154
+ if nps_rmse_df.empty:
155
+ raise ValueError(f"No NPS RMSE data found for solvents: {solvents_filter}")
156
+
157
+ def get_delta22_averaged_rmse(regression_df, formula):
158
+ df = regression_df[regression_df['formula'] == formula]
159
+ return df.groupby('seed')['Bootstrap_RMSE'].mean().values
160
+
161
+ def get_solute_bootstrap_averaged(nps_df, formula, nucleus):
162
+ np_formula = formula_remap.get(formula, formula)
163
+ df = nps_df[
164
+ (nps_df['formula'] == np_formula) &
165
+ (nps_df['nucleus'] == nucleus)
166
+ ]
167
+ if df.empty:
168
+ return {}
169
+
170
+ # average over solvent per solute/seed, then collect each solute's per-seed array
171
+ averaged_df = df.groupby(['solute', 'seed'])['Bootstrap_RMSE'].mean().reset_index()
172
+ solute_dict = {}
173
+ for solute in averaged_df['solute'].unique():
174
+ arr = averaged_df[averaged_df['solute'] == solute]['Bootstrap_RMSE'].values
175
+ if arr.size > 0:
176
+ solute_dict[solute] = arr
177
+ return solute_dict
178
+
179
+ def get_best_possible_averaged(best_df, solute, formula):
180
+ np_formula = formula_remap.get(formula, formula)
181
+ df = best_df[
182
+ (best_df['formula'] == np_formula) &
183
+ (best_df['solute'] == solute)
184
+ ]
185
+
186
+ if df.empty:
187
+ # fall back to the raw formula name in case best_possible_df wasn't remapped
188
+ df = best_df[
189
+ (best_df['formula'] == formula) &
190
+ (best_df['solute'] == solute)
191
+ ]
192
+
193
+ if df.empty:
194
+ return None
195
+ return df['rmse'].mean()
196
+
197
+ all_solutes = set()
198
+ for formula in formulas:
199
+ np_formula = formula_remap.get(formula, formula)
200
+ df_filtered = nps_rmse_df[
201
+ (nps_rmse_df['formula'] == np_formula) &
202
+ (nps_rmse_df['nucleus'] == nucleus)
203
+ ]
204
+ all_solutes.update(df_filtered['solute'].unique())
205
+ all_solutes = sorted(list(all_solutes))
206
+ if solute_order is not None: # explicit ordering (e.g. main-text Figure 5D)
207
+ ordered = [s for s in solute_order if s in all_solutes]
208
+ all_solutes = ordered + [s for s in all_solutes if s not in ordered]
209
+
210
+ delta22_by_formula = [get_delta22_averaged_rmse(regression_ss_df, f) for f in formulas]
211
+
212
+ # each formula block is 1 delta-22 box plus one box per solute
213
+ total_boxes_per_formula = 1 + len(all_solutes)
214
+
215
+ all_data = []
216
+ all_positions = []
217
+ all_colors = []
218
+ all_alphas = []
219
+ tick_positions = []
220
+ tick_labels = []
221
+ baseline_segments = [] # Store (x_start, x_end, y_value, solute_label) for baseline segments
222
+
223
+ count_bar_positions = []
224
+ count_bar_values = []
225
+ delta22_count_by_nucleus = {'H': 43.0, 'C': 50.0}
226
+
227
+ current_position = 0.0
228
+
229
+ for j, formula in enumerate(formulas):
230
+
231
+ # 1) Delta-22 box (solvent-averaged)
232
+ box_pos = current_position + box_width / 2
233
+ all_data.append(delta22_by_formula[j])
234
+ all_positions.append(box_pos)
235
+ all_colors.append(colors[j])
236
+ all_alphas.append(1.0)
237
+ tick_positions.append(box_pos)
238
+ tick_labels.append('delta-22')
239
+
240
+ delta22_count = delta22_count_by_nucleus.get(nucleus)
241
+ if delta22_count is not None:
242
+ count_bar_positions.append(box_pos)
243
+ count_bar_values.append(float(delta22_count))
244
+
245
+ current_position += box_width + box_gap
246
+
247
+ # 2) Individual solute boxes (solvent-averaged, lighter alpha)
248
+ solute_data = get_solute_bootstrap_averaged(nps_rmse_df, formula, nucleus)
249
+
250
+ count_col = None
251
+ if site_counts is not None:
252
+ if nucleus in site_counts.columns:
253
+ count_col = nucleus
254
+ elif 'H' in site_counts.columns:
255
+ count_col = 'H'
256
+
257
+ for solute in all_solutes:
258
+ box_pos = current_position + box_width / 2
259
+ if solute in solute_data and len(solute_data[solute]) > 0:
260
+ all_data.append(solute_data[solute])
261
+ all_positions.append(box_pos)
262
+ all_colors.append(solute_color_remap.get(solute, colors[j]))
263
+ all_alphas.append(0.5)
264
+ tick_positions.append(box_pos)
265
+ tick_labels.append(solute_remap.get(solute, solute))
266
+
267
+ if (
268
+ count_col is not None
269
+ and solute in site_counts.index
270
+ and pd.notna(site_counts.loc[solute, count_col])
271
+ ):
272
+ count_bar_positions.append(box_pos)
273
+ count_bar_values.append(float(site_counts.loc[solute, count_col]))
274
+ else:
275
+ count_bar_positions.append(box_pos)
276
+ count_bar_values.append(0.0)
277
+
278
+ # best_possible_df has no nucleus column, so this doesn't filter by nucleus
279
+ if show_baseline:
280
+ best_rmse = get_best_possible_averaged(best_possible_df, solute, formula)
281
+ if best_rmse is not None:
282
+ # baseline segment spans the box's full width including its gap
283
+ x_start = current_position - box_gap / 2
284
+ x_end = current_position + box_width + box_gap / 2
285
+ baseline_segments.append((x_start, x_end, best_rmse, solute))
286
+
287
+ current_position += box_width + box_gap
288
+
289
+ # Add gap after formula (except last)
290
+ if j < n_formulas - 1:
291
+ current_position += formula_gap
292
+
293
+ plot_figsize = figsize
294
+ fig, ax = plt.subplots(figsize=plot_figsize)
295
+
296
+ # skip boxes with no data
297
+ valid_indices = [i for i, data in enumerate(all_data) if len(data) > 0]
298
+ valid_data = [all_data[i] for i in valid_indices]
299
+ valid_positions = [all_positions[i] for i in valid_indices]
300
+ valid_colors = [all_colors[i] for i in valid_indices]
301
+ valid_alphas = [all_alphas[i] for i in valid_indices]
302
+
303
+ if valid_data:
304
+ box = ax.boxplot(
305
+ valid_data,
306
+ positions=valid_positions,
307
+ widths=box_width,
308
+ showfliers=False,
309
+ patch_artist=True,
310
+ whiskerprops=dict(color='#404040'),
311
+ capprops=dict(color='#404040')
312
+ )
313
+
314
+ for patch, color, alpha in zip(box['boxes'], valid_colors, valid_alphas):
315
+ patch.set_facecolor(color)
316
+ patch.set_alpha(alpha)
317
+ for median in box['medians']:
318
+ median.set_color('#404040')
319
+
320
+ if show_baseline:
321
+ for i, (x_start, x_end, y_value, _) in enumerate(baseline_segments):
322
+ ax.plot([x_start, x_end], [y_value, y_value],
323
+ color='gray', linestyle='--', linewidth=1.5, alpha=0.7)
324
+
325
+ # connect to the previous segment unless this is the first solute of a formula block
326
+ # (connects across formula gaps too, not just within one)
327
+ if len(formulas) > 0 and len(baseline_segments) > 0 and i % (len(baseline_segments) / len(formulas)) != 0:
328
+ _, prev_x_end, prev_y, _ = baseline_segments[i - 1]
329
+ ax.plot([prev_x_end, x_start], [prev_y, y_value],
330
+ color='gray', linestyle='--', linewidth=1.5, alpha=0.7)
331
+
332
+ if show_baseline and baseline_segments and baseline_annotation_text:
333
+ ax.text(
334
+ baseline_annotation_x,
335
+ baseline_annotation_y,
336
+ baseline_annotation_text,
337
+ transform=ax.transAxes,
338
+ ha='right',
339
+ va='top',
340
+ fontsize=baseline_annotation_fontsize,
341
+ color='gray',
342
+ style='italic',
343
+ alpha=0.85,
344
+ )
345
+
346
+ # the full-fit-line RMSE uses the first formula's delta-22 fit as its reference
347
+ if show_full_fit_line:
348
+ formula_remapped = formula_remap.get(formulas[0], formulas[0])
349
+ query = f'formula == "{formula_remapped}"' + (f' and solvent in {solvents_filter}' if solvents_filter is not None else '')
350
+ full_fit_rmse = all_solute_fitting_results[nucleus].query(query)['rmse'].mean()
351
+ ax.axhline(y=full_fit_rmse, color='red', linestyle='-.', linewidth=1.5, alpha=0.7)
352
+ ax.text(full_fit_line_label_x,
353
+ full_fit_rmse, f'{full_fit_line_label}: {full_fit_rmse:.3f} ppm',
354
+ transform=ax.get_yaxis_transform(),
355
+ ha='right', va='bottom', fontsize=9, color='red', style='italic', alpha=0.7)
356
+
357
+ ax.set_xticks(tick_positions)
358
+ ax.set_xticklabels(tick_labels, rotation=75, fontsize=xtick_fontsize, ha='center')
359
+ for i, label_obj in enumerate(ax.get_xticklabels()):
360
+ if tick_labels[i] == 'delta-22':
361
+ label_obj.set_weight('bold')
362
+ label_obj.set_fontsize(delta22_fontsize)
363
+ label_obj.set_style('italic')
364
+
365
+ # vertical separators between formula blocks
366
+ for j in range(1, n_formulas):
367
+ x_pos = j * (total_boxes_per_formula * box_width + formula_gap) - formula_gap / 2
368
+ ax.axvline(x=x_pos, color='gray', linestyle='--', linewidth=1, alpha=0.5)
369
+
370
+ if solvents_filter is not None:
371
+ solvents_str = ', '.join(solvents_filter)
372
+ ylabel = f"1H RMSE (ppm)\n(Averaged across: {solvents_str})"
373
+ else:
374
+ ylabel = "1H RMSE (ppm)"
375
+ ax.set_ylabel(ylabel, fontsize=ylabel_fontsize, fontweight='bold')
376
+ ax.set_ylim(bottom=0)
377
+
378
+ # caller may override via title=; the default text reflects the solvent filter
379
+ if title is None:
380
+ if solvents_filter is not None:
381
+ title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus; {solvents_filter})"
382
+ else:
383
+ title = f"Delta22 vs Individual Solute Bootstrap Sample RMSEs ({nucleus} nucleus, solvent-averaged)"
384
+ ax.set_title(title, fontsize=title_fontsize, fontweight='bold')
385
+
386
+ ax.set_xlim(-0.2, current_position + 0.2)
387
+
388
+ # site-count bars use a dedicated right (twin) axis
389
+ added_site_count_bars = False
390
+ site_count_ax = None
391
+ max_count = max(count_bar_values) if count_bar_values else 0.0
392
+ if site_count_axis_mode != 'off' and max_bar_height > 0 and len(count_bar_positions) > 0 and max_count > 0:
393
+ site_count_ax = ax.twinx()
394
+
395
+ tick_counts, rounded_count_top = get_rounded_count_ticks(max_count)
396
+ if site_count_preserve_visual_height:
397
+ rmse_top = ax.get_ylim()[1]
398
+ rmse_top = max(rmse_top, 1e-6)
399
+ bar_target = max(max_bar_height, 1e-6)
400
+ # Keep bars in the lower band while right-axis tick labels remain true counts.
401
+ axis_top = max(rounded_count_top * (rmse_top / bar_target), rounded_count_top * 1.05)
402
+ else:
403
+ axis_top = max(rounded_count_top, 1.0)
404
+
405
+ site_count_ax.bar(
406
+ count_bar_positions,
407
+ count_bar_values,
408
+ width=box_width + box_gap,
409
+ bottom=0,
410
+ color='#404040',
411
+ alpha=site_count_alpha,
412
+ edgecolor='none',
413
+ zorder=0,
414
+ )
415
+
416
+ site_count_ax.set_ylim(0, axis_top)
417
+
418
+ if site_count_axis_mode == 'inset':
419
+ # Hide inset twin-axis renderer and draw a short attached axis manually.
420
+ for spine_name in ('left', 'right', 'top', 'bottom'):
421
+ site_count_ax.spines[spine_name].set_visible(False)
422
+ site_count_ax.patch.set_visible(False)
423
+ site_count_ax.yaxis.set_visible(False)
424
+
425
+ inset_axis_top_frac = min(rounded_count_top / axis_top, 1.0)
426
+ # clip to keep the inset axis inside the plotting area near the right edge
427
+ inset_x = float(np.clip(1.0 + site_count_inset_axis_offset, 0.0, 1.0))
428
+
429
+ ax.plot(
430
+ [inset_x, inset_x],
431
+ [0.0, inset_axis_top_frac],
432
+ transform=ax.transAxes,
433
+ color='#404040',
434
+ linewidth=1.0,
435
+ clip_on=False,
436
+ zorder=3,
437
+ )
438
+
439
+ # Manual ticks and tick labels, confined to the short segment.
440
+ tick_len = 0.008
441
+ tick_label_pad = 0.012
442
+ for t in tick_counts:
443
+ if t == 0:
444
+ continue
445
+ y_frac = t / axis_top
446
+ if y_frac <= inset_axis_top_frac + 1e-9:
447
+ ax.plot(
448
+ [inset_x, inset_x + tick_len],
449
+ [y_frac, y_frac],
450
+ transform=ax.transAxes,
451
+ color='#404040',
452
+ linewidth=0.9,
453
+ clip_on=False,
454
+ zorder=3,
455
+ )
456
+ ax.text(
457
+ inset_x + tick_len + tick_label_pad,
458
+ y_frac,
459
+ f"{int(t)}",
460
+ transform=ax.transAxes,
461
+ va='center',
462
+ ha='left',
463
+ fontsize=10,
464
+ color='#404040',
465
+ clip_on=False,
466
+ )
467
+
468
+ label_x = inset_x + site_count_inset_axis_label_pad
469
+ ax.text(
470
+ label_x,
471
+ inset_axis_top_frac / 2,
472
+ site_count_label,
473
+ transform=ax.transAxes,
474
+ rotation=90,
475
+ va='center',
476
+ ha='left',
477
+ fontsize=11,
478
+ fontweight='bold',
479
+ color='#404040',
480
+ clip_on=False,
481
+ )
482
+ else:
483
+ site_count_ax.set_yticks(tick_counts)
484
+ site_count_ax.set_yticklabels([f"{int(t)}" for t in tick_counts])
485
+ site_count_ax.set_ylabel(site_count_label, fontsize=13, fontweight='bold', color='#404040')
486
+ site_count_ax.tick_params(axis='y', labelcolor='#404040')
487
+ site_count_ax.spines['right'].set_color('#404040')
488
+
489
+ site_count_ax.grid(False)
490
+
491
+ # Keep RMSE artists visually above site-count bars.
492
+ ax.set_zorder(2)
493
+ ax.patch.set_alpha(0)
494
+ site_count_ax.set_zorder(1)
495
+ added_site_count_bars = True
496
+
497
+
498
+ if site_count_axis_mode == 'inset' and added_site_count_bars:
499
+ ax.set_xlim(left=-box_width/2-box_gap/2, right=4.55)
500
+ plt.tight_layout()
501
+ # PNG is the release format; SVG is also supported for standalone vector export
502
+ if save_path is not None:
503
+ outdir = os.path.dirname(save_path)
504
+ if outdir:
505
+ os.makedirs(outdir, exist_ok=True)
506
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
507
+
508
+ if save_svg_path is not None:
509
+ # If user passed a directory, save into it with default name
510
+ if os.path.isdir(save_svg_path) or save_svg_path.endswith(os.sep):
511
+ save_svg_path = os.path.join(save_svg_path, default_svg_name)
512
+
513
+ if not save_svg_path.lower().endswith(".svg"):
514
+ save_svg_path = save_svg_path + ".svg"
515
+
516
+ outdir = os.path.dirname(save_svg_path)
517
+ if outdir:
518
+ os.makedirs(outdir, exist_ok=True)
519
+
520
+ fig.savefig(
521
+ save_svg_path,
522
+ format="svg",
523
+ bbox_inches="tight",
524
+ pad_inches=0.02,
525
+ transparent=svg_transparent,
526
+ )
527
+ print(f"Saved SVG to: {os.path.abspath(save_svg_path)}")
528
+
529
+ plt.show()
530
+
531
+
532
+ def plot_nps_benefit_barplot(regression_ss_df, nps_rmse_df, nucleus, solvents, np_solute_groups,
533
+ formulas, labels, colors, formula_remap=None, figsize=(6, 5),
534
+ box_width=0.18, formula_gap=0.04, solvent_gap=0.35, spacer_width=0.0,
535
+ y_min=0.0, y_max=None, save_path=None):
536
+ """Grouped bar chart of mean bootstrap RMSE per (solvent, dataset, formula), with 2.5-97.5
537
+ percentile bootstrap error bars. Delta-22 is drawn first within each solvent, then the
538
+ NP/complex bin. Used by main-text Figure 5C (implicit vs explicit correction, delta-22 vs the
539
+ test set)."""
540
+ sns.set_theme(style="ticks", context="paper")
541
+ formula_remap = formula_remap or {}
542
+ n_formulas = len(formulas)
543
+ label_remap = {labels[0]: "Implicit", labels[1]: "Explicit"}
544
+ label_to_color = {label_remap.get(labels[i], labels[i]): colors[i] for i in range(len(labels))}
545
+ flat_solvents = [s for g in solvents for s in g] if isinstance(solvents[0], list) else list(solvents)
546
+ solvent_groups_iter = solvents if isinstance(solvents[0], list) else [solvents]
547
+
548
+ def d22_by_solvent(formula):
549
+ df = regression_ss_df[regression_ss_df["formula"] == formula]
550
+ return {s: df[df["solvent"] == s]["Bootstrap_RMSE"].dropna().values for s in flat_solvents}
551
+
552
+ def np_grouped(formula, solvent):
553
+ df = nps_rmse_df[(nps_rmse_df["formula"] == formula) & (nps_rmse_df["solvent"] == solvent)
554
+ & (nps_rmse_df["nucleus"] == nucleus)]
555
+ if df.empty:
556
+ return {}
557
+ out = {}
558
+ for g in np_solute_groups.keys():
559
+ arr = df[df["solute_group"] == g]["Bootstrap_RMSE"].dropna().values
560
+ if arr.size:
561
+ out[g] = arr
562
+ return out
563
+
564
+ def summ(vals):
565
+ vals = np.asarray(vals, float); vals = vals[~np.isnan(vals)]
566
+ if vals.size == 0:
567
+ return None
568
+ m = np.mean(vals); lo, hi = np.percentile(vals, [2.5, 97.5])
569
+ return m, m - lo, hi - m
570
+
571
+ d22 = [d22_by_solvent(f) for f in formulas]
572
+ fig, ax = plt.subplots(figsize=figsize)
573
+ category_gap = 0.55
574
+ x = 0.0
575
+ ticks, ticklabels, solvent_centers, seen = [], [], {}, {}
576
+
577
+ def draw_pair(center, getter):
578
+ drew = False
579
+ for j, formula in enumerate(formulas):
580
+ stats = getter(j, formula)
581
+ if stats is None:
582
+ continue
583
+ mean, elo, ehi = stats
584
+ disp = label_remap.get(labels[j], labels[j])
585
+ offset = (j - (n_formulas - 1) / 2) * (box_width + formula_gap)
586
+ bar = ax.bar(center + offset, mean, width=box_width,
587
+ yerr=np.array([[elo], [ehi]]), capsize=3, color=label_to_color[disp],
588
+ edgecolor="black", linewidth=0.9, zorder=3,
589
+ label=disp if disp not in seen else None)
590
+ seen.setdefault(disp, bar)
591
+ drew = True
592
+ return drew
593
+
594
+ for solvent_group in solvent_groups_iter:
595
+ for solvent in solvent_group:
596
+ centers = [x]
597
+ draw_pair(x, lambda j, f: summ(d22[j].get(solvent, np.array([]))))
598
+ ticks.append(x); ticklabels.append("delta-22"); x += category_gap
599
+ for bin_label in np_solute_groups.keys():
600
+ if draw_pair(x, lambda j, f, s=solvent, b=bin_label:
601
+ summ(np_grouped(formula_remap.get(f, f), s).get(b, [])) if
602
+ np_grouped(formula_remap.get(f, f), s).get(b) is not None else None):
603
+ centers.append(x); ticks.append(x)
604
+ ticklabels.append("complex" if bin_label == "Test Set" else bin_label)
605
+ x += category_gap
606
+ solvent_centers[solvent] = np.mean(centers)
607
+ x += solvent_gap
608
+ x += spacer_width
609
+
610
+ ax.set_xticks(ticks)
611
+ ax.set_xticklabels(ticklabels, rotation=45, fontsize=9, style="italic", ha="center")
612
+ for lab in ax.get_xticklabels():
613
+ if lab.get_text() == "delta-22":
614
+ lab.set_fontweight("bold"); lab.set_fontsize(10); lab.set_style("normal")
615
+ for solvent, center in solvent_centers.items():
616
+ ax.text(center, -0.17, solvent, transform=ax.get_xaxis_transform(),
617
+ ha="center", va="top", fontsize=12, fontweight="bold")
618
+ nuc = "^{1}\\mathrm{H}" if nucleus == "H" else "^{13}\\mathrm{C}"
619
+ ax.set_ylabel(r"RMSE $(" + nuc + r"$ ppm)", fontsize=13)
620
+ ax.set_ylim(bottom=y_min, top=y_max)
621
+ disp_nuc = "1H" if nucleus == "H" else "13C"
622
+ ax.set_title(f"Delta-22 vs NP/Isomer Bootstrap RMSE by Solvent ({disp_nuc} nucleus)",
623
+ fontsize=14, fontweight="bold")
624
+ ax.tick_params(axis="both", which="major", labelsize=10, length=4, width=0.8)
625
+ ax.yaxis.grid(True, linestyle="-", linewidth=0.4, alpha=0.35, zorder=0)
626
+ ax.xaxis.grid(False)
627
+ sns.despine(ax=ax, top=True, right=True)
628
+ for side in ["left", "bottom", "right", "top"]:
629
+ ax.spines[side].set_linewidth(0.9)
630
+ leg = ax.legend(frameon=False, fontsize=10)
631
+ if leg is not None:
632
+ leg.set_title("")
633
+ fig.tight_layout()
634
+ if save_path:
635
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
636
+ plt.show()
637
+
638
+
639
+ def plot_fitting_rmse_comparison_bars(table, nucleus, solvent, figsize=(11, 4), save_path=None):
640
+ """One group of three bars per test-set solute: fit on that solute alone ("Scaled to Solute"),
641
+ fit once across the whole test set ("Scaled to Test Set"), and delta-22's bootstrap
642
+ coefficients applied cold ("Extrapolated from delta22"). Used by SI Figure S15's "Fitting RMSE
643
+ Comparisons" panel."""
644
+ colors = {"Scaled to Solute": "#4C72B0", "Scaled to Test Set": "#DD8452",
645
+ "Extrapolated from delta22": "#55A868"}
646
+ columns = list(colors.keys())
647
+ x = np.arange(len(table))
648
+ width = 0.27
649
+ fig, ax = plt.subplots(figsize=figsize)
650
+ for i, col in enumerate(columns):
651
+ ax.bar(x + (i - 1) * width, table[col].to_numpy(), width,
652
+ label=col.replace("delta22", "Δ22"), color=colors[col])
653
+ ax.set_xticks(x)
654
+ ax.set_xticklabels([str(s).replace("\n", " ") for s in table.index], rotation=30, ha="right")
655
+ ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)")
656
+ ax.set_title(f"Fitting RMSE Comparisons ({solvent}, {nucleus})")
657
+ ax.legend()
658
+ fig.tight_layout()
659
+ if save_path:
660
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
661
+ return fig, ax
662
+
663
+
664
+ # --- SI Figure S15 feature-space / residual 2x2 grids + distribution-shift bars ---
665
+ # style constants private to the two _dataset_scatter_grid callers below.
666
+ _DATASET_COLORS = {"Test Set": "#7e57c2", "Delta22": "#2ca02c"} # purple circles / green squares
667
+ _DATASET_MARKERS = {"Test Set": "o", "Delta22": "s"}
668
+ _SOLVENT_DISPLAY = {"chloroform": "Chloroform", "benzene": "Benzene",
669
+ "methanol": "Methanol", "TIP4P": "Water (TIP4P)"}
670
+
671
+
672
+ def _dataset_scatter_grid(table, x_col, y_col, solvents, title, subtitle, x_label, y_label,
673
+ figsize, zero_lines):
674
+ """2x2 solvent grid overlaying the Test Set and delta-22 point clouds, shared x/y across panels."""
675
+ fig, axes = plt.subplots(2, 2, figsize=figsize, sharex=True, sharey=True)
676
+ axes = axes.ravel()
677
+ for ax, solvent in zip(axes, solvents):
678
+ sdf = table[table["solvent"] == solvent]
679
+ for dataset in ["Test Set", "Delta22"]:
680
+ sub = sdf[sdf["dataset"] == dataset]
681
+ if not sub.empty:
682
+ ax.scatter(sub[x_col], sub[y_col], s=32, alpha=0.75, marker=_DATASET_MARKERS[dataset],
683
+ c=_DATASET_COLORS[dataset], edgecolors="white", linewidths=0.5, label=dataset)
684
+ if zero_lines:
685
+ ax.axhline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0)
686
+ ax.axvline(0, color="0.35", linestyle="--", linewidth=1.0, zorder=0)
687
+ else:
688
+ ax.axhline(0, color="0.5", linestyle="--", linewidth=1.0, zorder=0)
689
+ ax.set_title(_SOLVENT_DISPLAY.get(solvent, solvent), fontsize=11, fontweight="semibold")
690
+ ax.grid(alpha=0.2)
691
+ handles, labels = axes[0].get_legend_handles_labels()
692
+ fig.legend(handles, labels, loc="center left", bbox_to_anchor=(0.88, 0.5), frameon=False, title="Dataset")
693
+ fig.supxlabel(x_label, fontsize=11)
694
+ fig.supylabel(y_label, fontsize=11)
695
+ fig.suptitle(title, fontsize=13, fontweight="bold")
696
+ fig.text(0.5, 0.94, subtitle, ha="center", fontsize=10, color="0.35")
697
+ fig.tight_layout(rect=[0.02, 0.02, 0.86, 0.92])
698
+ return fig, axes
699
+
700
+
701
+ def plot_feature_space_coverage_grid(table, nucleus, x_label,
702
+ solvents=("chloroform", "benzene", "methanol", "TIP4P"),
703
+ figsize=(11, 9), save_path=None):
704
+ """SI Figure S15's "Feature Space Coverage by Solvent" panel."""
705
+ subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22"
706
+ fig, axes = _dataset_scatter_grid(table, "x", "y", solvents, "Feature Space Coverage by Solvent",
707
+ subtitle, x_label, "OpenMM Correction (centered, ppm)",
708
+ figsize, zero_lines=True)
709
+ if save_path:
710
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
711
+ return fig, axes
712
+
713
+
714
+ def plot_delta22_plane_residuals_grid(table, nucleus,
715
+ solvents=("chloroform", "benzene", "methanol", "TIP4P"),
716
+ figsize=(11, 9), save_path=None):
717
+ """SI Figure S15's "Residuals for Delta22 Fitting Coefficients" panel."""
718
+ subtitle = f"{'Hydrogen' if nucleus == 'H' else 'Carbon'} sites: Test Set vs Delta22"
719
+ fig, axes = _dataset_scatter_grid(table, "experimental", "residual", solvents,
720
+ "Residuals for Delta22 Fitting Coefficients", subtitle,
721
+ "Experimental Shielding (ppm)",
722
+ "Residual vs Delta22 Plane (experimental - predicted)",
723
+ figsize, zero_lines=False)
724
+ if save_path:
725
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
726
+ return fig, axes
727
+
728
+
729
+ def plot_distribution_shift_by_solvent_bars(table, nucleus, figsize=(7, 5), save_path=None):
730
+ """One group of three bars per solvent: extrapolated-from-delta22 / scaled-to-test-set /
731
+ scaled-to-solute RMSE. The three sitting close together (except water) is the transferability
732
+ story. Used by SI Figure S15's "Distribution Shift by Solvent" panel."""
733
+ colors = {"Extrapolated from delta22": "#A72608", "Scaled to Test Set": "#C93240",
734
+ "Scaled to Solute": "#61a89a"}
735
+ columns = list(colors.keys())
736
+ x = np.arange(len(table))
737
+ width = 0.26
738
+ fig, ax = plt.subplots(figsize=figsize)
739
+ for i, col in enumerate(columns):
740
+ ax.bar(x + (i - 1) * width, table[col].to_numpy(), width,
741
+ label=col.replace("delta22", "Δ22"), color=colors[col])
742
+ ax.set_xticks(x)
743
+ ax.set_xticklabels([str(s) for s in table.index])
744
+ ax.set_ylabel(f"{'¹H' if nucleus == 'H' else '¹³C'} RMSE (ppm)")
745
+ ax.set_title(f"Distribution Shift by Solvent ({nucleus})")
746
+ ax.legend(fontsize=9)
747
+ ax.grid(axis="y", alpha=0.3)
748
+ fig.tight_layout()
749
+ if save_path:
750
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
751
+ return fig, ax
752
+
analysis/code/build_composite_model.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Regenerate the composite_model/ group in applications.hdf5 from the shipped delta-22 data.
2
+
3
+ Replaces the old build_coefficients.py, which copied pre-fit CSVs out of a private tree via
4
+ environment variables. Everything here is fit in-repo from delta22.hdf5, so the natural-products
5
+ composite-model coefficients (Figure 5C/5D, SI S15) are reproducible from released inputs alone.
6
+
7
+ The stored group's structure (formula keys, parameter order, solvent order, seed count) is read
8
+ from the current applications.hdf5 and used as a template; only the numbers are regenerated.
9
+ """
10
+ import os
11
+ import random
12
+ import sys
13
+
14
+ import h5py
15
+ import numpy as np
16
+ import pandas as pd
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
20
+ for _p in ("analysis/code", "analysis/code/shared", "data/delta22", "data/applications"):
21
+ sys.path.insert(0, os.path.join(REPO, _p))
22
+
23
+ import delta22 as D # noqa: E402
24
+ import paths # noqa: E402
25
+
26
+ # The 22 delta-22 solutes in first-appearance order: the universe random.choices() draws from.
27
+ # nitromethane stays in the draw pool (index 14) but its rows are dropped from the fit data, exactly
28
+ # as the original pipeline did.
29
+ SOLUTE_ORDER = ["AcOH", "EtOAc", "EtOH", "THF", "acetone", "acetonitrile", "benzaldehyde", "dmf",
30
+ "dmso", "ethane", "ethylene", "glycol", "imidazole", "isopropanol", "nitromethane",
31
+ "propylene", "pyridine", "pyrrole", "pyrrolidine", "tbme", "toluene", "triethylamine"]
32
+ SEED_OFFSET = 100
33
+ BASE_FEATURES = ["experimental", "stationary", "qcd", "pcm", "desmond", "desmond_vib",
34
+ "openMM", "openMM_vib"]
35
+ ALIASES = {"des_vib": "desmond_vib", "op_vib": "openMM_vib"}
36
+
37
+
38
+ def _load_tables():
39
+ """Three delta-22 NN tables (nitromethane rows removed) plus the pcm conversion factors:
40
+
41
+ full_tab - every row; the OLS point fits use it and drop only each formula's own missing
42
+ features, so pcm/qcd formulas fit all 12 solvents.
43
+ fit_tab - strict: all base features present (only the 4 explicit-solvent solvents survive);
44
+ the bootstrap resamples fit here, matching the stored bootstrap's 4 solvents.
45
+ apply_tab - desmond or openMM present; the RMSE predictions are evaluated over it.
46
+ """
47
+ h5 = paths.dataset_file("delta22", root=REPO)
48
+ xlsx = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
49
+ nn = D.load_query_df_nn(h5, xlsx, verbose=False)
50
+ dft = D.load_query_df_dft(h5, xlsx, verbose=False)
51
+ factors = {"H": D.pcm_conversion_factors(dft, "H"), "C": D.pcm_conversion_factors(dft, "C")}
52
+ nn = nn[nn["solute"] != "nitromethane"].copy()
53
+ # stationary_plus_pcm folds in the per-solvent conversion factor; every other _plus_ column is a
54
+ # plain sum, built on demand by _ensure_column.
55
+ fac = nn.apply(lambda r: factors[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
56
+ nn["stationary_plus_pcm"] = nn["stationary"] + nn["pcm"] * fac
57
+ full_tab = nn.reset_index(drop=True)
58
+ fit_tab = nn.dropna(subset=BASE_FEATURES).reset_index(drop=True)
59
+ apply_tab = nn.dropna(subset=["desmond", "openMM"], how="all").reset_index(drop=True)
60
+ return full_tab, fit_tab, apply_tab, factors
61
+
62
+
63
+ def _canonical(name):
64
+ return ALIASES.get(name, name)
65
+
66
+
67
+ def _regressor_columns(formula_key):
68
+ """The design-matrix regressors for a formula key. '_AND_' separates independent regressors;
69
+ a '_plus_' name is one precomputed sum column. Returns the list of column names to fit on."""
70
+ return [g for g in formula_key.split("_AND_")]
71
+
72
+
73
+ def _plus_parts(regressor):
74
+ """Base features a '_plus_' regressor decomposes into (canonical names); [] if it is itself a
75
+ base feature."""
76
+ if "_plus_" not in regressor:
77
+ return []
78
+ return [_canonical(p) for p in regressor.split("_plus_") if p]
79
+
80
+
81
+ def _ensure_column(df, regressor):
82
+ """Make sure a precomputed _plus_ regressor column exists on df (sum of its parts; the pcm part
83
+ already carries the conversion factor via stationary_plus_pcm)."""
84
+ if regressor in df.columns:
85
+ return
86
+ parts = regressor.split("_plus_")
87
+ assert _canonical(parts[0]) == "stationary", f"unexpected base term in {regressor}"
88
+ # build cumulatively so stationary_plus_pcm_plus_op_vib = stationary_plus_pcm + openMM_vib. The
89
+ # pcm chains start from the scaled stationary_plus_pcm column; everything else from raw stationary.
90
+ if parts[1:2] == ["pcm"]:
91
+ col = df["stationary_plus_pcm"].to_numpy(float).copy()
92
+ rest = parts[2:]
93
+ else:
94
+ col = df["stationary"].to_numpy(float).copy()
95
+ rest = parts[1:]
96
+ for p in rest:
97
+ col = col + df[_canonical(p)].to_numpy(float)
98
+ df[regressor] = col
99
+
100
+
101
+ def _fit(df, regressors):
102
+ """OLS of experimental on the given regressor columns (with intercept); returns {name: coeff}
103
+ keyed by 'Intercept' and each regressor. NaN rows are dropped, matching statsmodels."""
104
+ y = pd.to_numeric(df["experimental"], errors="coerce").to_numpy(float)
105
+ cols = [df[r].to_numpy(float) for r in regressors]
106
+ X = np.column_stack([np.ones(len(df))] + cols)
107
+ mask = np.isfinite(y) & np.all(np.isfinite(X), axis=1)
108
+ beta, *_ = np.linalg.lstsq(X[mask], y[mask], rcond=None)
109
+ return dict(zip(["Intercept"] + list(regressors), beta))
110
+
111
+
112
+ def _decompose(coeffs, regressors, solvent, nucleus, factors):
113
+ """Expand each _plus_ regressor's coefficient onto its base features (same value copied). The pcm
114
+ row is scaled by the per-solvent conversion factor ONLY when the formula used a
115
+ stationary_plus_pcm term (a free _AND_pcm regressor stays raw). Returns {base_feature: coeff}."""
116
+ out = {"intercept": coeffs["Intercept"]}
117
+ for reg, val in coeffs.items():
118
+ if reg == "Intercept":
119
+ continue
120
+ parts = _plus_parts(reg)
121
+ for base in (parts if parts else [_canonical(reg)]):
122
+ out[base] = val
123
+ uses_spp = any(_plus_parts(r)[1:2] == ["pcm"] for r in regressors)
124
+ if uses_spp and "pcm" in out:
125
+ out["pcm"] = out["pcm"] * float(factors[nucleus].get(solvent, np.nan))
126
+ return out
127
+
128
+
129
+ def _bootstrap_sample(fit_tab, nucleus, solvent, seed):
130
+ """The rows fit for one bootstrap draw: seed the RNG with seed+100, draw 22 solutes with
131
+ replacement from SOLUTE_ORDER, keep that solvent/nucleus's rows for the drawn solutes."""
132
+ random.seed(int(seed) + SEED_OFFSET)
133
+ drawn = random.choices(SOLUTE_ORDER, k=22)
134
+ return fit_tab[(fit_tab["nucleus"] == nucleus) & (fit_tab["solvent"] == solvent)
135
+ & (fit_tab["solute"].isin(drawn))]
136
+
137
+
138
+ def _rmse(fit_tab, apply_tab, display_formula, nucleus, solvent, seed, factors):
139
+ """Bootstrap RMSE: fit the formula on one bootstrap draw, apply those raw regressor coefficients
140
+ to every apply-table row for the solvent, and take the RMSE over labeled rows. The RMSE display
141
+ names join regressors with ' + ' (each is a column, possibly a precomputed _plus_ one)."""
142
+ regs = [t.strip() for t in display_formula.split(" + ")]
143
+ for r in (fit_tab, apply_tab):
144
+ for reg in regs:
145
+ _ensure_column(r, reg)
146
+ coeffs = _fit(_bootstrap_sample(fit_tab, nucleus, solvent, seed), regs)
147
+ a = apply_tab[(apply_tab["nucleus"] == nucleus) & (apply_tab["solvent"] == solvent)]
148
+ pred = coeffs["Intercept"] + sum(coeffs[r] * a[r].to_numpy(float) for r in regs)
149
+ exp = pd.to_numeric(a["experimental"], errors="coerce").to_numpy(float)
150
+ mask = np.isfinite(pred) & np.isfinite(exp)
151
+ return float(np.sqrt(np.mean((pred[mask] - exp[mask]) ** 2)))
152
+
153
+
154
+ def _pcm_table(factors, nucleus):
155
+ """pcm_conversion_factors subgroup for one nucleus: solvent, pcm_conversion_factor. Stored under
156
+ the 'water' name rather than the loader's 'TIP4P'."""
157
+ s = factors[nucleus].rename(index={"TIP4P": "water"})
158
+ return pd.DataFrame({"solvent": s.index, "pcm_conversion_factor": s.to_numpy()})
159
+
160
+
161
+ def regenerate(reader):
162
+ """Regenerate every composite_model subgroup from delta-22, keyed and ordered to match the stored
163
+ group. Returns {"ols_coefficients"|"bootstrap_coefficients"|"rmse_distributions"|
164
+ "pcm_conversion_factors": {key: DataFrame}}."""
165
+ full_tab, fit_tab, apply_tab, factors = _load_tables()
166
+ out = {k: {} for k in ("ols_coefficients", "bootstrap_coefficients",
167
+ "rmse_distributions", "pcm_conversion_factors")}
168
+
169
+ for f in reader.composite_formulas("ols_coefficients"):
170
+ regs = _regressor_columns(f)
171
+ for reg in regs:
172
+ _ensure_column(full_tab, reg)
173
+ for nuc in ("H", "C"):
174
+ try:
175
+ template = reader.ols_coefficients(f, nuc)
176
+ except KeyError:
177
+ continue
178
+ params = template["parameter"].tolist()
179
+ solvents = [c for c in template.columns if c != "parameter"]
180
+ data = {"parameter": params}
181
+ for solvent in solvents:
182
+ sub = full_tab[(full_tab["nucleus"] == nuc) & (full_tab["solvent"] == solvent)]
183
+ dec = _decompose(_fit(sub, regs), regs, solvent, nuc, factors)
184
+ data[solvent] = [dec[p] for p in params]
185
+ out["ols_coefficients"][(f, nuc)] = pd.DataFrame(data)
186
+
187
+ for f in reader.composite_formulas("bootstrap_coefficients"):
188
+ regs = _regressor_columns(f)
189
+ for reg in regs:
190
+ _ensure_column(fit_tab, reg)
191
+ for nuc in ("H", "C"):
192
+ try:
193
+ template = reader.bootstrap_coefficients(f, nuc)
194
+ except KeyError:
195
+ continue
196
+ params = [c for c in template.columns if c not in ("solvent", "seed")]
197
+ rows = []
198
+ for solvent in dict.fromkeys(template["solvent"]):
199
+ seeds = sorted(template[template["solvent"] == solvent]["seed"].unique())
200
+ for seed in seeds:
201
+ dec = _decompose(_fit(_bootstrap_sample(fit_tab, nuc, solvent, seed), regs),
202
+ regs, solvent, nuc, factors)
203
+ row = {"solvent": solvent, "seed": seed}
204
+ for p in params:
205
+ row[p] = dec["intercept"] if p == "Intercept" else dec[p]
206
+ rows.append(row)
207
+ out["bootstrap_coefficients"][(f, nuc)] = pd.DataFrame(rows)[template.columns.tolist()]
208
+
209
+ for nuc in ("H", "C"):
210
+ template = reader.rmse_distribution(nuc)
211
+ rows = []
212
+ for f in dict.fromkeys(template["formula"]):
213
+ sub = template[template["formula"] == f]
214
+ for solvent in dict.fromkeys(sub["solvent"]):
215
+ for seed in sorted(sub[sub["solvent"] == solvent]["seed"].unique()):
216
+ rows.append({"solvent": solvent, "nucleus": nuc, "formula": f, "seed": seed,
217
+ "Bootstrap_RMSE": _rmse(fit_tab, apply_tab, f, nuc, solvent, seed, factors)})
218
+ out["rmse_distributions"][nuc] = pd.DataFrame(rows)[template.columns.tolist()]
219
+ out["pcm_conversion_factors"][nuc] = _pcm_table(factors, nuc)
220
+
221
+ return out
222
+
223
+
224
+ def verify(reader, regenerated, atol=5e-3):
225
+ """Compare regenerated numeric values against the stored group. Returns the global max abs diff.
226
+ The floor is the fixed-point encoding granularity (~1e-3), far below any recipe error."""
227
+ worst = 0.0
228
+ for (f, nuc), df in regenerated["ols_coefficients"].items():
229
+ st = reader.ols_coefficients(f, nuc)
230
+ worst = max(worst, np.abs(df.drop(columns="parameter").to_numpy(float)
231
+ - st.drop(columns="parameter").to_numpy(float)).max())
232
+ for (f, nuc), df in regenerated["bootstrap_coefficients"].items():
233
+ st = reader.bootstrap_coefficients(f, nuc).sort_values(["solvent", "seed"]).reset_index(drop=True)
234
+ df = df.sort_values(["solvent", "seed"]).reset_index(drop=True)
235
+ num = [c for c in df.columns if c not in ("solvent",)]
236
+ worst = max(worst, np.abs(df[num].to_numpy(float) - st[num].to_numpy(float)).max())
237
+ for nuc, df in regenerated["rmse_distributions"].items():
238
+ st = reader.rmse_distribution(nuc).sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
239
+ df = df.sort_values(["formula", "solvent", "seed"]).reset_index(drop=True)
240
+ worst = max(worst, np.abs(df["Bootstrap_RMSE"].to_numpy(float)
241
+ - st["Bootstrap_RMSE"].to_numpy(float)).max())
242
+ for nuc, df in regenerated["pcm_conversion_factors"].items():
243
+ st = reader.pcm_conversion_factors(nuc).set_index("solvent")["pcm_conversion_factor"]
244
+ got = df.set_index("solvent")["pcm_conversion_factor"]
245
+ worst = max(worst, np.abs(got - st.reindex(got.index)).max())
246
+ return float(worst)
247
+
248
+
249
+ _NOTE = ("Linear composite-model coefficients fit on the delta-22 dataset and applied to the "
250
+ "natural products (Figure 5C/5D, SI S15). Stored as CSV text; read via applications_reader. "
251
+ "Regenerated in-repo from delta22.hdf5 by build_composite_model.py.")
252
+
253
+
254
+ def write(hdf5_path, regenerated):
255
+ """Overwrite the composite_model/ group in applications.hdf5 with the regenerated CSV text."""
256
+ with h5py.File(hdf5_path, "a") as h:
257
+ if "composite_model" in h:
258
+ del h["composite_model"]
259
+ cm = h.create_group("composite_model")
260
+ cm.attrs["note"] = _NOTE
261
+ for (formula, nucleus), df in regenerated["ols_coefficients"].items():
262
+ cm.require_group(f"ols_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
263
+ for (formula, nucleus), df in regenerated["bootstrap_coefficients"].items():
264
+ cm.require_group(f"bootstrap_coefficients/{formula}").create_dataset(nucleus, data=df.to_csv(index=False))
265
+ for nucleus, df in regenerated["rmse_distributions"].items():
266
+ cm.require_group("rmse_distributions").create_dataset(nucleus, data=df.to_csv(index=False))
267
+ for nucleus, df in regenerated["pcm_conversion_factors"].items():
268
+ cm.require_group("pcm_conversion_factors").create_dataset(nucleus, data=df.to_csv(index=False))
269
+
270
+
271
+ def main():
272
+ import argparse
273
+ ap = argparse.ArgumentParser(description="Regenerate composite_model/ in applications.hdf5 from delta-22.")
274
+ ap.add_argument("--write", action="store_true",
275
+ help="overwrite the composite_model group in applications.hdf5 (default: verify only)")
276
+ args = ap.parse_args()
277
+ sys.path.insert(0, os.path.join(REPO, "data", "applications"))
278
+ from applications_reader import Applications
279
+ hdf5_path = paths.dataset_file("applications", root=REPO)
280
+ reader = Applications(hdf5_path)
281
+ regenerated = regenerate(reader)
282
+ max_diff = verify(reader, regenerated)
283
+ print(f"composite_model regenerated from delta-22; max abs diff vs stored = {max_diff:.2e}")
284
+ if args.write:
285
+ write(hdf5_path, regenerated)
286
+ print(f"wrote composite_model/ into {hdf5_path}")
287
+
288
+
289
+ if __name__ == "__main__":
290
+ main()
analysis/code/build_nb_applications.py ADDED
@@ -0,0 +1,282 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build_nb_applications.py -- generator for the natural-products figure notebooks.
2
+ #
3
+ # Source of truth for the notebooks: edit the cell sources below, then regenerate the notebook(s) you
4
+ # touched (regenerating overwrites the .ipynb, so edit this script, not the notebook):
5
+ #
6
+ # python3 build_nb_applications.py si_figure_s08_vomicine
7
+ #
8
+ # Run with no arguments to list the notebook names; the NOTEBOOKS dict maps each to its destination.
9
+ # The numbers come from applications.py; the notebooks arrange and plot them.
10
+ from nb_build import md, code, save_notebook as _save
11
+
12
+
13
+ # ----------------------------------------------------------------------------
14
+ # SI Figure S8, panel A: explicit-solvent shell-size convergence for vomicine
15
+ # ----------------------------------------------------------------------------
16
+ si_figure_s08 = [
17
+ md(r"""
18
+ # SI Figure S8 (panel A): explicit-solvent correction vs solvent-shell size (vomicine)
19
+
20
+ The MagNET-x explicit-solvent correction (solvated minus isolated, averaged over a proton site's
21
+ atoms and MD frames) vs solvent-shell size (heavy-atom count of the solute-plus-solvent cluster),
22
+ for vomicine, one panel per solvent.
23
+ """),
24
+ code(r"""
25
+ import os, sys
26
+
27
+ # make the in-repo modules importable (not pip-installed)
28
+ REPO = os.path.abspath("../..")
29
+ for _p in ("data/applications", "analysis/code", "analysis/code/shared"):
30
+ sys.path.insert(0, os.path.join(REPO, _p))
31
+ """),
32
+ code(r"""
33
+ import matplotlib.pyplot as plt
34
+
35
+ from applications_reader import Applications, SHELL_SIZES
36
+ import applications
37
+ import paths
38
+ """),
39
+ code(r"""
40
+ DATA = os.path.join(REPO, "data", "applications")
41
+
42
+ def figure_path(name):
43
+ os.makedirs("figures", exist_ok=True)
44
+ return os.path.join("figures", name)
45
+ """),
46
+ code(r"""
47
+ loader = Applications(paths.dataset_file("applications", root=REPO),
48
+ os.path.join(DATA, "applications_experimental.xlsx"))
49
+
50
+ # compute the per-site, per-solvent shell-convergence corrections for vomicine (proton sites)
51
+ SOLUTE, NUCLEUS = "vomicine", "H"
52
+ corrections = applications.shell_convergence_corrections(loader, SOLUTE, NUCLEUS)
53
+ corrections.head()
54
+ """),
55
+ code(r"""
56
+ # panel layout and font styling for the 2x2 grid (one panel per solvent)
57
+ solvent_order = ["chloroform", "methanol", "TIP4P", "benzene"]
58
+ shell_cols = [f"shell_{s}" for s in SHELL_SIZES]
59
+ plt.rcParams.update({"font.family": "serif", "font.serif": ["Arial", "DejaVu Serif"],
60
+ "axes.labelsize": 12, "axes.titlesize": 14})
61
+ """),
62
+ code(r"""
63
+ # 2x2 panel, one solvent each; one line per proton site
64
+ fig, axes = plt.subplots(2, 2, figsize=(11, 8), sharex=True, sharey=True)
65
+ for ax, solvent in zip(axes.ravel(), solvent_order):
66
+ sub = corrections.xs(solvent, level="solvent")
67
+ sub = sub.dropna(how="all")
68
+ ax.set_title("TIP4P" if solvent == "TIP4P" else solvent.capitalize(), fontweight="bold")
69
+ for site, row in sub.iterrows():
70
+ ax.plot(SHELL_SIZES, row[shell_cols].values.astype(float),
71
+ marker="o", linewidth=1.5, alpha=0.75, label=site)
72
+ ax.grid(True, alpha=0.3)
73
+ ax.set_xlabel("Shell Size (Heavy Atom Count)", fontweight="bold")
74
+ ax.set_ylabel("OpenMM correction (ppm)", fontweight="bold")
75
+ fig.suptitle("Shell-size Convergence for Vomicine", fontweight="bold", fontsize=18)
76
+ fig.tight_layout()
77
+ fig.savefig(figure_path("si_figure_s08_vomicine.png"), dpi=300, bbox_inches="tight")
78
+ plt.show()
79
+ """),
80
+ ]
81
+
82
+ # ----------------------------------------------------------------------------
83
+ # SI Figure S15: transferability of composite-model coefficients to the test set (1H + 13C)
84
+ # ----------------------------------------------------------------------------
85
+ si_figure_s15 = [
86
+ md(r"""
87
+ # SI Figure S15: per-solute RMSE with delta-22 composite coefficients applied
88
+
89
+ Per-solute ¹H and ¹³C RMSE for the natural-products / olefin-isomer test set with delta-22
90
+ coefficients applied (reproduces Figure 5C in ¹H, plus the ¹³C analogue), plus fitting-RMSE
91
+ comparisons across coefficient choices, feature-space coverage by solvent, delta-22-plane residuals,
92
+ and the RMSE distribution shift by solvent.
93
+ """),
94
+ code(r"""
95
+ import os, sys
96
+
97
+ # make the in-repo modules importable (not pip-installed)
98
+ REPO = os.path.abspath("../..")
99
+ for _p in ("data/applications", "data/delta22", "analysis/code", "analysis/code/shared"):
100
+ sys.path.insert(0, os.path.join(REPO, _p))
101
+ """),
102
+ code(r"""
103
+ import matplotlib.pyplot as plt
104
+
105
+ from applications_reader import Applications
106
+ import applications
107
+ import applications_plots
108
+ import delta22
109
+ import paths
110
+ """),
111
+ code(r"""
112
+ DATA = os.path.join(REPO, "data", "applications")
113
+ DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
114
+ DELTA22_XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
115
+
116
+ def figure_path(name):
117
+ os.makedirs("figures", exist_ok=True)
118
+ return os.path.join("figures", name)
119
+ """),
120
+ code(r"""
121
+ loader = Applications(paths.dataset_file("applications", root=REPO),
122
+ os.path.join(DATA, "applications_experimental.xlsx"))
123
+
124
+ # assemble the feature table and run the composite-model fits + bootstrap
125
+ query_df_nn = applications.build_query_df_nn(loader)
126
+ site_counts = (query_df_nn.drop_duplicates(subset=["solute", "nucleus", "site"])
127
+ .groupby(["solute", "nucleus"]).size().unstack(fill_value=0))
128
+
129
+ per_solute = applications.per_solute_fits(query_df_nn) # "scaled to solute" baseline
130
+ all_solute = applications.per_solvent_fits(query_df_nn) # "scaled to test set" full fit
131
+
132
+ seed = applications.build_bootstrap_seed_coeffs(loader)
133
+ bootstrap_rmses = {}
134
+ for nuc in ["H", "C"]:
135
+ preds = applications.apply_bootstrap_params_to_full_dataset(query_df_nn, seed[nuc], nucleus=nuc)
136
+ bootstrap_rmses[nuc] = applications.compute_solute_rmses(preds)
137
+ """),
138
+ code(r"""
139
+ # published solute order (isomers, then the two pyridines, then the natural products); matches
140
+ # main-text Figure 5D. delta-22 is drawn first by the box-plot engine, so it is not listed here.
141
+ S15_SOLUTE_ORDER = ["isomer_1E", "isomer_1Z", "isomer_2E", "isomer_2Z", "isomer_3E", "isomer_3Z",
142
+ "isomer_4N", "isomer_4O", "vomicine", "prednisone", "peptide", "flavone",
143
+ "dihydrotanshinone_I"]
144
+ """),
145
+ code(r"""
146
+ # 1H panel (reproduces Figure 5C)
147
+ applications_plots.plot_nps_on_boxplot_delta22_simplified(
148
+ loader.rmse_distribution("H"), bootstrap_rmses["H"], per_solute["H"],
149
+ nucleus="H", formulas=["stationary_plus_qcd + openMM"], colors=["#61a89a"],
150
+ site_counts=site_counts, formula_remap=applications.FORMULA_REMAP,
151
+ solute_remap=applications.SOLUTE_DISPLAY, solute_order=S15_SOLUTE_ORDER,
152
+ solute_color_remap=applications.PEPTIDE_HIGHLIGHT_H,
153
+ figsize=(14, 8), box_width=0.20, box_gap=0.1,
154
+ show_baseline=True, baseline_annotation_text="Scaled to solute",
155
+ baseline_annotation_x=0.215, baseline_annotation_y=0.39,
156
+ show_full_fit_line=True, full_fit_line_label="Scaled to Test Set", full_fit_line_label_x=0.835,
157
+ all_solute_fitting_results=all_solute,
158
+ max_bar_height=0.06, site_count_axis_mode="inset", site_count_inset_area_frac=0.1,
159
+ site_count_inset_axis_offset=-0.06, site_count_inset_axis_label_pad=0.045,
160
+ save_path=figure_path("si_figure_s15_1H.png"),
161
+ )
162
+ """),
163
+ code(r"""
164
+ # 13C panel
165
+ applications_plots.plot_nps_on_boxplot_delta22_simplified(
166
+ loader.rmse_distribution("C"), bootstrap_rmses["C"], per_solute["C"],
167
+ nucleus="C", formulas=["stationary_plus_op_vib + openMM"], colors=["#61a89a"],
168
+ site_counts=site_counts, formula_remap=applications.FORMULA_REMAP,
169
+ solute_remap=applications.SOLUTE_DISPLAY, solute_order=S15_SOLUTE_ORDER,
170
+ solute_color_remap=applications.PEPTIDE_HIGHLIGHT_C,
171
+ figsize=(14, 8), box_width=0.20, box_gap=0.1,
172
+ show_baseline=True, baseline_annotation_text="Scaled to solute",
173
+ baseline_annotation_x=0.28, baseline_annotation_y=0.355,
174
+ show_full_fit_line=True, full_fit_line_label="Scaled to Test Set", full_fit_line_label_x=0.98,
175
+ all_solute_fitting_results=all_solute,
176
+ max_bar_height=0.8, site_count_axis_mode="inset", site_count_inset_area_frac=0.1,
177
+ site_count_inset_axis_offset=-0.06, site_count_inset_axis_label_pad=0.045,
178
+ save_path=figure_path("si_figure_s15_13C.png"),
179
+ )
180
+ """),
181
+ md(r"""
182
+ ## Fitting RMSE Comparisons (chloroform)
183
+
184
+ Per-solute RMSE under three coefficient choices (Scaled to Solute / Scaled to Test Set / Extrapolated
185
+ from delta22).
186
+ """),
187
+ code(r"""
188
+ for nucleus, formula in [("H", "stationary_plus_qcd + openMM"), ("C", "stationary_plus_op_vib + openMM")]:
189
+ table = applications.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses[nucleus],
190
+ nucleus, "chloroform", formula)
191
+ table = table.rename(index=applications.SOLUTE_DISPLAY)
192
+ order = [applications.SOLUTE_DISPLAY.get(k, k) for k in S15_SOLUTE_ORDER] # published order; peptide has no chloroform data
193
+ table = table.reindex([n for n in order if n in table.index])
194
+ print(f"--- {nucleus} ---")
195
+ display(table.round(3))
196
+ applications_plots.plot_fitting_rmse_comparison_bars(
197
+ table, nucleus, "chloroform",
198
+ save_path=figure_path(f"si_figure_s15_fitting_rmse_comparisons_{'1H' if nucleus == 'H' else '13C'}.png"))
199
+ plt.show()
200
+ """),
201
+ md(r"""
202
+ ## Feature Space Coverage by Solvent
203
+
204
+ Test-set vs delta-22 mean-centered feature values across the four explicit-solvent solvents.
205
+ """),
206
+ code(r"""
207
+ delta22_query_df_nn = delta22.add_composite_columns(delta22.load_query_df_nn(
208
+ DELTA22_HDF5, DELTA22_XLSX, verbose=False))
209
+ """),
210
+ code(r"""
211
+ _FEATURE_X_LABELS = {
212
+ "H": "Gas-Phase Shielding + QCD Correction (centered, ppm)",
213
+ "C": "Gas-Phase Shielding + OpenMM Vibrational Correction (centered, ppm)",
214
+ }
215
+ for nucleus in ["H", "C"]:
216
+ coverage = applications.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, nucleus)
217
+ applications_plots.plot_feature_space_coverage_grid(
218
+ coverage, nucleus, _FEATURE_X_LABELS[nucleus],
219
+ save_path=figure_path(f"si_figure_s15_feature_space_coverage_{'1H' if nucleus == 'H' else '13C'}.png"))
220
+ plt.show()
221
+ """),
222
+ md(r"""
223
+ ## Residuals for Delta22 Fitting Coefficients
224
+
225
+ Residuals of a delta-22-only 2-feature plane applied to both delta-22 and the test set, vs
226
+ experimental shielding.
227
+ """),
228
+ code(r"""
229
+ for nucleus in ["H", "C"]:
230
+ residuals = applications.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus)
231
+ applications_plots.plot_delta22_plane_residuals_grid(
232
+ residuals, nucleus,
233
+ save_path=figure_path(f"si_figure_s15_delta22_plane_residuals_{'1H' if nucleus == 'H' else '13C'}.png"))
234
+ plt.show()
235
+ """),
236
+ md(r"""
237
+ ## Distribution Shift by Solvent
238
+
239
+ Per-solvent mean test-set RMSE under the three coefficient choices.
240
+ """),
241
+ code(r"""
242
+ for nucleus, formula in [("H", "stationary_plus_qcd + openMM"), ("C", "stationary_plus_op_vib + openMM")]:
243
+ shift = applications.distribution_shift_by_solvent_table(per_solute, all_solute, bootstrap_rmses[nucleus],
244
+ nucleus, formula)
245
+ print(f"--- {nucleus} ---")
246
+ display(shift.round(3))
247
+ applications_plots.plot_distribution_shift_by_solvent_bars(
248
+ shift, nucleus,
249
+ save_path=figure_path(f"si_figure_s15_distribution_shift_by_solvent_{'1H' if nucleus == 'H' else '13C'}.png"))
250
+ plt.show()
251
+ """),
252
+ ]
253
+
254
+ # NOTE: main-text Figure 5 panels C and D (analysis/manuscript_figures/fig5c.ipynb, fig5d.ipynb) are
255
+ # HAND-AUTHORED, not generated here. Their plotting engines (plot_nps_benefit_barplot for 5C,
256
+ # plot_nps_on_boxplot_delta22_simplified for 5D, also used by SI S15) live in applications_plots.py;
257
+ # the notebooks only load data and call them. The SI notebooks above are still generated here.
258
+
259
+
260
+ NOTEBOOKS = {
261
+ "si_figure_s08_vomicine": (si_figure_s08, "analysis/si_figures/si_figure_s08_panelA_vomicine.ipynb"),
262
+ "si_figure_s15": (si_figure_s15, "analysis/si_figures/si_figure_s15.ipynb"),
263
+ }
264
+
265
+ if __name__ == "__main__":
266
+ import os
267
+ import sys
268
+ here = os.path.dirname(os.path.abspath(__file__))
269
+ repo = os.path.abspath(os.path.join(here, "..", ".."))
270
+ names = sys.argv[1:]
271
+ if not names:
272
+ print("usage: python3 build_nb_applications.py <name> [<name> ...]")
273
+ print("regenerates ONLY the named notebook(s) -- pick just the one(s) you edited,")
274
+ print("since regenerating clears a notebook's execution outputs.")
275
+ print("available names:", ", ".join(NOTEBOOKS))
276
+ raise SystemExit(1)
277
+ unknown = [n for n in names if n not in NOTEBOOKS]
278
+ if unknown:
279
+ raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
280
+ for name in names:
281
+ cells, relpath = NOTEBOOKS[name]
282
+ _save(cells, os.path.join(repo, relpath))
analysis/code/build_nb_composite_models.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source of truth for the composite-model ablation-study notebook. Edit the cell sources here,
2
+ then regenerate:
3
+
4
+ python3 build_nb_composite_models.py --write
5
+ jupyter nbconvert --to notebook --execute --inplace ../si_figures/si_composite_models_ablations.ipynb
6
+
7
+ Regenerating overwrites the .ipynb, so do not hand-edit the notebook. All real code lives in
8
+ composite_models.py (numbers and the openpyxl table-writing layer, which itself calls delta22.py's
9
+ existing fitting harness); the notebook only runs it. The notebook itself lives in
10
+ analysis/si_figures/ (grouped by role), not here.
11
+
12
+ Run with no arguments to see this usage message; pass --write to regenerate. This generator produces
13
+ a single notebook and takes no name argument, so a bare run must not silently overwrite it.
14
+ """
15
+ import os
16
+ import sys
17
+
18
+ from nb_build import md, code, save_notebook as _save
19
+
20
+
21
+ _BOOTSTRAP = r"""
22
+ import os, sys
23
+
24
+ # make the in-repo modules importable (not pip-installed)
25
+ REPO = os.path.abspath("../..")
26
+ for _p in ("data/delta22", "analysis/code", "analysis/code/shared"):
27
+ sys.path.insert(0, os.path.join(REPO, _p))
28
+ """
29
+
30
+ _IMPORTS = r"""
31
+ import matplotlib.pyplot as plt
32
+
33
+ import delta22
34
+ import composite_models
35
+ import composite_plots
36
+ import paths
37
+ """
38
+
39
+ _SETUP = r"""
40
+ DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
41
+ XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
42
+
43
+ def figure_path(name):
44
+ os.makedirs("figures", exist_ok=True)
45
+ return os.path.join("figures", name)
46
+
47
+ def document_path(name):
48
+ os.makedirs("documents", exist_ok=True)
49
+ return os.path.join("documents", name)
50
+
51
+ # the shipped ablations.xlsx used 250 seeded train/test splits per (formula, solvent)
52
+ N_SPLITS = 250
53
+ """
54
+
55
+ si_composite_models_ablations = [
56
+ md(r"""
57
+ # SI Figure S15: composite-formula ablation workbook (`ablations.xlsx`)
58
+
59
+ Rebuilds the `ablations.xlsx` workbook behind ~32 of SI Figure S15's images: ~25 composite-formula
60
+ variants (stationary geometry plus some combination of implicit PCM, explicit Desmond, and
61
+ rovibrational QCD corrections) across all 12 delta-22 solvents, at two reference levels:
62
+ DSD-PBEP86/pcSseg-3 (geometry PBE0/tz) and the MagNET-Zero training reference (WP04/pcSseg-2 for ¹H,
63
+ wB97X-D/pcSseg-2 for ¹³C).
64
+ """),
65
+ code(_BOOTSTRAP),
66
+ code(_IMPORTS),
67
+ code(_SETUP),
68
+ code(r"""
69
+ query_df_dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))
70
+ solutes = delta22.delta22_solutes(DELTA22_HDF5)
71
+ print(len(query_df_dft), "rows;", len(solutes), "solutes")
72
+ """),
73
+ md("## Preview: one formula's mean test RMSE at the DSD-PBEP86 reference level"),
74
+ code(r"""
75
+ # preview before the full build (all ~25 formulas x 12 solvents x 250 splits x 2 nuclei x 2
76
+ # reference levels)
77
+ level = composite_models.REFERENCE_LEVELS["dsd"]
78
+ preview = composite_models.ablation_rmse_table(query_df_dft, "H", level["method_h"], level["basis_h"],
79
+ level["geometry_h"], solutes, n_splits=20)
80
+ preview[["chloroform", "benzene", "Mean Test RMSE"]].round(3)
81
+ """),
82
+ md("## Build the full ablations workbook (both reference levels, both nuclei)"),
83
+ code(r"""
84
+ output_path = document_path("ablations.xlsx")
85
+ composite_models.build_ablations_workbook(query_df_dft, solutes, output_path, n_splits=N_SPLITS)
86
+ """),
87
+ md(r"""
88
+ ## Correlations Between Features
89
+
90
+ Solvent-averaged Pearson r correlation matrix between the five composite-model features (stationary
91
+ shielding, PCM, Desmond, its vibrational analogue, QCD), both nuclei, plus a per-solvent
92
+ PCM-vs-Desmond table.
93
+ """),
94
+ code(r"""
95
+ for nucleus, label in [("H", "Proton"), ("C", "Carbon")]:
96
+ corr = delta22.si_s15_feature_correlations(query_df_dft, nucleus)
97
+ nuc_label = "1H" if nucleus == "H" else "13C"
98
+ nuc_title = "$^{1}$H" if nucleus == "H" else "$^{13}$C"
99
+ composite_plots.plot_feature_correlation_heatmap(
100
+ corr["r"], vmin=-1, vmax=1, cmap="RdBu",
101
+ title=f"Solvent-Averaged Pearson $r$ Correlation Matrix for {nuc_title}",
102
+ save_path=figure_path(f"si_figure_s15_feature_corr_r_{nuc_label}.png"))
103
+ plt.show()
104
+ """),
105
+ code(r"""
106
+ pcm_desmond_corr = delta22.pcm_desmond_correlation_by_solvent(query_df_dft)
107
+ print("PCM vs. Desmond Pearson R by nucleus and solvent:")
108
+ display(pcm_desmond_corr.round(3))
109
+ composite_plots.plot_pcm_desmond_correlation_table(pcm_desmond_corr,
110
+ save_path=figure_path("si_figure_s15_pcm_desmond_corr_table.png"))
111
+ plt.show()
112
+ """),
113
+ ]
114
+
115
+
116
+ if __name__ == "__main__":
117
+ if "--write" not in sys.argv:
118
+ print("usage: python3 build_nb_composite_models.py --write")
119
+ print("regenerates analysis/si_figures/si_composite_models_ablations.ipynb -- pass --write to actually do it,")
120
+ print("since regenerating clears the notebook's execution outputs.")
121
+ sys.exit(0)
122
+ here = os.path.dirname(os.path.abspath(__file__))
123
+ repo = os.path.abspath(os.path.join(here, "..", ".."))
124
+ _save(si_composite_models_ablations, os.path.join(repo, "analysis", "si_figures", "si_composite_models_ablations.ipynb"))
analysis/code/build_nb_delta22.py ADDED
@@ -0,0 +1,798 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # build_nb_delta22.py -- generator for the delta-22 figure notebooks.
2
+ #
3
+ # This script is the source of truth for the notebooks. Edit the cell sources here, then
4
+ # regenerate and re-execute ONLY the notebook(s) you touched, e.g.:
5
+ #
6
+ # python3 build_nb_delta22.py si_figure_s04
7
+ # jupyter nbconvert --to notebook --execute --inplace ../si_figures/si_figure_s04.ipynb
8
+ #
9
+ # Regenerating overwrites the .ipynb; do not hand-edit it. All real code lives in delta22.py
10
+ # (numbers); plotting is inline in each notebook.
11
+ #
12
+ # Run with no arguments to list the available notebook names. Regenerating a notebook clears its
13
+ # execution outputs, so there is no "build everything" shortcut -- pick just the notebook(s) touched.
14
+ from nb_build import md, code, save_notebook as _save
15
+
16
+
17
+ # under nbconvert the CWD is the notebook's own directory, so sys.path is set relative to it
18
+ _BOOTSTRAP = r"""
19
+ import os, sys
20
+
21
+ # make the in-repo modules importable (not pip-installed)
22
+ REPO = os.path.abspath("../..")
23
+ for _p in ("data/delta22", "analysis/code", "analysis/code/shared"):
24
+ sys.path.insert(0, os.path.join(REPO, _p))
25
+ """
26
+
27
+ # pure imports for the figure notebooks
28
+ _IMPORTS = r"""
29
+ import matplotlib.pyplot as plt
30
+
31
+ import delta22
32
+ import delta22_plots
33
+ import paths
34
+ """
35
+
36
+ # data-file path constants + the figure_path helper, for the figure notebooks
37
+ _SETUP = r"""
38
+ DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
39
+ XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
40
+
41
+ def figure_path(name):
42
+ os.makedirs("figures", exist_ok=True)
43
+ return os.path.join("figures", name)
44
+ """
45
+
46
+ # pure imports for the table notebooks (no matplotlib -- they only print and write spreadsheets)
47
+ _IMPORTS_TABLE = r"""
48
+ import delta22
49
+ import paths
50
+ """
51
+
52
+ # figure imports plus numpy, for the few notebooks that use numpy directly in a notebook cell
53
+ _IMPORTS_NP = _IMPORTS.replace("import matplotlib.pyplot as plt",
54
+ "import numpy as np\nimport matplotlib.pyplot as plt", 1)
55
+ # table imports plus numpy/pandas (or just pandas), used directly when building the table dataframes
56
+ _IMPORTS_TABLE_NPPD = _IMPORTS_TABLE.replace("import delta22",
57
+ "import numpy as np\nimport pandas as pd\n\nimport delta22", 1)
58
+ _IMPORTS_TABLE_PD = _IMPORTS_TABLE.replace("import delta22",
59
+ "import pandas as pd\n\nimport delta22", 1)
60
+
61
+ # data-file path constants + the document_path helper, for the table notebooks
62
+ _SETUP_TABLE = r"""
63
+ DELTA22_HDF5 = paths.dataset_file("delta22", root=REPO)
64
+ XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
65
+
66
+ def document_path(name):
67
+ os.makedirs("documents", exist_ok=True)
68
+ return os.path.join("documents", name)
69
+ """
70
+
71
+ # only the notebooks that use N_SPLITS include this cell (fig2a_pareto uses its own, smaller
72
+ # PARETO_N_SPLITS instead).
73
+ _N_SPLITS_CELL = r"""
74
+ # the published panels use 250 seeded train/test splits
75
+ N_SPLITS = 250
76
+ """
77
+
78
+
79
+ # Figure 3 (analysis/manuscript_figures/fig3.ipynb) is hand-authored with its plotting inline; its
80
+ # data comes from delta22.py (fig3a_pcm_benefit_by_solvent, fig3b_shift_differences,
81
+ # fig3d_formula_regressions).
82
+
83
+
84
+ # ----------------------------------------------------------------------------
85
+ # Figure 4 + SI S16/S17/S18: Explicit Solvent Corrections are Physically Meaningful
86
+ # ----------------------------------------------------------------------------
87
+ _FIG4_LOAD = r"""
88
+ # Figure 4 uses one DFT method for the proton sites; differences are taken against a reference
89
+ # solvent, and the solvent_mean pseudo-solvent is added for the solvent-averaged reference.
90
+ METHOD, BASIS, GEOM = "b3lyp_d3bj", "pcSseg2", "aimnet2"
91
+ q = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
92
+ one = q[(q["sap_nmr_method"] == METHOD) & (q["sap_basis"] == BASIS) & (q["sap_geometry_type"] == GEOM)]
93
+ one = delta22.add_solvent_mean(one)
94
+ print(len(one), "rows for", METHOD, BASIS, GEOM)
95
+ """
96
+
97
+ # shared display labels for the 12 delta-22 solvents, used by Figure 4C and SI Figures S16-S18
98
+ _SOLVENT_LABELS = r"""
99
+ FIG4C_LABELS = {
100
+ "chloroform": "CDCl3", "dichloromethane": "DCM", "tetrahydrofuran": "THF",
101
+ "acetonitrile": "MeCN", "dimethylsulfoxide": "DMSO", "acetone": "acetone",
102
+ "methanol": "MeOH", "TIP4P": "TIP4P", "trifluoroethanol": "TFE",
103
+ "benzene": "benzene", "toluene": "toluene", "chlorobenzene": "chlorobenzene",
104
+ }
105
+ """
106
+
107
+ # Figure 4 (analysis/manuscript_figures/fig4.ipynb) is hand-authored with its plotting inline; its
108
+ # data comes from delta22.py (solvent_induced_shifts, solvent_pair_differences,
109
+ # fit_differences_to_experimental). The _FIG4_LOAD / _SOLVENT_LABELS cells above are shared with the
110
+ # SI S16-S18 notebook below.
111
+
112
+
113
+ si_figure_s16_s18 = [
114
+ md(r"""
115
+ # SI Figures S16-S18: predicted vs experimental solvent-induced shifts by reference solvent
116
+
117
+ Per solvent, implicit (PCM) and explicit (Desmond) predicted solvent-induced shift differences vs
118
+ measured (y=x ideal), for three reference solvents: **S16** chloroform, **S17** benzene, **S18**
119
+ solvent-averaged. One panel per solvent, each point a proton site.
120
+ """),
121
+ code(_BOOTSTRAP),
122
+ code(_IMPORTS),
123
+ code(_SETUP),
124
+ code(_FIG4_LOAD),
125
+ code(_SOLVENT_LABELS),
126
+ code(r"""
127
+ # panel order matches the canonical figures; a different order from delta22.DESMOND_SOLVENTS
128
+ SI_S16_18_SOLVENT_ORDER = [
129
+ "chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
130
+ "dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene", "chlorobenzene",
131
+ ]
132
+
133
+ # panel titles use the full solvent name; axis labels use the compact NMR abbreviations (THF, DCM,
134
+ # DMSO, TFE, CDCl3); the reference is CDCl3 / Benzene / "Avg Corr." for S16 / S17 / S18.
135
+ TITLE_LABEL = {"chloroform": "Chloroform", "tetrahydrofuran": "Tetrahydrofuran",
136
+ "dichloromethane": "Dichloromethane", "acetone": "Acetone", "acetonitrile": "Acetonitrile",
137
+ "dimethylsulfoxide": "Dimethylsulfoxide", "trifluoroethanol": "Trifluoroethanol",
138
+ "methanol": "Methanol", "TIP4P": "TIP4P", "benzene": "Benzene", "toluene": "Toluene",
139
+ "chlorobenzene": "Chlorobenzene"}
140
+ AXIS_LABEL = {**TITLE_LABEL, "chloroform": "CDCl3", "tetrahydrofuran": "THF",
141
+ "dichloromethane": "DCM", "dimethylsulfoxide": "DMSO", "trifluoroethanol": "TFE"}
142
+ REF_LABEL = {"chloroform": "CDCl3", "benzene": "Benzene", "solvent_mean": "Avg Corr."}
143
+ """),
144
+ code(r"""
145
+ for figure, reference in [("S16", "chloroform"), ("S17", "benzene"), ("S18", "solvent_mean")]:
146
+ sh = delta22.solvent_induced_shifts(one, reference, SI_S16_18_SOLVENT_ORDER, nucleus="H", explicit="desmond")
147
+ implicit_fit = delta22.fit_differences_to_experimental(sh, "implicit_diff")
148
+ explicit_fit = delta22.fit_differences_to_experimental(sh, "explicit_diff")
149
+ print(f"{figure}: reference={reference:12s} n={explicit_fit['n']:4d} "
150
+ f"implicit fit RMSE={implicit_fit['rmse']:.3f} explicit fit RMSE={explicit_fit['rmse']:.3f}")
151
+ panel_solvents = [s for s in SI_S16_18_SOLVENT_ORDER if s != reference]
152
+ delta22_plots.plot_shift_prediction_scatter_grid(
153
+ sh, panel_solvents, REF_LABEL[reference], AXIS_LABEL, TITLE_LABEL,
154
+ save_path=figure_path(f"si_figure_{figure.lower()}.png"))
155
+ plt.show()
156
+ """),
157
+ ]
158
+
159
+
160
+ # ----------------------------------------------------------------------------
161
+ # SI Figure S7: Explicit Solvent Corrections are Method-Independent
162
+ # ----------------------------------------------------------------------------
163
+ si_figure_s07 = [
164
+ md(r"""
165
+ # SI Figure S7: DFT ¹³C explicit-solvent correction, Desmond vs OpenMM
166
+
167
+ For the four OpenMM solvents, the DFT ¹³C explicit-solvent correction from Desmond MD vs from OpenMM
168
+ (y=x reference line).
169
+ """),
170
+ code(_BOOTSTRAP),
171
+ code(_IMPORTS_NP),
172
+ code(_SETUP),
173
+ code(r"""
174
+ dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
175
+ """),
176
+ md("## DFT explicit corrections: Desmond vs OpenMM"),
177
+ code(r"""
178
+ SOLVENT_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
179
+ dft_pairs = {sv: delta22.explicit_correction_pairs(dft, sv, "C") for sv in SOLVENT_ORDER}
180
+ for sv, p in dft_pairs.items():
181
+ print(f"DFT {sv:12s} n={len(p):3d} r={np.corrcoef(p['desmond'], p['openMM'])[0,1]:.3f}")
182
+ delta22_plots.plot_desmond_vs_openmm_grid(dft_pairs, save_path=figure_path("si_figure_s07.png"))
183
+ plt.show()
184
+ """),
185
+ ]
186
+
187
+
188
+ # ----------------------------------------------------------------------------
189
+ # SI Figure S4: Implicit Solvent Corrections are Highly Correlated
190
+ # ----------------------------------------------------------------------------
191
+ si_figure_s04 = [
192
+ md(r"""
193
+ # SI Figure S4: correlation of implicit (PCM) corrections across solvents and methods
194
+
195
+ **S4A** correlation across solvents (both nuclei), **S4B** one solvent vs another (¹H), **S4C**
196
+ correlation across methods (both nuclei). Cells show -log10(1-r), so 3 means r=0.999.
197
+ """),
198
+ code(_BOOTSTRAP),
199
+ code(_IMPORTS_NP),
200
+ code(_SETUP),
201
+ code(r"""
202
+ METHOD, BASIS, GEOM = "b3lyp_d3bj", "pcSseg2", "pbe0_tz"
203
+ dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
204
+ base = dft[(dft["sap_nmr_method"] == METHOD) & (dft["sap_basis"] == BASIS)
205
+ & (dft["sap_geometry_type"] == GEOM)]
206
+ one = base[base["nucleus"] == "H"]
207
+
208
+ # solvent order that groups polar-aprotic -> polar-protic -> aromatic (matches the published panel)
209
+ ORDERED_SOLVENTS = ["chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
210
+ "dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P",
211
+ "benzene", "toluene", "chlorobenzene"]
212
+ """),
213
+ md("## S4A: solvent-vs-solvent PCM correlation (¹H and ¹³C)"),
214
+ code(r"""
215
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
216
+ solvent_corr = delta22.correlation_matrix(base[base["nucleus"] == nucleus], "pcm", ["solute", "site"], "solvent")
217
+ solvent_corr = solvent_corr.reindex(index=ORDERED_SOLVENTS, columns=ORDERED_SOLVENTS)
218
+ vals = solvent_corr.values[np.triu_indices_from(solvent_corr.values, k=1)]
219
+ print(f"solvent PCM correlation ({label}): mean r={np.nanmean(vals):.4f} min r={np.nanmin(vals):.4f}")
220
+ caption = ("Correlation coefficients between solvents across all 22 solutes.\n"
221
+ f"PCM corrections computed with {METHOD} with the {BASIS} basis.\n"
222
+ "A value of 3 means the coefficient is 0.999.")
223
+ delta22_plots.plot_correlation_matrix(solvent_corr, f"Solvents are Highly Correlated ({nucleus})", caption,
224
+ colormap="Reds", show_values=True,
225
+ save_path=figure_path(f"si_figure_s04a_{label}.png"))
226
+ """),
227
+ md("## S4B: PCM corrections, chloroform vs acetonitrile (1H) -- one square of S4A"),
228
+ code(r"""
229
+ pair = one.pivot_table(index=["solute", "site"], columns="solvent", values="pcm")[
230
+ ["chloroform", "acetonitrile"]].dropna()
231
+ delta22_plots.plot_pcm_scatter(pair["chloroform"], pair["acetonitrile"], "chloroform", "acetonitrile", "H",
232
+ save_path=figure_path("si_figure_s04b_1H.png"))
233
+ """),
234
+ md("## S4C: method-vs-method correlation (chloroform, double hybrids excluded, ¹H and ¹³C)"),
235
+ code(r"""
236
+ # one solvent, exclude the double hybrids whose PCM is the substituted reference value
237
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
238
+ chcl3 = dft[(dft["sap_basis"] == BASIS) & (dft["sap_geometry_type"] == GEOM)
239
+ & (dft["nucleus"] == nucleus) & (dft["solvent"] == "chloroform")
240
+ & (~dft["sap_nmr_method"].isin(delta22.DOUBLE_HYBRID_METHODS))]
241
+ method_corr = delta22.correlation_matrix(chcl3, "pcm", ["solute", "site"], "sap_nmr_method")
242
+ method_order = sorted(method_corr.index) # alphabetical, matches the published panel
243
+ method_corr = method_corr.reindex(index=method_order, columns=method_order)
244
+ mvals = method_corr.values[np.triu_indices_from(method_corr.values, k=1)]
245
+ print(f"method PCM correlation ({label}): mean r={np.nanmean(mvals):.4f} min r={np.nanmin(mvals):.4f}")
246
+ caption = ("Correlation coefficients between NMR methods across all 22 solutes.\n"
247
+ f"PCM corrections for chloroform with the {BASIS} basis.")
248
+ delta22_plots.plot_correlation_matrix(method_corr, f"NMR Methods are Highly Correlated ({nucleus})", caption,
249
+ colormap="Reds", show_values=True,
250
+ save_path=figure_path(f"si_figure_s04c_{label}.png"))
251
+ """),
252
+ ]
253
+
254
+
255
+ # ----------------------------------------------------------------------------
256
+ # SI Figures S11 and S13: MagNET reproduces the DFT corrections
257
+ # ----------------------------------------------------------------------------
258
+ _DFT_NN_LOAD = r"""
259
+ dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
260
+ nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)
261
+ """
262
+
263
+ si_figure_s11 = [
264
+ md(r"""
265
+ # SI Figure S11: MagNET vs DFT rovibrational (QCD) corrections
266
+
267
+ Four panels: **A** MagNET (NN) vs B3LYP/cc-pVDZ rovibrational (QCD) correction per site, both nuclei;
268
+ **B** the NN-minus-DFT error distribution; **C/D** every proton/carbon site's DFT and NN QCD
269
+ correction, one column per solute. (QCD is gas-phase, so no solvent axis.)
270
+ """),
271
+ code(_BOOTSTRAP),
272
+ code(_IMPORTS),
273
+ code(_SETUP),
274
+ code(_DFT_NN_LOAD),
275
+ md("## Panels A and B: MagNET vs DFT QCD correction, both nuclei"),
276
+ code(r"""
277
+ qcd = delta22.compare_dft_nn(dft, nn, "qcd", keys=("solute", "site", "nucleus"))
278
+ delta22_plots.plot_qcd_scatter(qcd, save_path=figure_path("si_figure_s11a_scatter.png"))
279
+ delta22_plots.plot_qcd_error_histogram(qcd, save_path=figure_path("si_figure_s11b_hist.png"))
280
+ plt.show()
281
+ """),
282
+ md("## Panel C: every proton site's QCD correction, one column per solute"),
283
+ code(r"""
284
+ delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus="H"), "H", "upper left",
285
+ save_path=figure_path("si_figure_s11c_sites_1H.png"))
286
+ plt.show()
287
+ """),
288
+ md("## Panel D: every carbon site's QCD correction, one column per solute"),
289
+ code(r"""
290
+ delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus="C"), "C", "upper right",
291
+ save_path=figure_path("si_figure_s11d_sites_13C.png"))
292
+ plt.show()
293
+ """),
294
+ ]
295
+
296
+ si_figure_s13 = [
297
+ md(r"""
298
+ # SI Figure S13: MagNET-x vs DFT explicit-solvent corrections
299
+
300
+ Four panels, both nuclei: **A** MagNET-x (NN) vs DFT explicit-solvent correction per site/solvent,
301
+ Desmond and OpenMM overlaid; **B** the NN-minus-DFT error distribution; **C** every site's DFT and NN
302
+ correction in chloroform, one column per solute; **D** the semi-parsimonious composite model's fit accuracy vs experiment across the four OpenMM solvents, split by engine and
303
+ DFT-vs-NN source.
304
+ """),
305
+ code(_BOOTSTRAP),
306
+ code(_IMPORTS),
307
+ code(_SETUP),
308
+ code(_DFT_NN_LOAD),
309
+ code(r"""
310
+ # openMM/Desmond engine hues (used by the scatter/histogram/site/fitting-accuracy panels below)
311
+ ENGINE_COLORS = {"openMM": "#2E86AB", "desmond": "#A23B72"}
312
+ ENGINE_LABELS = {"desmond": "Desmond", "openMM": "OpenMM"}
313
+ """),
314
+ md("## Panels A and B: MagNET-x vs DFT explicit corrections, Desmond and OpenMM overlaid"),
315
+ code(r"""
316
+ explicit_by_engine = delta22.compare_dft_nn_by_engine(dft, nn, keys=("solute", "site", "nucleus", "solvent"))
317
+ delta22_plots.plot_dft_nn_scatter_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,
318
+ save_path=figure_path("si_figure_s13a_scatter.png"))
319
+ delta22_plots.plot_dft_nn_error_histogram_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,
320
+ save_path=figure_path("si_figure_s13b_hist.png"))
321
+ plt.show()
322
+ """),
323
+ md("## Panel C: every proton/carbon site's explicit correction in chloroform"),
324
+ code(r"""
325
+ for nucleus, label in [("H", "Proton"), ("C", "Carbon")]:
326
+ pairs = delta22.explicit_correction_dft_nn_pairs(dft, nn, "chloroform", nucleus)
327
+ delta22_plots.plot_explicit_correction_by_site(
328
+ pairs, f"All {label} Sites: DFT vs NN Explicit Corrections (chloroform)", ENGINE_COLORS,
329
+ save_path=figure_path(f"si_figure_s13c_sites_{'1H' if nucleus == 'H' else '13C'}.png"))
330
+ plt.show()
331
+ """),
332
+ md("## Panel D: fitting accuracy of the semi-parsimonious composite model"),
333
+ code(_N_SPLITS_CELL),
334
+ code(r"""
335
+ SOLVENT_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
336
+ solutes = sorted(dft["solute"].unique())
337
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
338
+ fitting = delta22.si_s13d_fitting_accuracy(dft, nn, SOLVENT_ORDER, N_SPLITS, solutes, nucleus=nucleus)
339
+ delta22_plots.plot_fitting_accuracy_boxplot(
340
+ fitting, SOLVENT_ORDER, label, ENGINE_COLORS,
341
+ save_path=figure_path(f"si_figure_s13d_fitting_{'1H' if nucleus == 'H' else '13C'}.png"))
342
+ plt.show()
343
+ """),
344
+ ]
345
+
346
+
347
+ # ----------------------------------------------------------------------------
348
+ # SI Figure S5: PCM Captures Bulk Solvent Effects
349
+ # ----------------------------------------------------------------------------
350
+ si_figure_s05 = [
351
+ md(r"""
352
+ # SI Figure S5: per-solvent PCM benefit vs bulk dielectric constant and polarizability
353
+
354
+ Per-solvent PCM benefit (% test-RMSE reduction) vs bulk dielectric constant and polarizability, for
355
+ the MagNET-Zero reference method per nucleus (WP04 ¹H, wB97X-D ¹³C). Panels A (¹H) and B (¹³C)
356
+ show all solvents; C repeats ¹H excluding aromatics and trifluoroethanol.
357
+ """),
358
+ code(_BOOTSTRAP),
359
+ code(_IMPORTS),
360
+ code(_SETUP),
361
+ code(_N_SPLITS_CELL),
362
+ code(r"""
363
+ dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
364
+ solutes = sorted(dft["solute"].unique())
365
+ benefit = {}
366
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
367
+ method = delta22.MAGNET_PCM_OUTPUT_METHODS[nucleus]
368
+ benefit[nucleus] = delta22.pcm_benefit_per_solvent(dft, method, "pcSseg2", "aimnet2",
369
+ delta22.DESMOND_SOLVENTS, n_splits=N_SPLITS,
370
+ solutes=solutes, nucleus=nucleus)
371
+ print(f"{label} ({method}):"); print(benefit[nucleus].round(1).to_string())
372
+ """),
373
+ md("## S5A (1H, all solvents), S5B (13C, all solvents), and S5C (1H, excluding aromatics and trifluoroethanol)"),
374
+ code(r"""
375
+ # panels A (1H) and B (13C): all solvents
376
+ for nucleus, letter, nuc_label in [("H", "A", "H"), ("C", "B", "C")]:
377
+ delta22_plots.plot_pcm_benefit_vs_properties(
378
+ benefit[nucleus], delta22.SOLVENT_DIELECTRIC, delta22.SOLVENT_POLARIZABILITY, nuc_label,
379
+ save_path=figure_path(f"si_figure_s05{letter}_all.png"))
380
+ # panel C is 1H only, excluding the aromatic solvents and trifluoroethanol (matches the published SI)
381
+ delta22_plots.plot_pcm_benefit_vs_properties(
382
+ benefit["H"], delta22.SOLVENT_DIELECTRIC, delta22.SOLVENT_POLARIZABILITY, "H",
383
+ exclude=["benzene", "toluene", "chlorobenzene", "trifluoroethanol"],
384
+ title_extra="Excluding Aromatic Solvents and Trifluoroethanol",
385
+ save_path=figure_path("si_figure_s05A_no_aromatics_tfe.png"))
386
+ """),
387
+ ]
388
+
389
+
390
+ # ----------------------------------------------------------------------------
391
+ # SI Figure S8 (panels B-D): Convergence of Explicit Solvent Corrections
392
+ # ----------------------------------------------------------------------------
393
+ si_figure_s08 = [
394
+ md(r"""
395
+ # SI Figure S8 (panels B-D): explicit-solvent correction vs MD-frame count
396
+
397
+ For an example site (AcOH proton in chloroform), the explicit-solvent correction vs number of MD frames included (running average): OpenMM vs Desmond (B1) and DFT vs MagNET-x/"NN" at fixed OpenMM (B3), with per-frame distributions (B2/B4) and autocorrelation (C). Panel D shows which frames have computed DFT data per solvent (also the source of B's histogram normalization).
398
+ """),
399
+ code(_BOOTSTRAP),
400
+ code(r"""
401
+ import numpy as np
402
+ import matplotlib.pyplot as plt
403
+
404
+ import delta22
405
+ import delta22_reader
406
+ import delta22_plots
407
+ import paths
408
+ """),
409
+ code(_SETUP),
410
+ code(r"""
411
+ site_atoms = delta22_reader.load_site_atom_data(XLSX, verbose=False)
412
+ idx = delta22.site_atom_indices(site_atoms.loc[("AcOH", "H", "H"), "atom_numbers"])
413
+ SOLUTE, SOLVENT = "AcOH", "chloroform"
414
+ """),
415
+ code(r"""
416
+ # OpenMM/Desmond engine hues; DFT vs NN reuse the OpenMM hue (DFT lightened, NN full strength)
417
+ ENGINE_COLORS = {"openMM": "#2E86AB", "desmond": "#A23B72"}
418
+ ENGINE_LABELS = {"openMM": "OpenMM", "desmond": "Desmond"}
419
+
420
+ SOURCE_COLORS = {"dft": delta22_plots.lighten_color(ENGINE_COLORS["openMM"]), "nn": ENGINE_COLORS["openMM"]}
421
+ SOURCE_LABELS = {"dft": "DFT", "nn": "NN"}
422
+ LABEL_COLORS = {**ENGINE_COLORS, **SOURCE_COLORS}
423
+ LABEL_NAMES = {**ENGINE_LABELS, **SOURCE_LABELS}
424
+ """),
425
+ md(r"""
426
+ ## Panel B1/B2: OpenMM vs Desmond
427
+
428
+ Running-average convergence (B1) and per-frame distribution (B2), comparing the two MD engines.
429
+ """),
430
+ code(r"""
431
+ running_by_engine, finals_by_engine, per_frame_by_engine, n_frames_by_engine = {}, {}, {}, {}
432
+ for engine in ["openMM", "desmond"]:
433
+ perturbed = delta22_reader.load_perturbed_shieldings(DELTA22_HDF5, SOLUTE, SOLVENT, engine, "dft")
434
+ per_frame = delta22.frame_corrections(perturbed, idx)
435
+ running = delta22.running_average(per_frame)
436
+ running_by_engine[engine] = running
437
+ finals_by_engine[engine] = running[~np.isnan(running)][-1]
438
+ per_frame_by_engine[engine] = per_frame
439
+ n_frames_by_engine[engine] = len(per_frame) # the total trajectory length, valid or not
440
+ print(f"{engine}: {int(np.sum(~np.isnan(per_frame)))} / {len(per_frame)} valid frames, "
441
+ f"converged correction {finals_by_engine[engine]:.4f} ppm")
442
+
443
+ delta22_plots.plot_frame_convergence(
444
+ running_by_engine, finals_by_engine, LABEL_COLORS, LABEL_NAMES,
445
+ title=f"Convergence of Explicit Solvent Corrections\n({SOLUTE} in {SOLVENT})",
446
+ save_path=figure_path("si_figure_s08_b1_convergence_engine.png"))
447
+ plt.show()
448
+
449
+ delta22_plots.plot_frame_correction_histogram(
450
+ per_frame_by_engine, n_frames_by_engine, LABEL_COLORS, LABEL_NAMES,
451
+ title="Distribution of Frame-wise Corrections",
452
+ save_path=figure_path("si_figure_s08_b2_histogram_engine.png"))
453
+ plt.show()
454
+ """),
455
+ md(r"""
456
+ ## Panel B3/B4: DFT vs NN
457
+
458
+ Same running average (B3) and per-frame distribution (B4), at fixed OpenMM engine, comparing DFT vs
459
+ MagNET-x ("NN") shieldings.
460
+ """),
461
+ code(r"""
462
+ running_by_source, finals_by_source, per_frame_by_source, n_frames_by_source = {}, {}, {}, {}
463
+ for source in ["dft", "nn"]:
464
+ perturbed = delta22_reader.load_perturbed_shieldings(DELTA22_HDF5, SOLUTE, SOLVENT, "openMM", source)
465
+ per_frame = delta22.frame_corrections(perturbed, idx)
466
+ running = delta22.running_average(per_frame)
467
+ running_by_source[source] = running
468
+ finals_by_source[source] = running[~np.isnan(running)][-1]
469
+ per_frame_by_source[source] = per_frame
470
+ n_frames_by_source[source] = len(per_frame)
471
+ print(f"{source}: {int(np.sum(~np.isnan(per_frame)))} / {len(per_frame)} valid frames, "
472
+ f"converged correction {finals_by_source[source]:.4f} ppm")
473
+
474
+ delta22_plots.plot_frame_convergence(
475
+ running_by_source, finals_by_source, LABEL_COLORS, LABEL_NAMES, xlabel="Number of MD Frames",
476
+ title=f"OpenMM DFT vs. NN Convergence Comparison\n({SOLUTE} in {SOLVENT})",
477
+ save_path=figure_path("si_figure_s08_b3_convergence_source.png"))
478
+ plt.show()
479
+
480
+ delta22_plots.plot_frame_correction_histogram(
481
+ per_frame_by_source, n_frames_by_source, LABEL_COLORS, LABEL_NAMES,
482
+ title="Distribution of Frame-wise Corrections",
483
+ save_path=figure_path("si_figure_s08_b4_histogram_source.png"))
484
+ plt.show()
485
+ """),
486
+ md(r"""
487
+ ## Panel C: autocorrelation, DFT vs NN
488
+
489
+ Autocorrelation of the per-frame correction to lag 200 (~100 frames per trajectory repeat), DFT vs NN,
490
+ same OpenMM trajectory as B3/B4.
491
+ """),
492
+ code(r"""
493
+ autocorr_by_source = {source: delta22.autocorrelation(per_frame_by_source[source], max_lag=200)
494
+ for source in ["dft", "nn"]}
495
+ for source, autocorr in autocorr_by_source.items():
496
+ print(f"{source}: lag-1 autocorrelation {autocorr[1]:.3f}")
497
+
498
+ delta22_plots.plot_frame_autocorrelation(
499
+ autocorr_by_source, LABEL_COLORS, LABEL_NAMES,
500
+ title=f"Autocorrelation of Frame-wise Corrections\n({SOLUTE} in {SOLVENT})",
501
+ save_path=figure_path("si_figure_s08_c_autocorrelation.png"))
502
+ plt.show()
503
+ """),
504
+ md(r"""
505
+ ## Panel D: frame data availability
506
+
507
+ Which trajectory frames have computed DFT shielding for AcOH, across all 12 Desmond and 4 OpenMM
508
+ solvents. DFT was computed for a non-contiguous subset (compute-budget limits; jobs queued randomly).
509
+ """),
510
+ code(r"""
511
+ # row order matching the published panel: chloroform first, then by solvent class (aprotic ->
512
+ # protic -> aromatic); OpenMM only has the four explicit-solvent solvents
513
+ DESMOND_ORDER = ["chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
514
+ "dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P",
515
+ "benzene", "toluene", "chlorobenzene"]
516
+ OPENMM_ORDER = ["chloroform", "methanol", "TIP4P", "benzene"]
517
+
518
+ grids_by_engine, solvents_by_engine = {}, {}
519
+ for engine, solvents in [("desmond", DESMOND_ORDER), ("openMM", OPENMM_ORDER)]:
520
+ grid, frame_counts = delta22.frame_validity_grid(DELTA22_HDF5, SOLUTE, solvents, engine, shield_type="dft")
521
+ grids_by_engine[engine] = grid
522
+ solvents_by_engine[engine] = [delta22_plots.display_solvent_name(s) for s in solvents]
523
+ print(f"{engine}: {len(solvents)} solvents, frame counts {dict(zip(solvents, frame_counts))}")
524
+
525
+ delta22_plots.plot_frame_validity_heatmaps(grids_by_engine, solvents_by_engine, ENGINE_LABELS, solute=SOLUTE,
526
+ save_path=figure_path("si_figure_s08_d_frame_validity.png"))
527
+ plt.show()
528
+ """),
529
+ ]
530
+
531
+
532
+ # ----------------------------------------------------------------------------
533
+ # Figure 2A + SI Figure S1: the accuracy-vs-cost Pareto frontier
534
+ # ----------------------------------------------------------------------------
535
+ _PARETO_LOAD = r"""
536
+ # Load the flat DFT and MagNET tables and the timing tables.
537
+ dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)
538
+ nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)
539
+ dft_gas_timings, nn_timings = delta22.load_pareto_timings(DELTA22_HDF5)
540
+
541
+ # One point per method/basis/geometry/nucleus/solvent, plus the solvent-averaged rows, each with its
542
+ # mean test RMSE and total compute time. MagNET appears as a single method (aimnet2, basis "N/A").
543
+ # Figure 2A uses 100 seeded splits, not the 250 the other delta-22 panels use, and trains on the
544
+ # first 10 shuffled solutes / tests on the rest; fig2a_pareto_points handles that split convention.
545
+ PARETO_N_SPLITS = 100
546
+ points = delta22.fig2a_pareto_points(dft, nn, dft_gas_timings, nn_timings, n_splits=PARETO_N_SPLITS)
547
+ print(points["nmr_method"].nunique(), "methods;",
548
+ "MagNET total time", float(points.query("nmr_method=='MagNET'")["total_time"].iloc[0]), "s")
549
+ """
550
+
551
+ # Figure 2A (analysis/manuscript_figures/fig2a_pareto.ipynb) is hand-authored with its plotting
552
+ # inline; its data comes from delta22.py.
553
+
554
+ # SI Figure S1 (analysis/si_figures/si_figure_s01.ipynb) is hand-authored too, with the same Pareto
555
+ # panel engine as Figure 2A run for both nuclei; its data comes from delta22.py.
556
+
557
+ # ----------------------------------------------------------------------------
558
+ # SI Tables S1/S2: the Pareto plot data (same chloroform rows as SI Figure S1)
559
+ # ----------------------------------------------------------------------------
560
+ si_table_s01_s02_pareto = [
561
+ md(r"""
562
+ # SI Tables S1 and S2: the Pareto plot data
563
+
564
+ Each method's total compute time and CDCl3 test RMSE, for proton (S1) and carbon (S2): a curated
565
+ 55-row subset (MagNET, one AIMNet2-geometry row, and the DFT grid on PBE0/cc-pVTZ geometries).
566
+ """),
567
+ code(_BOOTSTRAP),
568
+ code(_IMPORTS_TABLE_NPPD),
569
+ code(_SETUP_TABLE),
570
+ code(_PARETO_LOAD),
571
+ md("## Tables S1 and S2"),
572
+ code(r"""
573
+ # full column set matching the SI tables: identity + fitting RMSE + the three time components and
574
+ # their log10 (all solvents here are chloroform). geometry_time and nmr_time sum to total_time.
575
+ COLUMNS = ["geometry_type", "nmr_method", "basis", "solvent", "fitting_RMSE",
576
+ "geometry_time", "nmr_time", "total_time", "log10_total_time"]
577
+
578
+ curated = {}
579
+ for nucleus, label in [("H", "S1"), ("C", "S2")]:
580
+ sub = delta22.pareto_table_curated(points, nucleus).copy()
581
+ sub["log10_total_time"] = np.log10(sub["total_time"])
582
+ sub = sub[COLUMNS]
583
+ curated[label] = sub
584
+ print(f"Table {label} ({nucleus}): {len(sub)} rows x {sub.shape[1]} columns")
585
+ print(sub.to_string(index=False), "\n")
586
+
587
+ # write the two tables to this notebook's documents/ folder, one sheet per SI table
588
+ out = document_path("si_table_s01_s02_pareto.xlsx")
589
+ with pd.ExcelWriter(out) as writer:
590
+ curated["S1"].to_excel(writer, sheet_name="Table S1 (1H)", index=False)
591
+ curated["S2"].to_excel(writer, sheet_name="Table S2 (13C)", index=False)
592
+ print("wrote", os.path.relpath(out, REPO))
593
+ """),
594
+ ]
595
+
596
+
597
+ # ----------------------------------------------------------------------------
598
+ # SI Figure S6: Explicit Solvation is Broadly Beneficial
599
+ # ----------------------------------------------------------------------------
600
+ si_figure_s06 = [
601
+ md(r"""
602
+ # SI Figure S6: per-solvent test RMSE of six solvent-correction models
603
+
604
+ Per nucleus, the per-solvent test RMSE of six solvent-correction models: SotA implicit (single-slope
605
+ PCM), the paper's implicit fit (stationary + PCM as separate terms), and explicit (Desmond), each
606
+ with and without a vibrational correction (QCD for ¹H, Desmond vibration for ¹³C). Level of theory:
607
+ dsd_pbep86/pcSseg3 on pbe0_tz geometries, PCM substituted from b3lyp_d3bj/pcSseg3.
608
+ """),
609
+ code(_BOOTSTRAP),
610
+ code(_IMPORTS),
611
+ code(_SETUP),
612
+ code(r"""
613
+ query = delta22.add_composite_columns(delta22.load_query_df_dft(
614
+ DELTA22_HDF5, XLSX, pcm_reference_method="b3lyp_d3bj", pcm_reference_basis="pcSseg3", verbose=False))
615
+ solutes = sorted(query["solute"].unique())
616
+
617
+ METHOD, BASIS, GEOM = "dsd_pbep86", "pcSseg3", "pbe0_tz"
618
+
619
+ # same six categories for both nuclei; the "+ Vibrations" term differs (qcd for H, desmond_vib for C)
620
+ LADDER = {
621
+ "H": {
622
+ "stationary_plus_pcm": "SotA Implicit Solvent",
623
+ "stationary_plus_pcm_plus_qcd": "SotA Implicit Solvent + Vibrations",
624
+ "stationary + pcm": "Implicit Solvent",
625
+ "stationary_plus_qcd + pcm": "Implicit Solvent + Vibrations",
626
+ "stationary + desmond": "Explicit Solvent",
627
+ "stationary_plus_qcd + desmond": "Explicit Solvent + Vibrations"},
628
+ "C": {
629
+ "stationary_plus_pcm": "SotA Implicit Solvent",
630
+ "stationary_plus_pcm_plus_des_vib": "SotA Implicit Solvent + Vibrations",
631
+ "stationary + pcm": "Implicit Solvent",
632
+ "stationary_plus_des_vib + pcm": "Implicit Solvent + Vibrations",
633
+ "stationary + desmond": "Explicit Solvent",
634
+ "stationary_plus_des_vib + desmond": "Explicit Solvent + Vibrations"},
635
+ }
636
+ """),
637
+ code(r"""
638
+ # one dark/light pair per solvent-treatment family (SotA implicit / implicit / explicit), light = +Vibrations
639
+ S6_COLORS = ["#C4B037", "#F5EDA0", "#A72608", "#E4A0A0", "#5F7C8A", "#B9E7DF"]
640
+ """),
641
+ md("## Formula ladder per nucleus"),
642
+ code(_N_SPLITS_CELL),
643
+ code(r"""
644
+ for nucleus, labels in LADDER.items():
645
+ results = delta22.fig3d_formula_regressions(query, METHOD, BASIS, GEOM, list(labels),
646
+ delta22.DESMOND_SOLVENTS, n_splits=N_SPLITS,
647
+ solutes=solutes, nucleus=nucleus)
648
+ med = results.groupby("formula")["test_RMSE"].median()
649
+ print(f"--- {nucleus} ({METHOD}) median test RMSE ---")
650
+ for formula in labels:
651
+ print(f" {labels[formula]:38s} {med[formula]:.4f}")
652
+ delta22_plots.plot_formula_ladder_boxplot(
653
+ results, labels, delta22.SOLVENT_GROUPS, nucleus=nucleus, colors=S6_COLORS,
654
+ title=f"Explicit Solvation + Vibrational Effects Improve Predictions Across Solvents ({nucleus} Nucleus)",
655
+ save_path=figure_path(f"si_figure_s06_ladder_{'1H' if nucleus == 'H' else '13C'}.png"))
656
+ plt.show()
657
+ """),
658
+ ]
659
+
660
+
661
+ # ----------------------------------------------------------------------------
662
+ # SI Table S6: Explicit solvent corrections are superior for predicting solvent-induced shifts
663
+ # ----------------------------------------------------------------------------
664
+ # SI table row order (chloroform is the reference and so is excluded)
665
+ _S6_SOLVENT_ORDER = [
666
+ "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile", "dimethylsulfoxide",
667
+ "trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene", "chlorobenzene",
668
+ ]
669
+ si_table_s06 = [
670
+ md(r"""
671
+ # SI Table S6: per-solvent implicit vs explicit fit RMSE (chloroform reference, ¹H)
672
+
673
+ Per solvent (chloroform reference), fit RMSE of experimental solvent-induced ¹H shifts using implicit
674
+ (PCM) vs explicit (Desmond), and the percent improvement.
675
+ """),
676
+ code(_BOOTSTRAP),
677
+ code(_IMPORTS_TABLE_PD),
678
+ code(_SETUP_TABLE),
679
+ code(_FIG4_LOAD),
680
+ md("## Table S6: per-solvent implicit vs explicit fit RMSE (chloroform reference, 1H)"),
681
+ code(r"""
682
+ rows = []
683
+ for solvent in _S6_SOLVENT_ORDER:
684
+ sp = delta22.solvent_pair_differences(one, solvent, "chloroform", nucleus="H", explicit="desmond")
685
+ implicit = delta22.fit_differences_to_experimental(sp, "implicit_diff")["rmse"]
686
+ explicit = delta22.fit_differences_to_experimental(sp, "explicit_diff")["rmse"]
687
+ rows.append({"solvent": solvent,
688
+ "implicit_fitting_rmse": round(implicit, 4),
689
+ "explicit_fitting_rmse": round(explicit, 4),
690
+ "explicit_improvement_pct": round(100.0 * (implicit - explicit) / implicit, 2)})
691
+ table_s6 = pd.DataFrame(rows)
692
+ print(table_s6.to_string(index=False))
693
+
694
+ # write the table to this notebook's documents/ folder
695
+ out = document_path("si_table_s06_solvent_corrections.xlsx")
696
+ with pd.ExcelWriter(out) as writer:
697
+ table_s6.to_excel(writer, sheet_name="Table S6", index=False)
698
+ print("wrote", os.path.relpath(out, REPO))
699
+ """.replace("_S6_SOLVENT_ORDER", repr(_S6_SOLVENT_ORDER))),
700
+ ]
701
+
702
+
703
+ # ----------------------------------------------------------------------------
704
+ # SI Figure S14: End-to-End Neural Network Acceleration Delivers Equivalent Performance
705
+ # ----------------------------------------------------------------------------
706
+ si_figure_s14 = [
707
+ md(r"""
708
+ # SI Figure S14: test RMSE with DFT vs MagNET features, by solvent
709
+
710
+ For the four explicit-solvent solvents (chloroform, methanol, TIP4P water, benzene), per-solvent test
711
+ RMSE of predicting experimental ¹H/¹³C shifts with **DFT** features (solid) vs end-to-end **MagNET/NN**
712
+ features (lightened), each under three conditions (implicit SOTA, explicit, explicit + vibrations).
713
+ Two pages, one per MD engine (Desmond, OpenMM); nitromethane dropped.
714
+ """),
715
+ code(_BOOTSTRAP),
716
+ code(_IMPORTS),
717
+ code(_SETUP),
718
+ code(_N_SPLITS_CELL),
719
+ code(r"""
720
+ dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))
721
+ nn = delta22.add_composite_columns(delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False))
722
+ print(len(dft), "DFT rows;", len(nn), "NN rows")
723
+
724
+ LABELS = ["Implicit Solvent (SOTA)", "Explicit Solvent", "Explicit Solvent + Vibrations"]
725
+ SS_LABELS = {"TIP4P": "Water (TIP4P)"}
726
+ S14_SOLVENTS = ["chloroform", "methanol", "TIP4P", "benzene"] # the 4 explicit solvents, SI order
727
+ """),
728
+ md("## Desmond page (DFT vs NN)"),
729
+ code(r"""
730
+ # implicit = 2-term (stationary + pcm); explicit = stationary + desmond;
731
+ # explicit + vibrations = stationary_plus_qcd + desmond (1H) / stationary_plus_des_vib + desmond (13C)
732
+ DESMOND_FORMULAS = {
733
+ "H": ["stationary + pcm", "stationary + desmond", "stationary_plus_qcd + desmond"],
734
+ "C": ["stationary + pcm", "stationary + desmond", "stationary_plus_des_vib + desmond"],
735
+ }
736
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
737
+ formulas = DESMOND_FORMULAS[nucleus]
738
+ delta22_plots.plot_ss_boxplot_dft_vs_nn(
739
+ delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),
740
+ delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),
741
+ S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,
742
+ save_path=figure_path(f"si_figure_s14_desmond_{label}.png"))
743
+ plt.show()
744
+ """),
745
+ md("## OpenMM page (DFT vs NN)"),
746
+ code(r"""
747
+ # implicit = 1-term composite (stationary_plus_pcm); explicit = stationary + openMM;
748
+ # explicit + vibrations = stationary_plus_qcd + openMM (1H) / stationary_plus_op_vib + openMM (13C)
749
+ OPENMM_FORMULAS = {
750
+ "H": ["stationary_plus_pcm", "stationary + openMM", "stationary_plus_qcd + openMM"],
751
+ "C": ["stationary_plus_pcm", "stationary + openMM", "stationary_plus_op_vib + openMM"],
752
+ }
753
+ for nucleus, label in [("H", "1H"), ("C", "13C")]:
754
+ formulas = OPENMM_FORMULAS[nucleus]
755
+ delta22_plots.plot_ss_boxplot_dft_vs_nn(
756
+ delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),
757
+ delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),
758
+ S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,
759
+ save_path=figure_path(f"si_figure_s14_openmm_{label}.png"))
760
+ plt.show()
761
+ """),
762
+ ]
763
+
764
+
765
+ # name -> (cells, path relative to repo root). Notebooks are grouped by role (manuscript/si_figures/
766
+ # si_tables), not by dataset.
767
+ NOTEBOOKS = {
768
+ "si_figure_s06": (si_figure_s06, "analysis/si_figures/si_figure_s06.ipynb"),
769
+ "si_figure_s14": (si_figure_s14, "analysis/si_figures/si_figure_s14.ipynb"),
770
+ "si_table_s06": (si_table_s06, "analysis/si_tables/si_table_s06_solvent_corrections.ipynb"),
771
+ "si_table_s01_s02_pareto": (si_table_s01_s02_pareto, "analysis/si_tables/si_table_s01_s02_pareto.ipynb"),
772
+ "si_figure_s16_s18": (si_figure_s16_s18, "analysis/si_figures/si_figure_s16_s18.ipynb"),
773
+ "si_figure_s07": (si_figure_s07, "analysis/si_figures/si_figure_s07.ipynb"),
774
+ "si_figure_s04": (si_figure_s04, "analysis/si_figures/si_figure_s04.ipynb"),
775
+ "si_figure_s11": (si_figure_s11, "analysis/si_figures/si_figure_s11.ipynb"),
776
+ "si_figure_s13": (si_figure_s13, "analysis/si_figures/si_figure_s13.ipynb"),
777
+ "si_figure_s05": (si_figure_s05, "analysis/si_figures/si_figure_s05.ipynb"),
778
+ "si_figure_s08": (si_figure_s08, "analysis/si_figures/si_figure_s08_panelsBCD.ipynb"),
779
+ }
780
+
781
+ if __name__ == "__main__":
782
+ import os
783
+ import sys
784
+ here = os.path.dirname(os.path.abspath(__file__))
785
+ repo = os.path.abspath(os.path.join(here, "..", ".."))
786
+ names = sys.argv[1:]
787
+ if not names:
788
+ print("usage: python3 build_nb_delta22.py <name> [<name> ...]")
789
+ print("regenerates ONLY the named notebook(s) -- pick just the one(s) you edited,")
790
+ print("since regenerating clears a notebook's execution outputs.")
791
+ print("available names:", ", ".join(NOTEBOOKS))
792
+ raise SystemExit(1)
793
+ unknown = [n for n in names if n not in NOTEBOOKS]
794
+ if unknown:
795
+ raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
796
+ for name in names:
797
+ cells, relpath = NOTEBOOKS[name]
798
+ _save(cells, os.path.join(repo, relpath))
analysis/code/build_nb_magnet_benchmark.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source of truth for the MagNET-benchmark table notebooks (Tables S3/S4 and Table S5). Edit the
2
+ cell sources here, then regenerate and re-execute ONLY the notebook you touched, e.g.:
3
+
4
+ python3 build_nb_magnet_benchmark.py si_table_s03_s04_performance
5
+ jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s03_s04_performance.ipynb
6
+
7
+ python3 build_nb_magnet_benchmark.py si_table_s05_qcd
8
+ jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s05_qcd.ipynb
9
+
10
+ Regenerating overwrites the .ipynb (clearing its execution outputs), so do not hand-edit the
11
+ notebooks and regenerate only what you changed. All real code lives in magnet_benchmark.py (the
12
+ PUBLISHED reference values and the exact-reproduction group mapping); the notebooks only read
13
+ data/magnet_test_predictions/ through it. The notebooks live in analysis/si_tables/ (grouped by
14
+ role), not here.
15
+
16
+ Run with no arguments to list the available notebook names.
17
+ """
18
+ import os
19
+ import sys
20
+
21
+ from nb_build import md, code, save_notebook as _save
22
+
23
+
24
+ _BOOTSTRAP = r"""
25
+ import os, sys
26
+
27
+ # make the in-repo modules importable (not pip-installed)
28
+ REPO = os.path.abspath("../..")
29
+ for _p in ("data/magnet_test_predictions", "analysis/code", "analysis/code/shared"):
30
+ sys.path.insert(0, os.path.join(REPO, _p))
31
+ """
32
+
33
+ _IMPORTS = r"""
34
+ import pandas as pd
35
+ import magnet_test_predictions_reader
36
+ import magnet_benchmark
37
+ import paths
38
+ """
39
+
40
+ _SETUP = r"""
41
+ PREDICTIONS = paths.dataset_file("magnet_test_predictions", root=REPO)
42
+
43
+ def document_path(name):
44
+ # table/spreadsheet outputs go under this notebook's documents/ folder, created on first save
45
+ os.makedirs("documents", exist_ok=True)
46
+ return os.path.join("documents", name)
47
+ """
48
+
49
+ si_table_s03_s04_performance = [
50
+ md(r"""
51
+ # Tables S3 and S4: MagNET performance across geometries
52
+
53
+ How accurately MagNET reproduces its DFT training reference (PBE0/pcSseg-1) as geometries move from
54
+ stationary to vibrated to solvated, for the foundation model and the chloroform MagNET-x.
55
+ """),
56
+ code(_BOOTSTRAP),
57
+ code(_IMPORTS),
58
+ code(_SETUP),
59
+ code(r"""
60
+ rows = magnet_benchmark.exact_stats_table(PREDICTIONS, magnet_test_predictions_reader)
61
+ res = pd.DataFrame(rows).set_index(["nucleus", "model", "test_set"])
62
+
63
+ table_rows = []
64
+ for nucleus in ("1H", "13C"):
65
+ for model in magnet_benchmark.MODELS:
66
+ for ts in magnet_benchmark.TEST_SETS:
67
+ r = res.loc[(nucleus, model, ts)]
68
+ pmed, pmae, prmse = magnet_benchmark.PUBLISHED[nucleus][(model, ts)]
69
+ table_rows.append(dict(nucleus=nucleus, model=model, test_set=ts, n=int(r.n),
70
+ median_repro=round(r.median_ae, 6), median_SI=round(pmed, 6),
71
+ mae_repro=round(r.mae, 6), mae_SI=round(pmae, 6),
72
+ rmse_repro=round(r.rmse, 5), rmse_SI=round(prmse, 5)))
73
+ table = pd.DataFrame(table_rows)
74
+ table_s3 = table[table.nucleus == "1H"].drop(columns="nucleus")
75
+ table_s4 = table[table.nucleus == "13C"].drop(columns="nucleus")
76
+ print("Table S3 (1H):"); display(table_s3)
77
+ print("Table S4 (13C):"); display(table_s4)
78
+
79
+ # write the reproduced tables to this notebook's documents/ folder, one sheet per SI table
80
+ out = document_path("si_table_s03_s04_performance.xlsx")
81
+ with pd.ExcelWriter(out) as writer:
82
+ table_s3.to_excel(writer, sheet_name="Table S3 (1H)", index=False)
83
+ table_s4.to_excel(writer, sheet_name="Table S4 (13C)", index=False)
84
+ print("wrote", os.path.relpath(out, REPO))
85
+ """),
86
+ md("## Exact-reproduction check"),
87
+ code(r"""
88
+ # every row's reproduced median/MAE/RMSE should match the published SI value to a few parts per million
89
+ max_dev = (table[["median_repro", "median_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
90
+ table[["mae_repro", "mae_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
91
+ table[["rmse_repro", "rmse_SI"]].diff(axis=1).iloc[:, -1].abs().max())
92
+ print("largest reproduced-vs-published deviation (median, MAE, RMSE):", max_dev)
93
+ assert max(max_dev) < 1e-3, "a row diverged from the SI by more than float rounding"
94
+ """),
95
+ ]
96
+
97
+
98
+ si_table_s05_qcd = [
99
+ md(r"""
100
+ # Table S5: Performance statistics for predicting QCD corrections
101
+
102
+ Foundation MagNET's accuracy at predicting the rovibrational (QCD) correction (stationary vs
103
+ trajectory-averaged shielding) over qcdtraj2500 (2500 molecules), ¹H and ¹³C, all shieldings at
104
+ PBE0/pcSseg-1.
105
+ """),
106
+ code(_BOOTSTRAP),
107
+ code(_IMPORTS),
108
+ code(_SETUP),
109
+ code(r"""
110
+ rows = magnet_benchmark.qcd_stats_table(PREDICTIONS, magnet_test_predictions_reader)
111
+ table_rows = []
112
+ for r in rows:
113
+ nucleus = r["model"].split("(")[1].rstrip(")") # "1H" / "13C"
114
+ pmed, pmae, prmse = magnet_benchmark.PUBLISHED_S5[nucleus]
115
+ table_rows.append(dict(model=r["model"], n=int(r["n"]),
116
+ median_repro=round(r["median_ae"], 8), median_SI=round(pmed, 8),
117
+ mae_repro=round(r["mae"], 8), mae_SI=round(pmae, 8),
118
+ rmse_repro=round(r["rmse"], 8), rmse_SI=round(prmse, 8)))
119
+ table_s5 = pd.DataFrame(table_rows)
120
+ print("Table S5 (QCD corrections):"); display(table_s5)
121
+
122
+ # write the reproduced table to this notebook's documents/ folder
123
+ out = document_path("si_table_s05_qcd.xlsx")
124
+ with pd.ExcelWriter(out) as writer:
125
+ table_s5.to_excel(writer, sheet_name="Table S5", index=False)
126
+ print("wrote", os.path.relpath(out, REPO))
127
+ """),
128
+ md("## Exact-reproduction check"),
129
+ code(r"""
130
+ # every reproduced median/MAE/RMSE should match the published SI value to a few parts per million
131
+ dev = max((table_s5[["median_repro", "median_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
132
+ table_s5[["mae_repro", "mae_SI"]].diff(axis=1).iloc[:, -1].abs().max(),
133
+ table_s5[["rmse_repro", "rmse_SI"]].diff(axis=1).iloc[:, -1].abs().max()))
134
+ print("largest reproduced-vs-published deviation:", dev)
135
+ assert dev < 1e-3, "a row diverged from the SI by more than float rounding"
136
+ """),
137
+ ]
138
+
139
+
140
+ # name -> (cells, path relative to repo root)
141
+ NOTEBOOKS = {
142
+ "si_table_s03_s04_performance": (si_table_s03_s04_performance,
143
+ "analysis/si_tables/si_table_s03_s04_performance.ipynb"),
144
+ "si_table_s05_qcd": (si_table_s05_qcd, "analysis/si_tables/si_table_s05_qcd.ipynb"),
145
+ }
146
+
147
+ if __name__ == "__main__":
148
+ here = os.path.dirname(os.path.abspath(__file__))
149
+ repo = os.path.abspath(os.path.join(here, "..", ".."))
150
+ names = sys.argv[1:]
151
+ if not names:
152
+ print("available names:", ", ".join(NOTEBOOKS))
153
+ sys.exit(0)
154
+ unknown = [n for n in names if n not in NOTEBOOKS]
155
+ if unknown:
156
+ raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
157
+ for name in names:
158
+ cells, relpath = NOTEBOOKS[name]
159
+ _save(cells, os.path.join(repo, relpath))
analysis/code/build_nb_scaling.py ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Source of truth for the Tables S10/S11 notebook (MagNET-Zero/MagNET-PCM scaling factors). Edit
2
+ the cell sources here, then regenerate:
3
+
4
+ python3 build_nb_scaling.py si_table_s10_s11_scaling
5
+ jupyter nbconvert --to notebook --execute --inplace ../si_tables/si_table_s10_s11_scaling.ipynb
6
+
7
+ Regenerating overwrites the .ipynb (clearing its execution outputs). All real code lives in
8
+ scaling_factors.py (the published tables and the re-derivation from delta-22); the notebook only
9
+ runs it. The notebook lives in analysis/si_tables/ (grouped by role), not here.
10
+
11
+ Run with no arguments to list the available notebook names.
12
+ """
13
+ import os
14
+ import sys
15
+
16
+ from nb_build import md, code, save_notebook as _save
17
+
18
+
19
+ _BOOTSTRAP = r"""
20
+ import os, sys
21
+
22
+ # make the in-repo modules importable (not pip-installed)
23
+ REPO = os.path.abspath("../..")
24
+ for _p in ("data/delta22", "data/scaling_factors", "data/applications",
25
+ "analysis/code", "analysis/code/shared"):
26
+ sys.path.insert(0, os.path.join(REPO, _p))
27
+ """
28
+
29
+ _IMPORTS = r"""
30
+ import numpy as np
31
+ import pandas as pd
32
+ import scaling_factors
33
+ import scaling_factors_reader
34
+ import build_composite_model
35
+ import paths
36
+ from applications_reader import Applications
37
+ """
38
+
39
+ _SETUP = r"""
40
+ DELTA22 = paths.dataset_file("delta22", root=REPO)
41
+ XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
42
+
43
+ def document_path(name):
44
+ os.makedirs("documents", exist_ok=True)
45
+ return os.path.join("documents", name)
46
+ """
47
+
48
+ si_table_s10_s11_scaling = [
49
+ md(r"""
50
+ # Tables S10 and S11: MagNET-Zero/MagNET-PCM scaling factors
51
+
52
+ Per-solvent linear coefficients (intercept, stationary, pcm) mapping MagNET-Zero shieldings plus a
53
+ MagNET-PCM correction to predicted shifts: ¹H (S10) and ¹³C (S11).
54
+ """),
55
+ code(_BOOTSTRAP),
56
+ code(_IMPORTS),
57
+ code(_SETUP),
58
+ code(r"""
59
+ tables = scaling_factors.published_scaling_tables() # {"H": Table S10, "C": Table S11}
60
+ print("Table S10 (1H):"); display(tables["H"])
61
+ print("Table S11 (13C):"); display(tables["C"])
62
+
63
+ out = document_path("si_table_s10_s11_scaling.xlsx")
64
+ with pd.ExcelWriter(out) as writer:
65
+ tables["H"].reset_index().to_excel(writer, sheet_name="Table S10 (1H)", index=False)
66
+ tables["C"].reset_index().to_excel(writer, sheet_name="Table S11 (13C)", index=False)
67
+ print("wrote", os.path.relpath(out, REPO))
68
+ """),
69
+ md("## Reproducibility check: re-derive from delta-22"),
70
+ code(r"""
71
+ derived = scaling_factors.build_scaling_tables(DELTA22, XLSX)
72
+ for nucleus in ("H", "C"):
73
+ p = tables[nucleus]
74
+ d = derived[nucleus].reindex(p.index)[p.columns]
75
+ max_dev = float(np.abs(p.values - d.values).max())
76
+ print(f"{nucleus}: largest published-vs-rederived deviation = {max_dev:.2e}")
77
+ assert max_dev < 1e-5, f"{nucleus} scaling table diverged from the published values"
78
+ print("both tables reproduce from delta-22")
79
+ """),
80
+ md(r"""
81
+ ## Deployment tables with reflection symmetrization
82
+
83
+ The tables above match the published SI exactly. For serving new molecules, the recommended tables
84
+ average each prediction with its mirror image, correcting a reflection-parity error. Reproducing
85
+ them needs the model checkpoints; the shipped tables are shown below.
86
+ """),
87
+ code(r"""
88
+ symmetrized = scaling_factors_reader.load_symmetrized_tables()
89
+ print("Symmetrized deployment Table S10 (1H):"); display(symmetrized["H"])
90
+ print("Symmetrized deployment Table S11 (13C):"); display(symmetrized["C"])
91
+ """),
92
+ md(r"""
93
+ ## Composite-model coefficients (Figure 5C/5D, SI S15)
94
+
95
+ The natural-products figures use a larger family of per-solvent coefficients fit on delta-22 (the
96
+ `composite_model` group in `applications.hdf5`): OLS fits, 1000-seed bootstrap resamples, their RMSE
97
+ distributions, and the PCM conversion factors. The group regenerates from released inputs alone.
98
+ """),
99
+ code(r"""
100
+ reader = Applications(paths.dataset_file("applications", root=REPO))
101
+ regenerated = build_composite_model.regenerate(reader)
102
+ max_dev = build_composite_model.verify(reader, regenerated)
103
+ print(f"composite_model largest regenerated-vs-stored deviation = {max_dev:.2e}")
104
+ """),
105
+ ]
106
+
107
+
108
+ # name -> (cells, path relative to repo root)
109
+ NOTEBOOKS = {
110
+ "si_table_s10_s11_scaling": (si_table_s10_s11_scaling,
111
+ "analysis/si_tables/si_table_s10_s11_scaling.ipynb"),
112
+ }
113
+
114
+ if __name__ == "__main__":
115
+ here = os.path.dirname(os.path.abspath(__file__))
116
+ repo = os.path.abspath(os.path.join(here, "..", ".."))
117
+ names = sys.argv[1:]
118
+ if not names:
119
+ print("available names:", ", ".join(NOTEBOOKS))
120
+ sys.exit(0)
121
+ unknown = [n for n in names if n not in NOTEBOOKS]
122
+ if unknown:
123
+ raise SystemExit(f"unknown notebook name(s): {unknown}; available: {', '.join(NOTEBOOKS)}")
124
+ for name in names:
125
+ cells, relpath = NOTEBOOKS[name]
126
+ _save(cells, os.path.join(repo, relpath))
analysis/code/composite_models.py ADDED
@@ -0,0 +1,395 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reproduces the delta-22 composite-formula ablation study behind SI Figure S15's Excel screenshots.
2
+
3
+ Rebuilds `ablations.xlsx`: ~25 composite-formula variants (stationary geometry plus some combination
4
+ of implicit PCM, explicit Desmond, and rovibrational QCD corrections) across all 12 delta-22 solvents,
5
+ at two reference levels: DSD-PBEP86/pcSseg-3 (geometry PBE0/tz) and the MagNET-Zero training reference
6
+ (WP04/pcSseg-2 for 1H, wB97X-D/pcSseg-2 for 13C, AIMNet2 geometries). The fitting reuses delta22.py's
7
+ harness (same as si_figure_s06 at the DSD level, widened to the full formula set and both reference
8
+ levels); the openpyxl table-writing layer (color scales, %benefit formulas, best-model block)
9
+ matches the published screenshots.
10
+
11
+ Nitromethane: kept in (unlike scaling_factors.py, which drops it as an ML outlier). Per the SI, this
12
+ is a plain OLS fit, not ML, so all 22 solutes are used (10 test / 12 train per split).
13
+ """
14
+ from openpyxl import Workbook
15
+ from openpyxl.styles import Font, PatternFill
16
+ from openpyxl.utils import get_column_letter
17
+ from openpyxl.formatting.rule import ColorScaleRule, FormulaRule
18
+
19
+ import delta22 as D
20
+
21
+ # ---------------------------------------------------------------------------
22
+ # formulas and reference-level configuration
23
+
24
+ # the full ablation formula set (one-term through three-term composites), Desmond explicit-solvent
25
+ # variant. This order is also the display order in "Multi-Term Models".
26
+ FORMULAS = [
27
+ "stationary",
28
+ "stationary + pcm",
29
+ "stationary + desmond",
30
+ "stationary + qcd",
31
+ "stationary + desmond_vib",
32
+ "stationary + pcm + qcd",
33
+ "stationary + pcm + desmond_vib",
34
+ "stationary + desmond + qcd",
35
+ "stationary + desmond + desmond_vib",
36
+ "stationary_plus_pcm",
37
+ "stationary_plus_desmond",
38
+ "stationary_plus_qcd",
39
+ "stationary_plus_des_vib",
40
+ "stationary_plus_pcm + qcd",
41
+ "stationary_plus_pcm + desmond_vib",
42
+ "stationary_plus_desmond + qcd",
43
+ "stationary_plus_desmond + desmond_vib",
44
+ "stationary_plus_qcd + pcm",
45
+ "stationary_plus_qcd + desmond",
46
+ "stationary_plus_des_vib + pcm",
47
+ "stationary_plus_des_vib + desmond",
48
+ "stationary_plus_pcm_plus_qcd",
49
+ "stationary_plus_pcm_plus_des_vib",
50
+ "stationary_plus_desmond_plus_qcd",
51
+ "stationary_plus_desmond_plus_des_vib",
52
+ ]
53
+
54
+ # display column order for the spreadsheet (distinct from D.DESMOND_SOLVENTS' iteration order)
55
+ ORDERED_SOLVENTS = [
56
+ "chloroform", "tetrahydrofuran", "dichloromethane", "acetone", "acetonitrile",
57
+ "dimethylsulfoxide", "trifluoroethanol", "methanol", "TIP4P", "benzene", "toluene",
58
+ "chlorobenzene",
59
+ ]
60
+
61
+ # the two reference levels the shipped workbook compares. "geometry" is the sap_geometry_type the
62
+ # stationary/pcm/etc columns were computed on; PCM's own reference level (used for the "PCM ="
63
+ # header line) is fixed at B3LYP-D3(BJ)/pcSseg-3 for both, matching load_delta22_dft_data's default.
64
+ # sheet_suffix_h/sheet_suffix_c differ within a level (the shipped workbook names the carbon sheet
65
+ # after wB97X-D, not WP04, since each nucleus uses its own MagNET-Zero training reference method).
66
+ REFERENCE_LEVELS = {
67
+ "dsd": {
68
+ "sheet_suffix_h": "DSD", "sheet_suffix_c": "DSD",
69
+ "method_h": "dsd_pbep86", "basis_h": "pcSseg3", "geometry_h": "pbe0_tz",
70
+ "method_c": "dsd_pbep86", "basis_c": "pcSseg3", "geometry_c": "pbe0_tz",
71
+ },
72
+ "wp04_wb97xd": {
73
+ "sheet_suffix_h": "WP04", "sheet_suffix_c": "wB97XD",
74
+ "method_h": "wp04", "basis_h": "pcSseg2", "geometry_h": "aimnet2",
75
+ "method_c": "wb97xd", "basis_c": "pcSseg2", "geometry_c": "aimnet2",
76
+ },
77
+ }
78
+ PCM_REFERENCE_METHOD = "b3lyp_d3bj"
79
+ PCM_REFERENCE_BASIS = "pcSseg3"
80
+
81
+ # vmin/vmax for the RMSE color scale, per nucleus (fixed, not data-dependent -- matches the shipped
82
+ # workbook so repeated runs render identically)
83
+ _VMIN_VMAX = {"H": (0.06, 0.28), "C": (1.2, 2.8)}
84
+
85
+ # per-nucleus labeled formula pairs the "story" sections of the sheet compare
86
+ PROTON_FORMULA_CONFIG = {
87
+ "pair1_a": "stationary + pcm + qcd",
88
+ "pair1_b": "stationary + desmond + qcd",
89
+ "pair2_title": "benefit of qcd over desmond_vib",
90
+ "pair2_a": "stationary + desmond_vib",
91
+ "pair2_b": "stationary + qcd",
92
+ "pair3_a": "stationary + desmond + desmond_vib",
93
+ "pair3_b": "stationary + desmond + qcd",
94
+ "best_multi": "stationary + desmond + qcd",
95
+ "best_semi": "stationary_plus_qcd + desmond",
96
+ "best_pars": "stationary_plus_desmond_plus_qcd",
97
+ }
98
+ CARBON_FORMULA_CONFIG = {
99
+ "pair1_a": "stationary + pcm + desmond_vib",
100
+ "pair1_b": "stationary + desmond + desmond_vib",
101
+ "pair2_title": "benefit of desmond_vib over qcd",
102
+ "pair2_a": "stationary + qcd",
103
+ "pair2_b": "stationary + desmond_vib",
104
+ "pair3_a": "stationary + desmond + qcd",
105
+ "pair3_b": "stationary + desmond + desmond_vib",
106
+ "best_multi": "stationary + desmond + desmond_vib",
107
+ "best_semi": "stationary_plus_des_vib + desmond",
108
+ "best_pars": "stationary_plus_desmond_plus_des_vib",
109
+ }
110
+ _FORMULA_CONFIG = {"H": PROTON_FORMULA_CONFIG, "C": CARBON_FORMULA_CONFIG}
111
+
112
+ # This ablation study fits plain OLS composite formulas, not an ML model. The canonical SI text
113
+ # for Figure S15 states that "since no ML was used in this particular analysis, all 22 molecules
114
+ # were considered, including nitromethane" -- other, ML-related analyses (e.g. scaling_factors.py's
115
+ # EXCLUDE_SOLUTES) drop nitromethane as an ML-training outlier; this module's default excludes nothing.
116
+ EXCLUDE_SOLUTES = ()
117
+
118
+
119
+ # ---------------------------------------------------------------------------
120
+ # fitting: reuses delta22.py's harness entirely
121
+
122
+ def _filtered_query_df(query_df_dft, method, basis, geometry, exclude_solutes=EXCLUDE_SOLUTES):
123
+ """One (method, basis, geometry) slice of the DFT query table, composite columns added,
124
+ any solutes in `exclude_solutes` dropped (default: none -- see EXCLUDE_SOLUTES above; this
125
+ analysis is documented in the SI as using all 22 delta-22 solutes, since it's a plain OLS fit,
126
+ not an ML model)."""
127
+ sub = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
128
+ & (query_df_dft["sap_basis"] == basis)
129
+ & (query_df_dft["sap_geometry_type"] == geometry)]
130
+ sub = sub[~sub["solute"].isin(set(exclude_solutes))]
131
+ return D.add_composite_columns(sub)
132
+
133
+
134
+ def ablation_rmse_table(query_df_dft, nucleus, method, basis, geometry, solutes,
135
+ formulas=FORMULAS, solvents=None, n_splits=250,
136
+ exclude_solutes=EXCLUDE_SOLUTES):
137
+ """The formula x solvent test-RMSE table for one nucleus at one reference level: mean test RMSE
138
+ over `n_splits` seeded splits (delta22.generate_solute_splits/run_fits), pivoted to formula rows
139
+ x solvent columns, with an appended "Mean Test RMSE" column (the row mean across solvents).
140
+ Row/column order matches FORMULAS / ORDERED_SOLVENTS. This is the DataFrame write_nucleus_table
141
+ reads from."""
142
+ solvents = list(solvents) if solvents is not None else ORDERED_SOLVENTS
143
+ nuc_df = query_df_dft[query_df_dft["nucleus"] == nucleus]
144
+ sub = _filtered_query_df(nuc_df, method, basis, geometry, exclude_solutes)
145
+ results = D.run_fits(sub, solvents, formulas, n_splits, solutes)
146
+ mean_rmse = results.groupby(["solvent", "formula"])["test_RMSE"].mean().reset_index()
147
+ table = mean_rmse.pivot(index="formula", columns="solvent", values="test_RMSE")
148
+ table = table.reindex(index=formulas, columns=solvents)
149
+ table["Mean Test RMSE"] = table[solvents].mean(axis=1)
150
+ return table
151
+
152
+
153
+ # ---------------------------------------------------------------------------
154
+ # openpyxl table-writing layer
155
+
156
+ _TABLE_COLUMNS = ORDERED_SOLVENTS + ["Mean Test RMSE"]
157
+ _WHITE_TEXT_THRESHOLD_FRACTION = 0.50
158
+ _PERCENT_COLOR_LIMIT = 0.40
159
+ _PERCENT_WHITE_TEXT_MIN_ABS = 0.25
160
+ _PERCENT_WHITE_TEXT_MAX_ABS = 0.40
161
+
162
+
163
+ def _canonical_formula_name(name):
164
+ return "".join(ch for ch in name.lower() if ch.isalnum())
165
+
166
+
167
+ def _build_formula_lookup(df):
168
+ return {_canonical_formula_name(idx): idx for idx in df.index}
169
+
170
+
171
+ def _get_rmse(df, lookup, formula_name, col):
172
+ key = _canonical_formula_name(formula_name)
173
+ if key not in lookup:
174
+ raise KeyError(f"Formula {formula_name!r} was not found in test RMSE dataframe index.")
175
+ return float(df.loc[lookup[key], col])
176
+
177
+
178
+ def _write_benefit_formula_row(ws, row, ref_row, target_row, start_col, n_cols):
179
+ # percent benefit = (reference - target) / reference, written as a live Excel formula so the
180
+ # spreadsheet stays self-consistent if a cell above it is ever hand-edited
181
+ for c in range(start_col, start_col + n_cols):
182
+ ref = f"{get_column_letter(c)}{ref_row}"
183
+ target = f"{get_column_letter(c)}{target_row}"
184
+ ws.cell(row=row, column=c, value=f"=IFERROR(({ref}-{target})/{ref},0)")
185
+ ws.cell(row=row, column=c).number_format = "0%"
186
+
187
+
188
+ def _format_method_label(method_name, basis_name):
189
+ return f"{method_name.upper().replace('_', '-')}/{basis_name}"
190
+
191
+
192
+ def _write_color_scale_note(ws, vmin, vmax, threshold_fraction, pct_limit, pct_white_min, pct_white_max):
193
+ note_col = 16 # column P
194
+ threshold_value = vmin + threshold_fraction * (vmax - vmin)
195
+
196
+ ws.cell(row=1, column=note_col, value="Color Scale Note").font = Font(bold=True)
197
+ ws.cell(row=2, column=note_col, value=f"RMSE gradient uses fixed range [{vmin:.2f}, {vmax:.2f}] from notebook settings.")
198
+ ws.cell(row=3, column=note_col, value=f"White text for RMSE cells below {threshold_fraction:.0%} of range (<= {threshold_value:.3f}).")
199
+ ws.cell(row=4, column=note_col, value=f"%benefit uses fixed RdBu range [-{pct_limit:.0%}, +{pct_limit:.0%}], centered at 0%.")
200
+ ws.cell(row=5, column=note_col, value=f"White text for %benefit where abs(value) is at least {pct_white_min:.0%}.")
201
+
202
+ ws.cell(row=7, column=note_col, value="RMSE Legend").font = Font(bold=True)
203
+ ws.cell(row=8, column=note_col, value=f"Low ({vmin:.2f})")
204
+ ws.cell(row=9, column=note_col, value=f"Mid ({(vmin + vmax) / 2:.2f})")
205
+ ws.cell(row=10, column=note_col, value=f"High ({vmax:.2f})")
206
+ ws.cell(row=8, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="2D004B")
207
+ ws.cell(row=9, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="CC4778")
208
+ ws.cell(row=10, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="F0F921")
209
+
210
+ ws.cell(row=12, column=note_col, value="%Benefit Legend").font = Font(bold=True)
211
+ ws.cell(row=13, column=note_col, value=f"Negative (-{pct_limit:.0%})")
212
+ ws.cell(row=14, column=note_col, value="Zero")
213
+ ws.cell(row=15, column=note_col, value=f"Positive (+{pct_limit:.0%})")
214
+ ws.cell(row=13, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="B2182B")
215
+ ws.cell(row=14, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="F7F7F7")
216
+ ws.cell(row=15, column=note_col + 1).fill = PatternFill(fill_type="solid", fgColor="2166AC")
217
+
218
+ ws.column_dimensions[get_column_letter(note_col)].width = 82
219
+ ws.column_dimensions[get_column_letter(note_col + 1)].width = 14
220
+
221
+
222
+ def _apply_rmse_conditional_format(ws, rmse_rows, start_col, end_col, vmin, vmax):
223
+ rule = ColorScaleRule(start_type="num", start_value=vmin, start_color="2D004B",
224
+ mid_type="num", mid_value=(vmin + vmax) / 2, mid_color="CC4778",
225
+ end_type="num", end_value=vmax, end_color="F0F921")
226
+ for r in rmse_rows:
227
+ ws.conditional_formatting.add(f"{get_column_letter(start_col)}{r}:{get_column_letter(end_col)}{r}", rule)
228
+
229
+
230
+ def _apply_percent_conditional_format(ws, pct_rows, start_col, end_col, pct_limit):
231
+ rule = ColorScaleRule(start_type="num", start_value=-pct_limit, start_color="B2182B",
232
+ mid_type="num", mid_value=0, mid_color="F7F7F7",
233
+ end_type="num", end_value=pct_limit, end_color="2166AC")
234
+ for r in pct_rows:
235
+ ws.conditional_formatting.add(f"{get_column_letter(start_col)}{r}:{get_column_letter(end_col)}{r}", rule)
236
+
237
+
238
+ def _apply_percent_font_contrast(ws, pct_rows, start_col, end_col, pct_white_min, pct_white_max):
239
+ min_s = f"{pct_white_min:.6f}"
240
+ for r in pct_rows:
241
+ for c in range(start_col, end_col + 1):
242
+ addr = f"{get_column_letter(c)}{r}"
243
+ rule = FormulaRule(formula=[f"ABS({addr})>={min_s}"], stopIfTrue=False, font=Font(color="FFFFFFFF"))
244
+ ws.conditional_formatting.add(addr, rule)
245
+
246
+
247
+ def _apply_rmse_font_contrast(ws, rmse_rows, start_col, end_col, vmin, vmax, threshold_fraction):
248
+ threshold_value = vmin + threshold_fraction * (vmax - vmin)
249
+ for r in rmse_rows:
250
+ for c in range(start_col, end_col + 1):
251
+ cell = ws.cell(row=r, column=c)
252
+ if isinstance(cell.value, (int, float)):
253
+ cell.font = Font(color="FFFFFFFF" if float(cell.value) <= threshold_value else "FF000000")
254
+
255
+
256
+ def write_nucleus_table(ws, start_row, nucleus_name, stationary_desc, pcm_desc, test_rmse_df,
257
+ vmin, vmax, formula_config,
258
+ threshold_fraction=_WHITE_TEXT_THRESHOLD_FRACTION,
259
+ pct_limit=_PERCENT_COLOR_LIMIT,
260
+ pct_white_min=_PERCENT_WHITE_TEXT_MIN_ABS,
261
+ pct_white_max=_PERCENT_WHITE_TEXT_MAX_ABS):
262
+ """Write one nucleus's ablation table into `ws` starting at `start_row`; returns the next free
263
+ row. Layout: a header block, a "Multi-Term Models" section (reference stationary row; benefit of
264
+ desmond over pcm; three labeled formula-pair comparisons from formula_config), then a "Best
265
+ Model: Approach Comparison" section (SOTA/multi-term/semi-parsimonious/parsimonious, each vs the
266
+ others). Every RMSE row gets a fixed-range color scale; every %benefit row is a live Excel
267
+ formula with a diverging color scale. The layout matches the published workbook screenshots
268
+ section-for-section."""
269
+ lookup = _build_formula_lookup(test_rmse_df)
270
+ c0, c1 = 1, 2
271
+ c_end = c1 + len(_TABLE_COLUMNS) - 1
272
+ rmse_rows, pct_rows = [], []
273
+
274
+ def _rmse_row(row, label, formula):
275
+ ws.cell(row=row, column=c0, value=label)
276
+ rmse_rows.append(row)
277
+ for i, col_name in enumerate(_TABLE_COLUMNS, start=c1):
278
+ ws.cell(row=row, column=i, value=_get_rmse(test_rmse_df, lookup, formula, col_name))
279
+ ws.cell(row=row, column=i).number_format = "0.000"
280
+
281
+ def _benefit_row(row, ref_row, target_row):
282
+ ws.cell(row=row, column=c0, value="%benefit")
283
+ pct_rows.append(row)
284
+ _write_benefit_formula_row(ws, row, ref_row, target_row, c1, len(_TABLE_COLUMNS))
285
+
286
+ ws.cell(row=start_row, column=c0, value=nucleus_name).font = Font(bold=True)
287
+ ws.cell(row=start_row, column=c1, value=f"stationary = {stationary_desc}")
288
+ ws.cell(row=start_row + 1, column=c1, value=f"PCM = {pcm_desc}")
289
+
290
+ ws.cell(row=start_row + 3, column=c0, value="Multi-Term Models").font = Font(bold=True)
291
+ ws.cell(row=start_row + 4, column=c0, value="Test RMSEs (Solvent-Specific)").font = Font(bold=True)
292
+ for i, col_name in enumerate(_TABLE_COLUMNS, start=c1):
293
+ ws.cell(row=start_row + 4, column=i, value=col_name).font = Font(bold=True)
294
+ ws.cell(row=start_row + 5, column=c0, value="formula").font = Font(bold=True)
295
+
296
+ ws.cell(row=start_row + 7, column=c0, value="reference").font = Font(bold=True)
297
+ _rmse_row(start_row + 8, "stationary", "stationary")
298
+
299
+ ws.cell(row=start_row + 10, column=c0, value="benefit of desmond over pcm").font = Font(bold=True)
300
+ _rmse_row(start_row + 11, "stationary + pcm", "stationary + pcm")
301
+ _rmse_row(start_row + 12, "stationary + desmond", "stationary + desmond")
302
+ _benefit_row(start_row + 13, start_row + 11, start_row + 12)
303
+
304
+ _rmse_row(start_row + 15, formula_config["pair1_a"], formula_config["pair1_a"])
305
+ _rmse_row(start_row + 16, formula_config["pair1_b"], formula_config["pair1_b"])
306
+ _benefit_row(start_row + 17, start_row + 15, start_row + 16)
307
+
308
+ ws.cell(row=start_row + 19, column=c0, value=formula_config["pair2_title"]).font = Font(bold=True)
309
+ _rmse_row(start_row + 20, formula_config["pair2_a"], formula_config["pair2_a"])
310
+ _rmse_row(start_row + 21, formula_config["pair2_b"], formula_config["pair2_b"])
311
+ _benefit_row(start_row + 22, start_row + 20, start_row + 21)
312
+
313
+ _rmse_row(start_row + 24, formula_config["pair3_a"], formula_config["pair3_a"])
314
+ _rmse_row(start_row + 25, formula_config["pair3_b"], formula_config["pair3_b"])
315
+ _benefit_row(start_row + 26, start_row + 24, start_row + 25)
316
+
317
+ ws.cell(row=start_row + 29, column=c0, value="Best Model: Approach Comparison").font = Font(bold=True)
318
+ _rmse_row(start_row + 31, "SOTA: stationary_plus_pcm", "stationary_plus_pcm")
319
+ _rmse_row(start_row + 32, f"Multi-term: {formula_config['best_multi']}", formula_config["best_multi"])
320
+ _rmse_row(start_row + 33, f"Semi-parsimonious: {formula_config['best_semi']}", formula_config["best_semi"])
321
+ _rmse_row(start_row + 34, f"Parsimonious: {formula_config['best_pars']}", formula_config["best_pars"])
322
+
323
+ ws.cell(row=start_row + 36, column=c0, value="%benefit Multi-term vs. SOTA")
324
+ pct_rows.append(start_row + 36)
325
+ _write_benefit_formula_row(ws, start_row + 36, start_row + 31, start_row + 32, c1, len(_TABLE_COLUMNS))
326
+ ws.cell(row=start_row + 37, column=c0, value="%benefit Semi-parsimonious vs. SOTA")
327
+ pct_rows.append(start_row + 37)
328
+ _write_benefit_formula_row(ws, start_row + 37, start_row + 31, start_row + 33, c1, len(_TABLE_COLUMNS))
329
+ ws.cell(row=start_row + 38, column=c0, value="%benefit Parsimonious vs. SOTA")
330
+ pct_rows.append(start_row + 38)
331
+ _write_benefit_formula_row(ws, start_row + 38, start_row + 31, start_row + 34, c1, len(_TABLE_COLUMNS))
332
+ ws.cell(row=start_row + 40, column=c0, value="%benefit Semi-parsimonious vs. Multi-term")
333
+ pct_rows.append(start_row + 40)
334
+ _write_benefit_formula_row(ws, start_row + 40, start_row + 32, start_row + 33, c1, len(_TABLE_COLUMNS))
335
+ ws.cell(row=start_row + 41, column=c0, value="%benefit Parsimonious vs. Multi-term")
336
+ pct_rows.append(start_row + 41)
337
+ _write_benefit_formula_row(ws, start_row + 41, start_row + 32, start_row + 34, c1, len(_TABLE_COLUMNS))
338
+
339
+ ws.column_dimensions["A"].width = 52
340
+ for col_idx in range(2, 2 + len(_TABLE_COLUMNS)):
341
+ ws.column_dimensions[get_column_letter(col_idx)].width = 14
342
+
343
+ _apply_rmse_conditional_format(ws, rmse_rows, c1, c_end, vmin, vmax)
344
+ _apply_percent_conditional_format(ws, pct_rows, c1, c_end, pct_limit)
345
+ _apply_rmse_font_contrast(ws, rmse_rows, c1, c_end, vmin, vmax, threshold_fraction)
346
+ _apply_percent_font_contrast(ws, pct_rows, c1, c_end, pct_white_min, pct_white_max)
347
+ _write_color_scale_note(ws, vmin, vmax, threshold_fraction, pct_limit, pct_white_min, pct_white_max)
348
+
349
+ return start_row + 45
350
+
351
+
352
+ # ---------------------------------------------------------------------------
353
+ # top-level pipeline
354
+
355
+ def build_ablations_workbook(query_df_dft, solutes, output_path,
356
+ reference_levels=("dsd", "wp04_wb97xd"), n_splits=250,
357
+ exclude_solutes=EXCLUDE_SOLUTES, verbose=True):
358
+ """Computes the ablation RMSE tables for both nuclei at each requested reference level and
359
+ writes them into one workbook, one sheet per (nucleus, reference level) -- 4 sheets for the
360
+ default two reference levels, matching the shipped ablations_updated.xlsx: "Proton (DSD)",
361
+ "Carbon (DSD)", "Proton (WP04)", "Carbon (wB97XD)". Pass a single-element reference_levels to
362
+ produce the plain "Proton"/"Carbon" two-sheet form for one reference level. query_df_dft is
363
+ delta22.load_query_df_dft(...)'s output (unfiltered by method/basis); this function does the
364
+ per-reference-level filtering itself."""
365
+ wb = Workbook()
366
+ first = True
367
+ for level_key in reference_levels:
368
+ level = REFERENCE_LEVELS[level_key]
369
+ for nucleus, method_key, basis_key, geom_key, suffix_key in (
370
+ ("H", "method_h", "basis_h", "geometry_h", "sheet_suffix_h"),
371
+ ("C", "method_c", "basis_c", "geometry_c", "sheet_suffix_c"),
372
+ ):
373
+ method, basis, geometry = level[method_key], level[basis_key], level[geom_key]
374
+ if verbose:
375
+ print(f"fitting {nucleus} at {method}/{basis} ({level_key}), {n_splits} splits/solvent...",
376
+ flush=True)
377
+ table = ablation_rmse_table(query_df_dft, nucleus, method, basis, geometry, solutes,
378
+ n_splits=n_splits, exclude_solutes=exclude_solutes)
379
+ nucleus_name = "Proton" if nucleus == "H" else "Carbon"
380
+ sheet_title = (f"{nucleus_name} ({level[suffix_key]})" if len(reference_levels) > 1
381
+ else nucleus_name)
382
+ ws = wb.active if first else wb.create_sheet()
383
+ ws.title = sheet_title
384
+ first = False
385
+ write_nucleus_table(
386
+ ws=ws, start_row=1, nucleus_name=nucleus_name,
387
+ stationary_desc=_format_method_label(method, basis),
388
+ pcm_desc=_format_method_label(PCM_REFERENCE_METHOD, PCM_REFERENCE_BASIS),
389
+ test_rmse_df=table, vmin=_VMIN_VMAX[nucleus][0], vmax=_VMIN_VMAX[nucleus][1],
390
+ formula_config=_FORMULA_CONFIG[nucleus],
391
+ )
392
+ wb.save(output_path)
393
+ if verbose:
394
+ print(f"wrote {output_path}", flush=True)
395
+ return output_path
analysis/code/composite_plots.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Correlation-heatmap panels for the composite-model ablation SI figure (Figure S15).
2
+
3
+ Two seaborn heatmaps behind Figure S15's feature-correlation panels: a per-nucleus lower-triangle
4
+ correlation matrix among the five composite-model features (stationary shielding, PCM, Desmond, its
5
+ vibrational analogue, QCD), and a PCM-vs-Desmond Pearson r table by nucleus and solvent. The notebook
6
+ supplies the correlation table(s) computed by delta22.py and calls these functions directly.
7
+ """
8
+ import numpy as np
9
+ import seaborn as sns
10
+ import matplotlib.pyplot as plt
11
+
12
+
13
+ def plot_feature_correlation_heatmap(corr_matrix, vmin, vmax, cmap, title="", figsize=(6, 5), save_path=None):
14
+ """Lower-triangle seaborn heatmap of a correlation matrix (Pearson r)."""
15
+ # In a strict lower triangle the first row and last column hold no cells, so drop them to avoid
16
+ # a blank leading/trailing strip; the diagonal of the trimmed matrix is a real below-diagonal value.
17
+ trimmed = corr_matrix.iloc[1:, :-1]
18
+ mask = np.triu(np.ones(trimmed.shape, dtype=bool), k=1) # keep the diagonal of the trimmed matrix
19
+ fig, ax = plt.subplots(figsize=figsize)
20
+ sns.heatmap(trimmed, mask=mask, annot=True, fmt=".3f", cmap=cmap, vmin=vmin, vmax=vmax,
21
+ square=True, cbar=False, ax=ax)
22
+ ax.set_title(title, fontsize=11)
23
+ fig.tight_layout()
24
+ if save_path:
25
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
26
+ return fig
27
+
28
+
29
+ def plot_pcm_desmond_correlation_table(corr_table, figsize=(13, 4.5), save_path=None):
30
+ """Annotated seaborn heatmap of PCM-vs-Desmond Pearson r: rows = nucleus, columns = solvent."""
31
+ # tall figsize keeps the two rows from being squashed by the rotated solvent labels
32
+ fig, ax = plt.subplots(figsize=figsize)
33
+ sns.heatmap(corr_table, annot=True, fmt=".3f", cmap="RdBu", vmin=-1, vmax=1, square=True,
34
+ linewidths=0.5, cbar=False, ax=ax)
35
+ ax.set_title("PCM vs. Desmond Pearson $r$ by Solvent")
36
+ _sup = {"H": "$^{1}$H", "C": "$^{13}$C"}
37
+ ax.set_yticklabels([_sup.get(t.get_text(), t.get_text()) for t in ax.get_yticklabels()], rotation=0)
38
+ fig.tight_layout()
39
+ if save_path:
40
+ fig.savefig(save_path, dpi=300, bbox_inches="tight")
41
+ return fig
analysis/code/convergence_plot.py ADDED
@@ -0,0 +1,621 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """3D accuracy-vs-slope-vs-year convergence panel for Figure 2D.
2
+
3
+ The notebook supplies the per-method statistics table, the method->rung map, and the rung->color map as
4
+ arguments (`results_df`, `methods_categorization`, `method_color_map`) and calls
5
+ `plot_statistics_3d_emphasize_rungs(...)`.
6
+ """
7
+ import os
8
+
9
+ import numpy as np
10
+ import matplotlib.pyplot as plt
11
+ import seaborn as sns
12
+ from matplotlib.ticker import MaxNLocator
13
+ from matplotlib import colors as mcolors
14
+ from mpl_toolkits.mplot3d import Axes3D # noqa: F401 (registers the 3d projection)
15
+
16
+
17
+ def plot_statistics_3d_emphasize_rungs(
18
+ x_param="Slope",
19
+ y_param="RMSE (scaled)",
20
+ z_param=None,
21
+ results_df=None,
22
+ methods_categorization=None,
23
+ method_color_map=None,
24
+ figsize=(9, 7),
25
+ elev=12,
26
+ azim=20,
27
+
28
+ # =========================
29
+ # SAVING
30
+ # =========================
31
+ save_path=None,
32
+ save_formats=("png",),
33
+ dpi=300,
34
+ svg_transparent=True,
35
+ png_transparent=False,
36
+
37
+ # =========================
38
+ # DROPLINES (ALL POINTS)
39
+ # =========================
40
+ droplines_for_all=True,
41
+ dropline_alpha=0.1,
42
+ dropline_lw=0.8,
43
+ dropline_color="0.4",
44
+ dropline_shadow_alpha=0.25,
45
+ dropline_shadow_size=18,
46
+
47
+ xlim=None,
48
+ ylim=None,
49
+ zlim=None,
50
+
51
+ # =========================
52
+ # YEAR CONTROL
53
+ # =========================
54
+ year_map=None,
55
+ year_default=np.nan,
56
+ year_tick_step=2,
57
+ drop_missing_years=False,
58
+
59
+ # =========================
60
+ # Z LANES
61
+ # =========================
62
+ z_lane_by_category=True,
63
+ z_lane_width=0.35,
64
+ z_jitter_by_category=0.0,
65
+
66
+ # =========================
67
+ # XY LANES
68
+ # =========================
69
+ xy_lane_by_category=True,
70
+ xy_lane_strength=0.25,
71
+ xy_lane_style="circle",
72
+ lane_exclude_categories=("ab initio",),
73
+
74
+ auto_expand_xy_limits=True,
75
+ xy_limit_pad_frac=0.06,
76
+
77
+ # =========================
78
+ # BROKEN Z
79
+ # =========================
80
+ z_break=None,
81
+ z_break_gap=2.0,
82
+ drop_years_in_break=False,
83
+
84
+ # =========================
85
+ # DECADE GROUPING
86
+ # =========================
87
+ group_by_decade=True,
88
+ decade_bin=10,
89
+ decade_alpha=0.85,
90
+ decade_marker_size=80,
91
+ decade_sort_ascending=True,
92
+
93
+ # =========================
94
+ # XY GRID PLANE
95
+ # =========================
96
+ show_xy_grid_plane=True,
97
+ xy_grid_z=0.0,
98
+ grid_alpha=0.15,
99
+ grid_lw=0.4,
100
+ show_plot=True,
101
+ auto_xy_grid_z_if_years=True,
102
+
103
+ # =========================
104
+ # BASELINE SHADOW COLOR
105
+ # =========================
106
+ baseline_tint_strength=0.35,
107
+ baseline_gray="#7A7A7A",
108
+
109
+ # =========================
110
+ # MAJOR TICKS (CUSTOMIZABLE)
111
+ # =========================
112
+ x_major_nbins=5,
113
+ y_major_nbins=5,
114
+ major_tick_length=0.1,
115
+ major_tick_width=0.1,
116
+ major_tick_labelsize=10,
117
+ major_tick_pad=2,
118
+ major_tick_color="0.2",
119
+ major_tick_labelcolor="0.1",
120
+ major_tick_rotation_x=0,
121
+ major_tick_rotation_y=0,
122
+ major_tick_rotation_z=0,
123
+
124
+ # =========================
125
+ # "SPINES"/BORDERS FOR 3D AXES (CUSTOMIZABLE)
126
+ # =========================
127
+ axisline_lw=0.3,
128
+ axisline_color="0.05",
129
+
130
+ pane_fill=False,
131
+ pane_alpha=0.0,
132
+ pane_color="white",
133
+ pane_edgecolor="0.15",
134
+ pane_edge_lw=0.3,
135
+
136
+ # alias (so passing axis_linewidth won't crash)
137
+ axis_linewidth=None,
138
+
139
+ # =========================
140
+ # HIGHLIGHT + LABEL METHODS
141
+ # =========================
142
+ highlight_methods=None, # list/tuple of method names to highlight
143
+ highlight_marker_size=140, # size for highlighted markers
144
+ highlight_edgecolor="k", # outline color
145
+ highlight_edgewidth=1.2, # outline thickness
146
+ highlight_alpha=1.0, # alpha for highlighted markers
147
+ highlight_text=True, # whether to draw labels
148
+ highlight_text_size=8, # label fontsize
149
+ highlight_text_color="k", # label color
150
+ highlight_text_dx_frac=0.008, # x-offset for text (fraction of x-range)
151
+ highlight_text_dy_frac=0.008, # y-offset for text (fraction of y-range)
152
+ highlight_text_dz=0.0, # z-offset in z-units (mapped units)
153
+ highlight_text_bbox=True, # draw a small white box behind text
154
+ highlight_text_bbox_alpha=0.65, # bbox alpha
155
+
156
+ # legend
157
+ show_legend=False,
158
+ legend_title="Rung Type",
159
+ legend_fontsize=8,
160
+ legend_title_fontsize=9,
161
+ legend_loc="center left",
162
+ legend_bbox_to_anchor=(1.02, 0.5),
163
+ ):
164
+ """Draw the Figure 2D 3D scatter of RMSE vs. slope vs. year, grouped and colored by rung."""
165
+ sns.set_theme(context="paper", style="ticks", font_scale=1.0)
166
+
167
+ def _coerce_pair(val, name, allow_none=True):
168
+ if val is None:
169
+ if allow_none:
170
+ return None
171
+ raise ValueError(f"{name} cannot be None")
172
+ if np.isscalar(val):
173
+ raise ValueError(
174
+ f"{name} must be a 2-tuple like ({name}_min, {name}_max), not a scalar: {val}"
175
+ )
176
+ if len(val) != 2:
177
+ raise ValueError(f"{name} must be length-2 like (min, max). Got: {val}")
178
+ return (float(val[0]), float(val[1]))
179
+
180
+ # apply alias if user passed axis_linewidth
181
+ if axis_linewidth is not None:
182
+ axisline_lw = float(axis_linewidth)
183
+
184
+ # coerce user limits early
185
+ xlim2 = _coerce_pair(xlim, "xlim", allow_none=True)
186
+ ylim2 = _coerce_pair(ylim, "ylim", allow_none=True)
187
+ zlim2 = _coerce_pair(zlim, "zlim", allow_none=True)
188
+
189
+ def _draw_xy_grid_plane(ax, xlim, ylim, z=0.0, alpha=0.25, lw=0.8):
190
+ x_ticks = ax.get_xticks()
191
+ y_ticks = ax.get_yticks()
192
+ for x in x_ticks:
193
+ if xlim[0] <= x <= xlim[1]:
194
+ ax.plot([x, x], [ylim[0], ylim[1]], [z, z],
195
+ color="0.0", alpha=alpha, linewidth=lw, zorder=1)
196
+ for y in y_ticks:
197
+ if ylim[0] <= y <= ylim[1]:
198
+ ax.plot([xlim[0], xlim[1]], [y, y], [z, z],
199
+ color="0.0", alpha=alpha, linewidth=lw, zorder=1)
200
+
201
+ def _blend_hex(c1, c2, t):
202
+ c1 = np.array(mcolors.to_rgb(c1))
203
+ c2 = np.array(mcolors.to_rgb(c2))
204
+ return (1 - t) * c1 + t * c2
205
+
206
+ def _make_z_break_mapper(z_break, gap, drop_in_break):
207
+ z_break2 = _coerce_pair(z_break, "z_break", allow_none=True)
208
+ if z_break2 is None:
209
+ return (lambda y: float(y)), (lambda z: float(z))
210
+
211
+ lo, hi = z_break2
212
+ if not (hi > lo):
213
+ raise ValueError(f"z_break must be (lo, hi) with hi>lo; got {z_break2}")
214
+ span = hi - lo
215
+ gap = float(gap)
216
+
217
+ def fwd(y):
218
+ y = float(y)
219
+ if lo < y < hi:
220
+ if drop_in_break:
221
+ return None
222
+ y = lo if (y - lo) <= (hi - y) else hi
223
+ if y <= lo:
224
+ return y
225
+ return y - span + gap
226
+
227
+ def inv(z):
228
+ z = float(z)
229
+ if z <= lo:
230
+ return z
231
+ return z + span - gap
232
+
233
+ return fwd, inv
234
+
235
+ def _norm_cat(s: str) -> str:
236
+ s = str(s).lower()
237
+ return "".join(ch for ch in s if ch.isalnum())
238
+
239
+ exclude_norm = {_norm_cat(c) for c in (lane_exclude_categories or ())}
240
+
241
+ z_map, _ = _make_z_break_mapper(z_break, z_break_gap, drop_years_in_break)
242
+
243
+ # --- Prepare dataframe ---
244
+ df = results_df.copy()
245
+ df["category"] = df["Method"].map(methods_categorization).fillna("Unknown")
246
+ df["_cat_norm"] = df["category"].apply(_norm_cat)
247
+
248
+ rung_order = [c for c in method_color_map.keys() if c in df["category"].unique()]
249
+ rung_order += [c for c in df["category"].unique() if c not in rung_order]
250
+
251
+ # --- Z assignment ---
252
+ if z_param is not None:
253
+ df["_z_raw"] = df[z_param].astype(float)
254
+ df["_year"] = df["_z_raw"].copy()
255
+ else:
256
+ if year_map is None:
257
+ raise ValueError("z_param is None, so you must provide year_map={Method: year}.")
258
+ df["_z_raw"] = df["Method"].map(year_map).fillna(year_default)
259
+
260
+ missing_methods = df[df["_z_raw"].isna()]["Method"].unique()
261
+ if len(missing_methods) > 0:
262
+ if drop_missing_years:
263
+ df = df.dropna(subset=["_z_raw"]).copy()
264
+ else:
265
+ examples = missing_methods[:12].tolist()
266
+ raise ValueError(
267
+ f"Missing years for {len(missing_methods)} methods. "
268
+ f"Examples: {examples}. "
269
+ f"Either add them to year_map or set drop_missing_years=True."
270
+ )
271
+
272
+ df["_z_raw"] = df["_z_raw"].astype(float)
273
+ df["_year"] = df["_z_raw"].copy()
274
+
275
+ if z_lane_by_category:
276
+ cat_index = {cat: i for i, cat in enumerate(rung_order)}
277
+ offsets = df["category"].map(lambda c: cat_index.get(c, 0)).astype(float)
278
+ offsets = offsets - offsets.mean()
279
+ is_excluded = df["_cat_norm"].isin(exclude_norm)
280
+ offsets = offsets.where(~is_excluded, 0.0)
281
+ df["_z_raw"] = df["_z_raw"] + offsets * float(z_lane_width)
282
+
283
+ if z_jitter_by_category and z_jitter_by_category != 0.0:
284
+ rng = np.random.default_rng(0)
285
+ df["_z_raw"] = df["_z_raw"] + rng.uniform(-1, 1, size=len(df)) * float(z_jitter_by_category)
286
+
287
+ # --- apply z break mapping ---
288
+ if z_break is not None:
289
+ z_mapped = df["_z_raw"].apply(z_map)
290
+ if drop_years_in_break:
291
+ df = df.loc[~z_mapped.isna()].copy()
292
+ z_mapped = df["_z_raw"].apply(z_map)
293
+ df["_z"] = z_mapped.astype(float)
294
+ else:
295
+ df["_z"] = df["_z_raw"].astype(float)
296
+
297
+ # --- Base limits from ORIGINAL (unshifted) data ---
298
+ x_min, x_max = df[x_param].min(), df[x_param].max()
299
+ y_min, y_max = df[y_param].min(), df[y_param].max()
300
+ x_pad = 0.15 * (x_max - x_min) if x_max > x_min else 0.5
301
+ y_pad = 0.15 * (y_max - y_min) if y_max > y_min else 0.5
302
+ xm_default = (x_min - x_pad, x_max + x_pad)
303
+ ym_default = (y_min - y_pad, y_max + y_pad)
304
+
305
+ # Use provided limits (or defaults) to define lane sizes
306
+ x0l, x1l = (xm_default if xlim2 is None else xlim2)
307
+ y0l, y1l = (ym_default if ylim2 is None else ylim2)
308
+ xr = (x1l - x0l) if (x1l - x0l) != 0 else 1.0
309
+ yr = (y1l - y0l) if (y1l - y0l) != 0 else 1.0
310
+
311
+ # --- XY category lanes ---
312
+ if xy_lane_by_category:
313
+ n = max(1, len(rung_order))
314
+ cat_index = {cat: i for i, cat in enumerate(rung_order)}
315
+
316
+ if xy_lane_style.lower() == "grid":
317
+ cols = int(np.ceil(np.sqrt(n)))
318
+ rows = int(np.ceil(n / cols))
319
+
320
+ def grid_xy(i):
321
+ r = i // cols
322
+ c = i % cols
323
+ cx = c - (cols - 1) / 2
324
+ cy = r - (rows - 1) / 2
325
+ return cx, cy
326
+
327
+ coords = {cat: grid_xy(cat_index[cat]) for cat in rung_order}
328
+ else:
329
+ angles = np.linspace(0, 2*np.pi, n, endpoint=False)
330
+ coords = {cat: (np.cos(angles[cat_index[cat]]), np.sin(angles[cat_index[cat]])) for cat in rung_order}
331
+
332
+ dx = float(xy_lane_strength) * xr
333
+ dy = float(xy_lane_strength) * yr
334
+ is_excluded = df["_cat_norm"].isin(exclude_norm)
335
+
336
+ def _dx(c): return coords.get(c, (0.0, 0.0))[0] * dx
337
+ def _dy(c): return coords.get(c, (0.0, 0.0))[1] * dy
338
+
339
+ df["_x_plot"] = df[x_param].astype(float) + np.where(is_excluded, 0.0, df["category"].map(_dx).astype(float))
340
+ df["_y_plot"] = df[y_param].astype(float) + np.where(is_excluded, 0.0, df["category"].map(_dy).astype(float))
341
+ else:
342
+ df["_x_plot"] = df[x_param].astype(float)
343
+ df["_y_plot"] = df[y_param].astype(float)
344
+
345
+ # Expand axis limits to include shifted points
346
+ if auto_expand_xy_limits:
347
+ xmn, xmx = float(np.nanmin(df["_x_plot"])), float(np.nanmax(df["_x_plot"]))
348
+ ymn, ymx = float(np.nanmin(df["_y_plot"])), float(np.nanmax(df["_y_plot"]))
349
+ xr2 = (xmx - xmn) if (xmx - xmn) != 0 else 1.0
350
+ yr2 = (ymx - ymn) if (ymx - ymn) != 0 else 1.0
351
+ xpad2 = xy_limit_pad_frac * xr2
352
+ ypad2 = xy_limit_pad_frac * yr2
353
+ xm_shifted = (xmn - xpad2, xmx + xpad2)
354
+ ym_shifted = (ymn - ypad2, ymx + ypad2)
355
+ else:
356
+ xm_shifted = xm_default
357
+ ym_shifted = ym_default
358
+
359
+ # baseline plane fix
360
+ if zlim2 is not None:
361
+ z_base = z_map(zlim2[0]) if (z_break is not None) else float(zlim2[0])
362
+ else:
363
+ z_base = float(np.nanmin(df["_z"]))
364
+ Z_BASELINE = z_base
365
+ if auto_xy_grid_z_if_years and (z_param is None):
366
+ xy_grid_z = Z_BASELINE
367
+
368
+ # decade grouping
369
+ if group_by_decade and (z_param is None):
370
+ df["_decade"] = (np.floor(df["_year"] / decade_bin) * decade_bin).astype(int)
371
+ decade_order = sorted(df["_decade"].unique(), reverse=not decade_sort_ascending)
372
+ else:
373
+ df["_decade"] = np.nan
374
+ decade_order = []
375
+
376
+ # --- Figure ---
377
+ fig = plt.figure(figsize=figsize, constrained_layout=True)
378
+ ax = fig.add_subplot(111, projection="3d")
379
+ ax.view_init(elev=elev, azim=azim)
380
+ ax.set_box_aspect((1.5, 2, 1.5))
381
+
382
+ # axis lines
383
+ for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
384
+ try:
385
+ axis.line.set_linewidth(axisline_lw)
386
+ axis.line.set_color(axisline_color)
387
+ except Exception:
388
+ pass
389
+
390
+ # panes
391
+ for axis in (ax.xaxis, ax.yaxis, ax.zaxis):
392
+ try:
393
+ axis.pane.set_edgecolor(pane_edgecolor)
394
+ axis.pane.set_linewidth(pane_edge_lw)
395
+ if pane_fill:
396
+ axis.pane.set_facecolor(mcolors.to_rgba(pane_color, pane_alpha))
397
+ else:
398
+ axis.pane.set_facecolor((1, 1, 1, 0))
399
+ except Exception:
400
+ pass
401
+
402
+ # --- SCATTER ---
403
+ if group_by_decade and (z_param is None):
404
+ for d in decade_order:
405
+ sub_d = df[df["_decade"] == d]
406
+ if sub_d.empty:
407
+ continue
408
+ for cat in rung_order:
409
+ sub = sub_d[sub_d["category"] == cat]
410
+ if sub.empty:
411
+ continue
412
+ ax.scatter(
413
+ sub["_x_plot"], sub["_y_plot"], sub["_z"],
414
+ s=decade_marker_size,
415
+ depthshade=False,
416
+ color=method_color_map.get(cat, "#777777"),
417
+ edgecolor="none",
418
+ alpha=decade_alpha,
419
+ zorder=5,
420
+ )
421
+ else:
422
+ for cat in rung_order:
423
+ sub = df[df["category"] == cat]
424
+ if sub.empty:
425
+ continue
426
+ ax.scatter(
427
+ sub["_x_plot"], sub["_y_plot"], sub["_z"],
428
+ s=80,
429
+ depthshade=False,
430
+ color=method_color_map.get(cat, "#777777"),
431
+ edgecolor="none",
432
+ alpha=0.85,
433
+ zorder=5,
434
+ )
435
+
436
+ # --- DROPLINES FOR ALL POINTS ---
437
+ if droplines_for_all:
438
+ cat_cols = df["category"].map(lambda c: method_color_map.get(c, "#777777"))
439
+ shadow_cols = [_blend_hex(baseline_gray, c, baseline_tint_strength) for c in cat_cols]
440
+ for (x0, y0, z0, shcol) in zip(df["_x_plot"].to_numpy(float),
441
+ df["_y_plot"].to_numpy(float),
442
+ df["_z"].to_numpy(float),
443
+ shadow_cols):
444
+ ax.plot([x0, x0], [y0, y0], [z0, Z_BASELINE],
445
+ color=dropline_color, alpha=dropline_alpha,
446
+ linewidth=dropline_lw, zorder=2)
447
+ ax.scatter([x0], [y0], [Z_BASELINE],
448
+ s=dropline_shadow_size,
449
+ color=shcol,
450
+ alpha=dropline_shadow_alpha,
451
+ depthshade=False,
452
+ zorder=2)
453
+
454
+ # --- limits ---
455
+ ax.set_xlim(xm_shifted if xlim2 is None else xlim2)
456
+ ax.set_ylim(ym_shifted if ylim2 is None else ylim2)
457
+ if zlim2 is not None:
458
+ ax.set_zlim(z_map(zlim2[0]), z_map(zlim2[1])) if (z_break is not None) else ax.set_zlim(zlim2)
459
+ else:
460
+ zmin = float(np.nanmin(df["_z"]))
461
+ zmax = float(np.nanmax(df["_z"]))
462
+ ax.set_zlim(zmin - 0.5, zmax + 0.5)
463
+
464
+ ax.set_ylabel("Scaled RMSE (" + r"$^{1}\mathrm{H}$" + " ppm)", fontsize=13, labelpad=10)
465
+ ax.set_xlabel("Slope vs. CCSD(T)", fontsize=13, labelpad=10)
466
+ ax.set_zlabel("Year", fontsize=13, labelpad=8)
467
+
468
+ # --- major tick locations ---
469
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=x_major_nbins))
470
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=y_major_nbins))
471
+
472
+ # --- major tick appearance ---
473
+ for axis_name in ("x", "y", "z"):
474
+ ax.tick_params(
475
+ axis=axis_name,
476
+ which="major",
477
+ length=major_tick_length,
478
+ width=major_tick_width,
479
+ pad=major_tick_pad,
480
+ colors=major_tick_color,
481
+ labelsize=major_tick_labelsize,
482
+ labelcolor=major_tick_labelcolor,
483
+ )
484
+
485
+ for lbl in ax.get_xticklabels():
486
+ lbl.set_rotation(major_tick_rotation_x)
487
+ for lbl in ax.get_yticklabels():
488
+ lbl.set_rotation(major_tick_rotation_y)
489
+ for lbl in ax.get_zticklabels():
490
+ lbl.set_rotation(major_tick_rotation_z)
491
+
492
+ # --- Z ticks as years ---
493
+ if (z_param is None) or (z_break is not None):
494
+ if zlim2 is not None:
495
+ real_min, real_max = float(zlim2[0]), float(zlim2[1])
496
+ else:
497
+ real_min, real_max = float(np.nanmin(df["_year"])), float(np.nanmax(df["_year"]))
498
+
499
+ if z_break is not None:
500
+ lo, hi = map(float, _coerce_pair(z_break, "z_break", allow_none=False))
501
+ step = int(year_tick_step)
502
+ ticks_real = list(np.arange(np.floor(real_min), np.floor(min(real_max, lo)) + 1, step))
503
+ ticks_real += list(np.arange(np.ceil(max(real_min, hi)), np.ceil(real_max) + 1, step))
504
+ ticks_real = [int(t) for t in ticks_real]
505
+ ticks_pos = [z_map(t) for t in ticks_real]
506
+ ax.set_zticks(ticks_pos)
507
+ ax.set_zticklabels([str(t) for t in ticks_real], fontsize=major_tick_labelsize, color=major_tick_labelcolor)
508
+ else:
509
+ ticks = np.arange(int(np.floor(real_min)), int(np.ceil(real_max)) + 1, int(year_tick_step))
510
+ ax.set_zticks(ticks)
511
+ ax.set_zticklabels([str(t) for t in ticks], fontsize=major_tick_labelsize, color=major_tick_labelcolor)
512
+
513
+ ax.grid(False)
514
+
515
+ if show_xy_grid_plane:
516
+ _draw_xy_grid_plane(ax, xlim=ax.get_xlim(), ylim=ax.get_ylim(), z=xy_grid_z,
517
+ alpha=grid_alpha, lw=grid_lw)
518
+
519
+ # =========================
520
+ # HIGHLIGHT + LABEL
521
+ # =========================
522
+ if highlight_methods:
523
+ hm = set(highlight_methods)
524
+ sub_h = df[df["Method"].isin(hm)].copy()
525
+ if not sub_h.empty:
526
+ ax.scatter(
527
+ sub_h["_x_plot"], sub_h["_y_plot"], sub_h["_z"],
528
+ s=highlight_marker_size,
529
+ depthshade=False,
530
+ color=sub_h["category"].map(lambda c: method_color_map.get(c, "#777777")),
531
+ edgecolor=highlight_edgecolor,
532
+ linewidths=highlight_edgewidth,
533
+ alpha=highlight_alpha,
534
+ zorder=20,
535
+ )
536
+
537
+ if highlight_text:
538
+ dx_text = float(highlight_text_dx_frac) * xr
539
+ dy_text = float(highlight_text_dy_frac) * yr
540
+ for _, r in sub_h.iterrows():
541
+ px, py, pz = float(r["_x_plot"]), float(r["_y_plot"]), float(r["_z"])
542
+ tx, ty, tz = px + dx_text, py + dy_text, pz + float(highlight_text_dz)
543
+ # thin leader line so the label reads in clear space, not under the marker
544
+ ax.plot([px, tx], [py, ty], [pz, tz], color="0.45", linewidth=0.6, zorder=25)
545
+ txt_kwargs = dict(
546
+ fontsize=highlight_text_size,
547
+ color=highlight_text_color,
548
+ zorder=30, ha="left", va="center",
549
+ )
550
+ if highlight_text_bbox:
551
+ txt_kwargs["bbox"] = dict(
552
+ boxstyle="round,pad=0.15",
553
+ facecolor=(1, 1, 1, highlight_text_bbox_alpha),
554
+ edgecolor="none",
555
+ )
556
+ ax.text(tx, ty, tz, str(r["Method"]), **txt_kwargs)
557
+
558
+ if show_legend:
559
+ import matplotlib.patches as mpatches
560
+ patches = [mpatches.Patch(color=method_color_map.get(cat, "#777777"), label=cat) for cat in rung_order]
561
+ ax.legend(handles=patches,
562
+ title=legend_title,
563
+ fontsize=legend_fontsize,
564
+ title_fontsize=legend_title_fontsize,
565
+ loc=legend_loc,
566
+ bbox_to_anchor=legend_bbox_to_anchor,
567
+ frameon=True)
568
+
569
+ # =========================
570
+ # SAVE
571
+ # =========================
572
+ def _save_figure(fig, save_path, formats=("svg", "png"), dpi=300,
573
+ svg_transparent=True, png_transparent=False):
574
+ if not save_path:
575
+ return []
576
+ root, ext = os.path.splitext(save_path)
577
+ os.makedirs(os.path.dirname(save_path) or ".", exist_ok=True)
578
+ saved = []
579
+
580
+ def _save_one(outpath, fmt):
581
+ fmt = fmt.lower().lstrip(".")
582
+ if fmt == "svg":
583
+ fig.savefig(outpath, format="svg", bbox_inches="tight", pad_inches=0.12,
584
+ dpi=dpi, transparent=bool(svg_transparent))
585
+ elif fmt in {"png", "jpg", "jpeg", "tif", "tiff"}:
586
+ fig.savefig(outpath, format=fmt, bbox_inches="tight", pad_inches=0.12,
587
+ dpi=dpi, transparent=bool(png_transparent))
588
+ else:
589
+ fig.savefig(outpath, format=fmt, bbox_inches="tight", pad_inches=0.12,
590
+ dpi=dpi, transparent=False)
591
+
592
+ if ext.lower() in {".png", ".svg", ".pdf", ".jpg", ".jpeg", ".tif", ".tiff"}:
593
+ fmt = ext.lower().lstrip(".")
594
+ _save_one(save_path, fmt)
595
+ saved.append(save_path)
596
+ return saved
597
+
598
+ for f in formats:
599
+ f = f.lower().lstrip(".")
600
+ out = f"{save_path}.{f}"
601
+ _save_one(out, f)
602
+ saved.append(out)
603
+ return saved
604
+
605
+ saved_files = _save_figure(
606
+ fig,
607
+ save_path=save_path,
608
+ formats=save_formats,
609
+ dpi=dpi,
610
+ svg_transparent=svg_transparent,
611
+ png_transparent=png_transparent,
612
+ )
613
+ if saved_files:
614
+ print("Saved:", *saved_files, sep="\n - ")
615
+
616
+ if show_plot:
617
+ plt.show()
618
+ else:
619
+ plt.close(fig)
620
+
621
+ return fig, ax, saved_files
analysis/code/dataset_summary.py ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SI Table S8 (Dataset Summary Statistics): molecule and 1H/13C site counts for the training datasets.
2
+
3
+ Site counts from each file's flat ``atomic_numbers`` (1H = 1, 13C = 6); molecule counts from each
4
+ file's per-molecule index. Vectorized reads, so seconds even on the multi-million-molecule files. The
5
+ MagNET-Zero rows combine the two sigma-pepper groups (initial PBE0/pcSseg-1, WP04/wB97X-D/pcSseg-2
6
+ fine-tuning) with sigma-concentrate.
7
+ """
8
+ import os
9
+
10
+ import h5py
11
+ import pandas as pd
12
+
13
+ import paths
14
+
15
+ # Published SI Table S8 values, for the exact-reproduction check: label -> (molecules, 1H, 13C).
16
+ PUBLISHED_S8 = {
17
+ "sigma-shake": (4_787_816, 72_175_813, 50_542_154),
18
+ "MagNET-Zero (initial round)": (2_167_210, 24_047_999, 15_330_361),
19
+ "MagNET-Zero (fine-tuning)": (370_105, 4_042_135, 2_557_211),
20
+ "MagNET-Zero (both rounds combined)": (2_537_315, 28_090_134, 17_887_572),
21
+ "sigma-concentrate": (50_000, 754_437, 528_569),
22
+ }
23
+
24
+
25
+ def _counts(node):
26
+ """(molecules, 1H sites, 13C sites) for an open h5py file or group."""
27
+ z = node["atomic_numbers"][:]
28
+ n_1h = int((z == 1).sum())
29
+ n_13c = int((z == 6).sum())
30
+ if "molecule_id" in node:
31
+ n_mol = node["molecule_id"].shape[0]
32
+ elif "smiles_offsets" in node: # offsets are n_molecules + 1
33
+ n_mol = node["smiles_offsets"].shape[0] - 1
34
+ elif "sigma_shake_id" in node:
35
+ n_mol = node["sigma_shake_id"].shape[0]
36
+ else:
37
+ raise KeyError("node has no recognized per-molecule key "
38
+ "(molecule_id / smiles_offsets / sigma_shake_id)")
39
+ return n_mol, n_1h, n_13c
40
+
41
+
42
+ def counts(path, group=None):
43
+ """(molecules, 1H sites, 13C sites) for one HDF5 file, optionally within one group."""
44
+ with h5py.File(path, "r") as f:
45
+ return _counts(f[group] if group else f)
46
+
47
+
48
+ def summary_table(data_dir):
49
+ """SI Table S8 as a DataFrame with columns dataset / molecules / n_1H_sites / n_13C_sites.
50
+
51
+ data_dir is the release repo's ``data/`` directory; the three source files are read from their
52
+ dataset subfolders. The two MagNET-Zero training rounds and their combination are assembled from
53
+ the sigma-pepper groups plus sigma-concentrate.
54
+ """
55
+ # data_dir is the repo's data/ folder; its parent is the repo root the deposit fallback needs.
56
+ root = os.path.dirname(os.path.normpath(data_dir))
57
+ shake = paths.dataset_file("sigma-shake", root=root)
58
+ pepper = paths.dataset_file("sigma-pepper", root=root)
59
+ concentrate = paths.dataset_file("sigma-concentrate", root=root)
60
+
61
+ sigma_shake = counts(shake)
62
+ initial = counts(pepper, "pbe0_pcSseg1") # MagNET-Zero initial round
63
+ pepper_ft = counts(pepper, "wp04_wb97xd_pcSseg2") # fine-tuning: sigma-pepper part
64
+ sigma_concentrate = counts(concentrate) # fine-tuning: sigma-concentrate part
65
+ fine_tuning = tuple(a + b for a, b in zip(pepper_ft, sigma_concentrate))
66
+ combined = tuple(a + b for a, b in zip(initial, fine_tuning))
67
+
68
+ rows = [
69
+ ("sigma-shake", sigma_shake),
70
+ ("MagNET-Zero (initial round)", initial),
71
+ ("MagNET-Zero (fine-tuning)", fine_tuning),
72
+ ("MagNET-Zero (both rounds combined)", combined),
73
+ ("sigma-concentrate", sigma_concentrate),
74
+ ]
75
+ return pd.DataFrame(
76
+ [{"dataset": label, "molecules": m, "n_1H_sites": h, "n_13C_sites": c}
77
+ for label, (m, h, c) in rows]
78
+ )
analysis/code/delta22.py ADDED
@@ -0,0 +1,927 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core analysis for the delta-22 figures (no plotting).
2
+
3
+ Shared machinery behind the delta-22 figures (Figures 2A, 3, 4; SI S1, S4-S8, S11, S13, S16-S18):
4
+ a seeded per-solute train/test fitting harness, the Pareto computation, and correlation matrices.
5
+ Reads the released data via `delta22_reader`; the figure notebooks and tests import from here.
6
+
7
+ Splits are seeded with random.seed(seed + 100), shuffle, first n_test solutes are the test set, so
8
+ results are deterministic. `fit` uses numpy lstsq directly instead of statsmodels smf.ols in the
9
+ inner loop (patsy re-parsing makes that ~1000x slower); `_fit_statsmodels` is kept as a test oracle.
10
+ """
11
+ import random
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+
16
+ from stats import rmse, mae, ols_fit, linear_fit_1d
17
+ from spreadsheet import site_atom_indices # noqa: F401 re-exported for notebooks (D.site_atom_indices)
18
+ from delta22_reader import (
19
+ load_delta22_dft_data,
20
+ load_delta22_nn_data,
21
+ load_solutes,
22
+ load_perturbed_shieldings,
23
+ )
24
+
25
+ # The 12 solvents with explicit (Desmond) data, in a stable order.
26
+ DESMOND_SOLVENTS = [
27
+ "tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene",
28
+ "acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "TIP4P",
29
+ ]
30
+
31
+ # the double hybrids (and DLPNO-MP2) have no implicit-solvent shielding of their own; the reader
32
+ # substitutes a B3LYP-D3(BJ) PCM correction for them, so their pcm column is not method-specific.
33
+ # Exclude them from any analysis that compares the PCM correction across methods.
34
+ DOUBLE_HYBRID_METHODS = ["B2GP_PLYP", "B2PLYP", "dlpno_mp2", "dsd_pbep86",
35
+ "mPW2PLYP", "revdsd_pbep86"]
36
+
37
+ STATIONARY_VS_PCM = ["stationary", "stationary + pcm"]
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # loading / assembling the flat query table
42
+
43
+ def delta22_solutes(delta22_path):
44
+ """The list of solute names stored in the file."""
45
+ return load_solutes(delta22_path)
46
+
47
+
48
+ def build_query_df(combined_df):
49
+ """Flatten a reader combined table (load_delta22_dft_data / load_delta22_nn_data output,
50
+ which is multi-indexed) into a plain DataFrame with the index levels as columns. Fitting
51
+ and split helpers operate on this flat table."""
52
+ return combined_df.reset_index()
53
+
54
+
55
+ def load_query_df_dft(delta22_path, experimental_path, **kwargs):
56
+ """Convenience: load the DFT combined table and flatten it in one call."""
57
+ return build_query_df(load_delta22_dft_data(delta22_path, experimental_path, **kwargs))
58
+
59
+
60
+ def load_query_df_nn(delta22_path, experimental_path, **kwargs):
61
+ """Convenience: load the MagNET (NN) combined table and flatten it in one call."""
62
+ return build_query_df(load_delta22_nn_data(delta22_path, experimental_path, **kwargs))
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # composite predictor columns
67
+
68
+ # the precomputed sum columns the formulas reference as single terms (e.g. "stationary_plus_pcm" is
69
+ # one predictor = stationary + pcm, distinct from the two-term "stationary + pcm"). For MagNET (NN)
70
+ # data, stationary_plus_pcm is precomputed by the loader using the solvent-specific PCM scaling
71
+ # factors; add_composite_columns leaves an existing stationary_plus_pcm column untouched.
72
+ # NOTE: the pcm three-term chains here (stationary_plus_pcm_plus_qcd etc.) are RAW sums with an
73
+ # unscaled pcm term. build_composite_model.py needs the pcm term carried at its scaled value, so it
74
+ # builds those columns itself on top of the scaled stationary_plus_pcm. Do not swap it to this
75
+ # helper: the results differ by order 1 ppm on every pcm chain.
76
+ _COMPOSITE_COLUMNS = {
77
+ "stationary_plus_pcm": ("stationary", "pcm"),
78
+ "stationary_plus_desmond": ("stationary", "desmond"),
79
+ "stationary_plus_openMM": ("stationary", "openMM"),
80
+ "stationary_plus_qcd": ("stationary", "qcd"),
81
+ "stationary_plus_des_vib": ("stationary", "desmond_vib"),
82
+ "stationary_plus_op_vib": ("stationary", "openMM_vib"),
83
+ "stationary_plus_pcm_plus_qcd": ("stationary", "pcm", "qcd"),
84
+ "stationary_plus_pcm_plus_des_vib": ("stationary", "pcm", "desmond_vib"),
85
+ "stationary_plus_pcm_plus_op_vib": ("stationary", "pcm", "openMM_vib"),
86
+ "stationary_plus_desmond_plus_qcd": ("stationary", "desmond", "qcd"),
87
+ "stationary_plus_desmond_plus_des_vib": ("stationary", "desmond", "desmond_vib"),
88
+ "stationary_plus_openMM_plus_qcd": ("stationary", "openMM", "qcd"),
89
+ "stationary_plus_openMM_plus_op_vib": ("stationary", "openMM", "openMM_vib"),
90
+ }
91
+
92
+
93
+ def add_composite_columns(query_df, keep_existing=("stationary_plus_pcm",)):
94
+ """Add the precomputed sum columns the composite formulas reference as single terms. Returns a
95
+ copy. Columns named in keep_existing that are already present (e.g. an NN stationary_plus_pcm
96
+ built with the solvent scaling factors) are not overwritten."""
97
+ out = query_df.copy()
98
+ for name, parts in _COMPOSITE_COLUMNS.items():
99
+ if name in keep_existing and name in out.columns:
100
+ continue
101
+ out[name] = sum(out[part] for part in parts)
102
+ return out
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # the linear fitting harness
107
+
108
+ def _formula_terms(formula):
109
+ """The additive predictor column names in 'a + b + c'. These figures use only additive main
110
+ effects (no interactions), so a plain split on '+' is exact."""
111
+ return [term.strip() for term in formula.split("+") if term.strip()]
112
+
113
+
114
+ def _design_matrix(df, terms):
115
+ """Design matrix [1, term_1, term_2, ...] as float, plus the response, with any row that has a
116
+ missing value in the response or a predictor dropped (statsmodels drops missing rows too)."""
117
+ response = df["experimental"].to_numpy(dtype=float)
118
+ columns = [df[term].to_numpy(dtype=float) for term in terms]
119
+ design = np.column_stack([np.ones(len(df))] + columns)
120
+ keep = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
121
+ return design[keep], response[keep]
122
+
123
+
124
+ def fit(train_df, test_df, formula, mode="rmse"):
125
+ """Fit experimental ~ formula by ordinary least squares on train_df and score on both sets.
126
+
127
+ Returns (train_error, test_error, params), where params is a pandas Series indexed by
128
+ "Intercept" and the predictor names (matching statsmodels' naming). mode is "rmse" (default)
129
+ or "mae". Uses numpy least squares directly (see the module docstring for why)."""
130
+ terms = _formula_terms(formula)
131
+ train_x, train_y = _design_matrix(train_df, terms)
132
+ beta = ols_fit(train_x, train_y)
133
+ params = pd.Series(beta, index=["Intercept"] + terms)
134
+
135
+ test_x, test_y = _design_matrix(test_df, terms)
136
+ train_pred = train_x @ beta
137
+ test_pred = test_x @ beta
138
+ score = rmse if mode == "rmse" else mae if mode == "mae" else None
139
+ if score is None:
140
+ raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")
141
+ return score(train_pred, train_y), score(test_pred, test_y), params
142
+
143
+
144
+ def _fit_statsmodels(train_df, test_df, formula, mode="rmse"):
145
+ """Statsmodels OLS, kept only as a test oracle for `fit`. Slow because statsmodels re-parses
146
+ the formula with patsy on every call; do not use it in the harness."""
147
+ import statsmodels.formula.api as smf
148
+ result = smf.ols(formula=f"experimental ~ {formula}", data=train_df).fit()
149
+ score = rmse if mode == "rmse" else mae
150
+ train_err = score(result.predict(train_df).to_numpy(), train_df["experimental"].to_numpy())
151
+ test_err = score(result.predict(test_df).to_numpy(), test_df["experimental"].to_numpy())
152
+ return train_err, test_err, result.params
153
+
154
+
155
+ def generate_solute_splits(n_splits, query_df, solvent, solutes,
156
+ n_test=10, seed_offset=100):
157
+ """Build n_splits seeded train/test partitions for one solvent, splitting on SOLUTE so a
158
+ molecule is never in both sets. For split k: seed the RNG with k + seed_offset, shuffle the
159
+ solute list, take the first n_test as the test set and the rest as the training set, then select
160
+ that solvent's rows. Returns a list of (train_df, test_df) pairs."""
161
+ solute_list = list(solutes)
162
+ # filter this solvent once, then group its rows by solute, so each seed just concatenates the
163
+ # relevant groups instead of masking the whole table again.
164
+ solvent_df = query_df[query_df["solvent"] == solvent]
165
+ by_solute = {solute: rows for solute, rows in solvent_df.groupby("solute")}
166
+ empty = solvent_df.iloc[0:0]
167
+
168
+ def _gather(molecules):
169
+ frames = [by_solute[m] for m in molecules if m in by_solute]
170
+ return pd.concat(frames) if frames else empty
171
+
172
+ splits = []
173
+ for seed in range(n_splits):
174
+ random.seed(seed + seed_offset)
175
+ molecules = list(solute_list)
176
+ random.shuffle(molecules)
177
+ splits.append((_gather(molecules[n_test:]), _gather(molecules[:n_test])))
178
+ return splits
179
+
180
+
181
+ def _solute_partitions(n_splits, solutes, n_test, seed_offset=100):
182
+ """The seeded (train_solutes, test_solutes) name partitions the harness fits over, matching
183
+ generate_solute_splits exactly: for split k, seed random with k + seed_offset, shuffle the full
184
+ solute list, test = the first n_test names, train = the rest. Row selection and solvent filtering
185
+ are left to the caller (see _split_fit_scores), so these partitions are computed once and reused
186
+ across formulas."""
187
+ partitions = []
188
+ base = list(solutes)
189
+ for seed in range(n_splits):
190
+ random.seed(seed + seed_offset)
191
+ molecules = list(base)
192
+ random.shuffle(molecules)
193
+ partitions.append((molecules[n_test:], molecules[:n_test]))
194
+ return partitions
195
+
196
+
197
+ def _split_fit_scores(group_df, solvent, formulas, partitions, mode="rmse", min_train=2, swap=False):
198
+ """Fit each formula over the given seeded solute partitions for one group and solvent, returning
199
+ {(formula, seed): (train_error, test_error)} for every split that was fit.
200
+
201
+ The fast path behind run_fits / pareto_solvent_averaged_rmse: converts each column to numpy once
202
+ and selects each split's rows by integer index, avoiding the per-split pd.concat that dominates
203
+ runtime in generate_solute_splits + fit. Mathematically identical to fit() on the same rows.
204
+ Splits whose train set is smaller than min_train, whose test set is empty, or that fail to solve
205
+ are omitted from the result. swap=True trains on the first n_test solutes instead of the rest
206
+ (Figure 2A)."""
207
+ solvent_df = group_df[group_df["solvent"] == solvent]
208
+ n_rows = len(solvent_df)
209
+ score = rmse if mode == "rmse" else mae if mode == "mae" else None
210
+ if score is None:
211
+ raise ValueError(f"mode must be 'rmse' or 'mae', got {mode!r}")
212
+
213
+ # row positions (0..n_rows-1 within solvent_df) for each solute, and the numeric columns as numpy
214
+ solute_values = solvent_df["solute"].to_numpy()
215
+ by_solute = {}
216
+ for position, solute in enumerate(solute_values):
217
+ by_solute.setdefault(solute, []).append(position)
218
+ by_solute = {s: np.array(v, dtype=np.intp) for s, v in by_solute.items()}
219
+ empty = np.array([], dtype=np.intp)
220
+
221
+ def gather(names):
222
+ arrays = [by_solute[m] for m in names if m in by_solute]
223
+ return np.concatenate(arrays) if arrays else empty
224
+
225
+ # each split's train/test row indices, computed once and shared across formulas
226
+ split_indices = []
227
+ for train_names, test_names in partitions:
228
+ train_idx, test_idx = gather(train_names), gather(test_names)
229
+ if swap:
230
+ train_idx, test_idx = test_idx, train_idx
231
+ split_indices.append((train_idx, test_idx))
232
+
233
+ response = solvent_df["experimental"].to_numpy(dtype=float)
234
+ term_columns = {term: solvent_df[term].to_numpy(dtype=float)
235
+ for formula in formulas for term in _formula_terms(formula)}
236
+
237
+ scores = {}
238
+ for formula in formulas:
239
+ terms = _formula_terms(formula)
240
+ design = np.column_stack([np.ones(n_rows)] + [term_columns[t] for t in terms])
241
+ finite = np.isfinite(response) & np.all(np.isfinite(design), axis=1)
242
+ for seed, (train_idx, test_idx) in enumerate(split_indices):
243
+ if len(train_idx) < min_train or len(test_idx) < 1:
244
+ continue
245
+ train_fit = train_idx[finite[train_idx]]
246
+ test_fit = test_idx[finite[test_idx]]
247
+ try:
248
+ beta = ols_fit(design[train_fit], response[train_fit])
249
+ train_err = score(design[train_fit] @ beta, response[train_fit])
250
+ test_err = score(design[test_fit] @ beta, response[test_fit])
251
+ except Exception:
252
+ continue
253
+ scores[(formula, seed)] = (train_err, test_err)
254
+ return scores
255
+
256
+
257
+ def run_fits(query_df, solvents, formulas, n_splits, solutes,
258
+ group_cols=None, n_test=10, mode="rmse", min_train=2):
259
+ """Run the fitting harness over every (group, solvent, formula, split) combination and return
260
+ a tidy DataFrame with train_RMSE / test_RMSE per row.
261
+
262
+ group_cols: optional list of columns to fit within separately (e.g.
263
+ ["sap_nmr_method", "sap_basis", "sap_geometry_type"] for the per-method figures). When None,
264
+ the whole query_df is used as a single group (the caller has pre-filtered it). Splits that are
265
+ too small to fit, or that cannot be solved, are skipped.
266
+ """
267
+ rows = []
268
+ if group_cols:
269
+ group_iter = list(query_df.groupby(group_cols))
270
+ else:
271
+ group_iter = [((), query_df)]
272
+ partitions = _solute_partitions(n_splits, solutes, n_test)
273
+ for group_key, group_df in group_iter:
274
+ meta = {}
275
+ if group_cols:
276
+ key = group_key if isinstance(group_key, tuple) else (group_key,)
277
+ meta = dict(zip(group_cols, key))
278
+ for solvent in solvents:
279
+ scores = _split_fit_scores(group_df, solvent, formulas, partitions,
280
+ mode=mode, min_train=min_train)
281
+ for formula in formulas:
282
+ for seed in range(n_splits):
283
+ got = scores.get((formula, seed))
284
+ if got is None:
285
+ continue
286
+ rows.append({**meta, "solvent": solvent, "formula": formula,
287
+ "seed": seed, "train_RMSE": got[0], "test_RMSE": got[1]})
288
+ return pd.DataFrame(rows)
289
+
290
+
291
+ def full_fit_coefficients(query_df, solvents, formula):
292
+ """Fit `formula` on the full data (no train/test split) for each solvent and return a DataFrame
293
+ indexed by parameter name ("Intercept" plus the predictors) with one column per solvent. This is
294
+ the deterministic full-fit OLS whose coefficients are the composite model's coefficients, stored
295
+ in applications.hdf5's composite_model/ols_coefficients group. Fitting the released delta-22 NN
296
+ data here (with nitromethane dropped) reproduces those coefficients to within the data's
297
+ integer-encoding precision."""
298
+ columns = {}
299
+ for solvent in solvents:
300
+ sub = query_df[query_df["solvent"] == solvent]
301
+ if len(sub) < 2:
302
+ continue
303
+ _, _, params = fit(sub, sub, formula)
304
+ columns[solvent] = params
305
+ return pd.DataFrame(columns)
306
+
307
+
308
+ def fit_coefficients(query_df, solvent, formula, solutes, n_splits,
309
+ n_test=10, seed_offset=100):
310
+ """Fit one formula for one solvent across all seeded splits and return the per-split fitted
311
+ coefficients as a DataFrame (one row per seed, columns = the statsmodels parameter names plus
312
+ the seed). This is the building block for the bootstrap coefficient distributions and for
313
+ verifying the composite-model coefficients fit on delta-22."""
314
+ splits = generate_solute_splits(n_splits, query_df, solvent, solutes,
315
+ n_test=n_test, seed_offset=seed_offset)
316
+ records = []
317
+ for seed, (train_df, test_df) in enumerate(splits):
318
+ if len(train_df) < 2:
319
+ continue
320
+ _, _, params = fit(train_df, test_df, formula)
321
+ record = {"solvent": solvent, "seed": seed}
322
+ record.update({name: float(value) for name, value in params.items()})
323
+ records.append(record)
324
+ return pd.DataFrame(records)
325
+
326
+
327
+ # ---------------------------------------------------------------------------
328
+ # Figure S8 B-D (Convergence of Explicit Solvent Corrections)
329
+
330
+ def frame_corrections(perturbed, atom_indices):
331
+ """Per-frame solvent correction for one site: the mean over the site's atoms of
332
+ (solvated - isolated), from a raw (n_frames, n_atoms, 2) perturbed-shielding array
333
+ (load_perturbed_shieldings). Frames whose site atoms were not computed read as NaN."""
334
+ site = (perturbed[:, :, 1] - perturbed[:, :, 0])[:, list(atom_indices)]
335
+ valid = ~np.isnan(site)
336
+ counts = valid.sum(axis=1)
337
+ sums = np.where(valid, site, 0.0).sum(axis=1)
338
+ return np.where(counts > 0, sums / np.maximum(counts, 1), np.nan)
339
+
340
+
341
+ def running_average(values):
342
+ """Cumulative mean over frames ignoring NaN frames - the running value a correction converges to
343
+ as more molecular-dynamics frames are included (Figure S8 panel B)."""
344
+ values = np.asarray(values, dtype=float)
345
+ valid = ~np.isnan(values)
346
+ counts = np.cumsum(valid)
347
+ sums = np.cumsum(np.where(valid, values, 0.0))
348
+ with np.errstate(invalid="ignore", divide="ignore"):
349
+ return np.where(counts > 0, sums / counts, np.nan)
350
+
351
+
352
+ def autocorrelation(values, max_lag=None):
353
+ """Autocorrelation of the valid per-frame corrections at lags 0..max_lag (Figure S8 panel C).
354
+ A fast drop to zero means frames are effectively independent."""
355
+ x = np.asarray(values, dtype=float)
356
+ x = x[~np.isnan(x)]
357
+ x = x - x.mean()
358
+ n = len(x)
359
+ if n < 2:
360
+ return np.array([1.0])
361
+ if max_lag is None:
362
+ max_lag = n - 1
363
+ denom = np.sum(x * x)
364
+ return np.array([np.sum(x[:n - lag] * x[lag:]) / denom for lag in range(max_lag + 1)])
365
+
366
+
367
+ def frame_validity(perturbed):
368
+ """Per frame, whether any shielding value was computed for that frame (a raw (n_frames,
369
+ n_atoms, 2) perturbed-shielding array from load_perturbed_shieldings has NaN entries for atoms
370
+ that were not computed). A frame with every entry NaN was skipped entirely by the DFT queue;
371
+ Figure S8 panel D visualizes this mask across solvents for a representative solute."""
372
+ return ~np.all(np.isnan(perturbed), axis=(1, 2))
373
+
374
+
375
+ def frame_validity_grid(hdf5_filepath, solute, solvents, explicit_solvation_model, shield_type="dft"):
376
+ """Figure S8 panel D data: for one solute and one molecular-dynamics engine, the per-frame
377
+ validity mask (frame_validity) for each solvent, stacked into a single boolean grid of shape
378
+ (n_solvents, max_frames). Solvents with fewer trajectory frames than the longest one are padded
379
+ with False (no data) past their own frame count, so every row lines up on a shared frame-index
380
+ axis. Returns (grid, frame_counts), where frame_counts[i] is the true (unpadded) frame count for
381
+ solvents[i], needed to normalize the frame-wise correction histograms in panel B."""
382
+ masks = []
383
+ for solvent in solvents:
384
+ perturbed = load_perturbed_shieldings(hdf5_filepath, solute, solvent,
385
+ explicit_solvation_model, shield_type)
386
+ masks.append(frame_validity(perturbed))
387
+ frame_counts = np.array([len(m) for m in masks])
388
+ max_len = int(frame_counts.max()) if len(frame_counts) else 0
389
+ grid = np.zeros((len(solvents), max_len), dtype=bool)
390
+ for i, mask in enumerate(masks):
391
+ grid[i, :len(mask)] = mask
392
+ return grid, frame_counts
393
+
394
+
395
+ # ---------------------------------------------------------------------------
396
+ # Figures S11 / S13 (MagNET vs DFT corrections)
397
+
398
+ def compare_dft_nn(dft_df, nn_df, value_col, keys=("solute", "site", "nucleus")):
399
+ """Merge the DFT and MagNET (NN) tables on the given keys and return, per site, the DFT and NN
400
+ value of one correction column plus their difference (error = NN - DFT). Used for the
401
+ rovibrational (qcd) comparison of Figure S11 and the explicit-solvent (desmond/openMM) comparison
402
+ of Figure S13. The correction is tiled across NMR methods, so duplicate keyed rows are dropped
403
+ before merging."""
404
+ keys = list(keys)
405
+ dft = dft_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
406
+ nn = nn_df[keys + [value_col]].drop_duplicates(subset=keys).dropna(subset=[value_col])
407
+ merged = dft.merge(nn, on=keys, suffixes=("_dft", "_nn"))
408
+ merged["error"] = merged[f"{value_col}_nn"] - merged[f"{value_col}_dft"]
409
+ return merged
410
+
411
+
412
+ def qcd_correction_by_site(dft_df, nn_df, nucleus="H"):
413
+ """Per site, the DFT and MagNET (NN) rovibrational (QCD) correction for one nucleus, sorted by
414
+ solute (SI Figure S11 panel C, the per-solute view behind panel A's aggregate scatter). QCD is a
415
+ gas-phase rovibrational correction, so unlike the explicit-solvent per-site view (Figure S13 panel
416
+ C) there is no solvent to split on: this is a plain wrapper around compare_dft_nn that keeps one
417
+ nucleus and orders the rows for the per-solute strip plot."""
418
+ merged = compare_dft_nn(dft_df, nn_df, "qcd", keys=("solute", "site", "nucleus"))
419
+ sub = merged[merged["nucleus"] == nucleus]
420
+ return sub.sort_values("solute", kind="stable").reset_index(drop=True)
421
+
422
+
423
+ def compare_dft_nn_by_engine(dft_df, nn_df, engines=("desmond", "openMM"),
424
+ keys=("solute", "site", "nucleus", "solvent")):
425
+ """SI Figure S13 panels A and B data: compare_dft_nn run separately for each molecular-dynamics
426
+ engine (Desmond and OpenMM), stacked into one table with a shared 'dft_value' / 'nn_value' /
427
+ 'error' column and an 'engine' label. compare_dft_nn alone cannot be stacked directly because it
428
+ names its value columns after value_col (e.g. 'desmond_dft' vs 'openMM_dft'); this renames them
429
+ to a common pair of columns first so both engines can share one axis."""
430
+ frames = []
431
+ for engine in engines:
432
+ one = compare_dft_nn(dft_df, nn_df, engine, keys=keys)
433
+ one = one.rename(columns={f"{engine}_dft": "dft_value", f"{engine}_nn": "nn_value"})
434
+ one["engine"] = engine
435
+ frames.append(one[list(keys) + ["dft_value", "nn_value", "error", "engine"]])
436
+ return pd.concat(frames, ignore_index=True)
437
+
438
+
439
+ # ---------------------------------------------------------------------------
440
+ # Figure S5 (PCM Captures Bulk Solvent Effects)
441
+
442
+ # bulk solvent properties used to test what the PCM benefit tracks (TIP4P uses water's values)
443
+ SOLVENT_DIELECTRIC = {
444
+ "chloroform": 4.81, "dichloromethane": 9.08, "methanol": 32.6, "TIP4P": 78.54,
445
+ "trifluoroethanol": 26.7, "acetone": 21.01, "acetonitrile": 36.64, "dimethylsulfoxide": 47.0,
446
+ "tetrahydrofuran": 7.52, "benzene": 2.28, "chlorobenzene": 5.69, "toluene": 2.38,
447
+ }
448
+ SOLVENT_POLARIZABILITY = {
449
+ "chloroform": 8.5, "dichloromethane": 6.5, "methanol": 3.3, "TIP4P": 1.5,
450
+ "trifluoroethanol": 4.5, "acetone": 6.4, "acetonitrile": 4.4, "dimethylsulfoxide": 7.9,
451
+ "tetrahydrofuran": 8.0, "benzene": 10.3, "chlorobenzene": 12.4, "toluene": 12.3,
452
+ }
453
+
454
+
455
+ def pcm_benefit_per_solvent(query_df_dft, method, basis, geometry, solvents, n_splits, solutes,
456
+ nucleus="H", n_test=10):
457
+ """Panel S5 data: per solvent, the percent reduction in mean test RMSE from adding the
458
+ implicit-solvent (PCM) term, for one DFT method. Returns a Series indexed by solvent. A positive
459
+ value means PCM lowered the error. Plotted against the bulk solvent properties above.
460
+
461
+ The published Figure S5 aggregates the per-split test RMSEs with the mean (not the median); this
462
+ is what reproduces the paper's Pearson R values exactly."""
463
+ one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
464
+ & (query_df_dft["sap_basis"] == basis)
465
+ & (query_df_dft["sap_geometry_type"] == geometry)
466
+ & (query_df_dft["nucleus"] == nucleus)]
467
+ one = add_composite_columns(one)
468
+ results = run_fits(one, solvents, ["stationary", "stationary_plus_pcm"],
469
+ n_splits=n_splits, solutes=solutes, n_test=n_test)
470
+ mean = results.groupby(["solvent", "formula"])["test_RMSE"].mean().unstack("formula")
471
+ return -((mean["stationary_plus_pcm"] - mean["stationary"]) / mean["stationary"]) * 100.0
472
+
473
+
474
+ # ---------------------------------------------------------------------------
475
+ # Figure S7 (Explicit Solvent Corrections are Method-Independent)
476
+
477
+ def explicit_correction_pairs(combined_df, solvent, nucleus="H"):
478
+ """Per site, the Desmond and OpenMM explicit-solvent corrections for one solvent (Figure S7).
479
+ The explicit correction does not depend on the NMR method, so the same value is tiled across the
480
+ method rows; duplicate site rows are dropped. Works on either the DFT or the NN combined table.
481
+ A tight Desmond-vs-OpenMM agreement is the method-independence claim."""
482
+ sub = combined_df[(combined_df["nucleus"] == nucleus) & (combined_df["solvent"] == solvent)]
483
+ out = sub[["solute", "site", "desmond", "openMM"]].drop_duplicates(subset=["solute", "site"])
484
+ return out.dropna(subset=["desmond", "openMM"]).reset_index(drop=True)
485
+
486
+
487
+ def explicit_correction_dft_nn_pairs(dft_df, nn_df, solvent, nucleus="H"):
488
+ """Per site, the Desmond and OpenMM explicit-solvent corrections from both DFT and MagNET-x (NN)
489
+ for one solvent and nucleus (Figure S13 panel C, the per-site view behind panel A's aggregate
490
+ scatter). Built from two explicit_correction_pairs calls (one per source) merged on
491
+ (solute, site), giving columns desmond_dft, openMM_dft, desmond_nn, openMM_nn."""
492
+ dft_pairs = explicit_correction_pairs(dft_df, solvent, nucleus)
493
+ nn_pairs = explicit_correction_pairs(nn_df, solvent, nucleus)
494
+ return dft_pairs.merge(nn_pairs, on=["solute", "site"], suffixes=("_dft", "_nn"))
495
+
496
+
497
+ def si_s13d_fitting_accuracy(query_df_dft, query_df_nn, solvents, n_splits, solutes, nucleus="H",
498
+ method="dsd_pbep86", basis="pcSseg3", geometry="pbe0_tz", n_test=10):
499
+ """Figure S13 panel D data: the semi-parsimonious composite model's (composite_models.py's
500
+ best_semi formula) test RMSE against experiment, for every solvent, split by molecular-dynamics
501
+ engine (Desmond vs OpenMM) and by the source of the explicit-solvent/vibrational term (DFT vs
502
+ MagNET-x, "NN"). Protons use the QCD rovibrational correction (stationary_plus_qcd), which the
503
+ SI text says is the same B3LYP/cc-pVDZ value for both DFT and NN bars; carbons use the classical
504
+ vibrational correction computed from the same engine's trajectories (stationary_plus_desmond_vib
505
+ or stationary_plus_openMM_vib), which does differ between the DFT and NN combined tables. Returns
506
+ a tidy DataFrame with one row per (engine, source, solvent, seed) and a test_RMSE column."""
507
+ engine_vib_column = {"desmond": "stationary_plus_des_vib", "openMM": "stationary_plus_op_vib"}
508
+ rows = []
509
+ for source_name, query_df in [("DFT", query_df_dft), ("NN", query_df_nn)]:
510
+ sub = query_df[query_df["nucleus"] == nucleus]
511
+ if source_name == "DFT":
512
+ sub = sub[(sub["sap_nmr_method"] == method) & (sub["sap_basis"] == basis)
513
+ & (sub["sap_geometry_type"] == geometry)]
514
+ sub = add_composite_columns(sub)
515
+ for engine in ("desmond", "openMM"):
516
+ base_term = "stationary_plus_qcd" if nucleus == "H" else engine_vib_column[engine]
517
+ formula = f"{base_term} + {engine}"
518
+ results = run_fits(sub, solvents, [formula], n_splits=n_splits, solutes=solutes,
519
+ n_test=n_test)
520
+ results["engine"] = engine
521
+ results["source"] = source_name
522
+ rows.append(results)
523
+ return pd.concat(rows, ignore_index=True)
524
+
525
+
526
+ # ---------------------------------------------------------------------------
527
+ # Figure 2A / Figure S1 (the accuracy-vs-cost Pareto frontier)
528
+
529
+ # MagNET predicts the implicit-solvent (PCM) correction for chloroform only. To get a value for the
530
+ # other solvents, the chloroform correction is scaled by a per-solvent factor, fit on the delta-22
531
+ # DFT PCM corrections themselves: the slope (through the origin) of one method's PCM correction in
532
+ # that solvent against the reference method's PCM correction in chloroform. The reference (input)
533
+ # method is B3LYP-D3(BJ); the output method is the one MagNET was trained to reproduce (wP04 for
534
+ # proton, wB97X-D for carbon). These factors are also stored in applications.hdf5, but recomputing
535
+ # them here keeps Figure 2A self-contained within the delta-22 data.
536
+ MAGNET_PCM_INPUT_METHOD = "b3lyp_d3bj"
537
+ MAGNET_PCM_OUTPUT_METHODS = {"H": "wp04", "C": "wb97xd"}
538
+
539
+
540
+ def pcm_conversion_factors(query_df_dft, nucleus, output_method=None,
541
+ input_method=MAGNET_PCM_INPUT_METHOD,
542
+ geometry="aimnet2", basis="pcSseg2"):
543
+ """The per-solvent factor that scales MagNET's chloroform PCM correction to each solvent, for
544
+ one nucleus. Recomputed from the delta-22 DFT PCM corrections: for each solvent, the
545
+ through-the-origin slope of the output method's PCM correction in that solvent against the input
546
+ (reference) method's PCM correction in chloroform, over the shared sites. Returns a Series
547
+ indexed by solvent in the reader's native naming (water is "TIP4P"). Matches the factors stored
548
+ in applications.hdf5 to machine precision."""
549
+ if output_method is None:
550
+ output_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
551
+ base = query_df_dft[(query_df_dft["sap_geometry_type"] == geometry)
552
+ & (query_df_dft["sap_basis"] == basis)
553
+ & (query_df_dft["nucleus"] == nucleus)]
554
+ reference = (base[(base["sap_nmr_method"] == input_method) & (base["solvent"] == "chloroform")]
555
+ .set_index(["solute", "site"])["pcm"])
556
+ output = base[base["sap_nmr_method"] == output_method]
557
+ factors = {}
558
+ for solvent in DESMOND_SOLVENTS:
559
+ y = output[output["solvent"] == solvent].set_index(["solute", "site"])["pcm"]
560
+ joined = pd.concat([reference.rename("x"), y.rename("y")], axis=1).dropna()
561
+ xv = joined["x"].to_numpy(dtype=float)
562
+ yv = joined["y"].to_numpy(dtype=float)
563
+ factors[solvent] = float((xv * yv).sum() / (xv * xv).sum()) if len(xv) else np.nan
564
+ return pd.Series(factors, name="pcm_conversion_factor")
565
+
566
+
567
+ def add_nn_stationary_plus_pcm(query_df_nn, factors_by_nucleus):
568
+ """Add the MagNET stationary_plus_pcm column to a flat NN query table: the gas-phase shielding
569
+ plus the chloroform PCM correction scaled by the per-solvent, per-nucleus factor from
570
+ pcm_conversion_factors. factors_by_nucleus maps "H"/"C" to those Series. Returns a copy."""
571
+ out = query_df_nn.copy()
572
+ factor = out.apply(lambda r: factors_by_nucleus[r["nucleus"]].get(r["solvent"], np.nan), axis=1)
573
+ out["stationary_plus_pcm"] = out["stationary"] + out["pcm"] * factor
574
+ return out
575
+
576
+
577
+ # the columns the Pareto fit needs from each flat table, before the "sap_" prefix is stripped
578
+ _PARETO_COLUMNS = ["sap_geometry_type", "sap_nmr_method", "sap_basis", "nucleus", "site",
579
+ "solute", "solvent", "experimental", "stationary_plus_pcm"]
580
+
581
+
582
+ def assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors=None,
583
+ exclude_solutes=("nitromethane",)):
584
+ """Build the combined DFT + MagNET table the Pareto fit runs on: experimental shift against the
585
+ single stationary_plus_pcm predictor, for every method/basis/geometry/nucleus/solvent. The DFT
586
+ predictor is stationary + pcm; the MagNET predictor uses the scaled chloroform correction
587
+ (conversion_factors, recomputed from query_df_dft if not given). TIP4P is renamed to water and
588
+ the excluded solutes (nitromethane by default, an outlier) are removed. The DFT rows come first
589
+ so the solute order is stable for the seeded splits."""
590
+ if conversion_factors is None:
591
+ conversion_factors = {nuc: pcm_conversion_factors(query_df_dft, nuc) for nuc in ("H", "C")}
592
+ dft = query_df_dft.copy()
593
+ dft["stationary_plus_pcm"] = dft["stationary"] + dft["pcm"]
594
+ nn = add_nn_stationary_plus_pcm(query_df_nn, conversion_factors)
595
+ combined = pd.concat([dft[_PARETO_COLUMNS], nn[_PARETO_COLUMNS]], ignore_index=True)
596
+ combined = combined.rename(columns=lambda c: c.replace("sap_", ""))
597
+ combined["solvent"] = combined["solvent"].replace("TIP4P", "water")
598
+ combined["experimental"] = pd.to_numeric(combined["experimental"], errors="coerce")
599
+ if exclude_solutes:
600
+ combined = combined[~combined["solute"].isin(set(exclude_solutes))]
601
+ return combined.reset_index(drop=True)
602
+
603
+
604
+ def pareto_solvent_averaged_rmse(fitting_df, solutes=None, n_splits=100, n_test=10,
605
+ random_state=1, formula="stationary_plus_pcm"):
606
+ """For every (geometry, method, basis, nucleus, solvent), the mean test RMSE of the
607
+ experimental ~ stationary_plus_pcm fit over n_splits seeded per-solute splits, then a
608
+ solvent-averaged row per (geometry, method, basis, nucleus). This is the accuracy axis of
609
+ Figure 2A / Figure S1.
610
+
611
+ Figure 2A splits differently from the other figures: it trains on the first n_test shuffled
612
+ solutes and tests on the rest, seeding with random_state + split index (the other figures train
613
+ on the remainder and seed with +100); swap=True on _split_fit_scores gets that train-on-first-
614
+ n_test behavior. The per-split errors are averaged with the mean, not the median used elsewhere."""
615
+ if solutes is None:
616
+ solutes = fitting_df["solute"].unique().tolist()
617
+ group_cols = ["geometry_type", "nmr_method", "basis", "nucleus", "solvent"]
618
+ rows = []
619
+ partitions = _solute_partitions(n_splits, solutes, n_test, seed_offset=random_state)
620
+ for key, group_df in fitting_df.groupby(group_cols):
621
+ solvent = key[group_cols.index("solvent")]
622
+ scores = _split_fit_scores(group_df, solvent, [formula], partitions, swap=True)
623
+ errors = [scores.get((formula, seed), (np.nan, np.nan))[1] for seed in range(n_splits)]
624
+ with np.errstate(invalid="ignore"):
625
+ mean_err = float(np.nanmean(errors)) if errors else np.nan
626
+ rows.append({**dict(zip(group_cols, key)), "fitting_RMSE": mean_err})
627
+ raw = pd.DataFrame(rows)
628
+ averaged = (raw.groupby(["geometry_type", "nmr_method", "basis", "nucleus"])["fitting_RMSE"]
629
+ .mean().reset_index())
630
+ averaged["solvent"] = "solvent-averaged"
631
+ return pd.concat([raw, averaged], ignore_index=True)
632
+
633
+
634
+ def load_pareto_timings(delta22_path):
635
+ """The compute-time axis of Figure 2A: (dft_gas_timings, nn_timings). DFT timings are the
636
+ gas-phase ('none' solvent) total time per (geometry, method, basis); MagNET timings sum the NMR
637
+ time across its solvents and keep one geometry time. Reads the timing groups through the reader."""
638
+ from delta22_reader import load_delta22_dft_timings, load_delta22_nn_timings
639
+ dft = load_delta22_dft_timings(delta22_path)
640
+ dft.index = dft.index.set_names([n.replace("sap_", "") for n in dft.index.names])
641
+ dft = dft.droplevel("solvent_model")
642
+ dft_gas = dft[dft.index.get_level_values("solvent") == "none"].droplevel("solvent")
643
+ nn = load_delta22_nn_timings(delta22_path)
644
+ nn.index = nn.index.set_names([n.replace("sap_", "") for n in nn.index.names])
645
+ nn = nn.droplevel("solvent_model")
646
+ nn = nn.groupby(["geometry_type", "nmr_method", "basis"]).agg(
647
+ {"geometry_time": "first", "nmr_time": "sum"})
648
+ nn["total_time"] = nn["geometry_time"] + nn["nmr_time"]
649
+ return dft_gas, nn
650
+
651
+
652
+ def attach_pareto_timings(rmse_df, dft_gas_timings, nn_timings):
653
+ """Join the gas-phase DFT timings onto the per-method RMSE rows and substitute the MagNET
654
+ timings (which are not in the DFT gas table) for the MagNET rows. Returns rmse_df with
655
+ geometry_time / nmr_time / total_time columns added."""
656
+ out = rmse_df.join(dft_gas_timings, on=["geometry_type", "nmr_method", "basis"], how="left")
657
+ magnet = out["nmr_method"] == "MagNET"
658
+ nn_row = nn_timings.loc[("aimnet2", "MagNET", "N/A")]
659
+ out.loc[magnet, ["geometry_time", "nmr_time", "total_time"]] = \
660
+ nn_row[["geometry_time", "nmr_time", "total_time"]].to_numpy()
661
+ return out
662
+
663
+
664
+ def fig2a_pareto_points(query_df_dft, query_df_nn, dft_gas_timings, nn_timings,
665
+ solutes=None, n_splits=100, n_test=10, random_state=1,
666
+ conversion_factors=None):
667
+ """The full Figure 2A / Figure S1 table: one row per (geometry, method, basis, nucleus, solvent)
668
+ plus the solvent-averaged rows, each carrying its mean test RMSE and its total compute time.
669
+ Pass the flat DFT and NN query tables (build_query_df output) and the two timing tables from
670
+ load_pareto_timings. The frontier for a given nucleus is pareto_frontier on the
671
+ solvent-averaged rows with x="total_time", y="fitting_RMSE"."""
672
+ fitting = assemble_pareto_fitting_df(query_df_dft, query_df_nn, conversion_factors)
673
+ rmse = pareto_solvent_averaged_rmse(fitting, solutes, n_splits=n_splits, n_test=n_test,
674
+ random_state=random_state)
675
+ return attach_pareto_timings(rmse, dft_gas_timings, nn_timings)
676
+
677
+
678
+ def pareto_table_curated(points_df, nucleus, solvent="chloroform"):
679
+ """SI Tables S1 (1H, nucleus="H") and S2 (13C, nucleus="C"): the curated 55-row subset of
680
+ fig2a_pareto_points' full cross product that the source tables print. fig2a_pareto_points (and
681
+ the SI Figure S1 scatter built from it) wants every aimnet2-geometry method/basis combination,
682
+ each an unlabeled point on the plot; the printed tables instead keep only MagNET plus a single
683
+ representative aimnet2-geometry DFT row (the same reference method each nucleus's MagNET-Zero
684
+ output is trained against, MAGNET_PCM_OUTPUT_METHODS, at pcSseg2), alongside the full
685
+ pbe0_tz-geometry grid (18 methods x 3 basis sets, minus mp2/pcSseg3 which was never computed, so
686
+ 53 rows). Sorted by total_time ascending to match the source table's row order."""
687
+ sub = points_df[(points_df["nucleus"] == nucleus) & (points_df["solvent"] == solvent)]
688
+ aimnet2_method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
689
+ keep = (
690
+ (sub["geometry_type"] == "pbe0_tz")
691
+ | (sub["nmr_method"] == "MagNET")
692
+ | ((sub["geometry_type"] == "aimnet2") & (sub["nmr_method"] == aimnet2_method)
693
+ & (sub["basis"] == "pcSseg2"))
694
+ )
695
+ return sub[keep].sort_values("total_time").reset_index(drop=True)
696
+
697
+
698
+ def pareto_frontier(points_df, x="total_time", y="test_RMSE"):
699
+ """Return the rows of points_df on the lower-left Pareto frontier, i.e. the points for which
700
+ no other point has both a smaller x (compute time) and a smaller y (error). Sorted by x.
701
+ """
702
+ ordered = points_df.sort_values([x, y]).reset_index(drop=True)
703
+ frontier_rows = []
704
+ best_y = np.inf
705
+ for _, row in ordered.iterrows():
706
+ if row[y] < best_y:
707
+ frontier_rows.append(row)
708
+ best_y = row[y]
709
+ return pd.DataFrame(frontier_rows).reset_index(drop=True)
710
+
711
+
712
+ # ---------------------------------------------------------------------------
713
+ # correlation matrices (Figure 2B / Figure S4)
714
+
715
+ def correlation_matrix(query_df, value_col, sites_index, column_var):
716
+ """Correlation matrix of value_col across the levels of column_var. Rows are the shared sites
717
+ (sites_index, e.g. ["solute", "site"]); columns are the column_var values (e.g. "solvent" or
718
+ "sap_nmr_method"). Returns a square DataFrame of Pearson correlations between the columns,
719
+ computed on the sites where both columns have a value."""
720
+ pivot = query_df.pivot_table(index=sites_index, columns=column_var, values=value_col)
721
+ return pivot.corr()
722
+
723
+
724
+ # SI Figure S15 "Correlations Between Features": correlations among the composite features within
725
+ # one nucleus and solvent.
726
+ FEATURE_CORRELATION_COLUMNS = ("stationary", "pcm", "desmond", "desmond_vib", "qcd")
727
+
728
+
729
+ def feature_correlation_matrix(query_df, nucleus, solvent, cols=FEATURE_CORRELATION_COLUMNS, squared=False):
730
+ """Pearson correlation matrix (R^2 if squared=True) between the feature columns, within one
731
+ nucleus and solvent, over sites where both features have a value. query_df must be filtered to a
732
+ single (sap_nmr_method, sap_basis, sap_geometry_type); si_s15_feature_correlations does that."""
733
+ sub = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == solvent)][list(cols)]
734
+ sub = sub.dropna(how="all")
735
+ if len(sub) < 2:
736
+ return pd.DataFrame(np.nan, index=cols, columns=cols)
737
+ corr = sub.corr(method="pearson")
738
+ return corr ** 2 if squared else corr
739
+
740
+
741
+ def average_feature_correlation_matrix(query_df, nucleus, solvents, cols=FEATURE_CORRELATION_COLUMNS,
742
+ squared=False):
743
+ """Cell-by-cell nanmean of feature_correlation_matrix over the solvents (SI Figure S15's
744
+ solvent-averaged matrix). nanmean so a solvent with too few sites for one feature pair drops out
745
+ of that cell alone, not the whole average. Same single-level-of-theory requirement."""
746
+ mats = [feature_correlation_matrix(query_df, nucleus, s, cols, squared) for s in solvents]
747
+ stacked = np.stack([m.to_numpy() for m in mats])
748
+ with np.errstate(invalid="ignore"):
749
+ avg = np.nanmean(stacked, axis=0)
750
+ return pd.DataFrame(avg, index=mats[0].index, columns=mats[0].columns)
751
+
752
+
753
+ def si_s15_feature_correlations(query_df_dft, nucleus, solvents=None):
754
+ """Solvent-averaged feature correlations for one nucleus from the unfiltered
755
+ load_query_df_dft(...) output: filters to the reference level
756
+ (MAGNET_PCM_OUTPUT_METHODS[nucleus], pcSseg2, aimnet2), then returns {"r": Pearson R matrix,
757
+ "r2": R^2 matrix}. The single-level filter is required; pooling levels of theory gives wrong
758
+ numbers."""
759
+ solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
760
+ method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
761
+ sub = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
762
+ & (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
763
+ return {"r": average_feature_correlation_matrix(sub, nucleus, solvents),
764
+ "r2": average_feature_correlation_matrix(sub, nucleus, solvents, squared=True)}
765
+
766
+
767
+ def pcm_desmond_correlation_by_solvent(query_df_dft, solvents=None):
768
+ """SI Figure S15's PCM-vs-Desmond table: per nucleus and solvent, the Pearson correlation
769
+ between the PCM (implicit) and Desmond (explicit) corrections. Filters to the reference level
770
+ per nucleus itself (same as si_s15_feature_correlations). Returns a DataFrame indexed by
771
+ nucleus, one column per solvent."""
772
+ solvents = list(solvents) if solvents is not None else sorted(query_df_dft["solvent"].unique())
773
+ rows = {}
774
+ for nucleus in ("H", "C"):
775
+ method = MAGNET_PCM_OUTPUT_METHODS[nucleus]
776
+ nuc_df = query_df_dft[(query_df_dft["nucleus"] == nucleus) & (query_df_dft["sap_nmr_method"] == method)
777
+ & (query_df_dft["sap_basis"] == "pcSseg2") & (query_df_dft["sap_geometry_type"] == "aimnet2")]
778
+ row = {}
779
+ for solvent in solvents:
780
+ sub = nuc_df[nuc_df["solvent"] == solvent][["pcm", "desmond"]].dropna()
781
+ row[solvent] = float(sub["pcm"].corr(sub["desmond"])) if len(sub) > 1 else float("nan")
782
+ rows[nucleus] = row
783
+ return pd.DataFrame(rows).T
784
+
785
+
786
+ # ---------------------------------------------------------------------------
787
+ # PCM benefit (Figure 3A / Figure S5): how much a correction reduces test error
788
+
789
+ def median_test_rmse(results_df, by):
790
+ """Median test_RMSE grouped by the given columns (e.g. ["solvent", "formula"] or
791
+ ["sap_nmr_method", "formula"]). A small convenience used by several figures."""
792
+ return results_df.groupby(by)["test_RMSE"].median().reset_index()
793
+
794
+
795
+ # ---------------------------------------------------------------------------
796
+ # Figure 3 panel data (Explicit Solvation is Critical for Accuracy)
797
+
798
+ # the three solvent classes Figure 3 / S6 group by
799
+ SOLVENT_GROUPS = {
800
+ "Polar Aprotic": ["chloroform", "dichloromethane", "tetrahydrofuran",
801
+ "acetonitrile", "dimethylsulfoxide", "acetone"],
802
+ "Polar Protic": ["methanol", "TIP4P", "trifluoroethanol"],
803
+ "Aromatic": ["benzene", "toluene", "chlorobenzene"],
804
+ }
805
+
806
+
807
+ def fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
808
+ """Panel 3A data: for every DFT method/basis/geometry, the seeded test RMSE with and without the
809
+ implicit-solvent term ("stationary" vs the single-column "stationary_plus_pcm"). The boxplot
810
+ shows how much the PCM term lowers error across methods. One nucleus at a time (proton and
811
+ carbon shieldings sit on different scales, so they are never pooled in a fit). Pass a query_df
812
+ that has been through add_composite_columns. Returns the tidy run_fits results (one row per
813
+ method/solvent/formula/seed)."""
814
+ sub = query_df_dft[query_df_dft["nucleus"] == nucleus]
815
+ return run_fits(sub, solvents, ["stationary", "stationary_plus_pcm"],
816
+ n_splits=n_splits, solutes=solutes,
817
+ group_cols=["sap_nmr_method", "sap_basis", "sap_geometry_type"], n_test=n_test)
818
+
819
+
820
+ def fig3a_pcm_benefit_by_solvent(query_df_dft, solvents, n_splits, solutes, nucleus="H", n_test=10):
821
+ """Panel 3A data, split by solvent: for every DFT method and solvent, the per-split percent
822
+ benefit of adding the implicit-solvent (PCM) term, 100 * (stationary - stationary_plus_pcm) /
823
+ stationary. A positive value means PCM lowered test RMSE for that split; a negative value means
824
+ PCM made it worse. Unlike fig3a_pcm_benefit (which reports raw RMSE pooled across solvents),
825
+ this keeps one row per (method, solvent, seed), so a solvent such as benzene can be shown
826
+ separately from chloroform and the aromatic-solvent-specific PCM penalty stays visible instead
827
+ of being averaged away."""
828
+ results = fig3a_pcm_benefit(query_df_dft, solvents, n_splits, solutes,
829
+ nucleus=nucleus, n_test=n_test)
830
+ pivot = results.pivot_table(
831
+ index=["sap_nmr_method", "sap_basis", "sap_geometry_type", "solvent", "seed"],
832
+ columns="formula", values="test_RMSE").reset_index()
833
+ pivot["percent_benefit"] = (100.0 * (pivot["stationary"] - pivot["stationary_plus_pcm"])
834
+ / pivot["stationary"])
835
+ return pivot
836
+
837
+
838
+ def fig3b_shift_differences(query_df_dft, method, basis, geometry, nucleus="H",
839
+ x_solvent="methanol", y_solvent="benzene", reference="chloroform"):
840
+ """Panel 3B data: per site, the experimental solvent-induced shift differences relative to a
841
+ reference solvent (x = shift in x_solvent minus reference, y = shift in y_solvent minus
842
+ reference) and the implicit-solvent (PCM) prediction of the same differences. One DFT method.
843
+ The scatter compares whether PCM reproduces the measured solvent-to-solvent differences."""
844
+ sub = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
845
+ & (query_df_dft["sap_basis"] == basis)
846
+ & (query_df_dft["sap_geometry_type"] == geometry)
847
+ & (query_df_dft["nucleus"] == nucleus)]
848
+ cols = ["solute", "site", "experimental", "pcm"]
849
+ ref = sub[sub["solvent"] == reference][cols]
850
+ xs = sub[sub["solvent"] == x_solvent][cols]
851
+ ys = sub[sub["solvent"] == y_solvent][cols]
852
+ merged = (xs.merge(ref, on=["solute", "site"], suffixes=("_x", "_ref"))
853
+ .merge(ys, on=["solute", "site"]))
854
+ merged.columns = ["solute", "site", "exp_x", "pcm_x", "exp_ref", "pcm_ref", "exp_y", "pcm_y"]
855
+ merged["exp_diff_x"] = merged["exp_x"] - merged["exp_ref"]
856
+ merged["exp_diff_y"] = merged["exp_y"] - merged["exp_ref"]
857
+ merged["pcm_diff_x"] = merged["pcm_x"] - merged["pcm_ref"]
858
+ merged["pcm_diff_y"] = merged["pcm_y"] - merged["pcm_ref"]
859
+ return merged.dropna(subset=["exp_diff_x", "exp_diff_y"]).reset_index(drop=True)
860
+
861
+
862
+ def fig3d_formula_regressions(query_df_dft, method, basis, geometry, formulas, solvents,
863
+ n_splits, solutes, nucleus="H", n_test=10):
864
+ """Panel 3D data: for one DFT method, the seeded test RMSE of an implicit-vs-explicit formula
865
+ ladder per solvent. Pass a query_df that has been through add_composite_columns. Returns the
866
+ tidy run_fits results (one row per solvent/formula/seed)."""
867
+ one = query_df_dft[(query_df_dft["sap_nmr_method"] == method)
868
+ & (query_df_dft["sap_basis"] == basis)
869
+ & (query_df_dft["sap_geometry_type"] == geometry)
870
+ & (query_df_dft["nucleus"] == nucleus)]
871
+ return run_fits(one, solvents, formulas, n_splits=n_splits, solutes=solutes, n_test=n_test)
872
+
873
+
874
+ # ---------------------------------------------------------------------------
875
+ # Figure 4 + SI S16/S17/S18 (solvent-induced shifts: experiment vs prediction)
876
+
877
+ def add_solvent_mean(query_df, group_cols=("solute", "site", "nucleus")):
878
+ """Append a 'solvent_mean' pseudo-solvent: for each site, the mean over the real solvents of the
879
+ numeric columns. Used as the solvent-averaged reference for Figure S18. Returns a new DataFrame
880
+ with the extra rows."""
881
+ means = query_df.groupby(list(group_cols)).mean(numeric_only=True).reset_index()
882
+ means["solvent"] = "solvent_mean"
883
+ return pd.concat([query_df, means], ignore_index=True)
884
+
885
+
886
+ def solvent_pair_differences(query_df, check_solvent, reference_solvent, nucleus="H",
887
+ explicit="desmond"):
888
+ """Per (solute, site), the experimental and predicted correction differences between one solvent
889
+ and a reference solvent (Figure 4): the experimental difference is reference minus check, and
890
+ each correction difference (implicit PCM, explicit, and explicit+vibrations) is check minus
891
+ reference. One DFT method's columns must already be selected."""
892
+ a = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == check_solvent)]
893
+ b = query_df[(query_df["nucleus"] == nucleus) & (query_df["solvent"] == reference_solvent)]
894
+ merged = a.merge(b, on=["solute", "site"], suffixes=(f"_{check_solvent}", f"_{reference_solvent}"))
895
+ c, r, e = check_solvent, reference_solvent, explicit
896
+ out = pd.DataFrame({
897
+ "solute": merged["solute"], "site": merged["site"],
898
+ "reference": reference_solvent, "solvent": check_solvent,
899
+ "exp_diff": merged[f"experimental_{r}"] - merged[f"experimental_{c}"],
900
+ "implicit_diff": merged[f"pcm_{c}"] - merged[f"pcm_{r}"],
901
+ "explicit_diff": merged[f"{e}_{c}"] - merged[f"{e}_{r}"],
902
+ "explicit_vib_diff": ((merged[f"{e}_{c}"] + merged[f"{e}_vib_{c}"])
903
+ - (merged[f"{e}_{r}"] + merged[f"{e}_vib_{r}"])),
904
+ })
905
+ return out.dropna(subset=["exp_diff"]).reset_index(drop=True)
906
+
907
+
908
+ def solvent_induced_shifts(query_df, reference, solvents, nucleus="H", explicit="desmond"):
909
+ """The full set of solvent-vs-reference differences for one reference solvent (the data behind
910
+ Figure S16 with chloroform, S17 with benzene, S18 with solvent_mean): every solvent except the
911
+ reference, stacked. query_df should already include the solvent_mean rows if reference is
912
+ 'solvent_mean' (see add_solvent_mean)."""
913
+ frames = [solvent_pair_differences(query_df, solvent, reference, nucleus, explicit)
914
+ for solvent in solvents if solvent != reference]
915
+ return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
916
+
917
+
918
+ def fit_differences_to_experimental(diff_df, predicted_col):
919
+ """Fit exp_diff = slope * predicted_col + intercept (the Figure 4C fit of the experimental
920
+ solvent-induced shift differences to a correction's predicted differences) and return
921
+ {slope, intercept, rmse, n}. Rows missing either value are dropped."""
922
+ data = diff_df.dropna(subset=["exp_diff", predicted_col])
923
+ x = data[predicted_col].to_numpy(dtype=float)
924
+ y = data["exp_diff"].to_numpy(dtype=float)
925
+ intercept, slope = linear_fit_1d(x, y)
926
+ pred = intercept + slope * x
927
+ return {"intercept": intercept, "slope": slope, "rmse": rmse(pred, y), "n": int(len(x))}
analysis/code/dft8k_residuals.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MagNET-Zero vs DFT residuals on the DFT8K benchmark (~7,000 organics, out of sample). No plotting.
2
+
3
+ Each nucleus is compared against the level MagNET-Zero reproduces: protons vs WP04/pcSseg-2, carbons
4
+ vs wB97X-D/pcSseg-2, gas phase on AIMNet2 geometries; residual = DFT shielding minus MagNET. Read via
5
+ data/dft8k/dft8k_reader.py. The figure notebook and tests import from here.
6
+ """
7
+ import numpy as np
8
+
9
+ from stats import summarize_errors
10
+
11
+ # the DFT level each MagNET-Zero model reproduces, and the element it predicts
12
+ NUCLEI = {
13
+ "H": {"atomic_number": 1, "dft": "wp04_pcSseg2", "nn": "nn_wp04_pcSseg2"},
14
+ "C": {"atomic_number": 6, "dft": "wb97xd_pcSseg2", "nn": "nn_wb97xd_pcSseg2"},
15
+ }
16
+
17
+
18
+ def load_shieldings(dft8k_path):
19
+ """The flat per-atom shielding arrays from the DFT8K AIMNet2 group, through the released
20
+ reader. Returns the reader's all_shieldings dict (atomic_numbers plus the DFT and MagNET
21
+ shieldings, each ppm, NaN where not computed)."""
22
+ from dft8k_reader import DFT8k
23
+ with DFT8k(dft8k_path) as ds:
24
+ return ds.aimnet2.all_shieldings()
25
+
26
+
27
+ def residuals(shieldings, nucleus):
28
+ """The DFT-minus-MagNET shielding residuals (ppm) for one nucleus, over the atoms of that
29
+ element where MagNET makes a prediction. shieldings is the load_shieldings dict; nucleus is
30
+ "H" or "C". Atoms the model does not predict (its prediction is NaN) are dropped."""
31
+ spec = NUCLEI[nucleus]
32
+ z = np.asarray(shieldings["atomic_numbers"])
33
+ dft = np.asarray(shieldings[spec["dft"]], dtype=float)
34
+ nn = np.asarray(shieldings[spec["nn"]], dtype=float)
35
+ keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
36
+ return dft[keep] - nn[keep]
37
+
38
+
39
+ def residual_stats(errors, small_threshold=0.1):
40
+ """Summary statistics of a residual array: count, RMSE, MAE, the 95th percentile of the
41
+ absolute error, and the fraction of atoms whose absolute error is below small_threshold ppm
42
+ (the paper reports >95% of proton sites below 0.1 ppm)."""
43
+ errors = np.asarray(errors, dtype=float)
44
+ abs_err = np.abs(errors)
45
+ if not errors.size:
46
+ return {"n": 0, "rmse": float("nan"), "mae": float("nan"), "median_ae": float("nan"),
47
+ "abs_p95": float("nan"), "frac_below": float("nan")}
48
+ stats = summarize_errors(errors, np.zeros_like(errors))
49
+ stats["abs_p95"] = float(np.quantile(abs_err, 0.95))
50
+ stats["frac_below"] = float(np.mean(abs_err < small_threshold))
51
+ return stats
52
+
53
+
54
+ def residual_summary(dft8k_path, small_threshold=0.1):
55
+ """Load the DFT8K shieldings and return {nucleus: residual_stats} for both nuclei."""
56
+ shieldings = load_shieldings(dft8k_path)
57
+ return {nucleus: residual_stats(residuals(shieldings, nucleus), small_threshold)
58
+ for nucleus in NUCLEI}
59
+
60
+
61
+ # ---------------------------------------------------------------------------------------------
62
+ # Figure 5B's molecule-level panels: the two extreme-residual callouts and the six functional-
63
+ # group boxes. Unlike the whole-dataset histogram above, these need each molecule's SMILES (to
64
+ # find/classify structures), so they read the reader's per-molecule API instead of all_shieldings.
65
+
66
+ def iter_molecule_residuals(dft8k_path, nucleus):
67
+ """Yield (molecule_index, molecule_id, smiles, residuals) for every DFT8K molecule, where
68
+ residuals is that molecule's DFT-minus-MagNET residual array (ppm) for its atoms of the given
69
+ nucleus (empty if the molecule has none of that element, or MagNET made no prediction there).
70
+ Molecules with no SMILES on file (the literal "none") are still yielded; callers that need a
71
+ parseable structure should skip those themselves."""
72
+ from dft8k_reader import DFT8k
73
+ spec = NUCLEI[nucleus]
74
+ with DFT8k(dft8k_path) as ds:
75
+ g = ds.aimnet2
76
+ # One bulk read of the flat per-atom arrays, then slice per molecule using the group's own
77
+ # precomputed atom offsets (g._start). Calling g.molecule(i) per molecule instead does
78
+ # several small h5py reads each for all ~7,000 molecules, ~260x slower for identical data.
79
+ flat = g.all_shieldings()
80
+ z_all, dft_all, nn_all = flat["atomic_numbers"], flat[spec["dft"]], flat[spec["nn"]]
81
+ start = g._start
82
+ for i in range(g.n_molecules):
83
+ sl = slice(int(start[i]), int(start[i + 1]))
84
+ z, dft, nn = z_all[sl], dft_all[sl], nn_all[sl]
85
+ keep = (z == spec["atomic_number"]) & np.isfinite(dft) & np.isfinite(nn)
86
+ yield i, int(g.molecule_ids[i]), g.smiles(i), (dft[keep] - nn[keep])
87
+
88
+
89
+ def find_extreme_residual(dft8k_path, nucleus, sign="max"):
90
+ """The single most extreme DFT-minus-MagNET residual anywhere in the DFT8K set for one
91
+ nucleus: the largest positive residual if sign="max", or the most negative if sign="min". The
92
+ exact extremum over the full released test set (not a random sample), so it is deterministic.
93
+ For 1H, sign="max" reproduces the published figure's positive-tail callout exactly (a porphyrin
94
+ ring, +17.62 ppm). sign="min" gives a different, chemically unremarkable ester outlier at
95
+ -4.06 ppm rather than the figure's negative-tail callout (a sulfonium zwitterion, -1.04 ppm):
96
+ that callout is a hand-picked illustrative example, not the strict extremum. See
97
+ molecule_by_id/find_extreme_zwitterion_residual for how that specific example was located.
98
+ Returns a dict with the parent molecule's id/smiles and the residual value, or None if no atom
99
+ of that nucleus has a prediction anywhere in the set."""
100
+ if sign not in ("max", "min"):
101
+ raise ValueError(f"sign must be 'max' or 'min', got {sign!r}")
102
+ best = None
103
+ for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
104
+ if res.size == 0:
105
+ continue
106
+ val = float(res.max()) if sign == "max" else float(res.min())
107
+ if best is None or (sign == "max" and val > best["residual"]) or (sign == "min" and val < best["residual"]):
108
+ best = {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles, "residual": val}
109
+ return best
110
+
111
+
112
+ def molecule_by_id(dft8k_path, nucleus, molecule_id):
113
+ """The residual data for one specific DFT8K molecule, by its integer id. Used to look up
114
+ Figure 5B's sulfonium-zwitterion callout (id 88779, identified by brute-force scanning the
115
+ most-negative 1H residuals for a match to the published -1.040 ppm value -- see
116
+ find_extreme_zwitterion_residual for the automated version of that search) once its id is
117
+ known, without re-scanning the whole dataset. Returns the same dict shape as
118
+ find_extreme_residual (residual is that molecule's single most negative residual for the given
119
+ nucleus), or None if the molecule has no atom of that nucleus with a prediction."""
120
+ for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
121
+ if mol_id == molecule_id:
122
+ if res.size == 0:
123
+ return None
124
+ return {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles,
125
+ "residual": float(res.min())}
126
+ return None
127
+
128
+
129
+ def find_extreme_zwitterion_residual(dft8k_path, nucleus, sign="min"):
130
+ """Like find_extreme_residual, but restricted to molecules containing BOTH a formally
131
+ positively-charged sulfur ([S+]) and a formally negatively-charged atom ([-]) -- i.e.
132
+ sulfonium zwitterions. Even this narrower filter does not land on the same molecule as the
133
+ published callout: a different, more negative sulfonium zwitterion exists in the set. Provided
134
+ for exploration, not as an exact reproduction. The published example itself is available via
135
+ molecule_by_id(..., molecule_id=88779)."""
136
+ from rdkit import Chem
137
+ if sign not in ("max", "min"):
138
+ raise ValueError(f"sign must be 'max' or 'min', got {sign!r}")
139
+ pos_s = Chem.MolFromSmarts("[S+]")
140
+ neg = Chem.MolFromSmarts("[-]")
141
+ best = None
142
+ for i, mol_id, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
143
+ if res.size == 0 or smiles in _UNPARSEABLE_SMILES:
144
+ continue
145
+ mol = Chem.MolFromSmiles(smiles)
146
+ if mol is None or not (mol.HasSubstructMatch(pos_s) and mol.HasSubstructMatch(neg)):
147
+ continue
148
+ val = float(res.max()) if sign == "max" else float(res.min())
149
+ if best is None or (sign == "max" and val > best["residual"]) or (sign == "min" and val < best["residual"]):
150
+ best = {"molecule_index": i, "molecule_id": int(mol_id), "smiles": smiles, "residual": val}
151
+ return best
152
+
153
+
154
+ # A handful of DFT8K molecules have no usable SMILES: some store the literal "none" (documented in
155
+ # dft8k_reader.py), and a few others store the literal "nan" (an apparent upstream data quirk). Both
156
+ # are skipped before ever reaching RDKit, which would otherwise print a harmless but noisy
157
+ # parse-error message to stderr for each one.
158
+ _UNPARSEABLE_SMILES = {"none", "nan", ""}
159
+
160
+
161
+ # SMARTS patterns for the six functional groups the published figure highlights. These identify
162
+ # whether a molecule CONTAINS the group at all (a molecule-level classification), not which atom
163
+ # specifically carries it: the released dft8k.hdf5 stores atoms in whatever order the original DFT
164
+ # input used, not SMILES parse order, so a matched substructure cannot be reliably mapped back to
165
+ # one specific stored atom without extra data. Averaging over every atom of the nucleus in every
166
+ # matching molecule is the robust alternative used here.
167
+ FUNCTIONAL_GROUP_SMARTS = {
168
+ "Carbonyls": "[CX3]=[OX1]",
169
+ "Amines": "[NX3;H2,H1,H0;!$(NC=O);!$(N=[!#6]);!a]",
170
+ "Sulfonyl": "[SX4](=[OX1])(=[OX1])",
171
+ "Pyridines": "[n;r6]",
172
+ "Furans": "[o;r5]",
173
+ "Nitroso": "[NX2]=[OX1]",
174
+ }
175
+
176
+
177
+ def functional_group_errors(dft8k_path, nucleus, patterns=None):
178
+ """Mean absolute DFT-minus-MagNET residual (ppm), for every atom of `nucleus`, in every DFT8K
179
+ molecule whose SMILES matches each functional-group SMARTS pattern (default
180
+ FUNCTIONAL_GROUP_SMARTS). A molecule can match more than one group (e.g. a pyridine bearing a
181
+ carbonyl counts in both), matching the natural reading of the published panel's per-group boxes
182
+ as independent slices of the same dataset, not a mutually exclusive partition.
183
+
184
+ Returns {group_name: {"mean_abs_error": ppm, "n_molecules": matching molecule count,
185
+ "n_atoms": total atoms of this nucleus pooled across those molecules}}. A group with no matches
186
+ in the released set gets NaN/0/0.
187
+ """
188
+ from rdkit import Chem
189
+ if patterns is None:
190
+ patterns = FUNCTIONAL_GROUP_SMARTS
191
+ compiled = {name: Chem.MolFromSmarts(smarts) for name, smarts in patterns.items()}
192
+ for name, patt in compiled.items():
193
+ if patt is None:
194
+ raise ValueError(f"invalid SMARTS for group {name!r}: {patterns[name]!r}")
195
+ pooled_residuals = {name: [] for name in patterns}
196
+ n_molecules = {name: 0 for name in patterns}
197
+ for _, _, smiles, res in iter_molecule_residuals(dft8k_path, nucleus):
198
+ if smiles in _UNPARSEABLE_SMILES or res.size == 0:
199
+ continue
200
+ mol = Chem.MolFromSmiles(smiles)
201
+ if mol is None:
202
+ continue
203
+ for name, patt in compiled.items():
204
+ if mol.HasSubstructMatch(patt):
205
+ pooled_residuals[name].append(res)
206
+ n_molecules[name] += 1
207
+ out = {}
208
+ for name in patterns:
209
+ chunks = pooled_residuals[name]
210
+ if chunks:
211
+ all_res = np.concatenate(chunks)
212
+ out[name] = {"mean_abs_error": float(np.mean(np.abs(all_res))),
213
+ "n_molecules": n_molecules[name], "n_atoms": int(all_res.size)}
214
+ else:
215
+ out[name] = {"mean_abs_error": float("nan"), "n_molecules": 0, "n_atoms": 0}
216
+ return out
analysis/code/diels_alder.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SI Figure S2: literature Diels-Alder barriers for ethylene + butadiene.
2
+
3
+ Published quantum-chemistry predictions span ~15-45 kcal/mol (experiment ~23.3). The barriers are a
4
+ literature compilation (paper reference 26); the table below is the data (no dataset/reader). This
5
+ file holds the table and its method-family grouping; the bar chart is drawn inline in the notebook.
6
+ """
7
+ # experimental activation barrier for the ethylene + butadiene Diels-Alder reaction, kcal/mol
8
+ EXPERIMENTAL_BARRIER = 23.3
9
+
10
+ # (method // geometry, predicted barrier in kcal/mol), compiled from the literature (reference 26).
11
+ # The label before "//" is the energy method; it sets the bar's group and label.
12
+ DATA = [
13
+ ("HF/3-21G//HF/3-21G", 35.9),
14
+ ("HF/6-31G*//HF/6-31G*", 45.0),
15
+ ("HF/6-31G**//HF/6-31G**", 45.9),
16
+ ("CASSCF(6,6)/3-21G// CASSCF(6,6)/3-21G", 37.3),
17
+ ("CASSCF(6,6)/6-31G*//B3LYP/6-31G*", 43.8),
18
+ ("MRMP2-CAS(6,6)/6-31G*//B3LYP/6-31G*", 28.6),
19
+ ("CASSCF(6,6)/6-31G*// CASSCF(6,6)/6-31G*", 43.8),
20
+ ("CASSCF(6,6)/6-31G(d,p)//CASSCF(6,6)/6-31(d,p)", 44.5),
21
+ ("CASSCF(6,6)-MP2/6-31G(d,p)//CASSCF(6,6)/6-31(d,p)", 39.4),
22
+ ("CASSCF(6,6)/6-311+G(d,p)//CASSCF(6,6)/6-31(d,p)", 37.0),
23
+ ("CASSCF(6,6)-MP2/6-311+G(d,p)//CASSCF(6,6)/6-31(d,p)", 40.9),
24
+ ("CASSCF(6,6)/6-31G*//CASSCF(6,6)/6-31G*", 25.0),
25
+ ("CASSCF(6,6)/6-31G*//CASSCF(6,6)/6-31G*", 43.6),
26
+ ("MR-AQCC/6-31G*//MR-AQCC/6-31G*", 25.7),
27
+ ("MR-AQCC/6-31G**//MR-AQCC/6-31G**", 25.3),
28
+ ("MR-AQCC/6-31G**//MR-AQCC/6-31G**", 24.2),
29
+ ("MR-AQCC/6-311G(2d,p)//MR-AQCC/6-311G(2d,p)", 23.7),
30
+ ("MP2/6-31G*//HF/6-31G*", 16.6),
31
+ ("MP2/6-31G*//MP2/6-31G*", 18.5),
32
+ ("MP2/6-31G*//B3LYP/6-31G*", 20.4),
33
+ ("MP2/6-31G**//MP2/6-31G**", 15.9),
34
+ ("MP2/6-311G(d,p)//B3LYP/6-31G", 17.5),
35
+ ("MP3/6-31G*//HF/6-31G*", 26.9),
36
+ ("MP3/6-311G(d,p)//B3LYP/6-31G", 28.2),
37
+ ("MP4(SDQ)/6-31G*//HF/6-31G*", 29.0),
38
+ ("MP4(SDTQ)/6-31G*//HF/6-31G*", 21.9),
39
+ ("MP4(SDTQ)/6-31G*//MP2/6-31G*", 22.4),
40
+ ("MP4(SDTQ)/6-311G(d,p)//B3LYP/6-31G", 22.8),
41
+ ("B3LYP/6-31G*//B3LYP/6-31G*", 23.1),
42
+ ("B3LYP/6-31G*//B3LYP/6-31G*", 24.9),
43
+ ("B3LYP/6-31G**//B3LYP/6-31G**", 22.4),
44
+ ("B3LYP/6-311+G(2d,p)//B3LYP/6-311+G(2d,p)", 27.2),
45
+ ("BPW91/6-31G*//BPW91/6-31G*", 26.2),
46
+ ("MPW1K/6-31+G**//MPW1K/6-31+G*", 24.4),
47
+ ("KMLYP/6-31G*//KMLYP/6-31G*", 21.1),
48
+ ("KMLYP/6-311G//KMLYP/6-31G", 22.4),
49
+ ("OLYP/6-31G(d)//OLYP/6-31G(d)", 26.7),
50
+ ("OLYP/6-311+G(2d,p)//OLYP/6-311+G(2d,p)", 30.1),
51
+ ("OLYP/6-311G(2df,2pd)// OLYP/6-311G(2df,2pd)", 29.2),
52
+ ("O3LYP/6-31G(d)//OLYP/6-31G(d)", 26.8),
53
+ ("O3LYP/6-311+G(2d,p)//OLYP/6-311+G(2d,p)", 30.1),
54
+ ("O3LYP/6-311G(2df,2pd)// OLYP/6-311G(2df,2pd)", 29.3),
55
+ ("M06-2x/6-31G(d)", 20.4),
56
+ ("M06-2x/cc-pVTZ", 23.2),
57
+ ("ωB97X-D/6-31G(d)", 21.7),
58
+ ("ωB97X-D/cc-pVTZ", 25.1),
59
+ ("B2PLYP/cc-pVDZ", 24.0),
60
+ ("QCISD(T)/6-31G*//CASSCF(6,6)/6-31G*", 25.5),
61
+ ("QCISD(T)/6-31G*//B3LYP/6-31G*", 25.0),
62
+ ("CCSD/6-311G(d,p)//B3LYP/6-31G", 30.8),
63
+ ("CCSD(T)/6-311G(d,p)//B3LYP/6-31G", 25.7),
64
+ ("CCSD(T)/6-31G**//B3LYP/6-31G**", 27.6),
65
+ ("G2MP2/6-311+G(3df,2p)", 24.6),
66
+ ("G2MS/6-311+G(2df,2p)", 23.9),
67
+ ("CBS-QB3", 22.9),
68
+ ]
69
+
70
+ # the method-family groups, in the left-to-right order the figure lays them out
71
+ CATEGORY_ORDER = ["HF", "MP2", "MP3", "MP4", "DFT", "CASSCF",
72
+ "Multireference", "Coupled Cluster", "Composite"]
73
+
74
+
75
+ def energy_method(method_string):
76
+ """The energy method (the part before '//') of a 'method//geometry' string. This is what the
77
+ bar is labeled with and categorized by; a string with no '//' is returned unchanged."""
78
+ return method_string.split("//")[0].strip() if "//" in method_string else method_string
79
+
80
+
81
+ def categorize_method(method):
82
+ """The method family ('HF', 'MP2', 'DFT', 'CASSCF', ...) of an energy method, by the same rules
83
+ as the original figure. Used to group the bars."""
84
+ m = method.upper()
85
+ if m.startswith("HF"):
86
+ return "HF"
87
+ if m.startswith("MP2"):
88
+ return "MP2"
89
+ if m.startswith("MP3"):
90
+ return "MP3"
91
+ if m.startswith("MP4"):
92
+ return "MP4"
93
+ if any(k in m for k in ("B3LYP", "BPW91", "MPW1K", "KMLYP", "OLYP", "O3LYP", "M06",
94
+ "ωB97X", "B2PLYP")):
95
+ return "DFT"
96
+ if "CASSCF" in m:
97
+ return "CASSCF"
98
+ if "CCSD" in m or "QCISD" in m:
99
+ return "Coupled Cluster"
100
+ if m.startswith("G2") or "CBS" in m:
101
+ return "Composite"
102
+ if "MR-AQCC" in m or "MRMP2" in m:
103
+ return "Multireference"
104
+ return "Other"
105
+
106
+
107
+ def ordered_layout(data=DATA, group_spacing=2):
108
+ """Lay the bars out grouped by method family and sorted by barrier within each group, with a gap
109
+ of group_spacing between groups. Returns a dict with parallel lists methods (the energy-method
110
+ labels), energies, positions (x of each bar), and group_labels / group_boundaries (the
111
+ (start, end) bar position of each group), in CATEGORY_ORDER.
112
+ """
113
+ grouped = {}
114
+ for method_string, energy in data:
115
+ method = energy_method(method_string)
116
+ grouped.setdefault(categorize_method(method), []).append((method, energy))
117
+ for entries in grouped.values():
118
+ entries.sort(key=lambda me: me[1])
119
+
120
+ methods, energies, positions = [], [], []
121
+ group_labels, group_boundaries = [], []
122
+ pos = 0
123
+ for i, category in enumerate(CATEGORY_ORDER):
124
+ if category not in grouped:
125
+ continue
126
+ if i > 0 and methods: # gap before each group after the first present one
127
+ pos += group_spacing
128
+ start = pos
129
+ for method, energy in grouped[category]:
130
+ methods.append(method)
131
+ energies.append(energy)
132
+ positions.append(pos)
133
+ pos += 1
134
+ group_labels.append(category)
135
+ group_boundaries.append((start, pos - 1))
136
+ return {"methods": methods, "energies": energies, "positions": positions,
137
+ "group_labels": group_labels, "group_boundaries": group_boundaries}
analysis/code/fig2b_plots.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Correlation-matrix plotting engine for Figure 2B.
2
+
3
+ The notebook computes the pairwise Pearson correlation table across methods (delta-22 1H stationary
4
+ shieldings, pcSseg-2 basis) and calls `plot_correlation_matrix(...)` to draw the lower-triangle
5
+ heatmap.
6
+ """
7
+ import numpy as np
8
+ import pandas as pd
9
+ import seaborn as sns
10
+ import matplotlib.pyplot as plt
11
+
12
+
13
+ def plot_correlation_matrix(corr_matrix, save_png, figsize=(6, 6)):
14
+ """Draw and save the Figure 2B lower-triangle heatmap of pairwise Pearson correlations."""
15
+ arr = corr_matrix.to_numpy(dtype=float, copy=True)
16
+ np.fill_diagonal(arr, np.nan) # ignore the self-correlation diagonal
17
+ transformed = pd.DataFrame(-np.log10(1 - arr), # spread values near 1 so differences are visible
18
+ index=corr_matrix.index, columns=corr_matrix.columns)
19
+ # Tight lower triangle: the first row and last method have no pairs below the diagonal, so drop the
20
+ # first row and last column, then hide the strict upper triangle. Every drawn cell is then a real
21
+ # method pair, with no empty self-correlation squares on the axes.
22
+ transformed = transformed.iloc[1:, :-1]
23
+ mask = np.triu(np.ones(transformed.shape, dtype=bool), k=1)
24
+
25
+ sns.set_theme(style="white", context="paper")
26
+ fig, ax = plt.subplots(figsize=figsize)
27
+
28
+ r_ticks = [0.99, 0.999, 0.9999]
29
+ cb_values = [-np.log10(1 - r) for r in r_ticks]
30
+ hm = sns.heatmap(transformed, mask=mask, cmap="Reds", vmin=1, vmax=4, square=True,
31
+ linewidths=1, cbar_kws={"shrink": 0.65, "pad": -0.05, "ticks": cb_values}, ax=ax)
32
+
33
+ cbar = hm.collections[0].colorbar
34
+ cbar.set_ticks(cb_values)
35
+ cbar.set_ticklabels([f"{r:g}" for r in r_ticks]) # ticks are Pearson r values
36
+ cax = cbar.ax
37
+ pos = cax.get_position()
38
+ cax.set_position([pos.x0, pos.y0 + 0.04, pos.width, pos.height])
39
+ cax.text(0.5, 1.08, r"Pearson $\boldsymbol{r}$", transform=cax.transAxes, ha="center", va="bottom",
40
+ fontsize=11, bbox=dict(boxstyle="round,pad=0.25", facecolor="#EBEBEB", edgecolor="none", alpha=0.3))
41
+
42
+ for side in ("left", "bottom"):
43
+ ax.spines[side].set_visible(True)
44
+ ax.spines[side].set_linewidth(0.6)
45
+ ax.tick_params(axis="x", length=1.5, width=0.6, labelsize=10)
46
+ ax.tick_params(axis="y", length=1.5, width=0.6, labelsize=10)
47
+ plt.setp(ax.get_xticklabels(), rotation=90, ha="right", rotation_mode="anchor", va="top")
48
+ plt.setp(ax.get_yticklabels(), rotation=0, va="center")
49
+ sns.despine(ax=ax, top=True, right=True, left=False, bottom=False)
50
+
51
+ fig.savefig(save_png, dpi=300, bbox_inches="tight", pad_inches=0.02)
52
+ plt.show()
analysis/code/fig3_plots.py ADDED
@@ -0,0 +1,160 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plotting engines for Figure 3 panels 3A, 3B, and 3D (delta-22 solvent-correction figures).
2
+
3
+ Each panel's notebook (fig3a_pcm_benefit.ipynb, fig3b_shifts.ipynb, fig3d_solvent_corrections.ipynb)
4
+ supplies the data table and styling dicts and calls the corresponding function here.
5
+ """
6
+ import numpy as np
7
+ import pandas as pd
8
+ import matplotlib.pyplot as plt
9
+ import matplotlib.ticker as mticker
10
+ import seaborn as sns
11
+ from matplotlib.ticker import MaxNLocator
12
+
13
+
14
+ def plot_pcm_benefit_with_arrows(benefit_df, combos, colors, save_path,
15
+ xlimits=(-20, 35), figsize=(6, 6)):
16
+ """Figure 3A: implicit-solvent (PCM) benefit by method, chloroform vs benzene."""
17
+ solvents = ["chloroform", "benzene"]
18
+ labels = [m for (m, _b, _g) in combos]
19
+ arrow_label = "__arrow__"
20
+ ordered_labels = [arrow_label] + labels # blank spacer row at the top holds the arrows
21
+
22
+ rows = []
23
+ for method, basis, geom in combos:
24
+ for solvent in solvents:
25
+ sub = benefit_df[(benefit_df["sap_nmr_method"] == method) &
26
+ (benefit_df["sap_basis"] == basis) &
27
+ (benefit_df["sap_geometry_type"] == geom) &
28
+ (benefit_df["solvent"] == solvent)]
29
+ for v in sub["percent_benefit"]:
30
+ rows.append({"Label": method, "Solvent": solvent, "PCM_Benefit": float(v)})
31
+ long_df = pd.DataFrame(rows)
32
+ long_df = pd.concat([long_df, pd.DataFrame([{"Label": arrow_label, "Solvent": solvents[0],
33
+ "PCM_Benefit": float(np.mean(xlimits))}])],
34
+ ignore_index=True)
35
+ long_df["Label"] = pd.Categorical(long_df["Label"], categories=ordered_labels, ordered=True)
36
+
37
+ sns.set_theme(context="paper", style="white")
38
+ fig, ax = plt.subplots(figsize=figsize)
39
+ sns.boxplot(data=long_df, x="PCM_Benefit", y="Label", order=ordered_labels,
40
+ hue="Solvent", hue_order=solvents, palette=colors, dodge=False, width=0.3,
41
+ fliersize=0, linewidth=0.5, whiskerprops=dict(alpha=0.5, linewidth=0.8),
42
+ capprops=dict(alpha=0.5, linewidth=1), medianprops=dict(alpha=0.9, linewidth=0.4),
43
+ boxprops=dict(alpha=0.9), ax=ax, orient="h")
44
+
45
+ # blank the spacer row's tick label
46
+ ticks = ax.get_yticks()
47
+ ax.yaxis.set_major_locator(mticker.FixedLocator(ticks))
48
+ ax.yaxis.set_major_formatter(mticker.FixedFormatter(
49
+ ["" if t.get_text() == arrow_label else t.get_text() for t in ax.get_yticklabels()]))
50
+
51
+ leg = ax.get_legend()
52
+ if leg is not None:
53
+ leg.set_title("")
54
+ for txt in leg.get_texts():
55
+ txt.set_text(txt.get_text().capitalize())
56
+ leg.set_frame_on(True); leg.get_frame().set_alpha(0.95)
57
+ for t in leg.get_texts():
58
+ t.set_fontsize(13)
59
+ leg.set_bbox_to_anchor((0.8, 1.02)); leg._loc = 9
60
+
61
+ # arrows on the blank spacer row
62
+ y_spacer = ordered_labels.index(arrow_label)
63
+ span = xlimits[1] - xlimits[0]
64
+ offset = 0.02 * span
65
+
66
+ def box_arrow(text, x, ha, facecolor):
67
+ ax.text(x, y_spacer, text, ha=ha, va="center", fontsize=11, fontweight="bold",
68
+ color="white", clip_on=False, zorder=10,
69
+ bbox=dict(boxstyle=f"{'larrow' if ha=='right' else 'rarrow'},pad=0.35",
70
+ fc=facecolor, ec="none", alpha=0.85))
71
+ box_arrow("Improves Acc.", +offset, "left", "#708B75")
72
+ box_arrow("Decreases Acc.", -offset, "right", "#C3A29E")
73
+
74
+ ax.axvline(0, color="black", lw=0.2, alpha=0.4, zorder=0)
75
+ ax.set_xlim(*xlimits)
76
+ ax.margins(x=0.01)
77
+ ax.set_xlabel("Benefit of PCM (%)", fontsize=13)
78
+ ax.set_ylabel("")
79
+ sns.despine(ax=ax, left=False, bottom=False)
80
+ for spine in ax.spines.values():
81
+ spine.set_linewidth(0.6)
82
+ ax.tick_params(axis="both", which="major", length=1.5, width=0.6, labelsize=10)
83
+ fig.tight_layout()
84
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
85
+ plt.show()
86
+
87
+
88
+ def plot_shift_vs_pcm(shift_df, save_path, xlimits=(-0.2, 0.2), ylimits=(-1.6, 0.3), figsize=(8, 4)):
89
+ """Figure 3B: measured vs PCM-predicted solvent-induced shift differences."""
90
+ sns.set_theme(style="white", context="paper")
91
+ fig, axes = plt.subplots(1, 2, figsize=figsize)
92
+ # left: experimental solvent-induced shifts (teal) -- large and uncorrelated
93
+ sns.scatterplot(data=shift_df, x="exp_diff_x", y="exp_diff_y", ax=axes[0],
94
+ color="#44AA99", s=15, alpha=1.0, edgecolor=None)
95
+ # right: PCM solvent corrections (dark red) -- small and linearly correlated. Same axis limits
96
+ # as the left panel, so the tiny PCM predictions are visible against the real shifts.
97
+ pcm = shift_df.dropna(subset=["pcm_diff_x", "pcm_diff_y"])
98
+ sns.scatterplot(data=pcm, x="pcm_diff_x", y="pcm_diff_y", ax=axes[1],
99
+ color="#A72608", s=15, alpha=0.5, edgecolor=None)
100
+ for ax in axes:
101
+ ax.set_xlim(*xlimits); ax.set_ylim(*ylimits)
102
+ ax.xaxis.set_major_locator(MaxNLocator(nbins=6))
103
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=8))
104
+ ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
105
+ axes[0].set_xlabel(r"$\delta_{MeOD} - \delta_{CDCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
106
+ axes[0].set_ylabel(r"$\delta_{benzene} - \delta_{CDCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
107
+ axes[1].set_xlabel(r"$\Delta\sigma_{MeOH} - \Delta\sigma_{CHCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
108
+ axes[1].set_ylabel(r"$\Delta\sigma_{benzene} - \Delta\sigma_{CHCl_3}$ ($^{1}\mathrm{H}$ ppm)", fontsize=13)
109
+ plt.tight_layout(rect=[0, 0, 1, 0.95])
110
+ sns.despine()
111
+ for ax in axes:
112
+ for spine in ax.spines.values():
113
+ spine.set_linewidth(0.6)
114
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
115
+ plt.show()
116
+
117
+
118
+ def plot_solvent_correction_boxplot(results_df, formula_labels, solvent_order, colors,
119
+ solvent_labels, save_path, box_widths=0.70, figsize=(8, 6)):
120
+ """Figure 3D: implicit vs explicit solvent corrections, one box per solvent."""
121
+ labels = list(formula_labels.values()) # implicit, implicit+vib, explicit, explicit+vib
122
+ palette = dict(zip(labels, colors))
123
+ df = results_df[results_df["formula"].isin(formula_labels)].copy()
124
+ df["Model"] = df["formula"].map(formula_labels)
125
+ df = df[df["solvent"].isin(solvent_order)].copy()
126
+ df["solvent"] = pd.Categorical(df["solvent"], categories=solvent_order, ordered=True)
127
+ xpos = {s: float(i) for i, s in enumerate(solvent_order)}
128
+ df["xpos"] = df["solvent"].map(xpos).astype(float)
129
+
130
+ sns.set_theme(context="paper", style="white")
131
+ fig, ax = plt.subplots(figsize=figsize)
132
+ sns.boxplot(data=df, x="xpos", y="test_RMSE", hue="Model", hue_order=labels, palette=palette,
133
+ width=box_widths, showfliers=False, linewidth=0.5,
134
+ whiskerprops=dict(alpha=0.5, linewidth=0.8), capprops=dict(alpha=0.5, linewidth=1),
135
+ medianprops=dict(alpha=0.9, linewidth=0.4), boxprops=dict(alpha=0.9), ax=ax)
136
+
137
+ # center each solvent's tick under its "Explicit Solvent" box
138
+ per_box = box_widths / len(labels)
139
+ offset = (labels.index("Explicit Solvent") - (len(labels) - 1) / 2) * per_box
140
+ ax.set_xticks([xpos[s] + offset for s in solvent_order])
141
+ ax.set_xticklabels([solvent_labels.get(s, s) for s in solvent_order], rotation=45, fontsize=8, ha="right")
142
+
143
+ ax.set_ylabel(r"Accuracy vs. Benchmark (RMSE, $^{1}\mathrm{H}$ ppm)", fontsize=13)
144
+ ax.set_xlabel("")
145
+ ax.set_ylim(bottom=0)
146
+ ax.set_title("Accuracy Improves Broadly with Explicit Solvation", fontsize=14, fontweight="bold")
147
+ leg = ax.get_legend()
148
+ if leg is not None:
149
+ leg.set_title("")
150
+ for t in leg.get_texts():
151
+ t.set_fontsize(13)
152
+ ax.yaxis.set_major_locator(MaxNLocator(nbins=6))
153
+ ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
154
+ ax.grid(False)
155
+ sns.despine(ax=ax)
156
+ for spine in ax.spines.values():
157
+ spine.set_linewidth(0.6)
158
+ fig.tight_layout()
159
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
160
+ plt.show()
analysis/code/fig4_plots.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Figure 4 panel-drawing engines.
2
+
3
+ 4A/4B share one scatter engine (predicted vs measured solvent-induced shift, implicit vs explicit
4
+ prediction column); 4C is a dumbbell plot of per-solvent fit RMSE vs experimental shift range. The
5
+ notebook supplies the data table and the styling dicts (highlight map, order, palette, markers /
6
+ colors) as arguments and calls `save_panel(...)` to draw and write the PNG.
7
+ """
8
+ import matplotlib.pyplot as plt
9
+ import seaborn as sns
10
+ from adjustText import adjust_text
11
+
12
+
13
+ def draw_shift_scatter(ax, shift_df, pred_col, highlight_map, order, palette, markers, limits=(-0.25, 2.0)):
14
+ """Figure 4A/4B: scatter predicted vs experimental solvent-induced shift, colored by solvent."""
15
+ df = shift_df[shift_df["solvent"].isin(highlight_map)].copy()
16
+ df["Solvent"] = df["solvent"].map(highlight_map)
17
+ sns.scatterplot(data=df, x="exp_diff", y=pred_col, hue="Solvent", style="Solvent",
18
+ hue_order=order, style_order=order, palette=palette,
19
+ markers=markers, s=60, alpha=0.6, edgecolor=None, ax=ax)
20
+ lo, hi = limits
21
+ ax.plot([lo, hi], [lo, hi], linestyle="--", alpha=0.35, linewidth=0.6, color="k") # ideal y = x
22
+ ax.set_xlim(lo, hi); ax.set_ylim(lo, hi); ax.set_box_aspect(1)
23
+ ax.set_xlabel(r"Experimental Solvent-Induced Shift (Δδ vs. CDCl3, $^{1}\mathrm{H}$ ppm)",
24
+ fontsize=13, labelpad=10)
25
+ ax.set_ylabel(r"Predicted Solvent-Induced Shift (Δδ vs. CDCl3, $^{1}\mathrm{H}$ ppm)",
26
+ fontsize=13, labelpad=10)
27
+ leg = ax.get_legend()
28
+ if leg is not None:
29
+ leg.set_title(None)
30
+ for side in ax.spines:
31
+ ax.spines[side].set_linewidth(0.6)
32
+ ax.tick_params(labelsize=10, length=1.5, width=0.6)
33
+
34
+
35
+ def save_panel(draw_fn, save_path, figsize=(6, 6)):
36
+ """Create a figure/axes, call `draw_fn(ax)`, then save and show the panel as a PNG."""
37
+ sns.set_theme(style="ticks", context="paper")
38
+ fig, ax = plt.subplots(figsize=figsize)
39
+ draw_fn(ax)
40
+ fig.tight_layout()
41
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
42
+ plt.show()
43
+
44
+
45
+ def draw_rmse_dumbbell(ax, rows, labels, colors):
46
+ """Figure 4C: dumbbell plot of implicit vs explicit solvent-correction RMSE per solvent."""
47
+ rows_sorted = sorted(rows, key=lambda r: r["range"])
48
+ xs = [r["range"] for r in rows_sorted]
49
+ for r in rows_sorted: # gray dumbbell connectors
50
+ ax.plot([r["range"], r["range"]], [r["implicit"], r["explicit"]],
51
+ color="#cccccc", lw=1.5, zorder=1)
52
+ ax.scatter(xs, [r["implicit"] for r in rows_sorted], s=60, marker="D",
53
+ color=colors["Implicit (PCM)"], alpha=0.85, edgecolors="none",
54
+ label="Implicit (PCM)", zorder=2)
55
+ ax.scatter(xs, [r["explicit"] for r in rows_sorted], s=60, marker="o",
56
+ color=colors["Explicit (Desmond)"], alpha=0.85, edgecolors="none",
57
+ label="Explicit (Desmond)", zorder=2)
58
+ # frame the data, then label each dumbbell at its midpoint (adjustText avoids overlaps)
59
+ xr = max(xs) - min(xs); yv = [r[k] for r in rows_sorted for k in ("implicit", "explicit")]
60
+ ax.set_xlim(min(xs) - 0.06 * xr, max(xs) + 0.10 * xr)
61
+ ax.set_ylim(min(yv) - 0.06 * (max(yv) - min(yv)), max(yv) + 0.10 * (max(yv) - min(yv)))
62
+ texts = [ax.text(r["range"], 0.5 * (r["implicit"] + r["explicit"]), labels[r["solvent"]],
63
+ fontsize=10, color="black", va="center", ha="left") for r in rows_sorted]
64
+ adjust_text(texts, ax=ax, arrowprops=dict(arrowstyle="-", color="gray", lw=0.5, alpha=0.8))
65
+ ax.legend(loc="upper left", framealpha=0.9, fontsize=11)
66
+ ax.set_box_aspect(1)
67
+ ax.set_xlabel(r"Range of Solvent-Induced Shifts ($^{1}\mathrm{H}$ ppm)", fontsize=13, labelpad=10)
68
+ ax.set_ylabel(r"Predicted Solvent-Induced Shift RMSE ($^{1}\mathrm{H}$ ppm)", fontsize=13, labelpad=10)
69
+ for side in ax.spines:
70
+ ax.spines[side].set_linewidth(0.6)
71
+ ax.tick_params(labelsize=10, length=1.5, width=0.6)
analysis/code/fig5b_plots.py ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Plotting engines for Figure 5B (MagNET-Zero vs DFT on the DFT8K benchmark).
2
+
3
+ The notebook computes residuals / functional-group error tables via `dft8k_residuals` and calls the
4
+ drawing functions here; `show_dft8k_molecule` renders the inline-only RDKit structure callouts (not
5
+ saved to disk).
6
+ """
7
+ import numpy as np
8
+ import matplotlib.pyplot as plt
9
+ import seaborn as sns
10
+
11
+
12
+ def plot_dft8k_residual_histogram(errors, save_path, color="#5D737E", bin_width=0.0025, xlim=0.23,
13
+ grey_box=0.1, x_tick=0.05, figsize=(8, 3)):
14
+ """Draw the DFT8K residual histogram for one nucleus and save it to save_path (or skip saving
15
+ if save_path is None)."""
16
+ # The published panel is slate (#5D737E) with a shaded +/-0.1 ppm window; the "MagNET-Zero Closely
17
+ # Reproduces DFT" title and "X% of Compounds" bracket are added in the figure compositing step.
18
+ sns.set_theme(context="paper", style="white")
19
+ fig, ax = plt.subplots(figsize=figsize)
20
+ edges = np.arange(-xlim, xlim + bin_width, bin_width)
21
+ sns.histplot(x=errors, bins=edges, stat="count", element="bars", ax=ax,
22
+ color=color, edgecolor=None, alpha=0.9, linewidth=0)
23
+ ax.set_xlim(-xlim, xlim)
24
+ ax.set_xlabel("Residual vs. DFT8K (ppm)", fontsize=13)
25
+ ax.set_ylabel("Counts", fontsize=13)
26
+ if grey_box is not None: # shade the +/- grey_box ppm window
27
+ ax.axvspan(-grey_box, grey_box, alpha=0.05, facecolor="k", edgecolor="none", zorder=0)
28
+ for xv in (-grey_box, grey_box):
29
+ ax.axvline(xv, color="k", ls="--", lw=1.0, alpha=0.35, zorder=1)
30
+ if x_tick is not None:
31
+ ax.set_xticks(np.arange(-np.floor(xlim / x_tick) * x_tick, xlim + x_tick / 2, x_tick))
32
+ sns.despine(ax=ax, top=True, right=True)
33
+ ax.spines["left"].set_linewidth(0.6); ax.spines["bottom"].set_linewidth(0.6)
34
+ ax.tick_params(axis="both", which="major", direction="out", length=1.5, width=0.6, labelsize=10)
35
+ ax.minorticks_off()
36
+ fig.tight_layout()
37
+ if save_path: # 13C is an inline-only companion, not saved
38
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
39
+ plt.show()
40
+
41
+
42
+ def plot_dft8k_functional_group_errors(group_errors, nucleus, save_path, figsize=(7, 3.5)):
43
+ """Bar chart of mean |residual| by functional group for one nucleus, saved to save_path."""
44
+ # The panel composites these mean |residual| values as molecule-icon insets; this bar chart is
45
+ # the plain reproduction of the numbers.
46
+ names = list(group_errors.keys())
47
+ values = [group_errors[n]["mean_abs_error"] for n in names]
48
+ counts = [group_errors[n]["n_molecules"] for n in names]
49
+ fig, ax = plt.subplots(figsize=figsize)
50
+ bars = ax.bar(names, values, color="#4C9B8F")
51
+ for bar, n in zip(bars, counts):
52
+ ax.text(bar.get_x() + bar.get_width() / 2, bar.get_height(), f"n={n:,}",
53
+ ha="center", va="bottom", fontsize=8)
54
+ lbl = "¹H" if nucleus == "H" else "¹³C"
55
+ ax.set_ylabel(f"mean |{lbl} residual| (ppm)")
56
+ ax.set_title(f"{lbl} error by functional group (whole DFT8K set)")
57
+ fig.autofmt_xdate(rotation=30)
58
+ fig.tight_layout()
59
+ if save_path: # only the 1H panel is a saved deliverable
60
+ fig.savefig(save_path, dpi=200, bbox_inches="tight")
61
+ plt.show()
62
+
63
+
64
+ def show_dft8k_molecule(smiles, size=(350, 300)):
65
+ """Display an RDKit structure drawing for the given SMILES inline; nothing is saved to disk."""
66
+ from rdkit import Chem
67
+ from rdkit.Chem.Draw import rdMolDraw2D
68
+ from IPython.display import Image, display
69
+ mol = Chem.MolFromSmiles(smiles)
70
+ if mol is None:
71
+ raise ValueError(f"RDKit could not parse SMILES: {smiles!r}")
72
+ drawer = rdMolDraw2D.MolDraw2DCairo(*size)
73
+ drawer.drawOptions().addStereoAnnotation = False
74
+ rdMolDraw2D.PrepareAndDrawMolecule(drawer, mol)
75
+ drawer.FinishDrawing()
76
+ display(Image(drawer.GetDrawingText())) # inline only; whole-molecule, no atom highlighting
analysis/code/leveling.py ADDED
@@ -0,0 +1,322 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core data loading and analysis for the leveling-effect study (NS372 and delta22). No plotting.
2
+
3
+ Reads the two datasets, decodes the stored shieldings, and runs the per-nucleus correlation / PCA
4
+ analysis. The figure notebook (built by build_nb_leveling.py) and test_leveling.py import from here.
5
+
6
+ The claim tested, on each dataset separately (never pooled): once a linear scaling is allowed for,
7
+ any two shielding methods are near-perfectly correlated, PC1 carries almost all between-method
8
+ variance, and the methods fall on a 1D curve in (PC1, PC2).
9
+ """
10
+ import numpy as np
11
+ import pandas as pd
12
+ import h5py
13
+ import openpyxl
14
+ from sklearn.decomposition import PCA
15
+ from sklearn.preprocessing import StandardScaler
16
+
17
+ from fixed_point import decode_fixed_point as _decode_fixed_point
18
+ from stats import rmse as _rmse
19
+
20
+ # canonical functional-family ordering used to sort method columns in every figure
21
+ FAMILY_ORDER = ['LDA', 'GGA', 'mGGA', 'GH', 'RSH', 'LH', 'DH', 'WFT', 'ref']
22
+
23
+ def sort_by_family(M, methods, families):
24
+ """Stable-sort method columns of M into FAMILY_ORDER (reference family last)."""
25
+ rank = {f: i for i, f in enumerate(FAMILY_ORDER)}
26
+ idx = sorted(range(len(methods)), key=lambda i: (rank.get(families[i], 99), i))
27
+ return M[:, idx], [methods[i] for i in idx], [families[i] for i in idx]
28
+
29
+ def log_r(R):
30
+ """-log10(1-|r|): 1 -> r=0.9, 2 -> 0.99, 3 -> 0.999, ..."""
31
+ return -np.log10(np.clip(1 - np.abs(R), 1e-6, None))
32
+
33
+ def scaled_rmse(method, ref):
34
+ """RMSE of residuals after the per-method linear fit method ~ a*ref + b."""
35
+ a, b = np.polyfit(ref, method, 1)
36
+ return _rmse(method, a * ref + b)
37
+
38
+ def _parabola_fit(xn, yn, n_angles=720):
39
+ # best degree-2 fit y_rot ~ x_rot searched over rotation angle theta
40
+ best = (0.0, -np.inf, None)
41
+ for th in np.linspace(0, np.pi, n_angles):
42
+ c, s = np.cos(th), np.sin(th)
43
+ xr, yr = c * xn + s * yn, -s * xn + c * yn
44
+ A = np.column_stack([np.ones_like(xr), xr, xr ** 2])
45
+ coef, *_ = np.linalg.lstsq(A, yr, rcond=None)
46
+ ss_res = ((yr - A @ coef) ** 2).sum()
47
+ ss_tot = ((yr - yr.mean()) ** 2).sum()
48
+ r2 = 1 - ss_res / ss_tot if ss_tot > 0 else 0.0
49
+ if r2 > best[1]:
50
+ best = (th, r2, coef)
51
+ return best
52
+
53
+ def analyze_nucleus(M, methods, ref=None):
54
+ """Correlation, PCA, and parabola-fit diagnostics for one nucleus.
55
+
56
+ M is an (n_observations, n_methods) shielding matrix; ref (optional) is the reference method's
57
+ values, used to compute each method's scaled RMSE against it.
58
+ """
59
+ R = np.corrcoef(M, rowvar=False)
60
+ iu = np.triu_indices_from(R, k=1)
61
+ rv = R[iu]
62
+ Z = StandardScaler().fit_transform(M)
63
+ pca = PCA().fit(Z)
64
+ ev = pca.explained_variance_ratio_
65
+ pc1, pc2 = pca.components_[0].copy(), pca.components_[1].copy()
66
+ if pc1.mean() < 0: # cosmetic sign convention
67
+ pc1 = -pc1
68
+ mx, my = pc1.mean(), pc2.mean()
69
+ sx, sy = max(pc1.std(), 1e-12), max(pc2.std(), 1e-12)
70
+ xn, yn = (pc1 - mx) / sx, (pc2 - my) / sy
71
+ th, pr2, coef = _parabola_fit(xn, yn)
72
+ c, s = np.cos(th), np.sin(th)
73
+ par = dict(theta=th, coef=coef, r2=pr2, mx=mx, sx=sx, my=my, sy=sy,
74
+ xr=c * xn + s * yn)
75
+ out = dict(R=R, methods=list(methods), pc1=pc1, pc2=pc2, ev=ev, par=par,
76
+ r_min=float(rv.min()), r_med=float(np.median(rv)),
77
+ r_max=float(rv.max()))
78
+ if ref is not None:
79
+ out['scaled_rmse'] = {m: scaled_rmse(M[:, i], ref)
80
+ for i, m in enumerate(methods)}
81
+ return out
82
+
83
+ def parabola_curve(par, npts=300):
84
+ """Sample points along the fitted parabola (from analyze_nucleus's `par`) in PC1/PC2 space."""
85
+ th, coef = par['theta'], par['coef']
86
+ c, s = np.cos(th), np.sin(th)
87
+ xr = par['xr']
88
+ vertex = -coef[1] / (2 * coef[2]) if coef[2] != 0 else float(xr.mean())
89
+ lo, hi = min(xr.min(), vertex), max(xr.max(), vertex)
90
+ pad = 0.15 * (hi - lo + 1e-9)
91
+ t = np.linspace(lo - pad, hi + pad, npts)
92
+ yr = coef[0] + coef[1] * t + coef[2] * t ** 2
93
+ xn, yn = c * t - s * yr, s * t + c * yr
94
+ return xn * par['sx'] + par['mx'], yn * par['sy'] + par['my']
95
+
96
+ def summarize(data, res):
97
+ """Per-nucleus leveling diagnostics (analyze_nucleus results) as one summary DataFrame."""
98
+ import pandas as pd
99
+ rows = []
100
+ for nuc, r in res.items():
101
+ rows.append(dict(nucleus=nuc,
102
+ n_obs=data[nuc]['M'].shape[0],
103
+ n_methods=data[nuc]['M'].shape[1],
104
+ r_min=r['r_min'], r_median=r['r_med'],
105
+ PC1_pct=r['ev'][0] * 100, PC2_pct=r['ev'][1] * 100,
106
+ PC3plus_pct=(1 - r['ev'][0] - r['ev'][1]) * 100,
107
+ parabola_R2=r['par']['r2']))
108
+ return pd.DataFrame(rows)
109
+
110
+ def dataset_logr_max(res):
111
+ """Largest -log10(1-|r|) over every nucleus of a dataset (lower triangle of each R)."""
112
+ vm = 0.0
113
+ for r in res.values():
114
+ nm = len(r['methods']); m = nm - 1
115
+ sub = r['R'][1:, :-1]
116
+ hide = np.triu(np.ones((m, m), dtype=bool), k=1)
117
+ vm = max(vm, float(np.nanmax(np.where(hide, np.nan, log_r(sub)))))
118
+ return float(np.ceil(vm * 10) / 10)
119
+
120
+
121
+ # ----------------------------------------------------------------------------
122
+ # NS372 (Kaupp) loader
123
+ # ----------------------------------------------------------------------------
124
+ # (functional, tau-treatment, %HF, family, display label)
125
+ # one tau-variant per functional: tauC (Gaussian default) for tau-dependent
126
+ # meta-GGAs / hybrids, tauD for local hybrids. Match keys use real Unicode.
127
+ METHODS = [
128
+ ('SVWN', None, 0, 'LDA', 'SVWN'),
129
+ ('BLYP', None, 0, 'GGA', 'BLYP'), ('BP86', None, 0, 'GGA', 'BP86'),
130
+ ('PBE', None, 0, 'GGA', 'PBE'), ('B97D', None, 0, 'GGA', 'B97D'),
131
+ ('HCTH', None, 0, 'GGA', 'HCTH'),
132
+ ('KT1', None, 0, 'GGA', 'KT1'), ('KT2', None, 0, 'GGA', 'KT2'), ('KT3', None, 0, 'GGA', 'KT3'),
133
+ ('TPSS', 'τC', 0, 'mGGA', 'TPSS/τC'), ('τ-HCTH', 'τC', 0, 'mGGA', 'τ-HCTH/τC'),
134
+ ('M06-L', 'τC', 0, 'mGGA', 'M06-L/τC'), ('VSXC', 'τC', 0, 'mGGA', 'VSXC/τC'),
135
+ ('MN15-L', 'τC', 0, 'mGGA', 'MN15-L/τC'), ('B97M-V', 'τC', 0, 'mGGA', 'B97M-V/τC'),
136
+ ('SCAN', 'τC', 0, 'mGGA', 'SCAN/τC'), ('rSCAN', 'τC', 0, 'mGGA', 'rSCAN/τC'),
137
+ ('r2SCAN', 'τC', 0, 'mGGA', 'r2SCAN/τC'),
138
+ ('TPSSh', 'τC', 10, 'GH', 'TPSSh/τC'), ('B3LYP', None, 20, 'GH', 'B3LYP'),
139
+ ('B97-2', None, 21, 'GH', 'B97-2'), ('PBE0', None, 25, 'GH', 'PBE0'),
140
+ ('M06', 'τC', 27, 'GH', 'M06/τC'), ('PW6B95', 'τC', 28, 'GH', 'PW6B95/τC'),
141
+ ('MN15', 'τC', 44, 'GH', 'MN15/τC'), ('BHLYP', None, 50, 'GH', 'BHLYP'),
142
+ ('M06-2X', 'τC', 54, 'GH', 'M06-2X/τC'),
143
+ ('CAM-B3LYP', None, 65, 'RSH', 'CAM-B3LYP'),
144
+ ('ωB97X-D', None, 100, 'RSH', 'ωB97X-D'), ('ωB97X-V', None, 100, 'RSH', 'ωB97X-V'),
145
+ ('ωB97M-V', 'τC', 100, 'RSH', 'ωB97M-V/τC'),
146
+ ('LH07s-SVWN', None, 22, 'LH', 'LH07s-SVWN'),
147
+ ('MPSTS', 'τD', 30, 'LH', 'mPSTS/τD'),
148
+ ('LHJ14', 'τD', 10, 'LH', 'LHJ14/τD'),
149
+ ('LH07t-SVWN', 'τD', 48, 'LH', 'LH07t-SVWN/τD'),
150
+ ('LH12ct-SsirPW92', 'τD', 65, 'LH', 'LH12ct-SsirPW92/τD'),
151
+ ('LH12ct-SsifPW92', 'τD', 71, 'LH', 'LH12ct-SsifPW92/τD'),
152
+ ('LH14t-calPBE', 'τD', 50, 'LH', 'LH14t-calPBE/τD'),
153
+ ('LH20t', 'τD', 71, 'LH', 'LH20t/τD'),
154
+ ('B2PLYP', None, 53, 'DH', 'B2PLYP'), ('B2GP-PLYP', None, 65, 'DH', 'B2GP-PLYP'),
155
+ ('DSD-PBEP86', None, 70, 'DH', 'DSD-PBEP86'),
156
+ ('Hartree-Fock', None, 100, 'WFT', 'HF'), ('MP2', None, 100, 'WFT', 'MP2'),
157
+ ]
158
+ # (display nucleus, source sheet, in-sheet nucleus filter, molecules to drop)
159
+ NUCLEI = [
160
+ ('1H', 'S6 - 1H Shieldings', None, ()),
161
+ ('11B', 'S7 - 11B Shieldings', None, ('BH',)),
162
+ ('13C', 'S8 - 13C Shieldings', None, ()),
163
+ ('15N', 'S9 - 15N Shieldings', None, ()),
164
+ ('17O', 'S10 - 17O Shieldings', None, ('O3',)),
165
+ ('19F', 'S11 - 19F Shieldings', None, ('F3-',)),
166
+ ('31P', 'S12 - 31P - 33S Shieldings', '31P', ()),
167
+ ('33S', 'S12 - 31P - 33S Shieldings', '33S', ()),
168
+ ]
169
+
170
+ def read_nucleus(wb, sheet, nuc_filter=None, exclude=()):
171
+ """Read the CCSD(T) reference plus each method's shielding vector from one Kaupp SI sheet."""
172
+ ws = wb[sheet]
173
+ rows = []; last_nuc = None
174
+ for r in range(4, ws.max_row + 1):
175
+ v = ws.cell(r, 1).value
176
+ if v:
177
+ last_nuc = v
178
+ if isinstance(ws.cell(r, 4).value, (int, float)):
179
+ mol = str(ws.cell(r, 2).value or '').strip()
180
+ if mol in exclude:
181
+ continue
182
+ if nuc_filter is None or last_nuc == nuc_filter:
183
+ rows.append(r)
184
+ ref = np.array([ws.cell(r, 4).value for r in rows], dtype=float)
185
+ last_f = None; cols = []
186
+ for c in range(7, ws.max_column + 1):
187
+ h = ws.cell(1, c).value
188
+ if h is not None:
189
+ last_f = h
190
+ cols.append((c, last_f, ws.cell(2, c).value))
191
+ data = {}
192
+ for c, f, sub in cols:
193
+ if f is None:
194
+ continue
195
+ vals = []; ok = True
196
+ for r in rows:
197
+ v = ws.cell(r, c).value
198
+ if not isinstance(v, (int, float)):
199
+ ok = False; break
200
+ vals.append(v)
201
+ if not ok or (f, sub) in data:
202
+ continue
203
+ data[(f, sub)] = np.array(vals, dtype=float)
204
+ return ref, data
205
+
206
+ def load_ns372(path):
207
+ """Load the NS372 (Kaupp) shielding matrix, sorted by family, for each of the 8 nuclei."""
208
+ wb = openpyxl.load_workbook(path, data_only=True)
209
+ out = {}
210
+ for nuc, sheet, filt, excl in NUCLEI:
211
+ ref, data = read_nucleus(wb, sheet, filt, excl)
212
+ used = [m for m in METHODS if (m[0], m[1]) in data]
213
+ cols = [data[(m[0], m[1])] for m in used]
214
+ labels = [m[4] for m in used] + ['CCSD(T)']
215
+ fams = [m[3] for m in used] + ['ref']
216
+ M = np.column_stack(cols + [ref])
217
+ M, labels, fams = sort_by_family(M, labels, fams)
218
+ out[nuc] = dict(M=M, methods=labels, ref=ref, families=fams)
219
+ return out
220
+
221
+
222
+ # ----------------------------------------------------------------------------
223
+ # main-text Figure 2D (convergence to the electronic-structure limit)
224
+ # ----------------------------------------------------------------------------
225
+ # The 1H / pcSseg-3 slice of the Kaupp SI, as a raw-named table, plus the two
226
+ # per-method statistics the 3D panel plots (slope and scaled RMSE vs CCSD(T)).
227
+ # These reuse the NS372 reader above; nothing new is read from disk.
228
+ def load_ns372_1h_convergence(path):
229
+ """1H CCSD(T) reference plus each benchmarked functional's shieldings, as columns.
230
+
231
+ Tau policy differs from the SI S3 leveling analysis: Figure 2D uses the tauD kinetic-energy-density
232
+ treatment for every tau-dependent functional, whereas load_ns372/METHODS uses tauC for meta-GGAs.
233
+ So this selects tauD when present, otherwise the functional's single column, keyed by raw
234
+ functional name ('Hartree-Fock' renamed 'HF' for the labels).
235
+ """
236
+ wb = openpyxl.load_workbook(path, data_only=True)
237
+ ref, data = read_nucleus(wb, 'S6 - 1H Shieldings')
238
+ names = {m[0] for m in METHODS} # the 44 benchmarked functionals
239
+ variants = {} # functional -> {tau: values}
240
+ for (func, tau), vals in data.items():
241
+ if func in names:
242
+ variants.setdefault(func, {})[tau] = vals
243
+ cols = {'CCSD(T)': ref}
244
+ for func, by_tau in variants.items():
245
+ vals = by_tau['τD'] if 'τD' in by_tau else next(iter(by_tau.values()))
246
+ cols['HF' if func == 'Hartree-Fock' else func] = vals
247
+ return pd.DataFrame(cols)
248
+
249
+ def convergence_statistics(cc_df, reference='CCSD(T)'):
250
+ """Per method: the slope of method ~ a*CCSD(T) + b, and the scaled RMSE that Figure 2D plots.
251
+
252
+ The prediction is rescaled back into reference units, y_scaled = (method - b) / a, and compared
253
+ to CCSD(T); this equals the residual RMSE divided by the slope, so it differs from scaled_rmse,
254
+ which the S3 bar plot leaves in the method's own units. These are the x/y coordinates of the
255
+ Figure 2D points.
256
+ """
257
+ ref = cc_df[reference].to_numpy(float)
258
+ rows = []
259
+ for method in cc_df.columns:
260
+ if method == reference:
261
+ continue
262
+ y = cc_df[method].to_numpy(float)
263
+ slope, intercept = np.polyfit(ref, y, 1)
264
+ y_scaled = (y - intercept) / slope
265
+ rows.append({'Method': method, 'Slope': slope,
266
+ 'RMSE (scaled)': _rmse(ref, y_scaled)})
267
+ return pd.DataFrame(rows)
268
+
269
+
270
+ # ----------------------------------------------------------------------------
271
+ # delta22 loader (reads the released file; shieldings are decoded on read)
272
+ # ----------------------------------------------------------------------------
273
+ DELTA22_REF = 'DSD-PBEP86'
274
+
275
+ def load_delta22(path):
276
+ """Load the delta22 shielding matrix, sorted by family, for 1H and 13C sites."""
277
+ f = h5py.File(path, 'r')
278
+ mn = [m.decode() for m in f['conventional_nmr_method_names'][:]]
279
+ order = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
280
+ 'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
281
+ 'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
282
+ 'mp2', 'dlpno_mp2']
283
+ disp = {'hf': 'HF', 'blyp_d3bj': 'BLYP-D3', 'bp86_d3bj': 'BP86-D3',
284
+ 'b97d3_d3bj': 'B97D3', 'tpsstpss_d3bj': 'TPSS-D3', 'b3lyp_d3bj': 'B3LYP-D3',
285
+ 'pbe0_d3bj': 'PBE0-D3', 'm062x_d3': 'M06-2X-D3', 'wb97xd': 'ωB97X-D',
286
+ 'wp04': 'WP04', 'wc04': 'WC04', 'B2PLYP': 'B2PLYP', 'mPW2PLYP': 'mPW2PLYP',
287
+ 'B2GP_PLYP': 'B2GP-PLYP', 'dsd_pbep86': 'DSD-PBEP86',
288
+ 'revdsd_pbep86': 'revDSD-PBEP86', 'mp2': 'MP2', 'dlpno_mp2': 'DLPNO-MP2'}
289
+ fam = {'hf': 'WFT', 'blyp_d3bj': 'GGA', 'bp86_d3bj': 'GGA', 'b97d3_d3bj': 'GGA',
290
+ 'tpsstpss_d3bj': 'mGGA', 'b3lyp_d3bj': 'GH', 'pbe0_d3bj': 'GH',
291
+ 'm062x_d3': 'GH', 'wb97xd': 'RSH', 'wp04': 'GH', 'wc04': 'GH',
292
+ 'B2PLYP': 'DH', 'mPW2PLYP': 'DH', 'B2GP_PLYP': 'DH', 'dsd_pbep86': 'DH',
293
+ 'revdsd_pbep86': 'DH', 'mp2': 'WFT', 'dlpno_mp2': 'WFT'}
294
+ gas = {}
295
+ for i, m in enumerate(mn):
296
+ func, basis, smodel, _ = m.split(',')
297
+ if smodel == 'gas':
298
+ gas.setdefault(func, {})[basis] = i
299
+ sel, labels, families = [], [], []
300
+ for func in order:
301
+ b = gas[func]
302
+ big = 'pcSseg3' if 'pcSseg3' in b else ('pcSseg2' if 'pcSseg2' in b else 'pcSseg1')
303
+ sel.append(b[big]); labels.append(disp[func]); families.append(fam[func])
304
+ GEOM = 1 # geometries axis: 0 = AIMNet2, 1 = PBE0/cc-pVTZ
305
+ rows = {1: [], 6: []}
306
+ for s in f['solutes']:
307
+ an = f['solutes'][s]['atomic_numbers'][:]
308
+ cs = _decode_fixed_point(
309
+ f['solutes'][s]['stationary_and_pcm']['conventional_shieldings'][:, GEOM, :])
310
+ for ai, z in enumerate(an):
311
+ if z in (1, 6):
312
+ rows[int(z)].append(cs[sel, ai])
313
+ f.close()
314
+ out = {}
315
+ for nuc, z in (('1H', 1), ('13C', 6)):
316
+ M = np.array(rows[z])
317
+ M, labs, fams = sort_by_family(M, list(labels), list(families))
318
+ # use DSD-PBEP86 as the reference vector for delta22 so the scaled RMSE
319
+ # bar plot mirrors NS372's CCSD(T) role
320
+ ref_idx = labs.index(DELTA22_REF)
321
+ out[nuc] = dict(M=M, methods=labs, ref=M[:, ref_idx], families=fams)
322
+ return out
analysis/code/leveling_plots.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Correlation-matrix and PCA-loadings plotting engine for the leveling-effect study (SI Figure S3).
2
+
3
+ Draws the two figure types built by build_nb_leveling.py: per-nucleus inter-method Pearson
4
+ correlation matrices (log-scaled, with a scaled-RMSE side bar and a colour-scale histogram inset)
5
+ and PC1/PC2 method-loading panels (with a fitted parabola and adjustText-repelled labels). The
6
+ notebook supplies the family-colour map, nucleus display labels, and adjustText tuning as arguments.
7
+ """
8
+ import numpy as np
9
+ import matplotlib.pyplot as plt
10
+ from adjustText import adjust_text
11
+
12
+ from leveling import log_r, parabola_curve
13
+
14
+
15
+ def _adjust_pca_labels(texts, ax, nuc, pca_adjust, pca_adjust_default):
16
+ params = pca_adjust.get(nuc, pca_adjust_default)
17
+ adjust_text(texts, ax=ax, max_move=None, min_arrow_len=0,
18
+ ensure_inside_axes=True,
19
+ arrowprops=dict(arrowstyle='-', color='0.55', lw=0.35),
20
+ **params)
21
+
22
+
23
+ def _family_legend(ax, families, family_colors, include_ref=True):
24
+ present = []
25
+ for f in families:
26
+ if f == 'ref' and not include_ref:
27
+ continue
28
+ if f not in present:
29
+ present.append(f)
30
+ handles = [plt.Line2D([], [], marker='*' if f=='ref' else 'o', ls='',
31
+ mfc=family_colors.get(f,'#333333'), mec='k',
32
+ ms=12 if f=='ref' else 6.5,
33
+ label='CCSD(T)' if f=='ref' else f)
34
+ for f in present]
35
+ ax.legend(handles=handles, loc='best', fontsize=6.5, ncol=2,
36
+ frameon=True, framealpha=0.9)
37
+
38
+
39
+ def _pca_panel(ax, name, nuc, r, families, family_colors, nuc_display, pca_adjust, pca_adjust_default):
40
+ pc1, pc2 = r['pc1'], r['pc2']
41
+ methods = r['methods']
42
+ cx, cy = parabola_curve(r['par'])
43
+ # small padding: the dense central cluster fills more of the plot in inches,
44
+ # giving adjustText more room per label
45
+ xpad = 0.08*(pc1.max()-pc1.min()+1e-9)
46
+ ypad = 0.08*(pc2.max()-pc2.min()+1e-9)
47
+ xlim = (pc1.min()-xpad, pc1.max()+xpad)
48
+ ylim = (pc2.min()-ypad, pc2.max()+ypad)
49
+ m = (cx>=xlim[0])&(cx<=xlim[1])&(cy>=ylim[0])&(cy<=ylim[1])
50
+ ax.plot(cx[m], cy[m], color='0.75', ls='--', lw=1.2, alpha=0.7, zorder=1)
51
+ texts = []
52
+ for i, lab in enumerate(methods):
53
+ fam = families[i]
54
+ if fam == 'ref':
55
+ ax.scatter(pc1[i], pc2[i], s=75, marker='*',
56
+ c=family_colors['ref'], edgecolors='white',
57
+ linewidths=0.8, zorder=5)
58
+ else:
59
+ ax.scatter(pc1[i], pc2[i], s=11,
60
+ c=family_colors.get(fam, '#333333'),
61
+ edgecolors='black', linewidths=0.3, zorder=4)
62
+ texts.append(ax.text(pc1[i], pc2[i], lab, fontsize=3.8, zorder=6))
63
+ ax.set_xlim(*xlim); ax.set_ylim(*ylim)
64
+ _adjust_pca_labels(texts, ax, nuc, pca_adjust, pca_adjust_default)
65
+ _family_legend(ax, families, family_colors, include_ref=True)
66
+ ev = r['ev']
67
+ ax.set_title(f'{name} {nuc_display.get(nuc, nuc)} - '
68
+ f'method loadings in PC1/PC2 space\n'
69
+ f'PC1 = {ev[0]*100:.2f}%, PC2 = {ev[1]*100:.2f}%, '
70
+ f'parabola R² = {r["par"]["r2"]:.3f}', fontsize=9)
71
+ ax.set_xlabel('PC1 loading', fontsize=8)
72
+ ax.set_ylabel('PC2 loading', fontsize=8)
73
+ ax.tick_params(labelsize=7)
74
+ ax.axhline(0, color='gray', lw=0.4); ax.axvline(0, color='gray', lw=0.4)
75
+ ax.grid(True, ls=':', alpha=0.3)
76
+
77
+
78
+ def plot_pca_pair(name, nucs, res, fam_lookup, family_colors, nuc_display, pca_adjust, pca_adjust_default):
79
+ """PC1/PC2 method-loading panels, one nucleus per row, stacked in a single figure."""
80
+ n = len(nucs)
81
+ fig, axes = plt.subplots(n, 1, figsize=(7.0, 5.2*n), squeeze=False)
82
+ for k, nuc in enumerate(nucs):
83
+ _pca_panel(axes[k][0], name, nuc, res[nuc], fam_lookup[nuc],
84
+ family_colors, nuc_display, pca_adjust, pca_adjust_default)
85
+ fig.tight_layout(h_pad=3.6)
86
+ return fig
87
+
88
+
89
+ def plot_corr_matrix(name, nucs, res, fam_lookup, vmax, ref_name, nuc_display):
90
+ """Per-nucleus inter-method correlation heatmap with a scaled-RMSE side bar.
91
+
92
+ Layout: [ RMSE bar (viridis, bars grow leftward) | heatmap (inferno) ]. Methods are reordered so
93
+ the reference is first and the rest are sorted by ascending scaled RMSE vs the reference. The
94
+ heatmap is the lower triangle with the diagonal removed; its upper-right corner carries an inset
95
+ histogram of all pairwise log-correlations with the colour bar below it, showing two tick sets
96
+ (bottom = -log10(1-|r|) integers, top = the equivalent raw r values). All matrices share `vmax`.
97
+ """
98
+ nm = len(res[nucs[0]]['methods'])
99
+ m = nm - 1
100
+ hm_h, hm_w, bar_w = 0.135*nm + 1.9, 6.0, 1.5
101
+ fig_w = bar_w + hm_w
102
+ fig_h = hm_h * len(nucs) + 0.4
103
+ fig, axes = plt.subplots(len(nucs), 2, sharey='row',
104
+ gridspec_kw={'width_ratios': [bar_w, hm_w]},
105
+ figsize=(fig_w, fig_h),
106
+ layout='constrained', squeeze=False)
107
+ fig.get_layout_engine().set(wspace=0.01) # tighten gap to y-labels
108
+ cmap_corr = plt.cm.inferno.copy(); cmap_corr.set_bad('white')
109
+ cmap_rmse = plt.cm.viridis
110
+ fs = 4.3 if m > 24 else 6.0
111
+ ticks_log = [t for t in [1, 2, 3, 4, 5] if t <= vmax + 0.01]
112
+ for k, nuc in enumerate(nucs):
113
+ axR = axes[k, 0]; ax = axes[k, 1]
114
+ r = res[nuc]; R = r['R']; methods = r['methods']
115
+ rmse_dict = r['scaled_rmse']
116
+ # ----- reorder: reference first, others by ascending RMSE vs ref -----
117
+ ref_idx = methods.index(ref_name)
118
+ others = [i for i in range(nm) if i != ref_idx]
119
+ others_sorted = sorted(others, key=lambda i: rmse_dict[methods[i]])
120
+ new_order = [ref_idx] + others_sorted
121
+ methods_s = [methods[i] for i in new_order]
122
+ R_s = R[np.ix_(new_order, new_order)]
123
+ rmse_s = np.array([rmse_dict[mth] for mth in methods_s])
124
+ # ----- heatmap (drop row 0 = ref, col -1 = worst-RMSE method) -----
125
+ sub = R_s[1:, :-1]
126
+ hide = np.triu(np.ones((m, m), dtype=bool), k=1)
127
+ lr = log_r(sub)
128
+ im = ax.imshow(np.ma.masked_array(lr, hide), cmap=cmap_corr,
129
+ vmin=0, vmax=vmax, aspect='auto')
130
+ ax.set_xticks(range(m))
131
+ ax.set_xticklabels(methods_s[:-1], rotation=90, fontsize=fs)
132
+ ax.tick_params(left=False, labelleft=False)
133
+ if m <= 20:
134
+ for i in range(m):
135
+ for j in range(i+1):
136
+ ax.text(j, i, f'{sub[i,j]:.5f}', ha='center', va='center',
137
+ fontsize=3.4,
138
+ color='white' if lr[i,j] < vmax*0.5 else 'black')
139
+ ax.set_title(f'{name} {nuc_display.get(nuc, nuc)} - '
140
+ f'inter-method Pearson correlation', fontsize=9)
141
+ # ----- histogram + colour bar inset, jammed into the upper-right -----
142
+ # NS372 (kaupp): scale the inset up 20% so it fills more of the corner.
143
+ # A small visible gap is left between hist bottom and cb top so the
144
+ # histogram's tick marks are visible; the dual stacked tick labels
145
+ # under the cb (top line = -log10(1-|r|), bottom line = Pearson r)
146
+ # serve both axes since hist and cb share x-extent.
147
+ all_lr = lr[~hide]
148
+ counts, edges = np.histogram(all_lr, bins=28, range=(0.0, vmax))
149
+ centers = 0.5*(edges[:-1] + edges[1:])
150
+ scale = 1.2 if name == 'NS372' else 1.0
151
+ hist_w = 0.50 * scale
152
+ hist_h = 0.18 * scale
153
+ cb_h = 0.014 # thinner colour bar
154
+ gap = 0.012 # visible gap for hist tick marks
155
+ hist_top = 0.97
156
+ x0 = 0.98 - hist_w
157
+ hist_y0 = hist_top - hist_h
158
+ cb_y0 = hist_y0 - gap - cb_h
159
+ hax = ax.inset_axes([x0, hist_y0, hist_w, hist_h])
160
+ hax.set_facecolor('white')
161
+ hax.bar(centers, counts, width=(edges[1]-edges[0])*0.92,
162
+ color=cmap_corr(centers / vmax), edgecolor='black', linewidth=0.3)
163
+ hax.axvline(float(np.median(all_lr)), color='black', lw=1.6, ls='--')
164
+ hax.set_xlim(0, vmax)
165
+ hax.set_xticks(ticks_log)
166
+ hax.tick_params(axis='x', labelbottom=False, bottom=True, length=3)
167
+ hax.set_ylabel('count', fontsize=6, labelpad=1)
168
+ hax.tick_params(axis='y', labelsize=5)
169
+ hax.grid(axis='y', ls=':', alpha=0.4)
170
+ # colour bar, slightly below the histogram with a small visible gap
171
+ cbax = ax.inset_axes([x0, cb_y0, hist_w, cb_h])
172
+ cb = fig.colorbar(im, cax=cbax, orientation='horizontal')
173
+ cb.set_ticks(ticks_log)
174
+ cb.ax.set_xticklabels([f'{t}\n0.{"9"*t}' for t in ticks_log])
175
+ cb.ax.tick_params(axis='x', labelsize=5.5, pad=1,
176
+ top=False, labeltop=False)
177
+ cb.set_label('-log10(1-|r|) / Pearson r', fontsize=6, labelpad=2)
178
+ # ----- RMSE bar on the LEFT (viridis, bars grow leftward) -----
179
+ row_methods = methods_s[1:]
180
+ rmse_row = rmse_s[1:]
181
+ rmse_max = max(rmse_row.max(), 1e-6)
182
+ bar_colors = cmap_rmse(rmse_row / rmse_max)
183
+ axR.barh(range(m), rmse_row, color=bar_colors,
184
+ edgecolor='black', linewidth=0.3)
185
+ axR.yaxis.set_ticks_position('right')
186
+ axR.yaxis.set_label_position('right')
187
+ axR.set_yticks(range(m))
188
+ axR.set_yticklabels(row_methods, fontsize=fs)
189
+ axR.set_xlim(rmse_max * 1.06, 0) # reversed: 0 on the right
190
+ axR.set_xlabel(f'scaled RMSE (ppm)\nvs {ref_name}', fontsize=6.5)
191
+ axR.tick_params(axis='x', labelsize=5.5)
192
+ axR.tick_params(axis='y', length=0, pad=2)
193
+ axR.grid(axis='x', ls=':', alpha=0.4)
194
+ axR.set_ylim(m - 0.5, -0.5) # imshow has inverted y
195
+ return fig
analysis/code/magnet_benchmark.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tables S3 and S4: MagNET shielding accuracy across stationary, vibrated, and solvated geometries.
2
+
3
+ How well MagNET reproduces its DFT training reference (PBE0/pcSseg-1) for the foundation model and the
4
+ chloroform MagNET-x, on four test sets:
5
+
6
+ stationary_internal held-out GDB resting geometries (sigma-shake test split)
7
+ vibrated_internal MD frames of those molecules (gdb_qcd)
8
+ vibrated_external perturbed outside-benchmark geometries (dft8k)
9
+ isolated_chloroform isolated-solute chloroform MD snapshots (sigma-fresh)
10
+
11
+ Two reproduction paths; the shipped notebook uses the first:
12
+ 1. EXACT, from `data/magnet_test_predictions/`: raw per-atom predictions over the FULL test sets,
13
+ computed once from the released checkpoints. `exact_stats_table` maps each (nucleus, model, test
14
+ set) to its group and calls the reader's `stats()`; median/mean/RMSE reproduce exactly, no
15
+ checkpoints needed.
16
+ 2. FROM SCRATCH: `cases_*` assembles (atomic numbers, geometry, DFT shielding) test cases and
17
+ `magnet_benchmark_run.py` runs a model over them. Needs the checkpoints and only samples each set.
18
+
19
+ RMSE note (path 2): median and mean AE reproduce from a modest sample; RMSE does not. For the vibrated
20
+ sets it is dominated by ~1-in-10,000 extreme MD frames (the CO-style breakdown of Figure S12), so the
21
+ published 13C RMSE (7.14 ppm) needs the full ~1.9M-frame set; path 1 sidesteps this. The
22
+ isolated-chloroform row runs in full even under path 2; its foundation 1H RMSE (0.30 ppm) vs MAE
23
+ (0.07 ppm) reflects a broad heavy tail of real deshielded geometries (not DFT failures), kept as-is.
24
+ The Figure S10 stationary-only ablation checkpoint is not released, so that figure is not reproduced.
25
+ """
26
+ import numpy as np
27
+
28
+ from stats import summarize_errors
29
+
30
+ # --------------------------------------------------------------------------- published SI values
31
+ # (median absolute error, mean absolute error, RMSE), per (model, test set). From SI Tables S3/S4.
32
+ # Keys: model in {"MagNET", "MagNET-x"}; test set as named above.
33
+ PUBLISHED = {
34
+ "1H": {
35
+ ("MagNET", "stationary_internal"): (0.020946503, 0.030467307, 0.05941977),
36
+ ("MagNET", "vibrated_internal"): (0.027399063, 0.039091997, 0.10485571),
37
+ ("MagNET", "vibrated_external"): (0.03241539, 0.04796361, 0.11397923),
38
+ ("MagNET", "isolated_chloroform"): (0.03216362, 0.071752764, 0.29437021),
39
+ ("MagNET-x", "stationary_internal"): (0.02163124, 0.031185, 0.06033938),
40
+ ("MagNET-x", "vibrated_internal"): (0.027683258, 0.039436683, 0.10467214),
41
+ ("MagNET-x", "vibrated_external"): (0.023479462, 0.034395833, 0.06877531),
42
+ ("MagNET-x", "isolated_chloroform"): (0.022384644, 0.030986413, 0.04612577),
43
+ },
44
+ "13C": {
45
+ ("MagNET", "stationary_internal"): (0.23955917, 0.36742094, 1.98238429),
46
+ ("MagNET", "vibrated_internal"): (0.31917763, 0.47851510, 7.13765463),
47
+ ("MagNET", "vibrated_external"): (0.38428497, 0.57037073, 0.98834883),
48
+ ("MagNET", "isolated_chloroform"): (0.36051940, 0.58715980, 1.22315871),
49
+ ("MagNET-x", "stationary_internal"): (0.25761414, 0.39035153, 1.98721069),
50
+ ("MagNET-x", "vibrated_internal"): (0.33805847, 0.50241500, 7.15089956),
51
+ ("MagNET-x", "vibrated_external"): (0.32779312, 0.47457147, 0.81721424),
52
+ ("MagNET-x", "isolated_chloroform"): (0.30544280, 0.43592300, 0.72233414),
53
+ },
54
+ }
55
+
56
+ NUCLEUS_Z = {"1H": 1, "13C": 6}
57
+ TEST_SETS = ["stationary_internal", "vibrated_internal", "vibrated_external", "isolated_chloroform"]
58
+ MODELS = ["MagNET", "MagNET-x"]
59
+
60
+ # Table S5: QCD-correction statistics for the foundation MagNET, one row per nucleus. Computed over
61
+ # the qcdtraj2500 test set (2500 molecules) at PBE0/pcSseg-1. Published (median AE, MAE, RMSE):
62
+ PUBLISHED_S5 = {
63
+ "1H": (0.01526165, 0.023047674, 0.04020983),
64
+ "13C": (0.17198181, 0.26223338, 0.50198762),
65
+ }
66
+ QCD_GROUP = {"1H": "qcdtraj2500_H_pretrained_H", "13C": "qcdtraj2500_C_pretrained_C"}
67
+
68
+
69
+ def qcd_stats_table(predictions_path, decode_module):
70
+ """Table S5: (median AE, MAE, RMSE, n) of the QCD-correction errors for the foundation MagNET,
71
+ one row per nucleus, read from data/magnet_test_predictions/ via qcd_correction_stats."""
72
+ rows = []
73
+ for nucleus in ("1H", "13C"):
74
+ med, mae, rmse, n = decode_module.qcd_correction_stats(predictions_path, QCD_GROUP[nucleus])
75
+ rows.append(dict(model=f"MagNET ({nucleus})", median_ae=med, mae=mae, rmse=rmse, n=n))
76
+ return rows
77
+
78
+ # --------------------------------------------------------------------------- exact reproduction
79
+ # from data/magnet_test_predictions/ (see the module docstring, path 1). That dataset names its
80
+ # groups after its own test-set families (gasphaseinternal, gasphasedft8k, solutesmd500isolated),
81
+ # which don't match the four TEST_SETS names above one-to-one, so this is the mapping between them.
82
+ TEST_PREDICTIONS_MODEL_GROUP = {"MagNET": "pretrained", "MagNET-x": "chloroform"}
83
+ TEST_PREDICTIONS_NUCLEUS = {"1H": "H", "13C": "C"}
84
+
85
+
86
+ def test_predictions_group(model, test_set, nucleus):
87
+ """The data/magnet_test_predictions/ group name holding the raw predictions for one Table S3/S4
88
+ row. `model` is "MagNET" or "MagNET-x", `test_set` is one of TEST_SETS, `nucleus` is "1H" or
89
+ "13C". See the TEST_PREDICTIONS_* maps below for the underlying group-naming scheme."""
90
+ n = TEST_PREDICTIONS_NUCLEUS[nucleus]
91
+ m = TEST_PREDICTIONS_MODEL_GROUP[model]
92
+ if test_set == "stationary_internal":
93
+ return f"gasphaseinternal_{n}_{m}_{n}_vib0"
94
+ if test_set == "vibrated_internal":
95
+ return f"gasphaseinternal_{n}_{m}_{n}_vib1"
96
+ if test_set == "vibrated_external":
97
+ return f"gasphasedft8k_{n}_{m}_{n}_vib1"
98
+ if test_set == "isolated_chloroform":
99
+ return f"solutesmd500isolated_chloroform_{n}_{m}_{n}"
100
+ raise ValueError(f"unknown test set {test_set!r}")
101
+
102
+
103
+ def exact_stats_table(predictions_path, decode_module):
104
+ """One row per (nucleus, model, test set), with (median_ae, mae, rmse, n) read directly from the
105
+ full released MagNET predictions -- no sampling, no live inference, so RMSE reproduces exactly
106
+ too (see the module docstring, path 1). `decode_module` is
107
+ data/magnet_test_predictions/magnet_test_predictions_reader.py; the caller imports and passes it
108
+ in, so this module carries no path dependency on data/magnet_test_predictions/."""
109
+ rows = []
110
+ for nucleus in NUCLEUS_Z:
111
+ for model in MODELS:
112
+ for test_set in TEST_SETS:
113
+ group = test_predictions_group(model, test_set, nucleus)
114
+ med, mae, rmse, n = decode_module.stats(predictions_path, group)
115
+ rows.append({"nucleus": nucleus, "model": model, "test_set": test_set,
116
+ "median_ae": med, "mae": mae, "rmse": rmse, "n": n})
117
+ return rows
118
+
119
+ # The elements MagNET was trained on (matches SUPPORTED_ELEMENTS in the magnet package). Structures
120
+ # with any other element (e.g. the phosphorus in dft8k) are excluded from the test sets: the model
121
+ # refuses them, and they cannot be fairly scored anyway.
122
+ SUPPORTED_ELEMENTS = frozenset({1, 6, 7, 8, 9, 16, 17})
123
+
124
+
125
+ def filter_supported(atomic_numbers_list, geometries_list, dft_list):
126
+ """Drop any structure whose atoms are not all in SUPPORTED_ELEMENTS. Returns the three lists,
127
+ filtered in lockstep, plus the number dropped."""
128
+ keep = [i for i, an in enumerate(atomic_numbers_list)
129
+ if set(np.unique(an).tolist()) <= SUPPORTED_ELEMENTS]
130
+ dropped = len(atomic_numbers_list) - len(keep)
131
+ return ([atomic_numbers_list[i] for i in keep],
132
+ [geometries_list[i] for i in keep],
133
+ [dft_list[i] for i in keep], dropped)
134
+
135
+
136
+ # --------------------------------------------------------------------------- error statistics
137
+
138
+ def collect_abs_errors(predicted_list, dft_list, atomic_numbers_list, z):
139
+ """Concatenate the absolute errors |predicted - dft| over all atoms with atomic number `z`,
140
+ across a list of test cases. Atoms where either value is NaN (not predicted or not computed) are
141
+ dropped."""
142
+ out = []
143
+ for pred, dft, an in zip(predicted_list, dft_list, atomic_numbers_list):
144
+ pred = np.asarray(pred, dtype=np.float64)
145
+ dft = np.asarray(dft, dtype=np.float64)
146
+ an = np.asarray(an)
147
+ mask = (an == z) & np.isfinite(pred) & np.isfinite(dft)
148
+ if mask.any():
149
+ out.append(np.abs(pred[mask] - dft[mask]))
150
+ return np.concatenate(out) if out else np.array([])
151
+
152
+
153
+ def summarize(abs_errors):
154
+ """Median absolute error, mean absolute error, RMSE, and count for an array of absolute errors."""
155
+ e = np.asarray(abs_errors, dtype=np.float64)
156
+ if e.size == 0:
157
+ return {"median_ae": float("nan"), "mae": float("nan"), "rmse": float("nan"), "n": 0}
158
+ return summarize_errors(e, np.zeros_like(e))
159
+
160
+
161
+ def error_table(predicted_list, dft_list, atomic_numbers_list):
162
+ """Summaries for both nuclei: {"1H": {...}, "13C": {...}}."""
163
+ return {
164
+ nucleus: summarize(collect_abs_errors(predicted_list, dft_list, atomic_numbers_list, z))
165
+ for nucleus, z in NUCLEUS_Z.items()
166
+ }
167
+
168
+
169
+ # --------------------------------------------------------------------------- test-case assembly
170
+ # Each function returns three parallel lists: atomic_numbers, geometry (n_atoms, 3), and the DFT
171
+ # reference shielding (n_atoms,), one entry per sampled structure. A model is then run over the
172
+ # geometries and compared with the DFT shieldings.
173
+
174
+ def cases_stationary_internal(sigma_shake, n_sample=2000, seed=0):
175
+ """Resting geometries of held-out GDB molecules (sigma-shake test split)."""
176
+ test_ids = sigma_shake.split("test")
177
+ rng = np.random.default_rng(seed)
178
+ chosen = rng.choice(test_ids, size=min(n_sample, len(test_ids)), replace=False)
179
+ ans, geos, dfts = [], [], []
180
+ for mid in chosen:
181
+ m = sigma_shake.by_id(int(mid))
182
+ ans.append(m["atomic_numbers"])
183
+ geos.append(m["stationary_coords"])
184
+ dfts.append(m["shielding_stationary"])
185
+ return ans, geos, dfts
186
+
187
+
188
+ def cases_vibrated_internal(gdb_qcd, n_sample=2000, frames_per_molecule=4, seed=0):
189
+ """Molecular-dynamics frames of held-out GDB molecules (gdb_qcd)."""
190
+ rng = np.random.default_rng(seed)
191
+ mol_idx = rng.choice(gdb_qcd.n_molecules, size=min(n_sample, gdb_qcd.n_molecules), replace=False)
192
+ ans, geos, dfts = [], [], []
193
+ for i in mol_idx:
194
+ m = gdb_qcd.molecule(int(i))
195
+ nt, nf = m["coordinates"].shape[0], m["coordinates"].shape[1]
196
+ for _ in range(frames_per_molecule):
197
+ t, f = int(rng.integers(nt)), int(rng.integers(nf))
198
+ ans.append(m["atomic_numbers"])
199
+ geos.append(m["coordinates"][t, f])
200
+ dfts.append(m["shieldings"][t, f])
201
+ return ans, geos, dfts
202
+
203
+
204
+ def cases_vibrated_external(dft8k, n_sample=2000, seed=0):
205
+ """Perturbed (non-stationary) geometries of the dft8k benchmark; geometry 0 is the reference, 1
206
+ and 2 are perturbations, so the vibrated set uses geometries 1 and 2."""
207
+ rng = np.random.default_rng(seed)
208
+ n = dft8k.b3lyp.n_molecules
209
+ chosen = rng.choice(n, size=min(n_sample, n), replace=False)
210
+ ans, geos, dfts = [], [], []
211
+ for i in chosen:
212
+ m = dft8k.b3lyp.molecule(int(i))
213
+ for g in (1, 2):
214
+ ans.append(m["atomic_numbers"])
215
+ geos.append(m["coordinates"][g])
216
+ dfts.append(m["shielding"][g])
217
+ return ans, geos, dfts
218
+
219
+
220
+ # The isolated-chloroform test set is defined entirely from the released sigma-fresh file, so the
221
+ # table is reproducible without any external file. The foundation model's RMSE on this row is large
222
+ # (0.30 ppm 1H) because the error is heavy-tailed, not because of a few bad frames: the optional
223
+ # scrub in isolated_chloroform_testset removes the most strongly-deshielded frames and the RMSE
224
+ # barely moves (0.31 -> 0.30). Those frames are real geometries, not DFT failures, so the default
225
+ # test set keeps them.
226
+
227
+ CHLOROFORM_OUTLIER_SIGMA = 5 # cutoff used by the MagNET-x training pipeline (train_MagNET_X.py)
228
+
229
+
230
+ def _chloroform_test_solutes(sigma_fresh):
231
+ """Solute indices flagged as the held-out test split in the released sigma-fresh file. This
232
+ matches the paper's chloroform test split exactly (534 solutes)."""
233
+ group = sigma_fresh.f["chloroform"]
234
+ out = []
235
+ for name in group.keys():
236
+ kind = group[name].attrs["type"]
237
+ if isinstance(kind, bytes):
238
+ kind = kind.decode()
239
+ if kind == "test":
240
+ out.append(int(name.split("_")[1]))
241
+ return sorted(out)
242
+
243
+
244
+ def isolated_chloroform_testset(sigma_fresh, max_frame=10, n_sigma=CHLOROFORM_OUTLIER_SIGMA):
245
+ """Assemble the chloroform isolated-solute test set for Tables S3/S4, from released data only.
246
+
247
+ Each case is one isolated-solute snapshot: the solute geometry from a chloroform MD frame and the
248
+ isolated (no-solvent) DFT shielding computed for it. The set is defined as:
249
+
250
+ * test solutes : attrs['type'] == 'test' (the paper's held-out split; evaluating MagNET-x on
251
+ its training solutes would be data leakage).
252
+ * frames : frame_index 1..max_frame where the isolated shielding was actually computed.
253
+
254
+ The published Tables S3/S4 use the full set (ans_all/geos_all/dfts_all). `flagged` marks the most
255
+ strongly deshielded frames (~16-17 ppm isolated shielding, far-downfield O-H / N-H), found with the
256
+ same +/- n_sigma cut the MagNET-x training pipeline applies (train_MagNET_X.py), recomputed here
257
+ from the dataset's own shieldings. Their shieldings vary smoothly along each MD trajectory and sit
258
+ on a continuous low-shielding tail, so this split is a diagnostic for documentation, not a
259
+ correction: ans/geos/dfts (with those frames removed) exist only to show how much of the RMSE they
260
+ carry.
261
+
262
+ Returns a dict:
263
+ ans, geos, dfts -- test set with the deshielded-tail frames removed (diagnostic only)
264
+ ans_all, geos_all, dfts_all -- the full published test set (reproduces Tables S3/S4)
265
+ flagged -- sorted [(solute_index, frame_index), ...] in the deshielded tail
266
+ bands -- {'isolated': (lo, hi), 'solvated': (lo, hi)} n_sigma bands (ppm)
267
+ """
268
+ frames = []
269
+ iso_H, solv_H = [], []
270
+ for si in _chloroform_test_solutes(sigma_fresh):
271
+ s = sigma_fresh.solute("chloroform", si)
272
+ ns = s["n_solute_atoms"]
273
+ an = s["atomic_numbers"][:ns] # solute is the leading ns atoms
274
+ is_h = an == 1
275
+ iso = s["shielding_isolated"] # (n_frames, n_solute) ppm, NaN where not done
276
+ solv = s["shielding_solvated"]
277
+ coords = s["coordinates"]
278
+ for row in range(min(max_frame, iso.shape[0])): # row r -> frame_index r+1 (1-based)
279
+ sh = iso[row]
280
+ if not np.isfinite(sh).any():
281
+ continue
282
+ solv_row = solv[row]
283
+ frames.append({"solute": si, "frame": row + 1, "an": an,
284
+ "geo": coords[row][:ns], "iso": sh, "solv": solv_row, "is_h": is_h})
285
+ h = sh[is_h]
286
+ iso_H.append(h[np.isfinite(h)])
287
+ h = solv_row[is_h]
288
+ solv_H.append(h[np.isfinite(h)])
289
+ iso_H = np.concatenate(iso_H)
290
+ solv_H = np.concatenate(solv_H)
291
+ iso_band = (iso_H.mean() - n_sigma * iso_H.std(), iso_H.mean() + n_sigma * iso_H.std())
292
+ solv_band = (solv_H.mean() - n_sigma * solv_H.std(), solv_H.mean() + n_sigma * solv_H.std())
293
+
294
+ def is_outlier(fr):
295
+ h = fr["iso"][fr["is_h"]]
296
+ h = h[np.isfinite(h)]
297
+ if ((h < iso_band[0]) | (h > iso_band[1])).any():
298
+ return True
299
+ h = fr["solv"][fr["is_h"]]
300
+ h = h[np.isfinite(h)]
301
+ return bool(h.size and ((h < solv_band[0]) | (h > solv_band[1])).any())
302
+
303
+ out = {"ans": [], "geos": [], "dfts": [], "ans_all": [], "geos_all": [], "dfts_all": [],
304
+ "flagged": [], "bands": {"isolated": iso_band, "solvated": solv_band}}
305
+ for fr in frames:
306
+ out["ans_all"].append(fr["an"])
307
+ out["geos_all"].append(fr["geo"])
308
+ out["dfts_all"].append(fr["iso"])
309
+ if is_outlier(fr):
310
+ out["flagged"].append((fr["solute"], fr["frame"]))
311
+ else:
312
+ out["ans"].append(fr["an"])
313
+ out["geos"].append(fr["geo"])
314
+ out["dfts"].append(fr["iso"])
315
+ out["flagged"].sort()
316
+ return out
317
+
318
+
319
+ def cases_isolated_chloroform(sigma_fresh, scrub=False, max_frame=10):
320
+ """Isolated-solute snapshots from chloroform MD (sigma-fresh), as (ans, geos, dfts).
321
+
322
+ Default (scrub=False) is the full published test set, which reproduces Tables S3/S4. scrub=True
323
+ drops the strongly-deshielded-proton frames; that is a diagnostic, not a correction (those frames
324
+ are real geometries, not DFT failures). See isolated_chloroform_testset.
325
+ """
326
+ t = isolated_chloroform_testset(sigma_fresh, max_frame=max_frame)
327
+ return (t["ans"], t["geos"], t["dfts"]) if scrub else (t["ans_all"], t["geos_all"], t["dfts_all"])
analysis/code/magnet_benchmark_run.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Produce the magnet_benchmark results table by running MagNET and MagNET-x over the four test sets
2
+ and write it to performance_results.csv.
3
+
4
+ Needs the checkpoints and datasets, so it does not run in CI; they ship in the repo (Git LFS via the
5
+ Hugging Face checkout) and resolve automatically. One row per (model, test set, nucleus) with median
6
+ AE, MAE, RMSE, and atom count. The three vibrated/stationary sets are sampled; isolated-chloroform is
7
+ run in full. The shipped CSV used --sample 500 --seed 0; re-running reproduces it (sampled rows within
8
+ tolerance, isolated rows exactly).
9
+
10
+ Usage:
11
+ python magnet_benchmark_run.py --out performance_results.csv --n-passes 1 --sample 500 --seed 0
12
+ """
13
+ import argparse
14
+ import csv
15
+ import os
16
+ import sys
17
+ import time
18
+
19
+ from paths import ensure_on_path, repo_root, dataset_file
20
+
21
+ HERE = os.path.dirname(os.path.abspath(__file__))
22
+ GH = repo_root(__file__)
23
+ if HERE not in sys.path:
24
+ sys.path.insert(0, HERE)
25
+ ensure_on_path("analysis", "code", "shared", file=__file__) # stats.py etc. live here
26
+ ensure_on_path("magnet", file=__file__)
27
+ for _d in ("sigma-shake", "gdb_qcd", "dft8k", "sigma-fresh"):
28
+ ensure_on_path("data", _d, file=__file__)
29
+
30
+ import magnet_benchmark as M # noqa: E402
31
+ from run_magnet import compute_MagNET_foundation_shieldings, _predict_with # noqa: E402
32
+
33
+
34
+ def magnet_x_chloroform_shieldings(ans, geos, n_passes, device):
35
+ """Absolute shieldings from the MagNET-x chloroform model (run as a plain predictor, no solvent),
36
+ used to score MagNET-x on the same test sets as the foundation model."""
37
+ return _predict_with("MagNET-x-chloroform_H", "MagNET-x-chloroform_C", ans, geos,
38
+ n_passes=n_passes, device=device)
39
+
40
+
41
+ MODEL_RUNNERS = {
42
+ "MagNET": compute_MagNET_foundation_shieldings,
43
+ "MagNET-x": magnet_x_chloroform_shieldings,
44
+ }
45
+
46
+
47
+ def assemble_all(paths, sample, seed):
48
+ """Assemble the four test sets, returning {test_set: (ans, geos, dfts)}."""
49
+ from decode_sigma_shake import SigmaShake
50
+ from decode_gdb_qcd import GDBQCD
51
+ from dft8k_reader import DFT8k
52
+ from decode_sigma_fresh import SigmaFresh
53
+
54
+ raw = {}
55
+ with SigmaShake(paths["sigma_shake"]) as ss:
56
+ raw["stationary_internal"] = M.cases_stationary_internal(ss, n_sample=sample, seed=seed)
57
+ with GDBQCD(paths["gdb_qcd"]) as gq:
58
+ raw["vibrated_internal"] = M.cases_vibrated_internal(gq, n_sample=sample // 3, seed=seed)
59
+ with DFT8k(paths["dft8k"]) as d8:
60
+ raw["vibrated_external"] = M.cases_vibrated_external(d8, n_sample=sample, seed=seed)
61
+ with SigmaFresh(paths["sigma_fresh"]) as sf:
62
+ # the full published test set (held-out solutes, frames 1..10 with an isolated calculation);
63
+ # not sampled, so this row reproduces Tables S3/S4 exactly
64
+ raw["isolated_chloroform"] = M.cases_isolated_chloroform(sf, scrub=False)
65
+ # exclude molecules with elements outside MagNET's vocabulary (e.g. phosphorus in dft8k); the
66
+ # model would refuse them anyway, and scoring them would not be a fair test
67
+ cases = {}
68
+ for ts, (ans, geos, dfts) in raw.items():
69
+ a, g, d, dropped = M.filter_supported(ans, geos, dfts)
70
+ if dropped:
71
+ print(f" {ts}: dropped {dropped} structure(s) with unsupported elements", flush=True)
72
+ cases[ts] = (a, g, d)
73
+ return cases
74
+
75
+
76
+ def main():
77
+ """Assemble the four test sets, run both models over them, and write performance_results.csv."""
78
+ ap = argparse.ArgumentParser()
79
+ ap.add_argument("--out", default=os.path.join(HERE, "..", "..", "data", "magnet_benchmark", "performance_results.csv"))
80
+ ap.add_argument("--n-passes", type=int, default=1)
81
+ ap.add_argument("--sample", type=int, default=500)
82
+ ap.add_argument("--seed", type=int, default=0)
83
+ ap.add_argument("--device", default="cpu")
84
+ ap.add_argument("--sigma-shake", default=dataset_file("sigma-shake", root=GH))
85
+ ap.add_argument("--gdb-qcd", default=dataset_file("gdb_qcd", root=GH))
86
+ ap.add_argument("--dft8k", default=dataset_file("dft8k", root=GH))
87
+ ap.add_argument("--sigma-fresh", default=dataset_file("sigma-fresh", root=GH))
88
+ args = ap.parse_args()
89
+
90
+ paths = {"sigma_shake": args.sigma_shake, "gdb_qcd": args.gdb_qcd,
91
+ "dft8k": args.dft8k, "sigma_fresh": args.sigma_fresh}
92
+ print(f"assembling test cases (sample={args.sample}, seed={args.seed})...", flush=True)
93
+ cases = assemble_all(paths, args.sample, args.seed)
94
+ for ts, (ans, _, _) in cases.items():
95
+ print(f" {ts}: {len(ans)} structures", flush=True)
96
+
97
+ rows = []
98
+ for model, runner in MODEL_RUNNERS.items():
99
+ for ts in M.TEST_SETS:
100
+ ans, geos, dfts = cases[ts]
101
+ t = time.time()
102
+ pred = runner(ans, geos, args.n_passes, args.device)
103
+ tab = M.error_table(pred, dfts, ans)
104
+ dt = time.time() - t
105
+ for nucleus in ("1H", "13C"):
106
+ s = tab[nucleus]
107
+ rows.append(dict(model=model, test_set=ts, nucleus=nucleus,
108
+ median_ae=s["median_ae"], mae=s["mae"], rmse=s["rmse"], n=s["n"]))
109
+ print(f"{model:9s} {ts:20s} ({dt:.0f}s) "
110
+ f"1H mae {tab['1H']['mae']:.4f} 13C mae {tab['13C']['mae']:.4f}", flush=True)
111
+
112
+ with open(args.out, "w", newline="") as f:
113
+ w = csv.DictWriter(f, fieldnames=["model", "test_set", "nucleus", "median_ae", "mae", "rmse", "n"])
114
+ w.writeheader()
115
+ for r in rows:
116
+ w.writerow(r)
117
+ print(f"wrote {args.out} ({len(rows)} rows)")
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
analysis/code/nb_build.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shared helpers for the ``build_nb_*.py`` notebook generators.
2
+
3
+ Each ``build_nb_<x>.py`` assembles a notebook from a list of cells built with :func:`md` and
4
+ :func:`code`, then writes it with :func:`save_notebook`. The notebook metadata written here is
5
+ intentionally minimal -- ``jupyter nbconvert --execute`` fills in the full ``language_info``/kernel
6
+ block when the notebook is run.
7
+ """
8
+ import nbformat
9
+
10
+
11
+ def md(source):
12
+ """Return a markdown cell from a string, with leading/trailing blank lines stripped."""
13
+ return nbformat.v4.new_markdown_cell(source.strip("\n"))
14
+
15
+
16
+ def code(source):
17
+ """Return a code cell from a string, with leading/trailing blank lines stripped."""
18
+ return nbformat.v4.new_code_cell(source.strip("\n"))
19
+
20
+
21
+ def save_notebook(cells, path):
22
+ """Write a notebook made of ``cells`` to ``path`` and print a confirmation line."""
23
+ nb = nbformat.v4.new_notebook()
24
+ nb.cells = cells
25
+ nb.metadata = {"language_info": {"name": "python"}}
26
+ with open(path, "w") as f:
27
+ nbformat.write(nb, f)
28
+ print("wrote", path)
analysis/code/pareto_plot.py ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Broken-axis accuracy-vs-cost Pareto panel used by Figure 2A and SI Figure S1.
2
+
3
+ Both figures draw the same panel (fitting RMSE vs total compute time, colored by method type, shaped by
4
+ geometry source, sized by basis) and differ only in their config dicts and which panels they show, so
5
+ that shared drawing engine lives here instead of being duplicated in each notebook. Call
6
+ `plot_pareto_panel(...)`; the notebook supplies the points table and the styling dicts.
7
+ """
8
+ import numpy as np
9
+ import seaborn as sns
10
+ import matplotlib.pyplot as plt
11
+ from matplotlib.lines import Line2D
12
+ from matplotlib.gridspec import GridSpec
13
+
14
+ # methods colored as double hybrids; dlpno_mp2 is classified ab initio instead (checked first)
15
+ DOUBLE_HYBRID_METHODS = ["dsd_pbep86", "B2GP_PLYP", "B2PLYP", "mPW2PLYP", "revdsd_pbep86", "dlpno_mp2"]
16
+
17
+
18
+ def _format_seconds(seconds):
19
+ if not np.isfinite(seconds) or seconds <= 0:
20
+ return ""
21
+ nice = lambda v: str(int(round(v)))
22
+ if seconds < 60:
23
+ return f"{nice(seconds)} s"
24
+ if seconds < 3600:
25
+ return f"{nice(seconds / 60)} min"
26
+ if seconds < 86400:
27
+ return f"{nice(seconds / 3600)} h"
28
+ return f"{nice(seconds / 86400)} day"
29
+
30
+
31
+ def _apply_time_xticklabels(ax, xticks):
32
+ ax.set_xticks(xticks)
33
+ ax.set_xticklabels([_format_seconds(10 ** float(t)) for t in xticks], fontsize=10)
34
+
35
+
36
+ def _classify_method(method):
37
+ # returns (label_text, category); category drives color, label_text is the annotation
38
+ if method in ["hf", "mp2", "dlpno_mp2"]:
39
+ return method, "ab initio"
40
+ if method in DOUBLE_HYBRID_METHODS:
41
+ return method.lower(), "double hybrids"
42
+ if method.startswith("MagNET"):
43
+ return "MagNET-Zero", "MagNET-Zero"
44
+ if method.startswith(("wp", "wc")):
45
+ return method, "NMR-specific"
46
+ return method.split("_")[0], "DFT"
47
+
48
+
49
+ def _classify_and_plot_points(df, x_range1, ax1, ax2, color_map, geometry_marker_map,
50
+ marker_alpha, manual_label_positions, labels_per_category=6):
51
+ size_dict = {"pcSseg1": 20, "pcSseg2": 50, "pcSseg3": 100, "N/A": 110}
52
+ color_counts = {k: 0 for k in ["ab initio", "double hybrids", "MagNET-Zero", "NMR-specific", "DFT"]}
53
+ basis_counts = {k: 0 for k in ["pcSseg1", "pcSseg2", "pcSseg3"]}
54
+ geometry_counts = {}
55
+
56
+ x_all = np.log10(df["total_time"].astype(float).values)
57
+ x_leftmost = np.nanmin(x_all) if len(x_all) else np.nan
58
+
59
+ # label selection: the min-RMSE point per category plus a few spread across time
60
+ tmp = df.copy()
61
+ tmp["_log10_time"] = np.log10(tmp["total_time"].astype(float))
62
+ tmp["_rmse"] = tmp["fitting_RMSE"].astype(float)
63
+ tmp["_category"] = tmp["nmr_method"].astype(str).apply(lambda m: _classify_method(m)[1])
64
+ label_indices = set()
65
+ for cat in ["MagNET-Zero", "ab initio", "double hybrids", "NMR-specific", "DFT"]:
66
+ sub = tmp[tmp["_category"] == cat]
67
+ if sub.empty:
68
+ continue
69
+ picks = [int(sub["_rmse"].idxmin())]
70
+ n_extra = max(0, labels_per_category - 1)
71
+ if n_extra > 0 and len(sub) > 1:
72
+ tvals = sub["_log10_time"].to_numpy()
73
+ for q in np.linspace(0.05, 0.95, num=n_extra):
74
+ picks.append(int((sub["_log10_time"] - np.quantile(tvals, q)).abs().idxmin()))
75
+ seen, unique = set(), []
76
+ for p in picks:
77
+ if p not in seen:
78
+ unique.append(p); seen.add(p)
79
+ label_indices.update(unique[:labels_per_category])
80
+
81
+ for idx, row in df.iterrows():
82
+ x_val = float(np.log10(float(row.total_time)))
83
+ y_val = float(row["fitting_RMSE"])
84
+ geometry_type = str(row["geometry_type"]); method_raw = str(row["nmr_method"]); basis = str(row["basis"])
85
+ label_text, category = _classify_method(method_raw)
86
+ color_counts[category] += 1
87
+ point_color = color_map[category]
88
+ marker = geometry_marker_map.get(geometry_type, "o")
89
+ geometry_counts[geometry_type] = geometry_counts.get(geometry_type, 0) + 1
90
+ if basis in basis_counts:
91
+ basis_counts[basis] += 1
92
+ base_size = size_dict.get(basis, size_dict.get("N/A", 30))
93
+ size = base_size * (2.8 if abs(x_val - x_leftmost) <= 1e-12 else 1.0)
94
+ ax = ax1 if (x_range1[0] <= x_val <= x_range1[1]) else ax2
95
+ unfilled = marker in ["x", "+", "1", "2", "3", "4", "|", "_"]
96
+ kws = dict(x=[x_val], y=[y_val], ax=ax, color=point_color, s=size, marker=marker,
97
+ legend=False, alpha=marker_alpha, zorder=3)
98
+ kws.update(dict(linewidth=1.2) if unfilled else dict(edgecolor="white", linewidth=0.6))
99
+ sns.scatterplot(**kws)
100
+
101
+ override = None
102
+ if manual_label_positions:
103
+ for key in [label_text, method_raw, (method_raw, basis),
104
+ (method_raw, basis, geometry_type), (label_text, basis, geometry_type)]:
105
+ if key in manual_label_positions:
106
+ override = manual_label_positions[key]; break
107
+ if (idx in label_indices) or (override is not None):
108
+ xytext = tuple(override["xytext"]) if isinstance(override, dict) else (4, 3)
109
+ ax.annotate(label_text, xy=(x_val, y_val), xytext=xytext, textcoords="offset points",
110
+ ha="left", va="bottom", fontsize=8, color=point_color, zorder=4,
111
+ annotation_clip=False)
112
+ return color_counts, basis_counts, geometry_counts
113
+
114
+
115
+ def _add_legends(ax1, ax2, color_counts, basis_counts, geometry_counts, color_map,
116
+ geometry_marker_map, method_legend_label_map, geometry_legend_label_map):
117
+ method_order = ["MagNET-Zero", "ab initio", "double hybrids", "NMR-specific", "DFT"]
118
+ method_elems = [Line2D([0], [0], marker="o", color="w", markerfacecolor=color_map[c],
119
+ markeredgecolor="white", markeredgewidth=0.6, markersize=8, linestyle="None",
120
+ label=method_legend_label_map.get(c, c))
121
+ for c in method_order if color_counts.get(c, 0) > 0]
122
+ basis_elems = [Line2D([0], [0], marker="o", color="none", markerfacecolor="k", markersize=ms, label=b)
123
+ for b, ms in [("pcSseg1", 3), ("pcSseg2", 6), ("pcSseg3", 8)] if basis_counts.get(b, 0) > 0]
124
+ geometry_elems = []
125
+ for g in ["aimnet2", "pbe0_tz"]:
126
+ if geometry_counts.get(g, 0) > 0:
127
+ gm = geometry_marker_map.get(g, "o")
128
+ geometry_elems.append(Line2D([0], [0], marker=gm, color="k",
129
+ markerfacecolor="none" if gm == "x" else "k",
130
+ markeredgecolor="k", markersize=7, linestyle="None",
131
+ label=geometry_legend_label_map.get(g, g)))
132
+ ax1.add_artist(ax1.legend(handles=method_elems, loc="upper left", bbox_to_anchor=(0.005, 0.995),
133
+ frameon=True, fontsize=9, title="Method Type", title_fontsize=9,
134
+ borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
135
+ ax2.add_artist(ax2.legend(handles=basis_elems, loc="upper right", bbox_to_anchor=(0.995, 0.995),
136
+ frameon=True, fontsize=9, title="Basis Set", title_fontsize=9,
137
+ borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
138
+ if geometry_elems:
139
+ ax2.add_artist(ax2.legend(handles=geometry_elems, loc="upper right", bbox_to_anchor=(0.80, 0.995),
140
+ frameon=True, fontsize=9, title="Geometries", title_fontsize=9,
141
+ borderaxespad=0.0, handletextpad=0.4, labelspacing=0.3))
142
+
143
+
144
+ def plot_pareto_panel(pareto_df, query_str, nucleus, method_color_map, method_legend_label_map,
145
+ geometry_legend_label_map, geometry_marker_map, xlim_left, xlim_right, ylim,
146
+ figsize, marker_alpha, manual_label_positions, save_png=None,
147
+ suptitle="MagNET-Zero Extends a Flat Pareto Frontier",
148
+ supxlabel="Total delta-22 calculation time"):
149
+ """Draw the broken-axis accuracy-vs-cost Pareto panel and optionally save it as a PNG."""
150
+ sns.set_theme(style="ticks", context="paper", rc={"axes.grid": False})
151
+ query_df = pareto_df.query(query_str.format(nucleus=nucleus), engine="python").reset_index(drop=True)
152
+ default_color_map = {"ab initio": "black", "double hybrids": "#ede6ba", "MagNET-Zero": "#61a89a",
153
+ "NMR-specific": "#a72608", "DFT": "black"}
154
+ color_map = {**default_color_map, **method_color_map}
155
+
156
+ fig = plt.figure(figsize=figsize)
157
+ gs = GridSpec(1, 2, width_ratios=[1, 5], wspace=0.05)
158
+ ax1 = fig.add_subplot(gs[0]); ax2 = fig.add_subplot(gs[1], sharey=ax1)
159
+
160
+ cc, bc, gc = _classify_and_plot_points(query_df, xlim_left, ax1, ax2, color_map,
161
+ geometry_marker_map, marker_alpha, manual_label_positions)
162
+
163
+ ax1.set_xlim(xlim_left); ax2.set_xlim(xlim_right)
164
+ ax1.set_ylim(ylim); ax2.set_ylim(ylim)
165
+ y_ticks = np.linspace(ylim[0], ylim[1], num=6)
166
+ ax1.set_yticks(y_ticks); ax1.set_yticklabels([f"{t:.2f}" for t in y_ticks], fontsize=10)
167
+
168
+ _apply_time_xticklabels(ax1, np.array([xlim_left[0], (xlim_left[0] + xlim_left[1]) / 2, xlim_left[1]]))
169
+ _apply_time_xticklabels(ax2, np.arange(xlim_right[0] + 0.25, xlim_right[1] + 0.25, 0.25))
170
+ ax1.tick_params(axis="both", which="both", length=1.5, width=0.6, labelsize=10)
171
+ ax2.tick_params(axis="x", which="both", length=1.5, width=0.6, labelsize=10)
172
+ ax2.tick_params(axis="y", which="both", left=False, right=False, labelleft=False)
173
+ ax1.grid(False); ax2.grid(False)
174
+
175
+ for ax in (ax1, ax2):
176
+ for side in ("left", "right", "top", "bottom"):
177
+ ax.spines[side].set_visible(True); ax.spines[side].set_linewidth(0.6)
178
+ ax1.spines["right"].set_visible(False); ax2.spines["left"].set_visible(False)
179
+
180
+ # broken-axis break marks
181
+ width_ratio = ax2.get_position().width / ax1.get_position().width
182
+ d1 = 0.01; d2 = d1 / width_ratio; slope2 = width_ratio
183
+ kw = dict(color="k", clip_on=False, lw=0.6, transform=ax1.transAxes)
184
+ ax1.plot((1 - d1, 1 + d1), (-d1, d1), **kw); ax1.plot((1 - d1, 1 + d1), (1 - d1, 1 + d1), **kw)
185
+ kw.update(transform=ax2.transAxes)
186
+ ax2.plot((-d2, d2), (-d2 * slope2, d2 * slope2), **kw); ax2.plot((-d2, d2), (1 - d2 * slope2, 1 + d2 * slope2), **kw)
187
+
188
+ fig.subplots_adjust(left=0.15, right=0.95, top=0.92, bottom=0.12)
189
+ fig.suptitle(suptitle, fontsize=14)
190
+ fig.supxlabel(supxlabel, fontsize=13)
191
+ ax1.set_ylabel(r"RMSE ($^{1}$H ppm, CDCl$_3$)" if nucleus == "H" else r"RMSE ($^{13}$C ppm, CDCl$_3$)",
192
+ fontsize=13)
193
+
194
+ _add_legends(ax1, ax2, cc, bc, gc, color_map, geometry_marker_map,
195
+ method_legend_label_map, geometry_legend_label_map)
196
+ if save_png:
197
+ fig.savefig(save_png, dpi=300, bbox_inches="tight")
198
+ plt.show()
analysis/code/paths.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repo-root and sys.path resolution for the analysis code.
2
+
3
+ Bootstrap-only: imports nothing from analysis/code/ (it is what makes that folder importable), so it
4
+ stays at analysis/code/ root. repo_root(file) assumes the caller sits two dirs below the repo root
5
+ (analysis/code/x.py -> ../..), which holds for every module here.
6
+ """
7
+ import os
8
+ import sys
9
+
10
+ def repo_root(file):
11
+ """The release repo root, given a module's __file__."""
12
+ return os.path.abspath(os.path.join(os.path.dirname(os.path.abspath(file)), "..", ".."))
13
+
14
+
15
+ def dataset_file(name, filename=None, *, file=None, root=None):
16
+ """Absolute path to a large released dataset file, in the repo at data/<name>/<filename>.
17
+
18
+ `filename` defaults to "<name>.hdf5". Give `root` (the repo root) or `file` (a module __file__,
19
+ from which the repo root is derived) so the file can be located. Only the large, git-ignored
20
+ data files go through here; small companion files (readers, spreadsheets) always live in the
21
+ repo and are addressed directly. The Hugging Face checkout carries these files via Git LFS.
22
+ """
23
+ if filename is None:
24
+ filename = "{}.hdf5".format(name)
25
+ if root is None:
26
+ if file is None:
27
+ raise ValueError(
28
+ "dataset_file needs root=<repo root> or file=__file__ to locate {!r}".format(filename)
29
+ )
30
+ root = repo_root(file)
31
+ return os.path.join(root, "data", name, filename)
32
+
33
+
34
+ def checkpoints_root(*, required=False):
35
+ """Absolute path to the in-repo model_checkpoints/ folder holding the trained weights.
36
+
37
+ Returns the path when it exists, else None so callers can skip checkpoint-dependent work, unless
38
+ required=True, which raises. The Hugging Face checkout carries the weights via Git LFS; they are
39
+ git-ignored on GitHub, so this is None in CI.
40
+ """
41
+ in_repo = os.path.join(repo_root(__file__), "model_checkpoints")
42
+ if os.path.exists(in_repo):
43
+ return in_repo
44
+ if required:
45
+ raise RuntimeError(
46
+ "No model_checkpoints/ found. It ships in the repo via Git LFS in the Hugging Face "
47
+ "checkout (hf download ekwan16/MagNET --include 'model_checkpoints/*')."
48
+ )
49
+ return None
50
+
51
+
52
+ def ensure_on_path(*relative_parts, file=None, root=None):
53
+ """Inserts os.path.join(root, *relative_parts) at the front of sys.path, if not already present.
54
+
55
+ Pass either `file` (typically __file__, resolved via repo_root) or `root` directly.
56
+ """
57
+ if root is None:
58
+ if file is None:
59
+ raise ValueError("ensure_on_path needs either file=__file__ or root=<repo root>")
60
+ root = repo_root(file)
61
+ target = os.path.join(root, *relative_parts)
62
+ if target not in sys.path:
63
+ sys.path.insert(0, target)
64
+ return target
analysis/code/scaling_factors.py ADDED
@@ -0,0 +1,220 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Recommended Linear Scaling Parameters for MagNET-Zero / MagNET-PCM (SI Tables S10 and S11).
2
+
3
+ Turns MagNET-Zero shieldings into chemical shifts via a per-solvent linear model:
4
+
5
+ shift = c_intercept + c_stationary * sigma_zero + c_pcm * delta_pcm
6
+
7
+ where sigma_zero is the MagNET-Zero gas-phase shielding and delta_pcm the MagNET-PCM chloroform
8
+ correction. Proton (S10) uses a THREE-parameter model (all coefficients free) because for benzene and
9
+ toluene the PCM correction points the wrong way and only a free c_pcm can go positive. Carbon (S11)
10
+ uses a TWO-parameter model: the gas and PCM terms share one per-solvent slope (PCM scaled to solvent
11
+ by a conversion factor first), which fit held-out carbons slightly better; the reported `pcm` is that
12
+ slope times the conversion factor.
13
+
14
+ Everything reproduces from the released delta-22 data to the precision of its integer encoding
15
+ (`test_scaling_factors.py` locks it down). `build_scaling_tables(..., symmetrized=True)` instead
16
+ computes a separate deployment variant via reflection-symmetrized inference
17
+ (`scaling_factors_symmetrized.py`); it does not match these tables and is not what the SI reports.
18
+ """
19
+ import os
20
+ import sys
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+
25
+ from paths import repo_root, ensure_on_path, dataset_file
26
+
27
+ # paths.py is the bootstrap at analysis/code/ root; the other shared utilities (stats, etc.) live in
28
+ # analysis/code/shared/, and the delta-22 reader/harness is reused from data/delta22. Put all of
29
+ # these on sys.path before importing them, so scaling_factors can be imported standalone (as its
30
+ # docstring invites), not only under pytest/notebooks that already pre-wire the paths.
31
+ _HERE = os.path.dirname(os.path.abspath(__file__))
32
+ _REPO = repo_root(__file__)
33
+ if _HERE not in sys.path:
34
+ sys.path.insert(0, _HERE)
35
+ ensure_on_path("analysis", "code", "shared", file=__file__)
36
+ ensure_on_path("data", "delta22", file=__file__)
37
+
38
+ from stats import linear_fit_1d # noqa: E402
39
+ import delta22 as D # noqa: E402
40
+
41
+ # The 12 solvents, in the reader's native naming (water is stored as "TIP4P").
42
+ SOLVENTS = tuple(D.DESMOND_SOLVENTS)
43
+
44
+ # Nitromethane is an outlier for both nuclei (its carbon shift is anomalous); the published tables
45
+ # reproduce only with it excluded throughout.
46
+ EXCLUDE_SOLUTES = ("nitromethane",)
47
+
48
+ # The model form recommended for each nucleus.
49
+ RECOMMENDED_MODEL = {"H": "three_parameter", "C": "two_parameter"}
50
+
51
+ _COLUMNS = ["intercept", "stationary", "pcm"]
52
+
53
+ # The published SI Tables S10 (proton) and S11 (carbon), shipped verbatim so you can convert
54
+ # shieldings to shifts WITHOUT the delta-22 data download. These are exactly what
55
+ # build_scaling_tables() reproduces from delta-22; test_scaling_factors.py asserts the two agree, so
56
+ # they cannot silently drift. Prefer published_scaling_tables() for a quick lookup, and
57
+ # build_scaling_tables() when you want to re-derive them from the raw data.
58
+ _PUBLISHED_TABLE_CSV = {
59
+ "H": """solvent,intercept,stationary,pcm
60
+ tetrahydrofuran,31.321785,-0.980175,-0.786672
61
+ dichloromethane,31.377310,-0.979870,-0.808573
62
+ chloroform,31.294992,-0.975794,-0.852690
63
+ toluene,31.716957,-0.996733,1.944856
64
+ benzene,31.987569,-1.005289,2.236515
65
+ chlorobenzene,31.681438,-0.993846,0.830236
66
+ acetone,31.512157,-0.987261,-1.238414
67
+ dimethylsulfoxide,31.598698,-0.991103,-1.355248
68
+ acetonitrile,31.496110,-0.985716,-0.974465
69
+ trifluoroethanol,30.876004,-0.959997,-0.958852
70
+ methanol,31.256030,-0.976446,-1.250121
71
+ TIP4P,31.359134,-0.978809,-1.478298
72
+ """,
73
+ "C": """solvent,intercept,stationary,pcm
74
+ tetrahydrofuran,171.054488,-0.919000,-1.069509
75
+ dichloromethane,171.509389,-0.921167,-1.115015
76
+ chloroform,171.728797,-0.924231,-0.936804
77
+ toluene,171.690907,-0.924987,-0.618410
78
+ benzene,171.967177,-0.927033,-0.593716
79
+ chlorobenzene,171.075910,-0.921238,-0.997641
80
+ acetone,171.308023,-0.919610,-1.239503
81
+ dimethylsulfoxide,170.598425,-0.918674,-1.296501
82
+ acetonitrile,171.879839,-0.922629,-1.287664
83
+ trifluoroethanol,174.237671,-0.939012,-1.290073
84
+ methanol,172.426161,-0.927566,-1.288847
85
+ TIP4P,173.696379,-0.937854,-1.342668
86
+ """,
87
+ }
88
+
89
+
90
+ def published_scaling_tables():
91
+ """The published SI scaling tables, {"H": Table S10, "C": Table S11}, as solvent-indexed
92
+ DataFrames. No data download needed; use these with predict_shift to turn MagNET-Zero/PCM
93
+ shieldings into chemical shifts."""
94
+ import io
95
+ return {nucleus: pd.read_csv(io.StringIO(csv)).set_index("solvent")
96
+ for nucleus, csv in _PUBLISHED_TABLE_CSV.items()}
97
+
98
+
99
+ def proton_scaling_table(query_df_nn, solvents=SOLVENTS, exclude_solutes=EXCLUDE_SOLUTES):
100
+ """SI Table S10: per-solvent [intercept, stationary, pcm] for the proton three-parameter model.
101
+
102
+ Fits experimental ~ stationary + pcm freely for each solvent on the MagNET-Zero shielding and the
103
+ MagNET-PCM (chloroform) correction. Returns a DataFrame indexed by solvent.
104
+ """
105
+ nn = query_df_nn[(query_df_nn["nucleus"] == "H")
106
+ & ~query_df_nn["solute"].isin(set(exclude_solutes))]
107
+ coeffs = D.full_fit_coefficients(nn, list(solvents), "stationary + pcm")
108
+ table = coeffs.T.rename(columns={"Intercept": "intercept"})[_COLUMNS]
109
+ table.index.name = "solvent"
110
+ return table
111
+
112
+
113
+ def carbon_conversion_factors(query_df_dft):
114
+ """The per-solvent PCM conversion factor for carbon (delta22.pcm_conversion_factors): for each
115
+ solvent, the through-the-origin slope of that solvent's PCM correction (at the level MagNET-Zero
116
+ reproduces, wB97X-D) against chloroform's B3LYP-D3(BJ) correction (the level MagNET-PCM
117
+ reproduces), from the delta-22 DFT PCM data. So one factor converts both chloroform to the solvent
118
+ and B3LYP to wB97X-D. A Series indexed by solvent."""
119
+ return D.pcm_conversion_factors(query_df_dft, "C")
120
+
121
+
122
+ def carbon_scaling_table(query_df_nn, query_df_dft=None, solvents=SOLVENTS,
123
+ exclude_solutes=EXCLUDE_SOLUTES, conversion_factors=None):
124
+ """SI Table S11: per-solvent [intercept, stationary, pcm] for the carbon two-parameter model.
125
+
126
+ For each solvent, fits experimental ~ (stationary + factor * pcm) with a single shared slope,
127
+ where `factor` is the per-solvent PCM conversion factor. The reported `stationary` is that slope
128
+ and the reported `pcm` is slope * factor, so the prediction equation in the module docstring
129
+ applies with MagNET-PCM's chloroform correction. The factors come from query_df_dft
130
+ (carbon_conversion_factors) unless passed in as `conversion_factors` (a Series or dict). A solvent
131
+ with fewer than two usable sites, or an undefined conversion factor, gets a row of NaN rather than
132
+ a fake zero fit. Returns a DataFrame indexed by solvent.
133
+ """
134
+ if conversion_factors is None:
135
+ if query_df_dft is None:
136
+ raise ValueError("provide query_df_dft or conversion_factors")
137
+ conversion_factors = carbon_conversion_factors(query_df_dft)
138
+ nn = query_df_nn[(query_df_nn["nucleus"] == "C")
139
+ & ~query_df_nn["solute"].isin(set(exclude_solutes))]
140
+ rows = {}
141
+ for solvent in solvents:
142
+ factor = float(conversion_factors[solvent])
143
+ sub = nn[nn["solvent"] == solvent]
144
+ y = pd.to_numeric(sub["experimental"], errors="coerce").to_numpy(float)
145
+ x = sub["stationary"].to_numpy(float) + factor * sub["pcm"].to_numpy(float)
146
+ keep = np.isfinite(x) & np.isfinite(y)
147
+ if not np.isfinite(factor) or keep.sum() < 2:
148
+ rows[solvent] = {"intercept": np.nan, "stationary": np.nan, "pcm": np.nan}
149
+ continue
150
+ intercept, slope = linear_fit_1d(x[keep], y[keep])
151
+ rows[solvent] = {"intercept": intercept, "stationary": slope, "pcm": slope * factor}
152
+ table = pd.DataFrame.from_dict(rows, orient="index")[_COLUMNS]
153
+ table.index.name = "solvent"
154
+ return table
155
+
156
+
157
+ def build_scaling_tables(delta22_path, experimental_path, symmetrized=False, n_passes=10):
158
+ """Reproduce both recommended-scaling tables from the released delta-22 data.
159
+
160
+ symmetrized=False (default) reproduces the published SI Tables S10/S11 exactly, using the
161
+ HDF5's stored MagNET-Zero/PCM shieldings -- this is what test_scaling_factors.py checks against
162
+ and what published_scaling_tables() ships.
163
+
164
+ symmetrized=True instead computes the shieldings via live, symmetrized inference
165
+ (predict_shieldings(..., n_passes=n_passes, symmetrize=True), see
166
+ scaling_factors_symmetrized.py), which corrects a reflection-parity bug in the stored HDF5
167
+ values (they come from a single unsymmetrized forward pass). Use this for a deployment-quality
168
+ scaling table -- e.g. serving MagNET-Zero/MagNET-PCM on new "everyday" molecules -- rather than
169
+ for reproducing the SI. Requires the magnet package and released model checkpoints; slower
170
+ (re-runs inference on all 22 solutes instead of reading pre-baked values).
171
+
172
+ Returns {"H": Table S10 DataFrame, "C": Table S11 DataFrame}.
173
+ """
174
+ if symmetrized:
175
+ import scaling_factors_symmetrized as _S
176
+ override_df = _S.compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=n_passes,
177
+ verbose=False)
178
+ nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False,
179
+ nn_shieldings_override_df=override_df)
180
+ else:
181
+ nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False)
182
+ dft = D.load_query_df_dft(delta22_path, experimental_path, verbose=False)
183
+ return {"H": proton_scaling_table(nn), "C": carbon_scaling_table(nn, dft)}
184
+
185
+
186
+ def predict_shift(table, solvent, magnet_zero_shielding, magnet_pcm_chloroform_correction):
187
+ """Apply a scaling table to MagNET-Zero / MagNET-PCM outputs to predict a chemical shift.
188
+
189
+ table: a proton or carbon scaling table (DataFrame indexed by solvent). Get one from
190
+ published_scaling_tables()[nucleus] (no data download) or build_scaling_tables(...)[nucleus]
191
+ (re-derived from delta-22).
192
+ solvent: the solvent name (water is "TIP4P").
193
+ magnet_zero_shielding: the MagNET-Zero gas-phase shielding (scalar or array).
194
+ magnet_pcm_chloroform_correction: MagNET-PCM's chloroform correction (scalar or array).
195
+
196
+ The same equation serves both nuclei because the stored `pcm` coefficient already folds in the
197
+ per-solvent scaling, so you always pass the chloroform correction.
198
+
199
+ End to end, from a geometry (the H/C shieldings each model returns are per-atom arrays):
200
+
201
+ from magnet.run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections
202
+ zero = compute_MagNET_Zero_shieldings([Z], [xyz])[0] # MagNET-Zero shieldings
203
+ pcm = compute_MagNET_PCM_corrections([Z], [xyz])[0] # MagNET-PCM chloroform correction
204
+ tables = published_scaling_tables()
205
+ carbons = Z == 6
206
+ shifts_13C = predict_shift(tables["C"], "benzene", zero[carbons], pcm[carbons])
207
+ """
208
+ row = table.loc[solvent]
209
+ return (row["intercept"]
210
+ + row["stationary"] * np.asarray(magnet_zero_shielding, dtype=float)
211
+ + row["pcm"] * np.asarray(magnet_pcm_chloroform_correction, dtype=float))
212
+
213
+
214
+ if __name__ == "__main__":
215
+ h5 = dataset_file("delta22", root=_REPO)
216
+ xlsx = os.path.join(_REPO, "data", "delta22", "delta22_experimental.xlsx")
217
+ tables = build_scaling_tables(h5, xlsx)
218
+ for nucleus, label in (("H", "Table S10 (1H)"), ("C", "Table S11 (13C)")):
219
+ print(f"\n=== {label}: {RECOMMENDED_MODEL[nucleus]} model ===")
220
+ print(tables[nucleus].round(6).to_string())
analysis/code/scaling_factors_symmetrized.py ADDED
@@ -0,0 +1,113 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Computes delta-22's MagNET-Zero / MagNET-PCM shieldings via live, symmetrized inference,
2
+ instead of reading the pre-baked (unsymmetrized, single-pass) values stored in delta22.hdf5.
3
+
4
+ Why: `data/delta22/delta22_reader.py::load_stationary_and_pcm_nn_shieldings` decodes
5
+ `nn_gas_shieldings`/`nn_pcm_corrections` straight out of the HDF5. Those were computed with a
6
+ single, unsymmetrized forward pass (see `magnet/run_magnet.py`'s module docstring: "The released
7
+ reference shieldings are NOT symmetrized"). Isotropic shielding is parity-even, so the SO(3)-only
8
+ backbone's un-symmetrized predictions carry a spurious reflection error that is not always
9
+ negligible, even on small molecules (delta-22's solutes are 6-22 atoms). `predict_shieldings(...,
10
+ symmetrize=True)` fixes this for free by averaging the prediction on the original geometry and its
11
+ mirror image.
12
+
13
+ This module produces a scaling-factor table meant for deployment (e.g. serving MagNET-Zero/PCM on
14
+ arbitrary "everyday" molecules), not a replacement for the SI's published Tables S10/S11: it is a
15
+ separate, corrected-methodology table for production use, computed the same way
16
+ `scaling_factors.build_scaling_tables(symmetrized=True)` does.
17
+ """
18
+ import contextlib
19
+ import os
20
+ import time
21
+
22
+ import h5py
23
+ import numpy as np
24
+ import pandas as pd
25
+
26
+ from paths import repo_root, ensure_on_path, checkpoints_root
27
+
28
+ _REPO = repo_root(__file__)
29
+ # _REPO itself must be on sys.path too: run_magnet.py does `from magnet import ...`, which needs
30
+ # the magnet/ package's PARENT directory on sys.path, not just magnet/ itself. Without this, the
31
+ # import only works by accident when something else (e.g. `pip install -e .`, or pytest's rootdir
32
+ # handling) has already put _REPO on sys.path.
33
+ ensure_on_path(root=_REPO)
34
+ ensure_on_path("magnet", file=__file__)
35
+ ensure_on_path("data", "delta22", file=__file__)
36
+
37
+ import run_magnet # noqa: E402
38
+ from run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections # noqa: E402
39
+ from delta22_reader import load_solutes, load_stationary_geometries # noqa: E402
40
+
41
+ @contextlib.contextmanager
42
+ def _resolved_checkpoints():
43
+ """Temporarily points run_magnet.MODEL_CHECKPOINTS at absolute paths, restoring the original
44
+ (relative-path) dict afterward -- run_magnet.MODEL_CHECKPOINTS is a shared module-level dict,
45
+ and other code importing run_magnet in the same process expects the original relative paths.
46
+ Its values already start with "model_checkpoints/", so join each against the repo root."""
47
+ base = os.path.dirname(checkpoints_root(required=True))
48
+ original = run_magnet.MODEL_CHECKPOINTS
49
+ run_magnet.MODEL_CHECKPOINTS = {
50
+ key: os.path.join(base, rel_path) for key, rel_path in original.items()
51
+ }
52
+ try:
53
+ yield
54
+ finally:
55
+ run_magnet.MODEL_CHECKPOINTS = original
56
+
57
+ _NN_COLUMNS = ["solute", "sap_geometry_type", "sap_nmr_method", "sap_basis",
58
+ "solvent_model", "solvent", "shieldings", "geometry_time", "nmr_time"]
59
+
60
+
61
+ def _load_atomic_numbers(delta22_path, solute):
62
+ with h5py.File(delta22_path, "r") as f:
63
+ return np.array(f["solutes"][solute]["atomic_numbers"])
64
+
65
+
66
+ def _load_aimnet2_geometry_time(delta22_path, solute):
67
+ with h5py.File(delta22_path, "r") as f:
68
+ return float(np.array(f["solutes"][solute]["stationary_and_pcm"]["geometry_optimization_timings"])[0])
69
+
70
+
71
+ def compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=10, device=None, verbose=True):
72
+ """Same row shape as load_stationary_and_pcm_nn_shieldings (one "gas" + one "pcm_correction"
73
+ row per solute), but the shieldings come from live predict_shieldings(symmetrize=True)
74
+ inference instead of the HDF5's stored, unsymmetrized values.
75
+
76
+ n_passes=10 with symmetrize=True means 10 forward passes on the original geometry and 10 on
77
+ its mirror image, all 20 averaged together.
78
+
79
+ Returns a DataFrame with the same 9 columns load_stationary_and_pcm_nn_shieldings produces;
80
+ pass it as `nn_shieldings_override_df` to delta22_reader.load_delta22_nn_data.
81
+ """
82
+ solutes = load_solutes(delta22_path)
83
+ atomic_numbers_list = [_load_atomic_numbers(delta22_path, s) for s in solutes]
84
+ geometries_list = [load_stationary_geometries(delta22_path, s)["aimnet2"] for s in solutes]
85
+
86
+ if verbose:
87
+ print(f"computing symmetrized MagNET-Zero/PCM shieldings for {len(solutes)} solutes "
88
+ f"(n_passes={n_passes}, symmetrize=True)...", flush=True)
89
+
90
+ t0 = time.time()
91
+ with _resolved_checkpoints():
92
+ zero_shieldings = compute_MagNET_Zero_shieldings(
93
+ atomic_numbers_list, geometries_list, n_passes=n_passes, symmetrize=True, device=device)
94
+ t1 = time.time()
95
+ pcm_corrections = compute_MagNET_PCM_corrections(
96
+ atomic_numbers_list, geometries_list, n_passes=n_passes, symmetrize=True, device=device)
97
+ t2 = time.time()
98
+
99
+ if verbose:
100
+ print(f" MagNET-Zero: {t1-t0:.1f}s total, MagNET-PCM: {t2-t1:.1f}s total", flush=True)
101
+
102
+ zero_time_per_solute = (t1 - t0) / len(solutes)
103
+ pcm_time_per_solute = (t2 - t1) / len(solutes)
104
+
105
+ rows = []
106
+ for solute, gas, pcm in zip(solutes, zero_shieldings, pcm_corrections):
107
+ geom_time = _load_aimnet2_geometry_time(delta22_path, solute)
108
+ rows.append([solute, "aimnet2", "MagNET", "N/A", "gas", "none",
109
+ np.asarray(gas, dtype=float), geom_time, zero_time_per_solute])
110
+ rows.append([solute, "aimnet2", "MagNET", "N/A", "pcm_correction", "chloroform",
111
+ np.asarray(pcm, dtype=float), geom_time, pcm_time_per_solute])
112
+
113
+ return pd.DataFrame(rows, columns=_NN_COLUMNS)
analysis/code/test_applications_analysis.py ADDED
@@ -0,0 +1,424 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/applications.py (the no-plotting analysis module).
2
+
3
+ Builds a tiny synthetic applications.hdf5 + spreadsheet so the correction math can be checked
4
+ in CI without the multi-hundred-megabyte release files.
5
+ """
6
+ import os
7
+ import sys
8
+
9
+ import numpy as np
10
+ import pandas as pd
11
+ import h5py
12
+ import pytest
13
+
14
+ HERE = os.path.dirname(os.path.abspath(__file__))
15
+ sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "applications"))
16
+ sys.path.insert(0, HERE)
17
+ import paths
18
+
19
+ from applications_reader import Applications, SHELL_SIZES, SOLVENTS # noqa: E402
20
+ import applications as A # noqa: E402
21
+
22
+ SCALE = 1e4
23
+ MISS = np.int32(-2147483648)
24
+
25
+
26
+ def test_distribution_shift_by_solvent_table_averages_per_solvent():
27
+ """distribution_shift_by_solvent_table averages the three coefficient-choice RMSEs per solvent,
28
+ and surfaces the water column as the biggest extrapolation gap (the S15 story)."""
29
+ remapped = A.FORMULA_REMAP["stationary_plus_qcd + openMM"]
30
+ per_solute = {"H": pd.DataFrame([
31
+ {"formula": remapped, "solvent": "chloroform", "solute": "a", "rmse": 0.10},
32
+ {"formula": remapped, "solvent": "chloroform", "solute": "b", "rmse": 0.20},
33
+ {"formula": remapped, "solvent": "TIP4P", "solute": "a", "rmse": 0.30},
34
+ {"formula": remapped, "solvent": "TIP4P", "solute": "b", "rmse": 0.50},
35
+ ])}
36
+ per_solvent = {"H": pd.DataFrame([
37
+ {"formula": remapped, "solvent": "chloroform", "rmse": 0.16},
38
+ {"formula": remapped, "solvent": "TIP4P", "rmse": 0.45},
39
+ ])}
40
+ bootstrap = pd.DataFrame([
41
+ {"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.14},
42
+ {"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.18},
43
+ {"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.60},
44
+ {"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.80},
45
+ ])
46
+ table = A.distribution_shift_by_solvent_table(per_solute, per_solvent, bootstrap, "H",
47
+ "stationary_plus_qcd + openMM")
48
+ assert list(table.index) == ["TIP4P", "chloroform"] # sorted; uppercase sorts before lowercase
49
+ assert list(table.columns) == ["Extrapolated from delta22", "Scaled to Test Set", "Scaled to Solute"]
50
+ assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.15)
51
+ assert table.loc["chloroform", "Scaled to Test Set"] == pytest.approx(0.16)
52
+ assert table.loc["chloroform", "Extrapolated from delta22"] == pytest.approx(0.16)
53
+ assert table.loc["TIP4P", "Scaled to Solute"] == pytest.approx(0.40)
54
+ assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.70)
55
+ chl_gap = table.loc["chloroform", "Extrapolated from delta22"] - table.loc["chloroform", "Scaled to Solute"]
56
+ water_gap = table.loc["TIP4P", "Extrapolated from delta22"] - table.loc["TIP4P", "Scaled to Solute"]
57
+ assert water_gap > chl_gap
58
+
59
+
60
+ def _enc(a):
61
+ a = np.asarray(a, dtype=np.float64)
62
+ nan = np.isnan(a)
63
+ out = np.round(np.where(nan, 0.0, a) * SCALE).astype(np.int32)
64
+ out[nan] = MISS
65
+ return out
66
+
67
+
68
+ @pytest.fixture
69
+ def loader(tmp_path):
70
+ """One solute (vomicine), 3 atoms, one H site over atoms 1+2, one C site over atom 3.
71
+ magnet_x is set so the solvated-minus-isolated correction is a known constant per shell."""
72
+ h5 = tmp_path / "applications.hdf5"
73
+ xlsx = tmp_path / "exp.xlsx"
74
+ with h5py.File(h5, "w") as f:
75
+ sg = f.create_group("solvents")
76
+ for sv in SOLVENTS:
77
+ sg.create_group(sv).create_dataset("atomic_numbers", data=np.array([6], np.int32))
78
+ g = f.create_group("vomicine")
79
+ g.create_dataset("atomic_numbers", data=np.array([1, 1, 6], np.int32))
80
+ mx = g.create_group("magnet_x")
81
+ for sv in SOLVENTS:
82
+ svg = mx.create_group(sv)
83
+ for k, shell in enumerate(SHELL_SIZES):
84
+ arr = np.zeros((4, 3, 2), dtype=np.float64)
85
+ arr[:, :, 0] = 10.0 # isolated
86
+ arr[:, :, 1] = 10.0 + 0.1 * (k + 1) # solvated -> correction = 0.1*(k+1)
87
+ svg.create_dataset(f"shell_{shell}", data=_enc(arr))
88
+ svg.create_dataset("stationary", data=_enc(np.full(3, 10.0)))
89
+ mz = g.create_group("magnet_zero")
90
+ mz.create_dataset("stationary", data=_enc(np.array([100.0, 101.0, 50.0])))
91
+ mz.create_dataset("pcm_correction", data=_enc(np.array([-1.0, -1.0, -2.0])))
92
+ q = g.create_group("qcd")
93
+ q.create_dataset("stationary", data=_enc(np.zeros((3, 4))))
94
+ q.create_dataset("trajectories", data=_enc(np.zeros((2, 3, 3, 4)))) # corr -> 0
95
+ cm = f.create_group("composite_model")
96
+ cmc = cm.create_group("pcm_conversion_factors")
97
+ # the real conversion table uses 'water' (renamed to TIP4P by build_query_df_nn), never both
98
+ rows = "solvent,pcm_conversion_factor\n" + "".join(
99
+ f"{s},1.0\n" for s in ["chloroform", "benzene", "methanol", "water"])
100
+ cmc.create_dataset("H", data=rows)
101
+ cmc.create_dataset("C", data=rows)
102
+ with pd.ExcelWriter(xlsx) as w:
103
+ pd.DataFrame({
104
+ "site": ["H", "1 C"], "nucleus": ["H", "C"],
105
+ "atom_numbers": ["1,2", "3"],
106
+ "chloroform": [1.0, 2.0], "benzene": [1.0, 2.0],
107
+ "methanol": [1.0, 2.0], "water": [1.0, 2.0],
108
+ }).to_excel(w, sheet_name="vomicine", index=False)
109
+ return Applications(h5, xlsx)
110
+
111
+
112
+ def test_shell_convergence_values(loader):
113
+ df = A.shell_convergence_corrections(loader, "vomicine", "H")
114
+ # one H site (uuid-prefixed) x 4 solvents
115
+ assert df.index.get_level_values("solvent").nunique() == len(SOLVENTS)
116
+ assert df.index.get_level_values("site").unique().tolist() == ["01 H"]
117
+ # correction for shell k (0-based) is exactly 0.1*(k+1) within the int32 quantum
118
+ row = df.xs("chloroform", level="solvent").iloc[0]
119
+ for k, shell in enumerate(SHELL_SIZES):
120
+ assert row[f"shell_{shell}"] == pytest.approx(0.1 * (k + 1), abs=5e-5)
121
+
122
+
123
+ def test_only_requested_nucleus(loader):
124
+ df = A.shell_convergence_corrections(loader, "vomicine", "H")
125
+ # C site must not appear in an H-nucleus convergence table
126
+ sites = df.index.get_level_values("site").unique().tolist()
127
+ assert all("C" not in s for s in sites)
128
+
129
+
130
+ def test_site_atom_indices():
131
+ assert A.site_atom_indices("1,2") == [0, 1]
132
+ assert A.site_atom_indices("21") == [20]
133
+
134
+
135
+ def test_fit_recovers_known_line():
136
+ # experimental = 2*stationary + 5 exactly -> intercept ~5, slope ~2, RMSE ~0
137
+ df = pd.DataFrame({"stationary": [1.0, 2.0, 3.0, 4.0], "experimental": [7.0, 9.0, 11.0, 13.0]})
138
+ rmse, params = A.fit(df, "stationary")
139
+ assert rmse < 1e-9
140
+ assert params["Intercept"] == pytest.approx(5.0, abs=1e-6)
141
+ assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
142
+
143
+
144
+ def test_fit_ignores_rows_with_no_experimental_value():
145
+ # one site has no experimental measurement (common in the real natural-products spreadsheet);
146
+ # it must not turn the whole RMSE into NaN, only be left out of the score
147
+ df = pd.DataFrame({
148
+ "stationary": [1.0, 2.0, 3.0, 4.0],
149
+ "experimental": [7.0, 9.0, 11.0, np.nan],
150
+ })
151
+ rmse, params = A.fit(df, "stationary")
152
+ assert rmse < 1e-9
153
+ assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
154
+
155
+
156
+ def test_fit_matches_statsmodels():
157
+ # the numpy fit must be numerically identical to the original statsmodels OLS oracle, across
158
+ # multi-term formulas and with missing values present (rows dropped identically by both).
159
+ rng = np.random.default_rng(7)
160
+ n = 60
161
+ df = pd.DataFrame({
162
+ "stationary": rng.normal(100, 20, n), "pcm": rng.normal(0, 2, n),
163
+ "qcd": rng.normal(0, 1, n), "openMM": rng.normal(0, 2, n),
164
+ })
165
+ df["experimental"] = (2.0 * df["stationary"] + 0.5 * df["pcm"] - 0.3 * df["qcd"]
166
+ + 1.0 + rng.normal(0, 1, n))
167
+ df.loc[[3, 17, 42], "experimental"] = np.nan # missing experimental values
168
+ df.loc[[8, 25], "pcm"] = np.nan # missing predictor values
169
+ for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd",
170
+ "stationary + pcm + qcd + openMM"]:
171
+ rmse, params = A.fit(df, formula)
172
+ s_rmse, s_params = A._fit_statsmodels(df, formula)
173
+ assert rmse == pytest.approx(s_rmse, abs=1e-8), formula
174
+ for name in s_params.index:
175
+ assert params[name] == pytest.approx(s_params[name], abs=1e-6), f"{formula}:{name}"
176
+
177
+
178
+ def test_build_query_df_nn(loader):
179
+ q = A.build_query_df_nn(loader)
180
+ expected = {"solute", "nucleus", "site", "solvent", "experimental", "stationary", "pcm",
181
+ "openMM", "openMM_vib", "qcd", "stationary_plus_pcm", "stationary_plus_qcd",
182
+ "stationary_plus_op_vib"}
183
+ assert expected.issubset(q.columns)
184
+ # H site covers atoms 1+2: stationary = mean(100, 101) = 100.5; pcm = mean(-1, -1) = -1;
185
+ # conversion factor 1.0 -> stationary_plus_pcm = 99.5
186
+ h = q[(q["nucleus"] == "H") & (q["solvent"] == "chloroform")].iloc[0]
187
+ assert h["stationary"] == pytest.approx(100.5, abs=1e-3)
188
+ assert h["stationary_plus_pcm"] == pytest.approx(99.5, abs=1e-3)
189
+ assert h["qcd"] == pytest.approx(0.0, abs=1e-3)
190
+
191
+
192
+ def test_apply_bootstrap_nan_handling():
193
+ # a missing coeff (openMM=NaN) contributes nothing; a present coeff with missing data -> NaN
194
+ coeffs = pd.DataFrame([{"solvent": "chloroform", "formula": "pcm2", "seed": 0,
195
+ "Intercept": 5.0, "stationary": 2.0, "openMM": np.nan}])
196
+ data = pd.DataFrame([
197
+ {"solute": "m", "nucleus": "H", "site": "a", "solvent": "chloroform",
198
+ "experimental": 7.0, "stationary": 1.0, "openMM": 9.0}, # -> 5 + 2*1 = 7
199
+ {"solute": "m", "nucleus": "H", "site": "b", "solvent": "chloroform",
200
+ "experimental": np.nan, "stationary": np.nan, "openMM": 9.0}, # stationary data NaN -> NaN
201
+ ])
202
+ out = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H").set_index("site")
203
+ assert out.loc["a", "predicted"] == pytest.approx(7.0)
204
+ assert np.isnan(out.loc["b", "predicted"])
205
+
206
+
207
+ def test_apply_bootstrap_and_solute_rmse():
208
+ # one formula/seed/solvent: predicted = 5 + 2*stationary; experimental matches -> RMSE 0
209
+ coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": ["pcm2"], "seed": [0],
210
+ "Intercept": [5.0], "stationary": [2.0]})
211
+ data = pd.DataFrame({
212
+ "solute": ["m", "m"], "nucleus": ["H", "H"], "site": ["01 H", "02 H"],
213
+ "solvent": ["chloroform", "chloroform"], "experimental": [7.0, 9.0], "stationary": [1.0, 2.0],
214
+ })
215
+ preds = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H")
216
+ assert np.allclose(preds["predicted"], preds["experimental"])
217
+ rmses = A.compute_solute_rmses(preds)
218
+ assert rmses["Bootstrap_RMSE"].iloc[0] == pytest.approx(0.0, abs=1e-9)
219
+
220
+
221
+ # --- SI Figure S15's "Fitting RMSE Comparisons" -----------------------------------------------
222
+
223
+ def _rmse_comparison_fixture():
224
+ """Two test-set solutes ("m", "n"), one solvent, formula "stationary + openMM". Each solute has
225
+ only 2 points against 3 free parameters, so a per-solute-only fit is always exact (RMSE 0) --
226
+ that part needs no special data. The 4 points are deliberately NOT co-planar in (stationary,
227
+ openMM, experimental) space (verified: an earlier version of this fixture picked co-planar
228
+ points by accident, which made the pooled "Scaled to Test Set" fit ALSO exact and silently
229
+ hid the "formula" column bug this fixture now catches -- see the FORMULA_REMAP note below), so
230
+ the pooled fit has nonzero RMSE and "Scaled to Test Set" != "Scaled to Solute". The bootstrap
231
+ coefficients are also chosen far from both fits, so all three columns differ."""
232
+ query_df_nn = pd.DataFrame([
233
+ {"solute": "m", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 10.0, "stationary": 5.0, "openMM": 1.0},
234
+ {"solute": "m", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 12.0, "stationary": 6.0, "openMM": 1.0},
235
+ {"solute": "n", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 20.0, "stationary": 5.0, "openMM": 2.0},
236
+ {"solute": "n", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 23.0, "stationary": 7.0, "openMM": 2.0},
237
+ ])
238
+ formula = "stationary + openMM"
239
+ per_solute = {"H": A.fit_formulas_per_solvent_and_solute(query_df_nn, [formula], ["chloroform"],
240
+ ["m", "n"], A.FORMULA_REMAP)}
241
+ all_solute = {"H": A.fit_formulas_per_solvent(query_df_nn, [formula], ["chloroform"], A.FORMULA_REMAP)}
242
+ # The real pipeline (applications.build_bootstrap_seed_coeffs) always stores the REMAPPED
243
+ # formula name in its "formula" column, same as per_solute_fits/per_solvent_fits above --
244
+ # fitting_rmse_comparison_table looks up bootstrap_rmses by the remapped name, so a coeffs
245
+ # frame built with the raw formula string would never match and "Extrapolated from delta22"
246
+ # would silently come out all-NaN instead of raising. Match that convention here.
247
+ remapped = A.FORMULA_REMAP[formula]
248
+ coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": [remapped], "seed": [0],
249
+ "Intercept": [0.0], "stationary": [1.0], "openMM": [1.0]})
250
+ preds = A.apply_bootstrap_params_to_full_dataset(query_df_nn, coeffs, nucleus="H")
251
+ bootstrap_rmses = A.compute_solute_rmses(preds)
252
+ return query_df_nn, per_solute, all_solute, bootstrap_rmses, formula
253
+
254
+
255
+ def test_scaled_to_test_set_per_solute_rmse_applies_one_pooled_fit_per_solute():
256
+ query_df_nn, _, all_solute, _, formula = _rmse_comparison_fixture()
257
+ out = A.scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute, "H", "chloroform", formula)
258
+ assert set(out["solute"]) == {"m", "n"}
259
+ # both solutes score under the SAME pooled coefficients, so their RMSEs need not be equal
260
+ assert out["Bootstrap_RMSE"].notna().all()
261
+
262
+
263
+ def test_fitting_rmse_comparison_table_has_all_three_columns_and_solutes():
264
+ query_df_nn, per_solute, all_solute, bootstrap_rmses, formula = _rmse_comparison_fixture()
265
+ table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses,
266
+ "H", "chloroform", formula)
267
+ assert set(table.columns) == {"Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22"}
268
+ assert set(table.index) == {"m", "n"}
269
+ assert table.notna().all().all() # a formula-remapping mismatch would leave a column all-NaN
270
+ # "Scaled to Solute" fits each solute alone with 2 free params on 2 points -> exact, RMSE 0
271
+ assert table["Scaled to Solute"].max() < 1e-8
272
+ # the fixture's points are deliberately non-coplanar (see _rmse_comparison_fixture), so the
273
+ # pooled fit is NOT exact and genuinely differs from the per-solute fit
274
+ assert table["Scaled to Test Set"].max() > 1e-3
275
+ # the bootstrap coefficients are far from either fit, so this column differs from both
276
+ assert (table["Extrapolated from delta22"] - table["Scaled to Solute"]).abs().min() > 0.1
277
+
278
+
279
+ @pytest.mark.skipif(
280
+ not os.path.exists(paths.dataset_file("applications", file=__file__)),
281
+ reason="real applications.hdf5 not present")
282
+ def test_fitting_rmse_comparison_table_reproduces_published_chloroform_values():
283
+ """Every published "Fitting RMSE Comparisons (chloroform, H)" value (SI Figure S15), read
284
+ directly off the SI, reproduced from the released data. Cross-checks the delta-22-bootstrap
285
+ ("Extrapolated") column at a slightly looser tolerance since it averages over random seeds."""
286
+ real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
287
+ loader = Applications(paths.dataset_file("applications", file=__file__),
288
+ os.path.join(real_data_dir, "applications_experimental.xlsx"))
289
+ query_df_nn = A.build_query_df_nn(loader)
290
+ seed = A.build_bootstrap_seed_coeffs(loader)
291
+ per_solute = A.per_solute_fits(query_df_nn)
292
+ all_solute = A.per_solvent_fits(query_df_nn)
293
+ preds_h = A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H")
294
+ bootstrap_rmses_h = A.compute_solute_rmses(preds_h)
295
+ table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses_h,
296
+ "H", "chloroform", "stationary_plus_qcd + openMM")
297
+ # (solute, column) -> published value, read off the SI Figure S15 bar chart
298
+ published = {
299
+ ("isomer_1E", "Scaled to Solute"): 0.138, ("isomer_1E", "Scaled to Test Set"): 0.138,
300
+ ("vomicine", "Scaled to Solute"): 0.173, ("vomicine", "Scaled to Test Set"): 0.183,
301
+ ("prednisone", "Scaled to Solute"): 0.130, ("prednisone", "Scaled to Test Set"): 0.153,
302
+ ("flavone", "Scaled to Solute"): 0.021, ("flavone", "Scaled to Test Set"): 0.062,
303
+ }
304
+ for (solute, col), expected in published.items():
305
+ assert table.loc[solute, col] == pytest.approx(expected, abs=3e-3), f"{solute}/{col}"
306
+ published_extrapolated = {"isomer_1E": 0.146, "vomicine": 0.180, "prednisone": 0.166, "flavone": 0.060}
307
+ for solute, expected in published_extrapolated.items():
308
+ assert table.loc[solute, "Extrapolated from delta22"] == pytest.approx(expected, abs=0.02), solute
309
+
310
+
311
+ @pytest.mark.skipif(
312
+ not os.path.exists(paths.dataset_file("applications", file=__file__)),
313
+ reason="real applications.hdf5 not present")
314
+ def test_distribution_shift_by_solvent_table_flags_water_on_real_data():
315
+ """The per-solvent distribution-shift check on real data: for protons, TIP4P (water) must show
316
+ the largest 'Extrapolated from delta22' minus 'Scaled to Solute' gap -- the exact claim the
317
+ function's docstring and SI Figure S15's narrative make (the peptide dominates the water column).
318
+ Also spot-checks two published per-solvent RMSE values."""
319
+ real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
320
+ loader = Applications(paths.dataset_file("applications", file=__file__),
321
+ os.path.join(real_data_dir, "applications_experimental.xlsx"))
322
+ query_df_nn = A.build_query_df_nn(loader)
323
+ seed = A.build_bootstrap_seed_coeffs(loader)
324
+ per_solute = A.per_solute_fits(query_df_nn)
325
+ all_solute = A.per_solvent_fits(query_df_nn)
326
+ bootstrap_h = A.compute_solute_rmses(
327
+ A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H"))
328
+ table = A.distribution_shift_by_solvent_table(per_solute, all_solute, bootstrap_h, "H",
329
+ "stationary_plus_qcd + openMM")
330
+ gap = table["Extrapolated from delta22"] - table["Scaled to Solute"]
331
+ assert gap.idxmax() == "TIP4P", f"expected water to have the largest extrapolation gap, got {gap.idxmax()}"
332
+ assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.336, abs=0.01)
333
+ assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.107, abs=0.01)
334
+
335
+
336
+ # --- SI Figure S15's "Feature Space Coverage by Solvent" / "Residuals for Delta22 Fitting Coefficients" ---
337
+
338
+ def _feature_space_fixture():
339
+ """A test-set solute ("m") and a delta-22 solute, both nucleus H, in chloroform and benzene
340
+ (TIP4P/methanol deliberately absent from "m" to check the solvent filter drops rows cleanly).
341
+ stationary_plus_qcd/openMM are chosen so delta-22's chloroform plane is exact-fittable
342
+ (3 points, 3 free parameters -> zero residual)."""
343
+ query_df_nn = pd.DataFrame([
344
+ {"solute": "m", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 10.0, "openMM": 1.0, "experimental": 12.0},
345
+ {"solute": "m", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 11.0, "openMM": 2.0, "experimental": 15.0},
346
+ {"solute": "m", "nucleus": "C", "solvent": "chloroform", "stationary_plus_op_vib": 100.0, "openMM": 3.0, "experimental": 120.0},
347
+ ])
348
+ delta22_query_df_nn = pd.DataFrame([
349
+ {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 12.0, "openMM": 0.0, "experimental": 12.0},
350
+ {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 14.0, "openMM": 1.0, "experimental": 15.0},
351
+ {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 16.0, "openMM": 2.0, "experimental": 18.0},
352
+ {"solute": "delta22", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 9.0, "openMM": 1.5, "experimental": 13.0},
353
+ ])
354
+ return query_df_nn, delta22_query_df_nn
355
+
356
+
357
+ def test_feature_space_coverage_table_combines_and_centers_both_datasets():
358
+ query_df_nn, delta22_query_df_nn = _feature_space_fixture()
359
+ out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
360
+ assert set(out["dataset"]) == {"Test Set", "Delta22"}
361
+ assert set(out["solvent"]) == {"chloroform", "benzene"}
362
+ # global centering: the pooled x column (both datasets, both solvents) averages to 0
363
+ assert out["x"].mean() == pytest.approx(0.0, abs=1e-9)
364
+ assert out["y"].mean() == pytest.approx(0.0, abs=1e-9)
365
+
366
+
367
+ def test_feature_space_coverage_table_drops_solvents_test_set_lacks():
368
+ query_df_nn, delta22_query_df_nn = _feature_space_fixture()
369
+ out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H",
370
+ solvents=("chloroform", "benzene", "methanol", "TIP4P"))
371
+ # "m" (the only test-set H solute) has no methanol/TIP4P rows, so those solvents drop out
372
+ assert set(out["solvent"]) == {"chloroform", "benzene"}
373
+
374
+
375
+ def test_delta22_plane_residuals_table_delta22_residuals_are_near_zero():
376
+ # delta-22's own 3 chloroform points exactly determine a 3-parameter plane, so delta-22's own
377
+ # residuals against that plane must be ~0 (an OLS fit's residuals average to 0 with an
378
+ # intercept; with exactly as many points as parameters, every residual is individually ~0)
379
+ query_df_nn, delta22_query_df_nn = _feature_space_fixture()
380
+ out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform",))
381
+ d22 = out[out["dataset"] == "Delta22"]
382
+ assert len(d22) == 3
383
+ assert np.allclose(d22["residual"], 0.0, atol=1e-6)
384
+ # the test-set row scores against that SAME plane and is not forced to be exact
385
+ test_row = out[out["dataset"] == "Test Set"]
386
+ assert len(test_row) == 1
387
+
388
+
389
+ def test_delta22_plane_residuals_table_skips_solvents_with_too_few_delta22_points():
390
+ # benzene has only 1 delta-22 point (< 3 needed to fit a plane) -> dropped entirely, no crash
391
+ query_df_nn, delta22_query_df_nn = _feature_space_fixture()
392
+ out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
393
+ assert set(out["solvent"]) == {"chloroform"}
394
+
395
+
396
+ @pytest.mark.skipif(
397
+ not os.path.exists(paths.dataset_file("applications", file=__file__)),
398
+ reason="real applications.hdf5 not present")
399
+ def test_feature_space_and_residuals_tables_sane_on_real_data():
400
+ """Not an exact-number reproduction (these are scatter plots, not summary statistics) --
401
+ structural sanity checks: delta-22's own residuals against its own fitted plane average to
402
+ (near) zero every solvent, and the known H/TIP4P data-scarcity caveat from the SI text ("the
403
+ available experimental data for water in the test set is limited to one solute") shows up as a
404
+ small test-set count for that one solvent."""
405
+ real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
406
+ delta22_dir = os.path.join(HERE, "..", "..", "data", "delta22")
407
+ sys.path.insert(0, delta22_dir)
408
+ import delta22 as D
409
+ loader = Applications(paths.dataset_file("applications", file=__file__),
410
+ os.path.join(real_data_dir, "applications_experimental.xlsx"))
411
+ query_df_nn = A.build_query_df_nn(loader)
412
+ delta22_query_df_nn = D.add_composite_columns(D.load_query_df_nn(
413
+ paths.dataset_file("delta22", file=__file__), os.path.join(delta22_dir, "delta22_experimental.xlsx"),
414
+ verbose=False))
415
+ for nucleus in ["H", "C"]:
416
+ residuals = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus)
417
+ d22_means = residuals[residuals["dataset"] == "Delta22"].groupby("solvent")["residual"].mean()
418
+ assert (d22_means.abs() < 1e-6).all()
419
+ test_counts = residuals[residuals["dataset"] == "Test Set"].groupby("solvent").size()
420
+ assert test_counts["chloroform"] > 50 # plenty of chloroform test-set data
421
+ # SI-documented data scarcity: "the available experimental data for water in the test set
422
+ # is limited to one solute" -- the residuals table (which requires an experimental value,
423
+ # unlike the feature-space-coverage table) sees far fewer TIP4P test-set rows than chloroform
424
+ assert 0 < test_counts.get("TIP4P", 0) < test_counts["chloroform"] / 5
analysis/code/test_carbon_monoxide.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/carbon_monoxide.py. The figure's claim is that MagNET tracks
2
+ DFT near the equilibrium bond length but drifts in the non-equilibrium tails; that is checked against
3
+ the shipped scan data, along with the loaders and the histogram helper."""
4
+ import os
5
+ import sys
6
+
7
+ import numpy as np
8
+ import pytest
9
+
10
+ HERE = os.path.dirname(os.path.abspath(__file__))
11
+ sys.path.insert(0, HERE)
12
+
13
+ import carbon_monoxide as co # noqa: E402
14
+
15
+
16
+ def test_comparison_loads_and_is_sorted():
17
+ df = co.load_comparison()
18
+ assert len(df) == 81
19
+ assert list(df.columns) == ["bond_length", "gaussian_c", "magnet_c", "delta_c",
20
+ "gaussian_o", "magnet_o", "delta_o"]
21
+ assert df["bond_length"].is_monotonic_increasing
22
+ assert df["bond_length"].min() == pytest.approx(0.70, abs=1e-3)
23
+ assert df["bond_length"].max() == pytest.approx(1.50, abs=1e-3)
24
+ # delta is exactly MagNET minus DFT
25
+ assert np.allclose(df["delta_c"], df["magnet_c"] - df["gaussian_c"])
26
+
27
+
28
+ def test_histogram_helper():
29
+ centers, counts, bin_width = co.load_bond_length_histogram()
30
+ assert len(centers) == len(counts) == 100
31
+ assert bin_width > 0
32
+ assert counts.sum() > 0 # there is real bond-length support behind the error panel
33
+
34
+
35
+ def test_error_grows_away_from_equilibrium():
36
+ """The published claim: the carbon error is small near the equilibrium bond length and much
37
+ larger in the stretched/compressed tails the model never trained on."""
38
+ near, tails = co.equilibrium_vs_extreme_error()
39
+ assert near < 30.0 # near-equilibrium error stays modest (about 19 ppm)
40
+ assert tails > 100.0 # tails blow up (about 185 ppm)
41
+ assert tails > 5 * near # the tails are far worse, which is the point of the figure
analysis/code/test_composite_model.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for build_composite_model.py: the composite_model/ group in applications.hdf5 reproduces
2
+ from the shipped delta-22 data, so the natural-products coefficients need no private tree or
3
+ environment variables. Skipped when the large hdf5 files are absent."""
4
+ import os
5
+ import random
6
+ import sys
7
+
8
+ import numpy as np
9
+ import pytest
10
+
11
+ HERE = os.path.dirname(os.path.abspath(__file__))
12
+ REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
13
+ for _p in ("analysis/code", "analysis/code/shared", "data/delta22", "data/applications"):
14
+ sys.path.insert(0, os.path.join(REPO, _p))
15
+
16
+ import build_composite_model as B # noqa: E402
17
+ import paths # noqa: E402
18
+
19
+ DELTA22 = paths.dataset_file("delta22", root=REPO)
20
+ APPLICATIONS = paths.dataset_file("applications", root=REPO)
21
+ _HAVE_DATA = os.path.exists(DELTA22) and os.path.exists(APPLICATIONS)
22
+
23
+ # The fixed-point integer encoding (int32 x 1e4) sets the reproduction floor; recipe errors are ~1.
24
+ ENCODING_TOL = 5e-3
25
+
26
+
27
+ def _reader():
28
+ from applications_reader import Applications
29
+ return Applications(APPLICATIONS)
30
+
31
+
32
+ @pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
33
+ def test_ols_and_pcm_reproduce():
34
+ """Every OLS formula (all solvents, both nuclei) and both PCM-conversion tables reproduce."""
35
+ reader = _reader()
36
+ full_tab, _fit_tab, _apply_tab, factors = B._load_tables()
37
+ worst = 0.0
38
+ for formula in reader.composite_formulas("ols_coefficients"):
39
+ regressors = B._regressor_columns(formula)
40
+ for reg in regressors:
41
+ B._ensure_column(full_tab, reg)
42
+ for nucleus in ("H", "C"):
43
+ stored = reader.ols_coefficients(formula, nucleus).set_index("parameter")
44
+ for solvent in stored.columns:
45
+ sub = full_tab[(full_tab["nucleus"] == nucleus) & (full_tab["solvent"] == solvent)]
46
+ got = B._decompose(B._fit(sub, regressors), regressors, solvent, nucleus, factors)
47
+ worst = max(worst, max(abs(float(stored.loc[p, solvent]) - got[p]) for p in stored.index))
48
+ assert worst < ENCODING_TOL, f"OLS max diff {worst}"
49
+
50
+ for nucleus in ("H", "C"):
51
+ stored = reader.pcm_conversion_factors(nucleus).set_index("solvent")["pcm_conversion_factor"]
52
+ got = B._pcm_table(factors, nucleus).set_index("solvent")["pcm_conversion_factor"]
53
+ assert np.abs(got - stored.reindex(got.index)).max() < 1e-9
54
+
55
+
56
+ @pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
57
+ def test_bootstrap_and_rmse_reproduce():
58
+ """Bootstrap coefficients and RMSE distributions reproduce on a sample of seeds (the fixed-seed
59
+ resampler makes every seed deterministic, so a sample is representative). This is the fast CI
60
+ check; build_composite_model.main() re-fits all 1000 seeds and is the authoritative full pass."""
61
+ reader = _reader()
62
+ _full_tab, fit_tab, apply_tab, factors = B._load_tables()
63
+ seeds = range(0, 1000, 97)
64
+
65
+ worst = 0.0
66
+ for formula in reader.composite_formulas("bootstrap_coefficients"):
67
+ regressors = B._regressor_columns(formula)
68
+ for reg in regressors:
69
+ B._ensure_column(fit_tab, reg)
70
+ for nucleus in ("H", "C"):
71
+ try:
72
+ stored = reader.bootstrap_coefficients(formula, nucleus)
73
+ except KeyError:
74
+ continue
75
+ params = [c for c in stored.columns if c not in ("solvent", "seed")]
76
+ for solvent in stored["solvent"].unique():
77
+ for seed in seeds:
78
+ row = stored[(stored["solvent"] == solvent) & (stored["seed"] == seed)]
79
+ if not len(row):
80
+ continue
81
+ got = B._decompose(B._fit(B._bootstrap_sample(fit_tab, nucleus, solvent, seed),
82
+ regressors), regressors, solvent, nucleus, factors)
83
+ for p in params:
84
+ key = "intercept" if p == "Intercept" else p
85
+ worst = max(worst, abs(float(row[p].iloc[0]) - got[key]))
86
+ assert worst < ENCODING_TOL, f"bootstrap max diff {worst}"
87
+
88
+ worst = 0.0
89
+ for nucleus in ("H", "C"):
90
+ stored = reader.rmse_distribution(nucleus)
91
+ for formula in stored["formula"].unique():
92
+ sub = stored[stored["formula"] == formula]
93
+ for solvent in sub["solvent"].unique():
94
+ for seed in seeds:
95
+ row = sub[(sub["solvent"] == solvent) & (sub["seed"] == seed)]
96
+ if not len(row):
97
+ continue
98
+ got = B._rmse(fit_tab, apply_tab, formula, nucleus, solvent, seed, factors)
99
+ worst = max(worst, abs(float(row["Bootstrap_RMSE"].iloc[0]) - got))
100
+ assert worst < ENCODING_TOL, f"rmse max diff {worst}"
101
+
102
+
103
+ @pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
104
+ def test_bootstrap_is_deterministic():
105
+ """The resampler reseeds internally, so a given seed draws the same solutes no matter the
106
+ external RNG state going in."""
107
+ fit_tab = B._load_tables()[1]
108
+ random.seed(123)
109
+ a = B._bootstrap_sample(fit_tab, "C", "chloroform", 7)
110
+ random.seed(456)
111
+ b = B._bootstrap_sample(fit_tab, "C", "chloroform", 7)
112
+ assert list(a["solute"]) == list(b["solute"])
113
+
114
+
115
+ @pytest.mark.skipif(not _HAVE_DATA, reason="delta22.hdf5 / applications.hdf5 not present")
116
+ def test_write_roundtrips(tmp_path):
117
+ """write() serializes the regenerated tables into an hdf5 file that reads back unchanged through
118
+ Applications, so --write cannot silently corrupt the shipped file's composite_model group."""
119
+ from applications_reader import Applications
120
+ regenerated = B.regenerate(_reader())
121
+ scratch = str(tmp_path / "cm.hdf5")
122
+ B.write(scratch, regenerated)
123
+ worst = B.verify(Applications(scratch), regenerated)
124
+ assert worst < 1e-6, f"write/read round-trip diff {worst}"
analysis/code/test_composite_models.py ADDED
@@ -0,0 +1,178 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/composite_models.py.
2
+
3
+ The fitting side is exercised on a small synthetic delta-22-shaped query table (composite_models
4
+ adds no new fitting logic of its own -- it calls delta22.py's harness directly, which has its own
5
+ tests). What's specific to this module is the pivot/reindex shape and the openpyxl table-writing
6
+ layer, so those get direct, hermetic tests here: the formula lookup helpers, the %benefit formula
7
+ writer, and the full workbook structure (sheet names, header cells, RMSE cell placement) on
8
+ synthetic data. An opt-in test checks the real data reproduces a plausible RMSE range.
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ import numpy as np
14
+ import pandas as pd
15
+ import pytest
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta22")) # delta22_reader
19
+ sys.path.insert(0, HERE)
20
+ import paths
21
+
22
+ import composite_models as CM # noqa: E402
23
+
24
+
25
+ def _synthetic_query_df_dft(solutes, lots, seed=0):
26
+ """A query_df_dft-shaped table with all the columns composite_models' formulas reference, at
27
+ each of the given (method, basis, geometry) triples, for both nuclei."""
28
+ rng = np.random.default_rng(seed)
29
+ rows = []
30
+ for solute in solutes:
31
+ for solvent in CM.ORDERED_SOLVENTS:
32
+ for nucleus in ["H", "C"]:
33
+ for method, basis, geometry in lots:
34
+ stationary = rng.normal(30 if nucleus == "H" else 150, 5)
35
+ pcm = rng.normal(0, 0.5)
36
+ desmond = rng.normal(0, 0.5)
37
+ qcd = rng.normal(0, 0.2)
38
+ desmond_vib = rng.normal(0, 0.3)
39
+ openMM = rng.normal(0, 0.5)
40
+ openMM_vib = rng.normal(0, 0.3)
41
+ experimental = stationary + 0.5 * pcm + 0.3 * desmond + rng.normal(0, 0.05)
42
+ rows.append(dict(
43
+ solute=solute, solvent=solvent, nucleus=nucleus,
44
+ sap_nmr_method=method, sap_basis=basis, sap_geometry_type=geometry,
45
+ stationary=stationary, pcm=pcm, desmond=desmond, qcd=qcd,
46
+ desmond_vib=desmond_vib, openMM=openMM, openMM_vib=openMM_vib,
47
+ experimental=experimental))
48
+ return pd.DataFrame(rows)
49
+
50
+
51
+ SOLUTES = [f"m{i}" for i in range(16)]
52
+ DSD_LOT = ("dsd_pbep86", "pcSseg3", "pbe0_tz")
53
+ WP04_LOT = ("wp04", "pcSseg2", "aimnet2")
54
+ WB97XD_LOT = ("wb97xd", "pcSseg2", "aimnet2")
55
+
56
+
57
+ def test_canonical_formula_name_ignores_case_spaces_underscores():
58
+ assert CM._canonical_formula_name("stationary + PCM") == CM._canonical_formula_name("stationary+pcm")
59
+ assert CM._canonical_formula_name("stationary_plus_pcm") != CM._canonical_formula_name("stationary + pcm")
60
+
61
+
62
+ def test_get_rmse_looks_up_by_canonical_name():
63
+ df = pd.DataFrame({"chloroform": [0.123]}, index=["stationary + pcm"])
64
+ lookup = CM._build_formula_lookup(df)
65
+ assert CM._get_rmse(df, lookup, "stationary+PCM", "chloroform") == pytest.approx(0.123)
66
+
67
+
68
+ def test_get_rmse_missing_formula_raises_keyerror():
69
+ df = pd.DataFrame({"chloroform": [0.1]}, index=["stationary"])
70
+ lookup = CM._build_formula_lookup(df)
71
+ with pytest.raises(KeyError):
72
+ CM._get_rmse(df, lookup, "not_a_real_formula", "chloroform")
73
+
74
+
75
+ def test_ablation_rmse_table_shape_and_exact_recovery():
76
+ # experimental = stationary + 0.5*pcm + 0.3*desmond exactly (no noise), so a formula containing
77
+ # both pcm and qcd should fit near-perfectly (tiny test RMSE) while "stationary" alone
78
+ # (missing both real predictors) should not. "stationary + pcm + qcd" is a real formula in
79
+ # CM.FORMULAS; there is no formula combining pcm and desmond together, so the ground truth is
80
+ # built from pcm and qcd instead.
81
+ rng = np.random.default_rng(1)
82
+ rows = []
83
+ for solute in SOLUTES:
84
+ for solvent in CM.ORDERED_SOLVENTS:
85
+ stationary = rng.normal(30, 5)
86
+ pcm = rng.normal(0, 0.5)
87
+ desmond = rng.normal(0, 0.5)
88
+ qcd = rng.normal(0, 0.2)
89
+ desmond_vib = rng.normal(0, 0.3)
90
+ openMM = rng.normal(0, 0.5)
91
+ openMM_vib = rng.normal(0, 0.3)
92
+ experimental = stationary + 0.5 * pcm + 0.3 * qcd
93
+ rows.append(dict(solute=solute, solvent=solvent, nucleus="H",
94
+ sap_nmr_method="dsd_pbep86", sap_basis="pcSseg3",
95
+ sap_geometry_type="pbe0_tz", stationary=stationary, pcm=pcm,
96
+ desmond=desmond, qcd=qcd, desmond_vib=desmond_vib, openMM=openMM,
97
+ openMM_vib=openMM_vib, experimental=experimental))
98
+ query_df_dft = pd.DataFrame(rows)
99
+
100
+ table = CM.ablation_rmse_table(query_df_dft, "H", "dsd_pbep86", "pcSseg3", "pbe0_tz", SOLUTES,
101
+ n_splits=10)
102
+ assert list(table.index) == CM.FORMULAS
103
+ assert list(table.columns) == CM.ORDERED_SOLVENTS + ["Mean Test RMSE"]
104
+ assert table.loc["stationary + pcm + qcd", "Mean Test RMSE"] < 0.05
105
+ assert table.loc["stationary", "Mean Test RMSE"] > table.loc["stationary + pcm + qcd", "Mean Test RMSE"]
106
+
107
+
108
+ def test_write_nucleus_table_cell_placement():
109
+ query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT])
110
+ table = CM.ablation_rmse_table(query_df_dft, "H", *DSD_LOT, SOLUTES, n_splits=5)
111
+
112
+ from openpyxl import Workbook
113
+ wb = Workbook()
114
+ ws = wb.active
115
+ next_row = CM.write_nucleus_table(
116
+ ws=ws, start_row=1, nucleus_name="Proton", stationary_desc="DSD-PBEP86/pcSseg3",
117
+ pcm_desc="B3LYP-D3BJ/pcSseg3", test_rmse_df=table, vmin=0.06, vmax=0.28,
118
+ formula_config=CM.PROTON_FORMULA_CONFIG)
119
+
120
+ assert ws["A1"].value == "Proton"
121
+ assert ws["A6"].value == "formula"
122
+ assert ws["A9"].value == "stationary"
123
+ assert ws["B9"].value == pytest.approx(table.loc["stationary", "chloroform"])
124
+ # the "Mean Test RMSE" column is the last of the 13 data columns (B..N)
125
+ assert ws["N5"].value == "Mean Test RMSE"
126
+ assert ws["N9"].value == pytest.approx(table.loc["stationary", "Mean Test RMSE"])
127
+ # %benefit rows carry a live Excel formula, not a precomputed number
128
+ assert str(ws["B14"].value).startswith("=IFERROR(")
129
+ assert next_row == 1 + 45
130
+
131
+
132
+ def test_build_ablations_workbook_single_level_two_sheets():
133
+ query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT])
134
+ import tempfile
135
+ with tempfile.TemporaryDirectory() as td:
136
+ out = os.path.join(td, "ablations.xlsx")
137
+ CM.build_ablations_workbook(query_df_dft, SOLUTES, out, reference_levels=("dsd",),
138
+ n_splits=5, verbose=False)
139
+ from openpyxl import load_workbook
140
+ wb = load_workbook(out)
141
+ assert wb.sheetnames == ["Proton", "Carbon"]
142
+
143
+
144
+ def test_build_ablations_workbook_both_levels_four_sheets_matching_shipped_names():
145
+ query_df_dft = _synthetic_query_df_dft(SOLUTES, [DSD_LOT, WP04_LOT, WB97XD_LOT])
146
+ import tempfile
147
+ with tempfile.TemporaryDirectory() as td:
148
+ out = os.path.join(td, "ablations.xlsx")
149
+ CM.build_ablations_workbook(query_df_dft, SOLUTES, out, n_splits=5, verbose=False)
150
+ from openpyxl import load_workbook
151
+ wb = load_workbook(out)
152
+ # matches wolford/ablations_updated 2.xlsx's exact sheet names
153
+ assert wb.sheetnames == ["Proton (DSD)", "Carbon (DSD)", "Proton (WP04)", "Carbon (wB97XD)"]
154
+
155
+
156
+ # --- opt-in: reproduce a plausible ablation table from the real delta-22 data ---------------------
157
+
158
+ REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
159
+ REAL_H5 = paths.dataset_file("delta22", root=REPO)
160
+ REAL_XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
161
+
162
+
163
+ @pytest.mark.skipif(not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
164
+ reason="real delta22.hdf5 / experimental xlsx not present")
165
+ def test_ablation_rmse_table_reproduces_plausible_range_from_real_data():
166
+ import delta22 as D
167
+ query_df_dft = D.add_composite_columns(D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False))
168
+ solutes = D.delta22_solutes(REAL_H5)
169
+ level = CM.REFERENCE_LEVELS["dsd"]
170
+ table = CM.ablation_rmse_table(query_df_dft, "H", level["method_h"], level["basis_h"],
171
+ level["geometry_h"], solutes, n_splits=20)
172
+ vmin, vmax = CM._VMIN_VMAX["H"]
173
+ # the shipped workbook's fixed color-scale range is itself a real-data-derived sanity bound:
174
+ # every formula's mean test RMSE should land inside it
175
+ assert (table["Mean Test RMSE"] >= vmin * 0.5).all()
176
+ assert (table["Mean Test RMSE"] <= vmax * 1.5).all()
177
+ # the fullest model should not be worse than the bare stationary reference
178
+ assert table.loc["stationary + desmond + qcd", "Mean Test RMSE"] < table.loc["stationary", "Mean Test RMSE"]
analysis/code/test_delta22_analysis.py ADDED
@@ -0,0 +1,916 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/delta22.py (the no-plotting analysis module).
2
+
3
+ The analysis functions operate on flat query DataFrames, so these tests build small synthetic
4
+ tables with a known linear relationship and check the fitting harness, the seeded splits, the
5
+ Pareto frontier, and the correlation matrix. One test proves the fast numpy `fit` gives the same
6
+ coefficients and RMSE as the original statsmodels fit.
7
+ """
8
+ import os
9
+ import random
10
+ import sys
11
+
12
+ import numpy as np
13
+ import pandas as pd
14
+ import pytest
15
+
16
+ HERE = os.path.dirname(os.path.abspath(__file__))
17
+ sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta22")) # delta22_reader
18
+ sys.path.insert(0, HERE)
19
+ import paths
20
+
21
+ import delta22 as D # noqa: E402
22
+
23
+
24
+ def make_query_df(n_solutes=12, solvents=("chloroform", "benzene"), noise=0.0, seed=0):
25
+ """A flat query table whose experimental value is exactly 2*stationary + 0.5*pcm + 1 (plus
26
+ optional noise), so a model with both terms fits near-perfectly and 'stationary' alone leaves
27
+ the 0.5*pcm residual."""
28
+ rng = np.random.default_rng(seed)
29
+ rows = []
30
+ for si in range(n_solutes):
31
+ solute = f"m{si:02d}"
32
+ for solvent in solvents:
33
+ for k in range(3):
34
+ stationary = rng.normal(100, 20)
35
+ pcm = rng.normal(0, 2)
36
+ experimental = 2.0 * stationary + 0.5 * pcm + 1.0 + rng.normal(0, noise)
37
+ rows.append({
38
+ "solute": solute, "solvent": solvent, "nucleus": "H",
39
+ "site": f"{solute}_{solvent}_{k}", "experimental": experimental,
40
+ "stationary": stationary, "pcm": pcm,
41
+ "desmond": rng.normal(0, 2), "openMM": rng.normal(0, 2),
42
+ "qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
43
+ "openMM_vib": rng.normal(0, 1),
44
+ })
45
+ return pd.DataFrame(rows)
46
+
47
+
48
+ def test_fit_recovers_known_line():
49
+ df = make_query_df(noise=0.0)
50
+ train_rmse, test_rmse, params = D.fit(df, df, "stationary + pcm")
51
+ assert params["Intercept"] == pytest.approx(1.0, abs=1e-6)
52
+ assert params["stationary"] == pytest.approx(2.0, abs=1e-6)
53
+ assert params["pcm"] == pytest.approx(0.5, abs=1e-6)
54
+ assert train_rmse == pytest.approx(0.0, abs=1e-6)
55
+ assert test_rmse == pytest.approx(0.0, abs=1e-6)
56
+
57
+
58
+ def test_fit_matches_statsmodels():
59
+ df = make_query_df(noise=1.0, seed=3)
60
+ train = df[df["solute"] <= "m07"]
61
+ test = df[df["solute"] > "m07"]
62
+ for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd"]:
63
+ tr, te, params = D.fit(train, test, formula)
64
+ s_tr, s_te, s_params = D._fit_statsmodels(train, test, formula)
65
+ assert tr == pytest.approx(s_tr, abs=1e-8)
66
+ assert te == pytest.approx(s_te, abs=1e-8)
67
+ for name in s_params.index:
68
+ assert params[name] == pytest.approx(s_params[name], abs=1e-6)
69
+
70
+
71
+ def test_fit_drops_missing_like_statsmodels():
72
+ # fitting must drop rows with a missing response or predictor, exactly as statsmodels does,
73
+ # and recover the same coefficients. (Scoring is checked on a clean test set; statsmodels would
74
+ # otherwise NaN-poison an RMSE computed over rows that have missing predictors.)
75
+ clean = make_query_df(noise=0.5, seed=5).reset_index(drop=True)
76
+ train = clean.copy()
77
+ train.loc[0, "experimental"] = np.nan # missing response
78
+ train.loc[1, "pcm"] = np.nan # missing predictor
79
+ _train_err, te, params = D.fit(train, clean, "stationary + pcm")
80
+ s_tr, s_te, s_params = D._fit_statsmodels(train, clean, "stationary + pcm")
81
+ for name in s_params.index:
82
+ assert params[name] == pytest.approx(s_params[name], abs=1e-6)
83
+ assert te == pytest.approx(s_te, abs=1e-8)
84
+
85
+
86
+ def test_generate_solute_splits_seeded_and_disjoint():
87
+ df = make_query_df()
88
+ solutes = [f"m{i:02d}" for i in range(12)]
89
+ splits = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
90
+ assert len(splits) == 5
91
+ # reproduce the exact seed logic for split 0
92
+ random.seed(0 + 100)
93
+ expected = list(solutes)
94
+ random.shuffle(expected)
95
+ train_df, test_df = splits[0]
96
+ assert set(test_df["solute"].unique()) == set(expected[:4])
97
+ assert set(train_df["solute"].unique()) == set(expected[4:])
98
+ # a solute is never in both halves, and only the requested solvent is present
99
+ assert set(test_df["solute"]).isdisjoint(set(train_df["solute"]))
100
+ assert set(test_df["solvent"].unique()) == {"chloroform"}
101
+ # deterministic: same call gives the same partition
102
+ again = D.generate_solute_splits(5, df, "chloroform", solutes, n_test=4)
103
+ assert set(again[0][1]["solute"].unique()) == set(test_df["solute"].unique())
104
+
105
+
106
+ def test_run_fits_more_terms_lower_error():
107
+ df = make_query_df(noise=0.3, seed=1)
108
+ solutes = sorted(df["solute"].unique())
109
+ results = D.run_fits(df, ["chloroform", "benzene"], D.STATIONARY_VS_PCM,
110
+ n_splits=5, solutes=solutes, n_test=4)
111
+ assert set(results.columns) >= {"solvent", "formula", "seed", "train_RMSE", "test_RMSE"}
112
+ med = D.median_test_rmse(results, ["formula"]).set_index("formula")["test_RMSE"]
113
+ assert med["stationary + pcm"] < med["stationary"] # the pcm term genuinely helps
114
+
115
+
116
+ def test_run_fits_group_cols():
117
+ df = make_query_df(noise=0.3, seed=2)
118
+ df = pd.concat([df.assign(sap_nmr_method="wp04"),
119
+ df.assign(sap_nmr_method="dsd_pbep86")], ignore_index=True)
120
+ solutes = sorted(df["solute"].unique())
121
+ results = D.run_fits(df, ["chloroform"], ["stationary + pcm"], n_splits=3,
122
+ solutes=solutes, group_cols=["sap_nmr_method"], n_test=4)
123
+ assert "sap_nmr_method" in results.columns
124
+ assert set(results["sap_nmr_method"].unique()) == {"wp04", "dsd_pbep86"}
125
+
126
+
127
+ def test_fit_coefficients_per_seed():
128
+ df = make_query_df(noise=0.4, seed=7)
129
+ solutes = sorted(df["solute"].unique())
130
+ coeffs = D.fit_coefficients(df, "chloroform", "stationary + pcm", solutes,
131
+ n_splits=6, n_test=4)
132
+ assert len(coeffs) == 6
133
+ assert {"solvent", "seed", "Intercept", "stationary", "pcm"}.issubset(coeffs.columns)
134
+ # the recovered slope should sit near the true value of 2
135
+ assert coeffs["stationary"].mean() == pytest.approx(2.0, abs=0.1)
136
+
137
+
138
+ def test_full_fit_coefficients_shape_and_values():
139
+ df = make_query_df(noise=0.0, solvents=("chloroform", "benzene"))
140
+ coeffs = D.full_fit_coefficients(df, ["chloroform", "benzene"], "stationary + pcm")
141
+ assert list(coeffs.columns) == ["chloroform", "benzene"]
142
+ assert set(coeffs.index) == {"Intercept", "stationary", "pcm"}
143
+ # the data is exactly 2*stationary + 0.5*pcm + 1, recovered per solvent
144
+ for solvent in ["chloroform", "benzene"]:
145
+ assert coeffs.loc["Intercept", solvent] == pytest.approx(1.0, abs=1e-6)
146
+ assert coeffs.loc["stationary", solvent] == pytest.approx(2.0, abs=1e-6)
147
+ assert coeffs.loc["pcm", solvent] == pytest.approx(0.5, abs=1e-6)
148
+
149
+
150
+ # ---- opt-in: checked against the STORED composite-model coefficients ----
151
+ # These coefficients live in applications.hdf5's composite_model/ols_coefficients group. Re-fitting
152
+ # the released delta-22 data with the fast numpy harness (nitromethane dropped) must reproduce them
153
+ # to within the data's integer-encoding precision (~5e-4 after error propagation through the small
154
+ # corrections).
155
+ REAL_H5 = paths.dataset_file("delta22", file=__file__)
156
+ REAL_XLSX = os.path.join(HERE, "..", "..", "data", "delta22", "delta22_experimental.xlsx")
157
+ APPLICATIONS_H5 = paths.dataset_file("applications", file=__file__)
158
+
159
+
160
+ @pytest.mark.skipif(
161
+ not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX) and os.path.exists(APPLICATIONS_H5)),
162
+ reason="real delta22.hdf5 / experimental xlsx / applications.hdf5 not present")
163
+ def test_full_fit_reproduces_stored_composite_coefficients():
164
+ import io
165
+ import h5py
166
+
167
+ query = D.load_query_df_nn(REAL_H5, REAL_XLSX, exclude_solutes=["nitromethane"], verbose=False)
168
+ worst = 0.0
169
+ with h5py.File(APPLICATIONS_H5, "r") as f:
170
+ ols = f["composite_model"]["ols_coefficients"]
171
+ for nucleus in ["H", "C"]:
172
+ sub = query[query["nucleus"] == nucleus]
173
+ for formula in ["stationary", "stationary + openMM", "stationary + openMM + qcd"]:
174
+ tag = "_AND_".join(term.strip() for term in formula.split("+"))
175
+ raw = np.array(ols[tag][nucleus]).item()
176
+ raw = raw.decode() if hasattr(raw, "decode") else raw
177
+ stored = pd.read_csv(io.StringIO(raw)).set_index("parameter")
178
+ mine = D.full_fit_coefficients(sub, list(stored.columns), formula)
179
+ mine = mine.rename(index={"Intercept": "intercept"})
180
+ for solvent in stored.columns:
181
+ for param in stored.index:
182
+ worst = max(worst, abs(mine.loc[param, solvent] - stored.loc[param, solvent]))
183
+ assert worst < 5e-4, f"worst coefficient diff {worst:.2e} exceeds the int32 precision floor"
184
+
185
+
186
+ def make_dft_query_df(methods=("wp04", "dsd_pbep86"), solvents=("chloroform", "methanol", "benzene"),
187
+ n_solutes=12, seed=0):
188
+ """A synthetic DFT query table with method/basis/geometry columns. Experimental is
189
+ 31 - (stationary + pcm) + tiny noise, i.e. a single shared slope on the gas shielding plus its
190
+ implicit-solvent correction (the real structure), so the single-column 'stationary_plus_pcm'
191
+ model fits well and 'stationary' alone leaves the pcm residual. desmond/openMM/qcd are
192
+ independent noise that does not help."""
193
+ rng = np.random.default_rng(seed)
194
+ rows = []
195
+ for method in methods:
196
+ for si in range(n_solutes):
197
+ solute = f"m{si:02d}"
198
+ for solvent in solvents:
199
+ for k in range(2):
200
+ stationary = rng.normal(100, 20)
201
+ pcm = rng.normal(0, 2)
202
+ rows.append({
203
+ "solute": solute, "solvent": solvent, "nucleus": "H",
204
+ "site": f"{solute}_{k}", "sap_nmr_method": method,
205
+ "sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
206
+ "experimental": 31.0 - (stationary + pcm) + rng.normal(0, 0.05),
207
+ "stationary": stationary, "pcm": pcm,
208
+ "desmond": rng.normal(0, 1), "openMM": rng.normal(0, 1),
209
+ "qcd": rng.normal(0, 1), "desmond_vib": rng.normal(0, 1),
210
+ "openMM_vib": rng.normal(0, 1),
211
+ })
212
+ return pd.DataFrame(rows)
213
+
214
+
215
+ def test_add_composite_columns():
216
+ df = make_query_df()
217
+ out = D.add_composite_columns(df)
218
+ assert np.allclose(out["stationary_plus_pcm"], out["stationary"] + out["pcm"])
219
+ assert np.allclose(out["stationary_plus_pcm_plus_qcd"],
220
+ out["stationary"] + out["pcm"] + out["qcd"])
221
+ # an existing NN-style stationary_plus_pcm (with scaling factors) is preserved
222
+ nn = df.copy()
223
+ nn["stationary_plus_pcm"] = 999.0
224
+ kept = D.add_composite_columns(nn)
225
+ assert np.allclose(kept["stationary_plus_pcm"], 999.0)
226
+
227
+
228
+ def test_fig3b_shift_differences_exact():
229
+ # one method, three solvents; experimental/pcm chosen so the differences are known
230
+ rows = []
231
+ vals = {"chloroform": (1.0, 0.1), "methanol": (1.5, 0.4), "benzene": (0.7, -0.2)}
232
+ for solvent, (exp, pcm) in vals.items():
233
+ rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
234
+ "sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
235
+ "sap_geometry_type": "aimnet2", "experimental": exp, "pcm": pcm})
236
+ df = pd.DataFrame(rows)
237
+ out = D.fig3b_shift_differences(df, "wp04", "pcSseg2", "aimnet2", nucleus="H",
238
+ x_solvent="methanol", y_solvent="benzene", reference="chloroform")
239
+ assert len(out) == 1
240
+ assert out["exp_diff_x"].iloc[0] == pytest.approx(1.5 - 1.0)
241
+ assert out["exp_diff_y"].iloc[0] == pytest.approx(0.7 - 1.0)
242
+ assert out["pcm_diff_x"].iloc[0] == pytest.approx(0.4 - 0.1)
243
+ assert out["pcm_diff_y"].iloc[0] == pytest.approx(-0.2 - 0.1)
244
+
245
+
246
+ def test_fig3a_and_fig3d_pcm_helps():
247
+ df = D.add_composite_columns(make_dft_query_df(seed=4))
248
+ solutes = sorted(df["solute"].unique())
249
+ solvents = ["chloroform", "methanol", "benzene"]
250
+ a = D.fig3a_pcm_benefit(df, solvents, n_splits=4, solutes=solutes, n_test=4)
251
+ assert {"sap_nmr_method", "solvent", "formula", "test_RMSE"}.issubset(a.columns)
252
+ med = a.groupby("formula")["test_RMSE"].median()
253
+ assert med["stationary_plus_pcm"] < med["stationary"] # PCM lowers error
254
+ d = D.fig3d_formula_regressions(df, "wp04", "pcSseg2", "aimnet2",
255
+ ["stationary + pcm", "stationary + desmond"], solvents,
256
+ n_splits=4, solutes=solutes, n_test=4)
257
+ assert set(d["formula"].unique()) == {"stationary + pcm", "stationary + desmond"}
258
+
259
+
260
+ def test_fig3a_pcm_benefit_by_solvent_keeps_solvents_separate():
261
+ # chloroform: pcm carries a real (though not perfectly scaled) part of the signal, so adding it
262
+ # to the fit lowers test error. benzene: pcm is unrelated noise on top of an already-explained
263
+ # response, so adding it as a predictor only overfits and raises test error. A solvent-pooled
264
+ # average would blend these two directions away; fig3a_pcm_benefit_by_solvent must keep one row
265
+ # per solvent so both signs are visible.
266
+ rng = np.random.default_rng(11)
267
+ rows = []
268
+ n_solutes = 16
269
+ for si in range(n_solutes):
270
+ solute = f"m{si:02d}"
271
+ for solvent in ["chloroform", "benzene"]:
272
+ for k in range(3):
273
+ stationary = rng.normal(100, 20)
274
+ if solvent == "chloroform":
275
+ pcm = rng.normal(0, 2)
276
+ experimental = 31.0 - stationary - 0.8 * pcm + rng.normal(0, 0.05)
277
+ else:
278
+ pcm = rng.normal(0, 8) # large noise unrelated to experimental
279
+ experimental = 31.0 - stationary + rng.normal(0, 0.05)
280
+ rows.append({
281
+ "solute": solute, "solvent": solvent, "nucleus": "H",
282
+ "site": f"{solute}_{solvent}_{k}", "sap_nmr_method": "wp04",
283
+ "sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
284
+ "experimental": experimental, "stationary": stationary, "pcm": pcm,
285
+ "desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
286
+ })
287
+ df = D.add_composite_columns(pd.DataFrame(rows))
288
+ solutes = sorted(df["solute"].unique())
289
+ out = D.fig3a_pcm_benefit_by_solvent(df, ["chloroform", "benzene"], n_splits=10,
290
+ solutes=solutes, n_test=6)
291
+ assert {"sap_nmr_method", "solvent", "seed", "percent_benefit"}.issubset(out.columns)
292
+ chloro_benefit = out[out["solvent"] == "chloroform"]["percent_benefit"].median()
293
+ benz_benefit = out[out["solvent"] == "benzene"]["percent_benefit"].median()
294
+ assert chloro_benefit > 0 # PCM helps in chloroform
295
+ assert benz_benefit < 0 # PCM hurts in benzene
296
+
297
+
298
+ def test_fig3a_does_not_pool_nuclei():
299
+ # H sites (~30 ppm) and C sites (~150 ppm) with different relationships. If fig3a pooled both
300
+ # nuclei into one fit the test RMSE would blow up; filtering to one nucleus keeps it small.
301
+ rng = np.random.default_rng(0)
302
+ rows = []
303
+ for si in range(12):
304
+ for solvent in ["chloroform", "methanol", "benzene"]:
305
+ for nucleus, base, slope, offset in [("H", 30, 1.0, 31.0), ("C", 150, 1.05, 200.0)]:
306
+ stationary = rng.normal(base, base * 0.1)
307
+ pcm = rng.normal(0, 1)
308
+ rows.append({
309
+ "solute": f"m{si:02d}", "site": f"m{si:02d}_{nucleus}", "nucleus": nucleus,
310
+ "solvent": solvent, "sap_nmr_method": "wp04", "sap_basis": "pcSseg2",
311
+ "sap_geometry_type": "aimnet2", "stationary": stationary, "pcm": pcm,
312
+ "experimental": offset - slope * (stationary + pcm) + rng.normal(0, 0.05),
313
+ "desmond": 0.0, "openMM": 0.0, "qcd": 0.0, "desmond_vib": 0.0, "openMM_vib": 0.0,
314
+ })
315
+ df = D.add_composite_columns(pd.DataFrame(rows))
316
+ solutes = sorted(df["solute"].unique())
317
+ res = D.fig3a_pcm_benefit(df, ["chloroform", "methanol", "benzene"], n_splits=4,
318
+ solutes=solutes, nucleus="H", n_test=4)
319
+ assert res["test_RMSE"].median() < 1.0 # would be tens of ppm if C were pooled in
320
+
321
+
322
+ def test_add_solvent_mean():
323
+ df = pd.DataFrame({
324
+ "solute": ["m0", "m0"], "site": ["s0", "s0"], "nucleus": ["H", "H"],
325
+ "solvent": ["chloroform", "benzene"], "experimental": [2.0, 4.0], "pcm": [0.2, 0.6],
326
+ })
327
+ out = D.add_solvent_mean(df)
328
+ mean_row = out[out["solvent"] == "solvent_mean"]
329
+ assert len(mean_row) == 1
330
+ assert mean_row["experimental"].iloc[0] == pytest.approx(3.0) # mean(2, 4)
331
+ assert mean_row["pcm"].iloc[0] == pytest.approx(0.4) # mean(0.2, 0.6)
332
+
333
+
334
+ def test_solvent_pair_differences_exact():
335
+ def row(solvent, exp, pcm, des, dvib):
336
+ return {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": solvent,
337
+ "experimental": exp, "pcm": pcm, "desmond": des, "desmond_vib": dvib}
338
+ df = pd.DataFrame([row("methanol", 2.0, 0.4, 0.5, 0.1), # check solvent
339
+ row("chloroform", 1.0, 0.1, 0.2, 0.05)]) # reference
340
+ out = D.solvent_pair_differences(df, "methanol", "chloroform", nucleus="H", explicit="desmond")
341
+ assert len(out) == 1
342
+ r = out.iloc[0]
343
+ assert r["exp_diff"] == pytest.approx(1.0 - 2.0) # reference - check
344
+ assert r["implicit_diff"] == pytest.approx(0.4 - 0.1) # check - reference
345
+ assert r["explicit_diff"] == pytest.approx(0.5 - 0.2)
346
+ assert r["explicit_vib_diff"] == pytest.approx((0.5 + 0.1) - (0.2 + 0.05))
347
+
348
+
349
+ def test_solvent_induced_shifts_stacks_solvents():
350
+ rng = np.random.default_rng(0)
351
+ rows = []
352
+ for solvent in ["chloroform", "methanol", "benzene"]:
353
+ for si in range(3):
354
+ rows.append({"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H", "solvent": solvent,
355
+ "experimental": rng.normal(), "pcm": rng.normal(), "desmond": rng.normal(),
356
+ "desmond_vib": rng.normal()})
357
+ df = pd.DataFrame(rows)
358
+ out = D.solvent_induced_shifts(df, "chloroform", ["chloroform", "methanol", "benzene"],
359
+ nucleus="H", explicit="desmond")
360
+ # two non-reference solvents x 3 sites = 6 rows, reference column constant
361
+ assert len(out) == 6
362
+ assert set(out["solvent"].unique()) == {"methanol", "benzene"}
363
+ assert (out["reference"] == "chloroform").all()
364
+
365
+
366
+ def test_fit_differences_to_experimental():
367
+ # exp_diff = -2 * predicted + 0.3 exactly
368
+ pred = np.linspace(-1, 1, 20)
369
+ df = pd.DataFrame({"exp_diff": -2.0 * pred + 0.3, "explicit_diff": pred})
370
+ fit = D.fit_differences_to_experimental(df, "explicit_diff")
371
+ assert fit["slope"] == pytest.approx(-2.0, abs=1e-9)
372
+ assert fit["intercept"] == pytest.approx(0.3, abs=1e-9)
373
+ assert fit["rmse"] == pytest.approx(0.0, abs=1e-9)
374
+ assert fit["n"] == 20
375
+
376
+
377
+ def test_explicit_correction_pairs_dedups_and_drops_nan():
378
+ rows = []
379
+ for method in ["wp04", "dsd_pbep86"]: # same site tiled across methods
380
+ rows.append({"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
381
+ "sap_nmr_method": method, "desmond": 0.5, "openMM": 0.4})
382
+ rows.append({"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
383
+ "sap_nmr_method": "wp04", "desmond": np.nan, "openMM": 0.2}) # dropped (NaN)
384
+ out = D.explicit_correction_pairs(pd.DataFrame(rows), "chloroform", "H")
385
+ assert len(out) == 1
386
+ assert out.iloc[0]["desmond"] == pytest.approx(0.5)
387
+ assert out.iloc[0]["openMM"] == pytest.approx(0.4)
388
+
389
+
390
+ def test_compare_dft_nn():
391
+ # method-tiled correction; dedup by keys, then error = NN - DFT
392
+ dft = pd.DataFrame([
393
+ {"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": 0.10},
394
+ {"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "mp2", "qcd": 0.10},
395
+ {"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": 0.50},
396
+ ])
397
+ nn = pd.DataFrame([
398
+ {"solute": "m0", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": 0.13},
399
+ {"solute": "m1", "site": "s1", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": 0.40},
400
+ ])
401
+ out = D.compare_dft_nn(dft, nn, "qcd", keys=("solute", "site", "nucleus"))
402
+ assert len(out) == 2
403
+ h = out[out["nucleus"] == "H"].iloc[0]
404
+ assert h["qcd_dft"] == pytest.approx(0.10)
405
+ assert h["qcd_nn"] == pytest.approx(0.13)
406
+ assert h["error"] == pytest.approx(0.13 - 0.10)
407
+
408
+
409
+ def test_qcd_correction_by_site_filters_nucleus_and_sorts_by_solute():
410
+ # three H sites across two solutes (out of alphabetical order) plus one C site that must be
411
+ # dropped; check the nucleus filter, the solute sort, and that the DFT/NN values pass through.
412
+ dft = pd.DataFrame([
413
+ {"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.70},
414
+ {"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.65},
415
+ {"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "wp04", "qcd": -0.80},
416
+ {"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "wp04", "qcd": -4.0},
417
+ ])
418
+ nn = pd.DataFrame([
419
+ {"solute": "zzz", "site": "s0", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.72},
420
+ {"solute": "AcOH", "site": "s1", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.64},
421
+ {"solute": "AcOH", "site": "s2", "nucleus": "H", "sap_nmr_method": "MagNET", "qcd": -0.79},
422
+ {"solute": "AcOH", "site": "s3", "nucleus": "C", "sap_nmr_method": "MagNET", "qcd": -4.1},
423
+ ])
424
+ out = D.qcd_correction_by_site(dft, nn, nucleus="H")
425
+ assert len(out) == 3 # the C row is dropped
426
+ assert (out["nucleus"] == "H").all()
427
+ assert list(out["solute"]) == ["AcOH", "AcOH", "zzz"] # sorted, "zzz" sorts after "AcOH"
428
+ row = out[out["site"] == "s1"].iloc[0]
429
+ assert row["qcd_dft"] == pytest.approx(-0.65)
430
+ assert row["qcd_nn"] == pytest.approx(-0.64)
431
+ assert row["error"] == pytest.approx(-0.64 - (-0.65))
432
+
433
+
434
+ def test_compare_dft_nn_by_engine_stacks_both_engines():
435
+ # one shared site, a different desmond/openMM value each, so the two engines land in separate
436
+ # rows with a common dft_value/nn_value pair of columns instead of engine-specific column names
437
+ dft = pd.DataFrame([
438
+ {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
439
+ "sap_nmr_method": "wp04", "desmond": 0.10, "openMM": 0.20},
440
+ ])
441
+ nn = pd.DataFrame([
442
+ {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
443
+ "sap_nmr_method": "MagNET", "desmond": 0.13, "openMM": 0.17},
444
+ ])
445
+ out = D.compare_dft_nn_by_engine(dft, nn)
446
+ assert len(out) == 2
447
+ assert set(out["engine"]) == {"desmond", "openMM"}
448
+ assert list(out.columns) == ["solute", "site", "nucleus", "solvent", "dft_value", "nn_value",
449
+ "error", "engine"]
450
+ desmond_row = out[out["engine"] == "desmond"].iloc[0]
451
+ assert desmond_row["dft_value"] == pytest.approx(0.10)
452
+ assert desmond_row["nn_value"] == pytest.approx(0.13)
453
+ assert desmond_row["error"] == pytest.approx(0.13 - 0.10)
454
+ openmm_row = out[out["engine"] == "openMM"].iloc[0]
455
+ assert openmm_row["dft_value"] == pytest.approx(0.20)
456
+ assert openmm_row["nn_value"] == pytest.approx(0.17)
457
+ assert openmm_row["error"] == pytest.approx(0.17 - 0.20)
458
+
459
+
460
+ def test_explicit_correction_dft_nn_pairs_merges_sources():
461
+ dft = pd.DataFrame([
462
+ {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
463
+ "sap_nmr_method": "wp04", "desmond": 0.5, "openMM": 0.4},
464
+ {"solute": "m1", "site": "s1", "nucleus": "H", "solvent": "chloroform",
465
+ "sap_nmr_method": "wp04", "desmond": -0.2, "openMM": -0.3},
466
+ ])
467
+ nn = pd.DataFrame([
468
+ {"solute": "m0", "site": "s0", "nucleus": "H", "solvent": "chloroform",
469
+ "sap_nmr_method": "MagNET", "desmond": 0.55, "openMM": 0.42},
470
+ # site s1 has no NN row, so it should not appear in the merged (inner-join) result
471
+ ])
472
+ out = D.explicit_correction_dft_nn_pairs(dft, nn, "chloroform", "H")
473
+ assert len(out) == 1
474
+ row = out.iloc[0]
475
+ assert row["solute"] == "m0"
476
+ assert row["desmond_dft"] == pytest.approx(0.5)
477
+ assert row["openMM_dft"] == pytest.approx(0.4)
478
+ assert row["desmond_nn"] == pytest.approx(0.55)
479
+ assert row["openMM_nn"] == pytest.approx(0.42)
480
+
481
+
482
+ def test_si_s13d_fitting_accuracy_selects_formula_by_nucleus_and_engine():
483
+ # stationary + qcd + desmond exactly reproduces experimental for H; stationary + desmond_vib +
484
+ # openMM exactly reproduces it for C. Any other combination of (nucleus, engine) leaves a
485
+ # nonzero residual from the term the formula does NOT include, so a near-zero test RMSE proves
486
+ # si_s13d_fitting_accuracy picked the QCD-based formula for H and the vib-based one for C, and
487
+ # picked the desmond/openMM explicit term matching the "engine" column.
488
+ rng = np.random.default_rng(5)
489
+ solvents = ["chloroform", "methanol", "TIP4P", "benzene"]
490
+ rows = []
491
+ for si in range(14):
492
+ solute = f"m{si:02d}"
493
+ for solvent in solvents:
494
+ for k in range(2):
495
+ stationary = rng.normal(50, 5)
496
+ qcd = rng.normal(0, 1)
497
+ desmond = rng.normal(0, 1)
498
+ desmond_vib = rng.normal(0, 1)
499
+ openMM = rng.normal(0, 1)
500
+ openMM_vib = rng.normal(0, 1)
501
+ rows.append({
502
+ "solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
503
+ "sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
504
+ "sap_geometry_type": "pbe0_tz",
505
+ "stationary": stationary, "qcd": qcd, "pcm": 0.0,
506
+ "desmond": desmond, "openMM": openMM,
507
+ "desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
508
+ "nucleus": "H",
509
+ "experimental": stationary + qcd + desmond,
510
+ })
511
+ rows.append({
512
+ "solute": solute, "solvent": solvent, "site": f"{solute}_{k}",
513
+ "sap_nmr_method": "dsd_pbep86", "sap_basis": "pcSseg3",
514
+ "sap_geometry_type": "pbe0_tz",
515
+ "stationary": stationary, "qcd": qcd, "pcm": 0.0,
516
+ "desmond": desmond, "openMM": openMM,
517
+ "desmond_vib": desmond_vib, "openMM_vib": openMM_vib,
518
+ "nucleus": "C",
519
+ "experimental": stationary + openMM_vib + openMM,
520
+ })
521
+ dft = pd.DataFrame(rows)
522
+ nn = dft.copy() # same synthetic values for both sources; the exact-fit check is source-agnostic
523
+ solutes = sorted(dft["solute"].unique())
524
+
525
+ h_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
526
+ nucleus="H")
527
+ h_desmond = h_results[h_results["engine"] == "desmond"]["test_RMSE"]
528
+ assert (h_desmond < 1e-8).all()
529
+ h_openmm = h_results[h_results["engine"] == "openMM"]["test_RMSE"]
530
+ assert (h_openmm > 0.1).all() # missing the desmond term used to build "experimental"
531
+
532
+ c_results = D.si_s13d_fitting_accuracy(dft, nn, solvents, n_splits=3, solutes=solutes,
533
+ nucleus="C")
534
+ c_openmm = c_results[c_results["engine"] == "openMM"]["test_RMSE"]
535
+ assert (c_openmm < 1e-8).all()
536
+ c_desmond = c_results[c_results["engine"] == "desmond"]["test_RMSE"]
537
+ assert (c_desmond > 0.1).all() # missing the openMM term used to build "experimental"
538
+
539
+
540
+ def test_pcm_benefit_per_solvent_positive():
541
+ df = make_dft_query_df(seed=3) # experimental = 31 - (stationary + pcm), so PCM genuinely helps
542
+ solutes = sorted(df["solute"].unique())
543
+ benefit = D.pcm_benefit_per_solvent(df, "wp04", "pcSseg2", "aimnet2",
544
+ ["chloroform", "methanol", "benzene"], n_splits=4,
545
+ solutes=solutes, nucleus="H", n_test=4)
546
+ assert set(benefit.index) == {"chloroform", "methanol", "benzene"}
547
+ assert (benefit > 0).all() # adding PCM lowers the error in every solvent
548
+
549
+
550
+ def test_frame_convergence_math():
551
+ # 3 atoms, 5 frames; site = atoms 0 and 2; solvated - isolated = a known per-frame value
552
+ n_frames = 5
553
+ isolated = np.zeros((n_frames, 3))
554
+ solvated = np.zeros((n_frames, 3))
555
+ per_frame_truth = np.array([0.2, 0.4, np.nan, 0.6, 0.8])
556
+ for f in range(n_frames):
557
+ solvated[f, 0] = per_frame_truth[f]
558
+ solvated[f, 2] = per_frame_truth[f]
559
+ solvated[2, :] = np.nan # frame 2 invalid
560
+ perturbed = np.stack([isolated, solvated], axis=-1) # (5, 3, 2)
561
+ fc = D.frame_corrections(perturbed, [0, 2])
562
+ assert fc[0] == pytest.approx(0.2)
563
+ assert np.isnan(fc[2])
564
+ ra = D.running_average(fc)
565
+ # running average over the valid frames 0.2, 0.4, (skip), 0.6, 0.8
566
+ assert ra[0] == pytest.approx(0.2)
567
+ assert ra[1] == pytest.approx(0.3)
568
+ assert ra[2] == pytest.approx(0.3) # invalid frame does not move the average
569
+ assert ra[4] == pytest.approx((0.2 + 0.4 + 0.6 + 0.8) / 4)
570
+
571
+
572
+ def test_autocorrelation():
573
+ ac = D.autocorrelation(np.array([1.0, -1.0, 1.0, -1.0, 1.0, -1.0]), max_lag=2)
574
+ assert ac[0] == pytest.approx(1.0) # lag 0 is always 1
575
+ assert ac[1] < 0 # alternating -> negative lag-1 autocorrelation
576
+
577
+
578
+ def test_frame_validity():
579
+ # 4 frames, 2 atoms: frame 0 fully computed, frame 1 only the isolated value, frame 2 all NaN
580
+ # (skipped entirely), frame 3 fully computed. A frame counts as valid if anything was computed.
581
+ perturbed = np.full((4, 2, 2), np.nan)
582
+ perturbed[0] = [[1.0, 2.0], [3.0, 4.0]]
583
+ perturbed[1, 0, 0] = 5.0
584
+ perturbed[3] = [[1.0, 2.0], [3.0, 4.0]]
585
+ valid = D.frame_validity(perturbed)
586
+ assert list(valid) == [True, True, False, True]
587
+
588
+
589
+ def test_frame_validity_grid_pads_to_the_longest_solvent(monkeypatch):
590
+ # two "solvents" with different trajectory lengths; the grid must pad the shorter one with
591
+ # False past its own frame count, and frame_counts must report each solvent's true length.
592
+ fake_data = {
593
+ "acetone": np.array([[[1.0, 2.0]], [[np.nan, np.nan]], [[1.0, 2.0]]]), # 3 frames, 1 invalid
594
+ "benzene": np.array([[[1.0, 2.0]], [[1.0, 2.0]]]), # 2 frames, all valid
595
+ }
596
+
597
+ def fake_load_perturbed_shieldings(hdf5_filepath, solute, solvent, explicit_solvation_model,
598
+ shield_type):
599
+ return fake_data[solvent]
600
+
601
+ monkeypatch.setattr(D, "load_perturbed_shieldings", fake_load_perturbed_shieldings)
602
+ grid, frame_counts = D.frame_validity_grid("unused.hdf5", "solute", ["acetone", "benzene"],
603
+ "openMM")
604
+ assert grid.shape == (2, 3)
605
+ assert list(frame_counts) == [3, 2]
606
+ assert list(grid[0]) == [True, False, True]
607
+ assert list(grid[1]) == [True, True, False] # padded with False past benzene's 2 frames
608
+
609
+
610
+ def test_pareto_frontier():
611
+ points = pd.DataFrame({
612
+ "label": ["a", "b", "c", "d", "e"],
613
+ "total_time": [1.0, 2.0, 3.0, 4.0, 5.0],
614
+ "test_RMSE": [5.0, 3.0, 4.0, 1.0, 2.0],
615
+ })
616
+ front = D.pareto_frontier(points, x="total_time", y="test_RMSE")
617
+ assert list(front["label"]) == ["a", "b", "d"] # c and e are dominated
618
+
619
+
620
+ def test_correlation_matrix():
621
+ # two solvents whose per-site pcm corrections are perfectly correlated
622
+ base = np.array([0.1, 0.5, -0.3, 0.7, -0.2, 0.4])
623
+ rows = []
624
+ for i, value in enumerate(base):
625
+ rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "chloroform", "pcm": value})
626
+ rows.append({"solute": f"m{i}", "site": f"s{i}", "solvent": "benzene", "pcm": 2 * value + 1})
627
+ df = pd.DataFrame(rows)
628
+ corr = D.correlation_matrix(df, "pcm", ["solute", "site"], "solvent")
629
+ assert corr.loc["chloroform", "benzene"] == pytest.approx(1.0, abs=1e-9)
630
+
631
+
632
+ # --- Figure 2A / S1 (Pareto) ----------------------------------------------------------------
633
+
634
+ def test_pcm_conversion_factors_recovers_known_slope():
635
+ # build DFT rows where the output method's PCM in each solvent is a known multiple of the input
636
+ # method's chloroform PCM, so the recomputed factor must equal that multiple.
637
+ rng = np.random.default_rng(0)
638
+ multiples = {"chloroform": 1.0, "benzene": 0.65, "methanol": 1.4}
639
+ rows = []
640
+ for si in range(8):
641
+ ref = rng.normal(0, 2) # the input method's chloroform PCM for this site
642
+ for solvent, mult in multiples.items():
643
+ common = {"solute": f"m{si}", "site": f"m{si}_s", "nucleus": "H",
644
+ "sap_geometry_type": "aimnet2", "sap_basis": "pcSseg2", "solvent": solvent}
645
+ # input (reference) method: chloroform carries `ref`, other solvents irrelevant
646
+ rows.append({**common, "sap_nmr_method": D.MAGNET_PCM_INPUT_METHOD,
647
+ "pcm": ref if solvent == "chloroform" else rng.normal()})
648
+ # output method: this solvent's PCM is mult * the chloroform reference
649
+ rows.append({**common, "sap_nmr_method": "wp04", "pcm": mult * ref})
650
+ df = pd.DataFrame(rows)
651
+ factors = D.pcm_conversion_factors(df, "H", output_method="wp04")
652
+ for solvent, mult in multiples.items():
653
+ assert factors[solvent] == pytest.approx(mult, abs=1e-9)
654
+
655
+
656
+ def test_add_nn_stationary_plus_pcm():
657
+ df = pd.DataFrame({
658
+ "solute": ["m0", "m0"], "site": ["a", "b"], "nucleus": ["H", "C"],
659
+ "solvent": ["benzene", "benzene"], "stationary": [100.0, 50.0], "pcm": [2.0, 4.0],
660
+ })
661
+ factors = {"H": pd.Series({"benzene": 0.5}), "C": pd.Series({"benzene": 3.0})}
662
+ out = D.add_nn_stationary_plus_pcm(df, factors)
663
+ assert out["stationary_plus_pcm"].iloc[0] == pytest.approx(100.0 + 2.0 * 0.5)
664
+ assert out["stationary_plus_pcm"].iloc[1] == pytest.approx(50.0 + 4.0 * 3.0)
665
+
666
+
667
+ def _pareto_synthetic():
668
+ """A tiny DFT+NN pair where a 'fast' method is also more accurate, so it must sit on the
669
+ frontier and a 'slow, worse' method must not."""
670
+ rng = np.random.default_rng(1)
671
+ solvents = ["chloroform", "benzene", "TIP4P"]
672
+ dft_rows, nn_rows = [], []
673
+ for si in range(12):
674
+ solute = f"m{si:02d}"
675
+ for solvent in solvents:
676
+ stat = rng.normal(100, 20)
677
+ pcm = rng.normal(0, 2)
678
+ exp = 31.0 - (stat + pcm)
679
+ for method, noise in [("good_fast", 0.02), ("bad_slow", 1.5)]:
680
+ dft_rows.append({
681
+ "solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
682
+ "sap_nmr_method": method, "sap_basis": "pcSseg2", "sap_geometry_type": "aimnet2",
683
+ "experimental": exp + rng.normal(0, noise), "stationary": stat, "pcm": pcm})
684
+ nn_rows.append({
685
+ "solute": solute, "site": f"{solute}_s", "nucleus": "H", "solvent": solvent,
686
+ "sap_nmr_method": "MagNET", "sap_basis": "N/A", "sap_geometry_type": "aimnet2",
687
+ "experimental": exp + rng.normal(0, 0.05), "stationary": stat, "pcm": pcm})
688
+ return pd.DataFrame(dft_rows), pd.DataFrame(nn_rows)
689
+
690
+
691
+ def test_assemble_pareto_fitting_df_and_rmse():
692
+ dft, nn = _pareto_synthetic()
693
+ factors = {"H": pd.Series({s: 1.0 for s in ["chloroform", "benzene", "TIP4P"]}),
694
+ "C": pd.Series(dtype=float)}
695
+ fitting = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors, exclude_solutes=())
696
+ # TIP4P renamed to water, all three methods present, single predictor column built
697
+ assert "water" in set(fitting["solvent"]) and "TIP4P" not in set(fitting["solvent"])
698
+ assert set(fitting["nmr_method"]) == {"good_fast", "bad_slow", "MagNET"}
699
+ assert "stationary_plus_pcm" in fitting.columns
700
+ rmse = D.pareto_solvent_averaged_rmse(fitting, n_splits=6, n_test=6)
701
+ assert "solvent-averaged" in set(rmse["solvent"])
702
+ sav = rmse[rmse["solvent"] == "solvent-averaged"].set_index("nmr_method")["fitting_RMSE"]
703
+ assert sav["good_fast"] < sav["bad_slow"] # the accurate method has the lower RMSE
704
+
705
+
706
+ def test_attach_pareto_timings_substitutes_magnet():
707
+ rmse = pd.DataFrame({
708
+ "geometry_type": ["aimnet2", "aimnet2"], "nmr_method": ["bad_slow", "MagNET"],
709
+ "basis": ["pcSseg2", "N/A"], "nucleus": ["H", "H"], "solvent": ["solvent-averaged"] * 2,
710
+ "fitting_RMSE": [1.0, 0.2],
711
+ })
712
+ dft_gas = pd.DataFrame({"geometry_time": [10.0], "nmr_time": [990.0], "total_time": [1000.0]},
713
+ index=pd.MultiIndex.from_tuples([("aimnet2", "bad_slow", "pcSseg2")],
714
+ names=["geometry_type", "nmr_method", "basis"]))
715
+ nn = pd.DataFrame({"geometry_time": [5.0], "nmr_time": [15.0], "total_time": [20.0]},
716
+ index=pd.MultiIndex.from_tuples([("aimnet2", "MagNET", "N/A")],
717
+ names=["geometry_type", "nmr_method", "basis"]))
718
+ out = D.attach_pareto_timings(rmse, dft_gas, nn)
719
+ slow = out[out["nmr_method"] == "bad_slow"].iloc[0]
720
+ magnet = out[out["nmr_method"] == "MagNET"].iloc[0]
721
+ assert slow["total_time"] == pytest.approx(1000.0)
722
+ assert magnet["total_time"] == pytest.approx(20.0) # substituted from the NN timings
723
+ # MagNET dominates: faster and more accurate, so it is on the frontier and slow is not
724
+ front = D.pareto_frontier(out, x="total_time", y="fitting_RMSE")
725
+ assert list(front["nmr_method"]) == ["MagNET"]
726
+
727
+
728
+ def test_pareto_table_curated_keeps_only_the_documented_rows():
729
+ points = pd.DataFrame([
730
+ # kept: MagNET itself
731
+ {"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "MagNET", "basis": "N/A", "total_time": 1.0},
732
+ # kept: the one representative aimnet2 row (H's reference method wp04, pcSseg2)
733
+ {"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 2.0},
734
+ # dropped: aimnet2 + wp04 but the WRONG basis
735
+ {"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg1", "total_time": 3.0},
736
+ # dropped: aimnet2 + pcSseg2 but the WRONG method (not H's reference)
737
+ {"nucleus": "H", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "hf", "basis": "pcSseg2", "total_time": 4.0},
738
+ # kept: part of the full pbe0_tz grid, any method/basis
739
+ {"nucleus": "H", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 5.0},
740
+ # dropped: right nucleus/geometry, WRONG solvent
741
+ {"nucleus": "H", "solvent": "benzene", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 6.0},
742
+ # dropped: right solvent/geometry, WRONG nucleus
743
+ {"nucleus": "C", "solvent": "chloroform", "geometry_type": "pbe0_tz", "nmr_method": "hf", "basis": "pcSseg1", "total_time": 7.0},
744
+ ])
745
+ out = D.pareto_table_curated(points, "H", solvent="chloroform")
746
+ assert len(out) == 3
747
+ assert set(out["total_time"]) == {1.0, 2.0, 5.0}
748
+ assert list(out["total_time"]) == sorted(out["total_time"]) # sorted by total_time ascending
749
+
750
+
751
+ def test_pareto_table_curated_uses_the_carbon_reference_method():
752
+ # C's reference method is wb97xd (MAGNET_PCM_OUTPUT_METHODS), not H's wp04
753
+ points = pd.DataFrame([
754
+ {"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wp04", "basis": "pcSseg2", "total_time": 1.0},
755
+ {"nucleus": "C", "solvent": "chloroform", "geometry_type": "aimnet2", "nmr_method": "wb97xd", "basis": "pcSseg2", "total_time": 2.0},
756
+ ])
757
+ out = D.pareto_table_curated(points, "C", solvent="chloroform")
758
+ assert list(out["nmr_method"]) == ["wb97xd"]
759
+
760
+
761
+ # Published Figure 2A values to reproduce from the released data (from the original
762
+ # fig2a_pareto_results.csv). MagNET sits at the cheap end of a flat frontier.
763
+ FIG2A_MAGNET_SOLVENT_AVERAGED = {"H": 0.1647203288369508, "C": 1.7268703504301826}
764
+ FIG2A_CONVERSION_FACTORS_H_BENZENE = 0.6513139306748533 # from magnet_pcm_conversion_factors_H.csv
765
+
766
+
767
+ @pytest.mark.skipif(
768
+ not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
769
+ reason="real delta22.hdf5 / experimental xlsx not present")
770
+ def test_fig2a_reproduces_published_magnet_point():
771
+ """Recompute the MagNET Pareto point and conversion factors from the released data and check
772
+ them against the published Figure 2A numbers (the int32 encoding sets the precision floor)."""
773
+ dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
774
+ nn = D.load_query_df_nn(REAL_H5, REAL_XLSX, verbose=False)
775
+ # conversion factors recomputed from the DFT PCM data match the stored CSV value
776
+ cf_h = D.pcm_conversion_factors(dft, "H")
777
+ assert cf_h["benzene"] == pytest.approx(FIG2A_CONVERSION_FACTORS_H_BENZENE, abs=1e-6)
778
+ # build only the MagNET fitting rows and average over solvents (fast; avoids the full DFT sweep)
779
+ factors = {nuc: D.pcm_conversion_factors(dft, nuc) for nuc in ("H", "C")}
780
+ nn_fit = D.add_nn_stationary_plus_pcm(nn, factors).rename(columns=lambda c: c.replace("sap_", ""))
781
+ nn_fit["solvent"] = nn_fit["solvent"].replace("TIP4P", "water")
782
+ nn_fit["experimental"] = pd.to_numeric(nn_fit["experimental"], errors="coerce")
783
+ nn_fit = nn_fit[nn_fit["solute"] != "nitromethane"]
784
+ # the split solute order must match the full combined table (DFT solutes, in file order)
785
+ solutes = D.assemble_pareto_fitting_df(dft, nn, conversion_factors=factors)["solute"].unique().tolist()
786
+ rmse = D.pareto_solvent_averaged_rmse(nn_fit, solutes=solutes)
787
+ sav = rmse[(rmse["nmr_method"] == "MagNET") & (rmse["solvent"] == "solvent-averaged")]
788
+ for nucleus, expected in FIG2A_MAGNET_SOLVENT_AVERAGED.items():
789
+ got = sav[sav["nucleus"] == nucleus]["fitting_RMSE"].iloc[0]
790
+ assert got == pytest.approx(expected, abs=1e-4), f"{nucleus}: {got} vs {expected}"
791
+
792
+
793
+ # --- SI Figure S15's "Correlations Between Features" ---------------------------------------------
794
+
795
+ def test_feature_correlation_matrix_synthetic():
796
+ # a and b perfectly correlated, c independent noise uncorrelated with either
797
+ n = 200
798
+ rng = np.random.default_rng(0)
799
+ a = rng.normal(size=n)
800
+ df = pd.DataFrame({
801
+ "nucleus": ["H"] * n, "solvent": ["chloroform"] * n,
802
+ "stationary": a, "pcm": a * 2 + 1, # perfectly correlated with stationary
803
+ "desmond": rng.normal(size=n), "desmond_vib": rng.normal(size=n), "qcd": rng.normal(size=n),
804
+ })
805
+ corr = D.feature_correlation_matrix(df, "H", "chloroform")
806
+ assert corr.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
807
+ corr2 = D.feature_correlation_matrix(df, "H", "chloroform", squared=True)
808
+ assert corr2.loc["stationary", "pcm"] == pytest.approx(1.0, abs=1e-9)
809
+
810
+
811
+ @pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
812
+ def test_average_feature_correlation_matrix_averages_across_solvents():
813
+ df = pd.DataFrame({
814
+ "nucleus": ["H"] * 4, "solvent": ["chloroform", "chloroform", "benzene", "benzene"],
815
+ "stationary": [1.0, 2.0, 1.0, 3.0], "pcm": [1.0, 2.0, 3.0, 1.0],
816
+ "desmond": [0.5, 0.5, 0.5, 0.5], "desmond_vib": [0.1, 0.2, 0.1, 0.2], "qcd": [0, 0, 0, 0],
817
+ })
818
+ avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
819
+ chloroform_only = D.feature_correlation_matrix(df, "H", "chloroform")
820
+ benzene_only = D.feature_correlation_matrix(df, "H", "benzene")
821
+ expected = (chloroform_only.loc["stationary", "pcm"] + benzene_only.loc["stationary", "pcm"]) / 2
822
+ assert avg.loc["stationary", "pcm"] == pytest.approx(expected)
823
+
824
+
825
+ @pytest.mark.filterwarnings("ignore:Mean of empty slice:RuntimeWarning")
826
+ def test_average_feature_correlation_matrix_ignores_a_solvent_with_too_few_sites():
827
+ # benzene has only 1 site -> feature_correlation_matrix returns all-NaN for benzene alone
828
+ # (< 2 sites). A plain sum()/len() average would make the WHOLE averaged cell NaN even though
829
+ # chloroform (4 sites) has a perfectly good value; nanmean should use chloroform alone instead.
830
+ df = pd.DataFrame({
831
+ "nucleus": ["H"] * 5,
832
+ "solvent": ["chloroform", "chloroform", "chloroform", "chloroform", "benzene"],
833
+ "stationary": [1.0, 2.0, 3.0, 4.0, 1.0], "pcm": [2.0, 4.0, 6.0, 8.0, 5.0],
834
+ "desmond": [0.5] * 5, "desmond_vib": [0.1] * 5, "qcd": [0.0] * 5,
835
+ })
836
+ avg = D.average_feature_correlation_matrix(df, "H", ["chloroform", "benzene"])
837
+ assert not np.isnan(avg.loc["stationary", "pcm"])
838
+ assert avg.loc["stationary", "pcm"] == pytest.approx(1.0) # chloroform alone: perfectly correlated
839
+
840
+
841
+ @pytest.mark.skipif(
842
+ not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
843
+ reason="real delta22.hdf5 / experimental xlsx not present")
844
+ def test_si_s15_feature_correlations_reproduces_published_matrices():
845
+ """Every entry of both nuclei's published Pearson R and R^2 "Correlations Between Features"
846
+ matrices (SI Figure S15), reproduced from the released data to 3 decimal places. Guards against
847
+ pooling all DFT methods/bases/geometries together instead of filtering to the MagNET-Zero
848
+ reference level, which throws entries off by up to 0.03."""
849
+ dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
850
+ # R^2 here is the average of each solvent's r^2 (Jensen's inequality: != the square of the
851
+ # averaged r), so it is hardcoded separately from published_r, not derived from it.
852
+ published_r = {
853
+ "H": {("pcm", "stationary"): 0.298, ("desmond", "stationary"): 0.262, ("desmond", "pcm"): 0.399,
854
+ ("desmond_vib", "stationary"): -0.170, ("desmond_vib", "pcm"): -0.219, ("desmond_vib", "desmond"): -0.141,
855
+ ("qcd", "stationary"): -0.851, ("qcd", "pcm"): -0.362, ("qcd", "desmond"): -0.394, ("qcd", "desmond_vib"): 0.405},
856
+ "C": {("pcm", "stationary"): 0.584, ("desmond", "stationary"): 0.106, ("desmond", "pcm"): 0.667,
857
+ ("desmond_vib", "stationary"): 0.531, ("desmond_vib", "pcm"): 0.369, ("desmond_vib", "desmond"): 0.075,
858
+ ("qcd", "stationary"): -0.616, ("qcd", "pcm"): -0.635, ("qcd", "desmond"): -0.418, ("qcd", "desmond_vib"): -0.138},
859
+ }
860
+ published_r2 = {
861
+ "H": {("pcm", "stationary"): 0.089, ("desmond", "stationary"): 0.093, ("desmond", "pcm"): 0.386,
862
+ ("desmond_vib", "stationary"): 0.032, ("desmond_vib", "pcm"): 0.049, ("desmond_vib", "desmond"): 0.025,
863
+ ("qcd", "stationary"): 0.724, ("qcd", "pcm"): 0.131, ("qcd", "desmond"): 0.184, ("qcd", "desmond_vib"): 0.167},
864
+ "C": {("pcm", "stationary"): 0.341, ("desmond", "stationary"): 0.047, ("desmond", "pcm"): 0.463,
865
+ ("desmond_vib", "stationary"): 0.284, ("desmond_vib", "pcm"): 0.139, ("desmond_vib", "desmond"): 0.019,
866
+ ("qcd", "stationary"): 0.380, ("qcd", "pcm"): 0.404, ("qcd", "desmond"): 0.189, ("qcd", "desmond_vib"): 0.021},
867
+ }
868
+ for nucleus in ("H", "C"):
869
+ corr = D.si_s15_feature_correlations(dft, nucleus)
870
+ for (a, b), expected in published_r[nucleus].items():
871
+ assert corr["r"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r[{a},{b}]"
872
+ for (a, b), expected in published_r2[nucleus].items():
873
+ assert corr["r2"].loc[a, b] == pytest.approx(expected, abs=5e-4), f"{nucleus} r2[{a},{b}]"
874
+
875
+
876
+ @pytest.mark.skipif(
877
+ not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
878
+ reason="real delta22.hdf5 / experimental xlsx not present")
879
+ def test_pcm_desmond_correlation_by_solvent_reproduces_published_table():
880
+ dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
881
+ table = D.pcm_desmond_correlation_by_solvent(dft)
882
+ published = {("H", "chloroform"): 0.739, ("H", "TIP4P"): 0.728, ("H", "benzene"): -0.454,
883
+ ("C", "chloroform"): 0.408, ("C", "TIP4P"): 0.840, ("C", "benzene"): 0.594}
884
+ for (nucleus, solvent), expected in published.items():
885
+ assert table.loc[nucleus, solvent] == pytest.approx(expected, abs=5e-4), f"{nucleus}/{solvent}"
886
+
887
+
888
+ @pytest.mark.skipif(
889
+ not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
890
+ reason="real delta22.hdf5 / experimental xlsx not present")
891
+ def test_pcm_benefit_reproduces_published_figure_s5_correlations():
892
+ # Figure S5 aggregates the per-split PCM benefit with the mean across splits, not the median;
893
+ # only the mean reproduces the published Pearson R values.
894
+ dft = D.load_query_df_dft(REAL_H5, REAL_XLSX, verbose=False)
895
+ solutes = sorted(dft["solute"].unique())
896
+
897
+ def pearson_r(benefit, prop, exclude=()):
898
+ s = benefit.drop(index=[x for x in exclude if x in benefit.index])
899
+ x = np.array([prop[k] for k in s.index])
900
+ return float(np.corrcoef(x, s.values)[0, 1])
901
+
902
+ aromatics_tfe = ["benzene", "toluene", "chlorobenzene", "trifluoroethanol"]
903
+ benefit_h = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["H"], "pcSseg2", "aimnet2",
904
+ D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="H")
905
+ benefit_c = D.pcm_benefit_per_solvent(dft, D.MAGNET_PCM_OUTPUT_METHODS["C"], "pcSseg2", "aimnet2",
906
+ D.DESMOND_SOLVENTS, n_splits=250, solutes=solutes, nucleus="C")
907
+ published = { # (panel, axis): published Pearson R
908
+ "S5A 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC), 0.617),
909
+ "S5A 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY), -0.801),
910
+ "S5B 13C dielectric": (pearson_r(benefit_c, D.SOLVENT_DIELECTRIC), 0.722),
911
+ "S5B 13C polarizability": (pearson_r(benefit_c, D.SOLVENT_POLARIZABILITY), -0.743),
912
+ "S5C 1H dielectric": (pearson_r(benefit_h, D.SOLVENT_DIELECTRIC, aromatics_tfe), 0.884),
913
+ "S5C 1H polarizability": (pearson_r(benefit_h, D.SOLVENT_POLARIZABILITY, aromatics_tfe), -0.587),
914
+ }
915
+ for name, (got, expected) in published.items():
916
+ assert got == pytest.approx(expected, abs=2e-3), f"{name}: {got} != {expected}"
analysis/code/test_dft8k_residuals.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for analysis/code/dft8k_residuals.py.
2
+
3
+ The synthetic tests check the residual selection and statistics on a hand-built shieldings dict.
4
+ One opt-in test runs against the real dft8k.hdf5 and asserts the published DFT8K residual numbers
5
+ (MagNET-Zero is a useful surrogate for DFT): 1H RMSE ~0.11 ppm, 13C RMSE ~0.81 ppm, with >95% of
6
+ proton sites below 0.1 ppm error.
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", "dft8k")) # dft8k_reader
16
+ sys.path.insert(0, HERE)
17
+ import paths
18
+
19
+ import dft8k_residuals as D # noqa: E402
20
+
21
+
22
+ def make_shieldings():
23
+ """A flat shieldings dict like the reader's all_shieldings: H, C, C, H, N. MagNET predicts H
24
+ with the WP04 model and C with the wB97X-D model; other atoms are NaN. The 1H residuals are
25
+ -0.1 and +0.2; the 13C residuals are 0.1 and 0.1."""
26
+ return {
27
+ "atomic_numbers": np.array([1, 6, 6, 1, 7]),
28
+ "wp04_pcSseg2": np.array([30.5, 150.5, 151.5, 31.5, 200.5]),
29
+ "wb97xd_pcSseg2": np.array([30.7, 150.7, 151.7, 31.7, 200.7]),
30
+ "nn_wp04_pcSseg2": np.array([30.6, np.nan, np.nan, 31.3, np.nan]),
31
+ "nn_wb97xd_pcSseg2": np.array([np.nan, 150.6, 151.6, np.nan, np.nan]),
32
+ }
33
+
34
+
35
+ def test_residuals_select_nucleus_and_drop_unpredicted():
36
+ s = make_shieldings()
37
+ h = D.residuals(s, "H")
38
+ # only the two H atoms (indices 0, 3), residual = DFT - MagNET
39
+ np.testing.assert_allclose(sorted(h), sorted([30.5 - 30.6, 31.5 - 31.3]))
40
+ c = D.residuals(s, "C")
41
+ np.testing.assert_allclose(sorted(c), sorted([150.7 - 150.6, 151.7 - 151.6]))
42
+ # the nitrogen atom is in neither nucleus' residuals
43
+ assert len(h) == 2 and len(c) == 2
44
+
45
+
46
+ def test_residual_stats():
47
+ errors = np.array([-0.1, 0.2, 0.05, -0.05])
48
+ st = D.residual_stats(errors, small_threshold=0.1)
49
+ assert st["n"] == 4
50
+ assert st["rmse"] == pytest.approx(np.sqrt(np.mean(errors ** 2)))
51
+ assert st["mae"] == pytest.approx(np.mean(np.abs(errors)))
52
+ # two of four are strictly below 0.1 ppm absolute (0.05 and 0.05); 0.1 and 0.2 are not
53
+ assert st["frac_below"] == pytest.approx(0.5)
54
+
55
+
56
+ def test_residual_stats_empty():
57
+ st = D.residual_stats(np.array([]))
58
+ assert st["n"] == 0 and np.isnan(st["rmse"])
59
+
60
+
61
+ # --- opt-in: reproduce the published DFT8K residual numbers from the released data --------------
62
+
63
+ REAL = paths.dataset_file("dft8k", file=__file__)
64
+
65
+
66
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
67
+ def test_reproduces_published_dft8k_residuals():
68
+ summary = D.residual_summary(REAL)
69
+ # 1H: RMSE ~0.11 ppm, >95% of sites below 0.1 ppm error (paper Figure 5 / DFT8K text)
70
+ assert summary["H"]["rmse"] == pytest.approx(0.105, abs=0.01)
71
+ assert summary["H"]["mae"] == pytest.approx(0.035, abs=0.01)
72
+ assert summary["H"]["frac_below"] >= 0.95
73
+ # 13C: RMSE ~0.81 ppm, MAE ~0.41 ppm
74
+ assert summary["C"]["rmse"] == pytest.approx(0.807, abs=0.02)
75
+ assert summary["C"]["mae"] == pytest.approx(0.409, abs=0.02)
76
+
77
+
78
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
79
+ def test_extreme_residual_reproduces_the_published_porphyrin_callout():
80
+ # Figure 5B's positive-tail outlier callout ("Porphyrin Rings", error +17.620 ppm) is the true
81
+ # global maximum 1H residual, so this reproduces it exactly.
82
+ extreme = D.find_extreme_residual(REAL, "H", sign="max")
83
+ assert extreme["residual"] == pytest.approx(17.6197, abs=1e-3)
84
+ assert extreme["molecule_id"] == 20170716
85
+
86
+
87
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
88
+ def test_extreme_residual_min_is_not_the_published_zwitterion_callout():
89
+ # The negative-tail callout ("Sulfonium Zwitterion", -1.040 ppm) is NOT the true global
90
+ # minimum -- a different, chemically unremarkable molecule is more negative. This test locks
91
+ # down that documented discrepancy so it is not silently "fixed" by a future data update.
92
+ extreme = D.find_extreme_residual(REAL, "H", sign="min")
93
+ assert extreme["residual"] < -1.5
94
+ assert extreme["molecule_id"] != 88779
95
+
96
+
97
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
98
+ def test_molecule_by_id_reproduces_the_published_zwitterion_callout():
99
+ zwitterion = D.molecule_by_id(REAL, "H", molecule_id=88779)
100
+ assert zwitterion["residual"] == pytest.approx(-1.0403, abs=1e-3)
101
+ assert "[S+]" in zwitterion["smiles"] and "[O-]" in zwitterion["smiles"]
102
+
103
+
104
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
105
+ def test_functional_group_errors_reproduces_published_values():
106
+ # published (Figure 5B, mean |1H residual| per group): Carbonyls 0.034, Amines 0.036,
107
+ # Sulfonyl 0.039, Pyridines 0.042, Furans 0.034, Nitroso 0.088 ppm.
108
+ published = {"Carbonyls": 0.034, "Amines": 0.036, "Sulfonyl": 0.039,
109
+ "Pyridines": 0.042, "Furans": 0.034, "Nitroso": 0.088}
110
+ group_errors = D.functional_group_errors(REAL, "H")
111
+ for name, expected in published.items():
112
+ assert group_errors[name]["mean_abs_error"] == pytest.approx(expected, abs=2e-3), name
113
+ assert group_errors[name]["n_molecules"] > 0
114
+
115
+
116
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
117
+ def test_functional_group_errors_respects_an_explicit_empty_patterns_dict():
118
+ # patterns=None means "use the default set"; patterns={} is a real, distinct request for "no
119
+ # groups" and must return {} rather than silently falling back to the default (an `x or
120
+ # DEFAULT` check would wrongly treat an empty dict as falsy and do exactly that).
121
+ assert D.functional_group_errors(REAL, "H", patterns={}) == {}
122
+
123
+
124
+ def test_find_extreme_zwitterion_residual_requires_a_valid_sign():
125
+ with pytest.raises(ValueError):
126
+ D.find_extreme_zwitterion_residual(REAL, "H", sign="sideways")
127
+
128
+
129
+ @pytest.mark.skipif(not os.path.exists(REAL), reason="real dft8k.hdf5 not present")
130
+ def test_find_extreme_zwitterion_residual_finds_a_real_sulfonium_zwitterion():
131
+ # not asserted to match the published callout exactly (see molecule_by_id's docstring: the
132
+ # published example is a hand-picked one, not the strict extremum among zwitterions) -- this
133
+ # just checks the search itself works and returns a genuine sulfonium zwitterion.
134
+ result = D.find_extreme_zwitterion_residual(REAL, "H", sign="min")
135
+ assert result is not None
136
+ assert "[S+]" in result["smiles"] and "[O-]" in result["smiles"]
137
+ assert result["residual"] < 0
analysis/code/test_leveling.py ADDED
@@ -0,0 +1,233 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Tests for the leveling-effect analysis core (leveling.py).
3
+
4
+ Most tests build tiny synthetic inputs in the exact on-disk formats (so the fixtures
5
+ double as a spec of what the loaders expect) and need no large data. Two opt-in tests
6
+ run against the real delta22.hdf5 and the Kaupp NS372 spreadsheet if they are present.
7
+
8
+ Run: pytest test_leveling.py -q
9
+ Requires: pytest, numpy, h5py, pandas, openpyxl, scikit-learn.
10
+ """
11
+ import os
12
+ import numpy as np
13
+ import h5py
14
+ import openpyxl
15
+ import pytest
16
+
17
+ from fixed_point import MISSING_MARKER as _MISSING_MARKER
18
+ from leveling import (
19
+ _decode_fixed_point,
20
+ analyze_nucleus,
21
+ read_nucleus,
22
+ load_delta22,
23
+ load_ns372,
24
+ load_ns372_1h_convergence,
25
+ convergence_statistics,
26
+ DELTA22_REF,
27
+ )
28
+
29
+ SCALE = 1e4
30
+
31
+
32
+ def _enc(arr):
33
+ """Encode floats the way the real file does: int32 = round(value * 1e4), NaN -> marker."""
34
+ a = np.asarray(arr, dtype=np.float64)
35
+ mask = np.isnan(a)
36
+ out = np.zeros(a.shape, dtype=np.int32)
37
+ out[~mask] = np.rint(a[~mask] * SCALE).astype(np.int32)
38
+ out[mask] = _MISSING_MARKER
39
+ return out
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # fixed-point decoding
44
+ # ---------------------------------------------------------------------------
45
+ def test_decode_round_trip_and_marker():
46
+ values = np.array([0.0, 207.07, -3.5, np.nan, 12345.6789], dtype=np.float64)
47
+ decoded = _decode_fixed_point(_enc(values))
48
+ assert np.isnan(decoded[3])
49
+ np.testing.assert_allclose(decoded[~np.isnan(decoded)],
50
+ values[~np.isnan(values)], atol=1e-4)
51
+
52
+
53
+ def test_decode_passes_through_float():
54
+ # an older plain-decimal copy must read unchanged (decode is a no-op on floats)
55
+ values = np.array([1.0, 2.5, -7.25], dtype=np.float64)
56
+ np.testing.assert_array_equal(_decode_fixed_point(values), values)
57
+
58
+
59
+ def test_decode_handles_int64_and_no_marker():
60
+ # a re-encode at int64 must decode like int32 (any integer dtype), and an
61
+ # integer array with no missing marker must round-trip with no spurious blanks
62
+ vals = np.array([10000, 20000, -35000], dtype=np.int64) # = 1.0, 2.0, -3.5
63
+ out = _decode_fixed_point(vals)
64
+ np.testing.assert_allclose(out, [1.0, 2.0, -3.5])
65
+ assert not np.isnan(out).any()
66
+
67
+
68
+ # ---------------------------------------------------------------------------
69
+ # leveling analysis on a synthetic, perfectly-leveled matrix
70
+ # ---------------------------------------------------------------------------
71
+ def test_analyze_nucleus_detects_leveling():
72
+ # build methods that are affine transforms of one base signal plus tiny noise:
73
+ # after scaling they are interchangeable, so PC1 should dominate and every
74
+ # pairwise correlation should be near 1.
75
+ rng = np.random.RandomState(0)
76
+ base = rng.normal(size=60)
77
+ slopes = np.linspace(0.8, 1.3, 8)
78
+ offsets = np.linspace(-5, 5, 8)
79
+ cols = [a * base + b + 0.01 * rng.normal(size=60)
80
+ for a, b in zip(slopes, offsets)]
81
+ M = np.column_stack(cols)
82
+ methods = [f"m{i}" for i in range(8)]
83
+ res = analyze_nucleus(M, methods, ref=M[:, 0])
84
+ assert res['ev'][0] > 0.99 # PC1 carries essentially all variance
85
+ assert res['r_min'] > 0.99 # worst pairwise correlation still near 1
86
+ assert set(res['scaled_rmse']) == set(methods)
87
+ assert 'r2' in res['par'] and 'theta' in res['par']
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # delta22 loader against a synthetic int32 HDF5 in the real layout
92
+ # ---------------------------------------------------------------------------
93
+ DELTA22_FUNCS = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
94
+ 'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
95
+ 'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
96
+ 'mp2', 'dlpno_mp2']
97
+
98
+
99
+ def _make_synthetic_delta22(path):
100
+ # one gas method label per functional. most sit at pcSseg3, but mp2 is only at
101
+ # pcSseg2 and hf only at pcSseg1, so the loader's largest-basis fallback ladder
102
+ # (pcSseg3 then pcSseg2 then pcSseg1) is exercised.
103
+ basis_for = {f: "pcSseg3" for f in DELTA22_FUNCS}
104
+ basis_for["mp2"] = "pcSseg2"
105
+ basis_for["hf"] = "pcSseg1"
106
+ names = [f"{f},{basis_for[f]},gas,none" for f in DELTA22_FUNCS]
107
+ n_methods = len(names)
108
+ with h5py.File(path, "w") as f:
109
+ f.create_dataset("conventional_nmr_method_names",
110
+ data=np.array(names, dtype=h5py.string_dtype()))
111
+ # one solute, atoms H, C, H, C, N (N is ignored: not 1H/13C)
112
+ z = np.array([1, 6, 1, 6, 7], np.uint8)
113
+ g = f.create_group("solutes/mol1")
114
+ g.create_dataset("atomic_numbers", data=z)
115
+ # conventional_shieldings[method, geometry, atom]; geometry 1 = PBE0/cc-pVTZ
116
+ base = np.array([30.0, 150.0, 31.0, 151.0, 200.0])
117
+ cs = np.zeros((n_methods, 2, len(z)))
118
+ for mi in range(n_methods):
119
+ cs[mi, 0] = base + 0.5 * mi # aimnet2 geometry (unused)
120
+ cs[mi, 1] = base + 0.01 * mi # pbe0_tz geometry (used)
121
+ g.create_dataset("stationary_and_pcm/conventional_shieldings", data=_enc(cs))
122
+ return base
123
+
124
+
125
+ def test_load_delta22_synthetic(tmp_path):
126
+ p = tmp_path / "delta22.hdf5"
127
+ base = _make_synthetic_delta22(str(p))
128
+ out = load_delta22(str(p))
129
+ assert set(out) == {"1H", "13C"}
130
+ # 1 solute has two H sites (atoms 0,2) and two C sites (atoms 1,3)
131
+ assert out["1H"]["M"].shape == (2, len(DELTA22_FUNCS))
132
+ assert out["13C"]["M"].shape == (2, len(DELTA22_FUNCS))
133
+ assert DELTA22_REF in out["1H"]["methods"]
134
+ # every functional was selected via the basis-fallback ladder, so no method is missing
135
+ assert not np.isnan(out["1H"]["M"]).any()
136
+ assert not np.isnan(out["13C"]["M"]).any()
137
+ # decoded values must match what we wrote on the pbe0_tz geometry; pick the
138
+ # DSD-PBEP86 method column for the first H site (atom 0)
139
+ ref_method_index = DELTA22_FUNCS.index("dsd_pbep86")
140
+ expected = base[0] + 0.01 * ref_method_index
141
+ col = out["1H"]["methods"].index(DELTA22_REF)
142
+ np.testing.assert_allclose(out["1H"]["M"][0, col], expected, atol=1e-4)
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # NS372 sheet parsing against a synthetic Kaupp-style worksheet
147
+ # ---------------------------------------------------------------------------
148
+ def _make_synthetic_kaupp_sheet(path):
149
+ wb = openpyxl.Workbook()
150
+ ws = wb.active
151
+ ws.title = "S6 - 1H Shieldings"
152
+ # row 1: functional headers from column 7; row 2: tau sub-labels
153
+ # two methods: SVWN (no tau) and TPSS (tau = tauC), matching leveling.METHODS keys
154
+ ws.cell(1, 7, "SVWN")
155
+ ws.cell(1, 8, "TPSS"); ws.cell(2, 8, "τC")
156
+ # data rows from row 4: col1 nucleus (sticky), col2 molecule, col4 CCSD(T) ref
157
+ rows = [("1H", "molA", 30.0, 30.1, 30.2),
158
+ ("1H", "molB", 28.0, 28.2, 28.1),
159
+ ("1H", "molC", 25.0, 25.3, 25.1)]
160
+ for i, (nuc, mol, ref, svwn, tpss) in enumerate(rows):
161
+ r = 4 + i
162
+ ws.cell(r, 1, nuc)
163
+ ws.cell(r, 2, mol)
164
+ ws.cell(r, 4, ref)
165
+ ws.cell(r, 7, svwn)
166
+ ws.cell(r, 8, tpss)
167
+ wb.save(path)
168
+
169
+
170
+ def test_read_nucleus_synthetic(tmp_path):
171
+ p = tmp_path / "kaupp.xlsx"
172
+ _make_synthetic_kaupp_sheet(str(p))
173
+ wb = openpyxl.load_workbook(str(p), data_only=True)
174
+ ref, data = read_nucleus(wb, "S6 - 1H Shieldings")
175
+ np.testing.assert_allclose(ref, [30.0, 28.0, 25.0])
176
+ assert ("SVWN", None) in data
177
+ assert ("TPSS", "τC") in data
178
+ np.testing.assert_allclose(data[("SVWN", None)], [30.1, 28.2, 25.3])
179
+ np.testing.assert_allclose(data[("TPSS", "τC")], [30.2, 28.1, 25.1])
180
+
181
+
182
+ # ---------------------------------------------------------------------------
183
+ # opt-in tests against the real files (skipped when absent, e.g. in CI)
184
+ # ---------------------------------------------------------------------------
185
+ def _find(*candidates):
186
+ for c in candidates:
187
+ if c and os.path.exists(c):
188
+ return c
189
+ return None
190
+
191
+ _HERE = os.path.dirname(os.path.abspath(__file__))
192
+ import paths
193
+ _REAL_DELTA22 = _find(
194
+ paths.dataset_file("delta22", file=__file__), # deposit copy, then in-repo data/delta22/
195
+ os.path.join(_HERE, "delta22.hdf5"),
196
+ os.path.join(_HERE, "delta22", "delta22.hdf5"),
197
+ )
198
+ _REAL_KAUPP = _find(
199
+ os.path.join(_HERE, "..", "..", "data", "ns372", "ct1c00919_si_002.xlsx"),
200
+ )
201
+
202
+
203
+ @pytest.mark.skipif(_REAL_DELTA22 is None, reason="real delta22.hdf5 not present")
204
+ def test_real_delta22_reproduces_leveling():
205
+ out = load_delta22(_REAL_DELTA22)
206
+ assert out["1H"]["M"].shape == (145, 18)
207
+ assert out["13C"]["M"].shape == (76, 18)
208
+ res = analyze_nucleus(out["1H"]["M"], out["1H"]["methods"], out["1H"]["ref"])
209
+ assert res['ev'][0] > 0.99 # PC1 dominance on the real proton data
210
+
211
+
212
+ @pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
213
+ def test_real_ns372_loads_all_nuclei():
214
+ out = load_ns372(_REAL_KAUPP)
215
+ assert set(out) == {"1H", "11B", "13C", "15N", "17O", "19F", "31P", "33S"}
216
+ res = analyze_nucleus(out["13C"]["M"], out["13C"]["methods"], out["13C"]["ref"])
217
+ assert res['ev'][0] > 0.99
218
+
219
+
220
+ @pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
221
+ def test_real_ns372_1h_convergence_stats():
222
+ # Figure 2D input: the 1H slice reduces to 44 methods vs the CCSD(T) reference,
223
+ # and each method's slope/scaled RMSE lands in the expected physical range.
224
+ cc = load_ns372_1h_convergence(_REAL_KAUPP)
225
+ assert cc.shape == (124, 45) # 44 methods + CCSD(T)
226
+ assert "CCSD(T)" in cc.columns and "HF" in cc.columns # 'Hartree-Fock' header renamed
227
+ res = convergence_statistics(cc).set_index("Method")
228
+ assert len(res) == 44
229
+ assert (res["Slope"].between(0.9, 1.15)).all() # near the y=x limit
230
+ assert (res["RMSE (scaled)"] > 0).all()
231
+ # the double hybrid sits closest to the electronic-structure limit
232
+ assert res["RMSE (scaled)"].idxmin() == "DSD-PBEP86"
233
+ assert res.loc["DSD-PBEP86", "RMSE (scaled)"] < 0.1
analysis/si_figures/si_figure_s03_leveling.ipynb ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "b61775ea",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SI Figure S3: inter-method correlation and PCA of NS372 and delta-22 shieldings\n",
9
+ "\n",
10
+ "Inter-method correlation matrices and PC1/PC2 loadings for NS372 (44 functionals x 8 nuclei) and\n",
11
+ "delta22 (18 functionals, ¹H/¹³C), plus a combined summary table."
12
+ ]
13
+ },
14
+ {
15
+ "cell_type": "markdown",
16
+ "id": "aeb46df4",
17
+ "metadata": {},
18
+ "source": [
19
+ "Two independent NMR shielding datasets, analyzed separately (never pooled):\n",
20
+ "\n",
21
+ "| Dataset | Source | Methods | Nuclei | Reference |\n",
22
+ "|---|---|---|---|---|\n",
23
+ "| **NS372** | Schattenberg & Kaupp, *JCTC* **17**, 7602 (2021) | 44 DFT/WFT functionals | ¹H ¹¹B ¹³C ¹⁵N ¹⁷O ¹⁹F ³¹P ³³S | CCSD(T)/pcSseg-3 |\n",
24
+ "| **delta22** | in-house `delta22.hdf5` | 18 gas-phase functionals, largest basis (pcSseg-3; mp2 → pcSseg-2), PBE0/cc-pVTZ geometry | ¹H ¹³C | DSD-PBEP86 (highest-rung in-set method, stands in for CCSD(T)) |"
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "markdown",
29
+ "id": "d0ce838b",
30
+ "metadata": {},
31
+ "source": [
32
+ "## 1. Configuration"
33
+ ]
34
+ },
35
+ {
36
+ "cell_type": "code",
37
+ "execution_count": null,
38
+ "id": "62157a75",
39
+ "metadata": {},
40
+ "outputs": [],
41
+ "source": [
42
+ "import os, sys\n",
43
+ "\n",
44
+ "REPO = os.path.abspath(\"../..\")\n",
45
+ "for _p in (\"analysis/code\", \"analysis/code/shared\"):\n",
46
+ " sys.path.insert(0, os.path.join(REPO, _p))"
47
+ ]
48
+ },
49
+ {
50
+ "cell_type": "code",
51
+ "execution_count": null,
52
+ "id": "8ea46824",
53
+ "metadata": {},
54
+ "outputs": [],
55
+ "source": [
56
+ "import glob\n",
57
+ "import pandas as pd\n",
58
+ "import matplotlib.pyplot as plt\n",
59
+ "\n",
60
+ "import paths\n",
61
+ "import leveling\n",
62
+ "import leveling_plots"
63
+ ]
64
+ },
65
+ {
66
+ "cell_type": "code",
67
+ "execution_count": null,
68
+ "id": "34796b33",
69
+ "metadata": {},
70
+ "outputs": [],
71
+ "source": [
72
+ "# inputs:\n",
73
+ "# - the Kaupp NS372 spreadsheet is small and ships in the repo's data/ns372/ folder\n",
74
+ "# - delta22.hdf5 is large: it resolves from the repo's data/delta22/ folder, carried by the\n",
75
+ "# Hugging Face checkout via Git LFS (see analysis/code/paths.py)\n",
76
+ "# the delta22 file is encoded as whole numbers (real value x 10,000); the loader\n",
77
+ "# in leveling.py decodes it on read (a plain-decimal copy also works, since the\n",
78
+ "# decode step is a no-op on floating-point data).\n",
79
+ "KAUPP_XLSX = os.path.join(REPO, \"data\", \"ns372\", \"ct1c00919_si_002.xlsx\")\n",
80
+ "DELTA22_H5 = paths.dataset_file(\"delta22\", root=REPO)\n",
81
+ "SAVE_DPI = 200 # SI-quality raster output\n",
82
+ "\n",
83
+ "def figure_path(name):\n",
84
+ " os.makedirs(\"figures\", exist_ok=True)\n",
85
+ " return os.path.join(\"figures\", name)\n",
86
+ "\n",
87
+ "# self-clean: this notebook builds PNG names dynamically (one per nucleus), so drop any\n",
88
+ "# previously written figures before regenerating (glob on a missing folder returns [])\n",
89
+ "for _stale in glob.glob(os.path.join(\"figures\", \"*.png\")):\n",
90
+ " os.remove(_stale)\n",
91
+ "\n",
92
+ "pd.set_option('display.width', 150)\n",
93
+ "pd.set_option('display.max_columns', 25)"
94
+ ]
95
+ },
96
+ {
97
+ "cell_type": "code",
98
+ "execution_count": null,
99
+ "id": "a402f2fc",
100
+ "metadata": {},
101
+ "outputs": [],
102
+ "source": [
103
+ "# functional-family colours used for every figure (family ordering lives in leveling.py)\n",
104
+ "FAMILY_COLORS = {'LDA':'#777777','GGA':'#1f77b4','mGGA':'#17becf','GH':'#2ca02c',\n",
105
+ " 'RSH':'#9467bd','LH':'#8c564b','DH':'#ff7f0e','WFT':'#d62728',\n",
106
+ " 'ref':'#FF1493'}\n",
107
+ "\n",
108
+ "# proper Unicode-superscript labels for nuclei in figure titles\n",
109
+ "NUC_DISPLAY = {'1H':'¹H','11B':'¹¹B','13C':'¹³C',\n",
110
+ " '15N':'¹⁵N','17O':'¹⁷O','19F':'¹⁹F',\n",
111
+ " '31P':'³¹P','33S':'³³S'}\n",
112
+ "\n",
113
+ "# adjustText parameters tuned per nucleus -- 1H and 13C have very dense central\n",
114
+ "# clusters and need stronger expansion; the paramagnetic-shielding nuclei\n",
115
+ "# (15N/17O/19F) already spread methods along the parabola and need a gentler\n",
116
+ "# pass to avoid over-flinging labels.\n",
117
+ "PCA_ADJUST_DEFAULT = dict(\n",
118
+ " force_text=(0.35, 0.55), force_explode=(0.25, 0.40),\n",
119
+ " force_static=(0.10, 0.15), force_pull=(0.02, 0.02),\n",
120
+ " expand=(1.25, 1.35), time_lim=4,\n",
121
+ ")\n",
122
+ "PCA_ADJUST = {\n",
123
+ " '1H': {**PCA_ADJUST_DEFAULT, 'force_text':(0.75, 1.05),\n",
124
+ " 'force_explode':(0.65, 0.90), 'expand':(1.7, 1.9), 'time_lim':7},\n",
125
+ " '11B': {**PCA_ADJUST_DEFAULT, 'force_text':(0.50, 0.75),\n",
126
+ " 'force_explode':(0.40, 0.60), 'expand':(1.4, 1.55), 'time_lim':5},\n",
127
+ " '13C': {**PCA_ADJUST_DEFAULT, 'force_text':(0.70, 1.00),\n",
128
+ " 'force_explode':(0.60, 0.85), 'expand':(1.6, 1.8), 'time_lim':6},\n",
129
+ " '15N': {**PCA_ADJUST_DEFAULT, 'force_text':(0.60, 0.85),\n",
130
+ " 'force_explode':(0.50, 0.70), 'expand':(1.5, 1.65), 'time_lim':6},\n",
131
+ " '17O': {**PCA_ADJUST_DEFAULT, 'force_text':(0.60, 0.85),\n",
132
+ " 'force_explode':(0.50, 0.70), 'expand':(1.5, 1.65), 'time_lim':6},\n",
133
+ " '31P': {**PCA_ADJUST_DEFAULT, 'force_text':(0.65, 0.90),\n",
134
+ " 'force_explode':(0.55, 0.75), 'expand':(1.55, 1.7), 'time_lim':6},\n",
135
+ " '33S': {**PCA_ADJUST_DEFAULT, 'force_text':(0.65, 0.90),\n",
136
+ " 'force_explode':(0.55, 0.75), 'expand':(1.55, 1.7), 'time_lim':6},\n",
137
+ "}"
138
+ ]
139
+ },
140
+ {
141
+ "cell_type": "markdown",
142
+ "id": "52b5cdac",
143
+ "metadata": {},
144
+ "source": [
145
+ "## 2. NS372 (Kaupp) - definitions\n",
146
+ "\n",
147
+ "Conventional GIAO shieldings for 44 functionals across 8 main-group nuclei, with a\n",
148
+ "CCSD(T)/pcSseg-3 reference. Kaupp Reduced-Set exclusions (F₃⁻, O₃, BH - multireference\n",
149
+ "outliers) are applied. Input: `ct1c00919_si_002.xlsx`."
150
+ ]
151
+ },
152
+ {
153
+ "cell_type": "markdown",
154
+ "id": "f87c7141",
155
+ "metadata": {},
156
+ "source": [
157
+ "## 3. delta22 - definitions\n",
158
+ "\n",
159
+ "Gas-phase conventional GIAO shieldings from `delta22.hdf5`: 18 functionals at their\n",
160
+ "largest available basis (pcSseg-3; plain `mp2` only to pcSseg-2), at the PBE0/cc-pVTZ\n",
161
+ "geometry. Observations are pooled ¹H / ¹³C atom sites across all 22 solutes. There is no\n",
162
+ "CCSD(T) reference in the file; **DSD-PBEP86 is used as the reference** for delta22 - it\n",
163
+ "is the highest-rung double-hybrid available in this method set and stands in for CCSD(T)\n",
164
+ "in the same role. The stored whole-number shieldings are decoded on read."
165
+ ]
166
+ },
167
+ {
168
+ "cell_type": "markdown",
169
+ "id": "be87913f",
170
+ "metadata": {},
171
+ "source": [
172
+ "## 4. Load datasets + global colour scale\n",
173
+ "\n",
174
+ "Run the loaders, analyse every nucleus, and compute the figure-wide\n",
175
+ "`-log10(1-|r|)` maximum (`GLOBAL_VMAX`) used as the colour scale on every correlation matrix\n",
176
+ "below, for NS372 and delta22 alike."
177
+ ]
178
+ },
179
+ {
180
+ "cell_type": "code",
181
+ "execution_count": null,
182
+ "id": "d0a24460",
183
+ "metadata": {},
184
+ "outputs": [],
185
+ "source": [
186
+ "ns372 = leveling.load_ns372(KAUPP_XLSX)\n",
187
+ "delta22 = leveling.load_delta22(DELTA22_H5)\n",
188
+ "\n",
189
+ "res_ns372 = {nuc: leveling.analyze_nucleus(d['M'], d['methods'], d['ref'])\n",
190
+ " for nuc, d in ns372.items()}\n",
191
+ "res_delta22 = {nuc: leveling.analyze_nucleus(d['M'], d['methods'], d['ref'])\n",
192
+ " for nuc, d in delta22.items()}\n",
193
+ "\n",
194
+ "GLOBAL_VMAX = max(leveling.dataset_logr_max(res_ns372), leveling.dataset_logr_max(res_delta22))\n",
195
+ "print(f'NS372 : {len(ns372)} nuclei')\n",
196
+ "print(f'delta22: {len(delta22)} nuclei (reference = {leveling.DELTA22_REF})')\n",
197
+ "print(f'global colour scale: 0 -> {GLOBAL_VMAX} on the -log10(1-|r|) axis')\n",
198
+ "for nuc, d in ns372.items():\n",
199
+ " print(f' NS372 {nuc:4s}: {d[\"M\"].shape[0]:4d} mols x {d[\"M\"].shape[1]-1} methods + CCSD(T)')\n",
200
+ "for nuc, d in delta22.items():\n",
201
+ " print(f' delta22 {nuc:4s}: {d[\"M\"].shape[0]:4d} sites x {d[\"M\"].shape[1]} methods')"
202
+ ]
203
+ },
204
+ {
205
+ "cell_type": "markdown",
206
+ "id": "0d07150e",
207
+ "metadata": {},
208
+ "source": [
209
+ "## 5. NS372 results"
210
+ ]
211
+ },
212
+ {
213
+ "cell_type": "markdown",
214
+ "id": "747989ff",
215
+ "metadata": {},
216
+ "source": [
217
+ "### 5.1 Leveling diagnostics - NS372"
218
+ ]
219
+ },
220
+ {
221
+ "cell_type": "code",
222
+ "execution_count": null,
223
+ "id": "52a133ee",
224
+ "metadata": {},
225
+ "outputs": [],
226
+ "source": [
227
+ "sum_ns372 = leveling.summarize(ns372, res_ns372)\n",
228
+ "print('NS372 - leveling diagnostics (CCSD(T) included as a method column):')\n",
229
+ "sum_ns372.round(5)"
230
+ ]
231
+ },
232
+ {
233
+ "cell_type": "markdown",
234
+ "id": "a385e6f5",
235
+ "metadata": {},
236
+ "source": [
237
+ "### 5.2 Per-method scaled RMSE vs CCSD(T)\n",
238
+ "\n",
239
+ "`scaled_rmse` = RMSE of residuals after a per-method linear fit\n",
240
+ "`sigma_method ~ a*sigma_CCSD(T) + b` - the error that survives empirical linear scaling."
241
+ ]
242
+ },
243
+ {
244
+ "cell_type": "code",
245
+ "execution_count": null,
246
+ "id": "319abc43",
247
+ "metadata": {},
248
+ "outputs": [],
249
+ "source": [
250
+ "rmse_ns372 = pd.DataFrame({nuc: res_ns372[nuc]['scaled_rmse'] for nuc in res_ns372})\n",
251
+ "rmse_ns372 = rmse_ns372.drop(index='CCSD(T)', errors='ignore')\n",
252
+ "rmse_ns372 = rmse_ns372.reindex(ns372['1H']['methods'][:-1]) # family order\n",
253
+ "print('NS372 - per-method scaled RMSE vs CCSD(T) (ppm):')\n",
254
+ "rmse_ns372.round(3)"
255
+ ]
256
+ },
257
+ {
258
+ "cell_type": "markdown",
259
+ "id": "59cb8672",
260
+ "metadata": {},
261
+ "source": [
262
+ "### 5.3 Inter-method correlation matrices - NS372 (one nucleus per file)"
263
+ ]
264
+ },
265
+ {
266
+ "cell_type": "code",
267
+ "execution_count": null,
268
+ "id": "73c90f1e",
269
+ "metadata": {},
270
+ "outputs": [],
271
+ "source": [
272
+ "NS372_NUCS = ['1H', '11B', '13C', '15N', '17O', '19F', '31P', '33S']\n",
273
+ "for nuc in NS372_NUCS:\n",
274
+ " fams = {nuc: ns372[nuc]['families']}\n",
275
+ " fig = leveling_plots.plot_corr_matrix('NS372', [nuc], res_ns372, fams,\n",
276
+ " GLOBAL_VMAX, ref_name='CCSD(T)',\n",
277
+ " nuc_display=NUC_DISPLAY)\n",
278
+ " fig.savefig(figure_path(f'si_figure_s03_corr_{nuc}.png'),\n",
279
+ " dpi=SAVE_DPI, bbox_inches='tight')\n",
280
+ " plt.show()"
281
+ ]
282
+ },
283
+ {
284
+ "cell_type": "markdown",
285
+ "id": "33da4dd0",
286
+ "metadata": {},
287
+ "source": [
288
+ "### 5.4 PC1/PC2 structure - NS372 (one nucleus per file)"
289
+ ]
290
+ },
291
+ {
292
+ "cell_type": "code",
293
+ "execution_count": null,
294
+ "id": "034bf193",
295
+ "metadata": {},
296
+ "outputs": [],
297
+ "source": [
298
+ "for nuc in NS372_NUCS:\n",
299
+ " fams = {nuc: ns372[nuc]['families']}\n",
300
+ " fig = leveling_plots.plot_pca_pair('NS372', [nuc], res_ns372, fams,\n",
301
+ " family_colors=FAMILY_COLORS, nuc_display=NUC_DISPLAY,\n",
302
+ " pca_adjust=PCA_ADJUST, pca_adjust_default=PCA_ADJUST_DEFAULT)\n",
303
+ " fig.savefig(figure_path(f'si_figure_s03_pca_{nuc}.png'),\n",
304
+ " dpi=SAVE_DPI, bbox_inches='tight')\n",
305
+ " plt.show()"
306
+ ]
307
+ },
308
+ {
309
+ "cell_type": "markdown",
310
+ "id": "3ec202cf",
311
+ "metadata": {},
312
+ "source": [
313
+ "### 5.5 delta22 panels (lead the published figure: corr ¹H/¹³C + PCA)\n",
314
+ "\n",
315
+ "The same correlation + PCA layout on the delta22 gas-phase set. DSD-PBEP86 is the reference (delta22\n",
316
+ "has no CCSD(T)); it appears as an ordinary double-hybrid point in the PCA, with no CCSD(T) star."
317
+ ]
318
+ },
319
+ {
320
+ "cell_type": "code",
321
+ "execution_count": null,
322
+ "id": "fc1d57ab",
323
+ "metadata": {},
324
+ "outputs": [],
325
+ "source": [
326
+ "# delta22 correlation matrices (canonical S3 A = 1H, B = 13C)\n",
327
+ "for nuc in ['1H', '13C']:\n",
328
+ " fams = {nuc: delta22[nuc]['families']}\n",
329
+ " fig = leveling_plots.plot_corr_matrix('delta22', [nuc], res_delta22, fams,\n",
330
+ " GLOBAL_VMAX, ref_name=leveling.DELTA22_REF,\n",
331
+ " nuc_display=NUC_DISPLAY)\n",
332
+ " fig.savefig(figure_path(f'si_figure_s03_delta22_corr_{nuc}.png'),\n",
333
+ " dpi=SAVE_DPI, bbox_inches='tight')\n",
334
+ " plt.show()\n",
335
+ "\n",
336
+ "# delta22 PCA projection: 1H and 13C stacked in one figure (canonical S3 C)\n",
337
+ "fams = {nuc: delta22[nuc]['families'] for nuc in ['1H', '13C']}\n",
338
+ "fig = leveling_plots.plot_pca_pair('delta22', ['1H', '13C'], res_delta22, fams,\n",
339
+ " family_colors=FAMILY_COLORS, nuc_display=NUC_DISPLAY,\n",
340
+ " pca_adjust=PCA_ADJUST, pca_adjust_default=PCA_ADJUST_DEFAULT)\n",
341
+ "fig.savefig(figure_path('si_figure_s03_delta22_pca.png'),\n",
342
+ " dpi=SAVE_DPI, bbox_inches='tight')\n",
343
+ "plt.show()"
344
+ ]
345
+ },
346
+ {
347
+ "cell_type": "markdown",
348
+ "id": "4c1e4350",
349
+ "metadata": {},
350
+ "source": [
351
+ "## 6. Combined summary (both datasets)"
352
+ ]
353
+ },
354
+ {
355
+ "cell_type": "code",
356
+ "execution_count": null,
357
+ "id": "185ce298",
358
+ "metadata": {},
359
+ "outputs": [],
360
+ "source": [
361
+ "# delta22's per-nucleus diagnostics (same summarize() used for NS372 above), computed here\n",
362
+ "# too so this combined table is self-contained -- it reuses res_delta22 from the shared load\n",
363
+ "# cell above, so this is cheap (no new correlation/PCA computation).\n",
364
+ "sum_delta22 = leveling.summarize(delta22, res_delta22)\n",
365
+ "\n",
366
+ "combined = pd.concat([sum_ns372.assign(dataset='NS372'),\n",
367
+ " sum_delta22.assign(dataset='delta22')], ignore_index=True)\n",
368
+ "combined = combined[['dataset','nucleus','n_obs','n_methods','r_min','r_median',\n",
369
+ " 'PC1_pct','PC2_pct','PC3plus_pct','parabola_R2']]\n",
370
+ "print('Leveling effect - both datasets (analyzed separately):')\n",
371
+ "print(f' PC1 range : {combined.PC1_pct.min():.2f}% - {combined.PC1_pct.max():.2f}%')\n",
372
+ "print(f' min pairwise r : {combined.r_min.min():.5f} (worst case, all nuclei)')\n",
373
+ "print(f' parabola R2 range : {combined.parabola_R2.min():.3f} - {combined.parabola_R2.max():.3f}')\n",
374
+ "print(f' global colour scale : 0 -> {GLOBAL_VMAX} on -log10(1-|r|)')\n",
375
+ "combined.round(5)"
376
+ ]
377
+ }
378
+ ],
379
+ "metadata": {
380
+ "language_info": {
381
+ "name": "python"
382
+ }
383
+ },
384
+ "nbformat": 4,
385
+ "nbformat_minor": 5
386
+ }
analysis/si_figures/si_figure_s11.ipynb ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "934387b2",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SI Figure S11: MagNET vs DFT rovibrational (QCD) corrections\n",
9
+ "\n",
10
+ "Four panels: **A** MagNET (NN) vs B3LYP/cc-pVDZ rovibrational (QCD) correction per site, both nuclei;\n",
11
+ "**B** the NN-minus-DFT error distribution; **C/D** every proton/carbon site's DFT and NN QCD\n",
12
+ "correction, one column per solute. (QCD is gas-phase, so no solvent axis.)"
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": null,
18
+ "id": "d004ad45",
19
+ "metadata": {},
20
+ "outputs": [],
21
+ "source": [
22
+ "import os, sys\n",
23
+ "\n",
24
+ "# make the in-repo modules importable (not pip-installed)\n",
25
+ "REPO = os.path.abspath(\"../..\")\n",
26
+ "for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
27
+ " sys.path.insert(0, os.path.join(REPO, _p))"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": null,
33
+ "id": "366412b1",
34
+ "metadata": {},
35
+ "outputs": [],
36
+ "source": [
37
+ "import matplotlib.pyplot as plt\n",
38
+ "\n",
39
+ "import delta22\n",
40
+ "import delta22_plots\n",
41
+ "import paths"
42
+ ]
43
+ },
44
+ {
45
+ "cell_type": "code",
46
+ "execution_count": null,
47
+ "id": "d241818b",
48
+ "metadata": {},
49
+ "outputs": [],
50
+ "source": [
51
+ "DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
52
+ "XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
53
+ "\n",
54
+ "def figure_path(name):\n",
55
+ " os.makedirs(\"figures\", exist_ok=True)\n",
56
+ " return os.path.join(\"figures\", name)"
57
+ ]
58
+ },
59
+ {
60
+ "cell_type": "code",
61
+ "execution_count": null,
62
+ "id": "787bb8c4",
63
+ "metadata": {},
64
+ "outputs": [],
65
+ "source": [
66
+ "dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)\n",
67
+ "nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)"
68
+ ]
69
+ },
70
+ {
71
+ "cell_type": "markdown",
72
+ "id": "0f824acd",
73
+ "metadata": {},
74
+ "source": [
75
+ "## Panels A and B: MagNET vs DFT QCD correction, both nuclei"
76
+ ]
77
+ },
78
+ {
79
+ "cell_type": "code",
80
+ "execution_count": null,
81
+ "id": "31fdd1fa",
82
+ "metadata": {},
83
+ "outputs": [],
84
+ "source": [
85
+ "qcd = delta22.compare_dft_nn(dft, nn, \"qcd\", keys=(\"solute\", \"site\", \"nucleus\"))\n",
86
+ "delta22_plots.plot_qcd_scatter(qcd, save_path=figure_path(\"si_figure_s11a_scatter.png\"))\n",
87
+ "delta22_plots.plot_qcd_error_histogram(qcd, save_path=figure_path(\"si_figure_s11b_hist.png\"))\n",
88
+ "plt.show()"
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "markdown",
93
+ "id": "080ca90b",
94
+ "metadata": {},
95
+ "source": [
96
+ "## Panel C: every proton site's QCD correction, one column per solute"
97
+ ]
98
+ },
99
+ {
100
+ "cell_type": "code",
101
+ "execution_count": null,
102
+ "id": "26be5d0a",
103
+ "metadata": {},
104
+ "outputs": [],
105
+ "source": [
106
+ "delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus=\"H\"), \"H\", \"upper left\",\n",
107
+ " save_path=figure_path(\"si_figure_s11c_sites_1H.png\"))\n",
108
+ "plt.show()"
109
+ ]
110
+ },
111
+ {
112
+ "cell_type": "markdown",
113
+ "id": "42977cbe",
114
+ "metadata": {},
115
+ "source": [
116
+ "## Panel D: every carbon site's QCD correction, one column per solute"
117
+ ]
118
+ },
119
+ {
120
+ "cell_type": "code",
121
+ "execution_count": null,
122
+ "id": "4430159b",
123
+ "metadata": {},
124
+ "outputs": [],
125
+ "source": [
126
+ "delta22_plots.plot_qcd_correction_by_site(delta22.qcd_correction_by_site(dft, nn, nucleus=\"C\"), \"C\", \"upper right\",\n",
127
+ " save_path=figure_path(\"si_figure_s11d_sites_13C.png\"))\n",
128
+ "plt.show()"
129
+ ]
130
+ }
131
+ ],
132
+ "metadata": {
133
+ "language_info": {
134
+ "name": "python"
135
+ }
136
+ },
137
+ "nbformat": 4,
138
+ "nbformat_minor": 5
139
+ }
analysis/si_figures/si_figure_s12_carbon_monoxide.ipynb ADDED
@@ -0,0 +1,126 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "cecbae9e",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SI Figure S12: MagNET vs DFT ¹³C shielding of carbon monoxide across bond lengths\n",
9
+ "\n",
10
+ "Carbon monoxide stretched/compressed over a wide range of bond lengths, ¹³C shielding by DFT\n",
11
+ "(PBE1PBE/pcSseg-1, gas) vs the MagNET foundation model. The right panel shades the bond-length\n",
12
+ "distribution seen in real molecules."
13
+ ]
14
+ },
15
+ {
16
+ "cell_type": "code",
17
+ "execution_count": null,
18
+ "id": "326ed41a",
19
+ "metadata": {},
20
+ "outputs": [],
21
+ "source": [
22
+ "import os, sys\n",
23
+ "\n",
24
+ "# make the in-repo modules importable (not pip-installed)\n",
25
+ "REPO = os.path.abspath(\"../..\")\n",
26
+ "for _p in (\"analysis/code\", \"analysis/code/shared\"):\n",
27
+ " sys.path.insert(0, os.path.join(REPO, _p))"
28
+ ]
29
+ },
30
+ {
31
+ "cell_type": "code",
32
+ "execution_count": null,
33
+ "id": "19aad17e",
34
+ "metadata": {},
35
+ "outputs": [],
36
+ "source": [
37
+ "import matplotlib.pyplot as plt\n",
38
+ "\n",
39
+ "import carbon_monoxide"
40
+ ]
41
+ },
42
+ {
43
+ "cell_type": "code",
44
+ "execution_count": null,
45
+ "id": "42d7d8b0",
46
+ "metadata": {},
47
+ "outputs": [],
48
+ "source": [
49
+ "def figure_path(name):\n",
50
+ " os.makedirs(\"figures\", exist_ok=True)\n",
51
+ " return os.path.join(\"figures\", name)"
52
+ ]
53
+ },
54
+ {
55
+ "cell_type": "code",
56
+ "execution_count": null,
57
+ "id": "17186f45",
58
+ "metadata": {},
59
+ "outputs": [],
60
+ "source": [
61
+ "df = carbon_monoxide.load_comparison()\n",
62
+ "near, tails = carbon_monoxide.equilibrium_vs_extreme_error(df)\n",
63
+ "print(f'{len(df)} bond lengths from {df.bond_length.min():.2f} to {df.bond_length.max():.2f} angstrom')\n",
64
+ "print(f'mean |carbon error|: {near:.1f} ppm near equilibrium vs {tails:.1f} ppm in the tails')"
65
+ ]
66
+ },
67
+ {
68
+ "cell_type": "code",
69
+ "execution_count": null,
70
+ "id": "48de095f",
71
+ "metadata": {},
72
+ "outputs": [],
73
+ "source": [
74
+ "fig, axes = plt.subplots(1, 2, figsize=(12, 5))\n",
75
+ "\n",
76
+ "ax = axes[0]\n",
77
+ "ax.plot(df[\"bond_length\"], df[\"gaussian_c\"], marker=\"o\", linestyle=\"-\", color=\"black\", label=\"Gaussian C\")\n",
78
+ "ax.plot(df[\"bond_length\"], df[\"magnet_c\"], marker=\"o\", linestyle=\"-\", color=\"gray\", label=\"MagNET C\")\n",
79
+ "ax.set_xlabel(\"Bond length (Å)\", fontweight=\"bold\")\n",
80
+ "ax.set_ylabel(\"Shielding (ppm)\", fontweight=\"bold\")\n",
81
+ "ax.set_title(\"13C Shielding vs. Bond Length\", fontweight=\"bold\")\n",
82
+ "ax.legend(frameon=False)\n",
83
+ "\n",
84
+ "ax = axes[1]\n",
85
+ "centers, counts, bin_width = carbon_monoxide.load_bond_length_histogram()\n",
86
+ "ax_hist = ax.twinx()\n",
87
+ "ax_hist.bar(centers, counts, width=bin_width, color=\"0.75\", edgecolor=\"none\", zorder=0)\n",
88
+ "ax_hist.set_yticks([])\n",
89
+ "ax_hist.grid(False)\n",
90
+ "ax_hist.set_zorder(0)\n",
91
+ "ax.set_zorder(1)\n",
92
+ "ax.patch.set_alpha(0) # let the histogram show through the transparent foreground axes\n",
93
+ "ax.plot(df[\"bond_length\"], df[\"delta_c\"], marker=\"o\", linestyle=\"-\", color=\"black\")\n",
94
+ "ax.axhline(0, color=\"gray\", linewidth=1, linestyle=\"--\")\n",
95
+ "ax.set_xlabel(\"Bond length (Å)\", fontweight=\"bold\")\n",
96
+ "ax.set_ylabel(\"Δ shielding (MagNET − Gaussian, ppm)\", fontweight=\"bold\")\n",
97
+ "ax.set_title(\"Shielding Differences vs. Bond Lengths\", fontweight=\"bold\")\n",
98
+ "\n",
99
+ "fig.tight_layout()\n",
100
+ "fig.savefig(figure_path(\"si_figure_s12.png\"), dpi=300, bbox_inches=\"tight\")\n",
101
+ "plt.show()"
102
+ ]
103
+ }
104
+ ],
105
+ "metadata": {
106
+ "kernelspec": {
107
+ "display_name": "Python 3",
108
+ "language": "python",
109
+ "name": "python3"
110
+ },
111
+ "language_info": {
112
+ "codemirror_mode": {
113
+ "name": "ipython",
114
+ "version": 3
115
+ },
116
+ "file_extension": ".py",
117
+ "mimetype": "text/x-python",
118
+ "name": "python",
119
+ "nbconvert_exporter": "python",
120
+ "pygments_lexer": "ipython3",
121
+ "version": "3.12.13"
122
+ }
123
+ },
124
+ "nbformat": 4,
125
+ "nbformat_minor": 5
126
+ }
analysis/si_figures/si_figure_s13.ipynb ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "1127ad90",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SI Figure S13: MagNET-x vs DFT explicit-solvent corrections\n",
9
+ "\n",
10
+ "Four panels, both nuclei: **A** MagNET-x (NN) vs DFT explicit-solvent correction per site/solvent,\n",
11
+ "Desmond and OpenMM overlaid; **B** the NN-minus-DFT error distribution; **C** every site's DFT and NN\n",
12
+ "correction in chloroform, one column per solute; **D** the semi-parsimonious composite model's fit accuracy vs experiment across the four OpenMM solvents, split by engine and\n",
13
+ "DFT-vs-NN source."
14
+ ]
15
+ },
16
+ {
17
+ "cell_type": "code",
18
+ "execution_count": null,
19
+ "id": "649a6ece",
20
+ "metadata": {},
21
+ "outputs": [],
22
+ "source": [
23
+ "import os, sys\n",
24
+ "\n",
25
+ "# make the in-repo modules importable (not pip-installed)\n",
26
+ "REPO = os.path.abspath(\"../..\")\n",
27
+ "for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
28
+ " sys.path.insert(0, os.path.join(REPO, _p))"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": null,
34
+ "id": "dd166a12",
35
+ "metadata": {},
36
+ "outputs": [],
37
+ "source": [
38
+ "import matplotlib.pyplot as plt\n",
39
+ "\n",
40
+ "import delta22\n",
41
+ "import delta22_plots\n",
42
+ "import paths"
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "code",
47
+ "execution_count": null,
48
+ "id": "da96ff0e",
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": [
52
+ "DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
53
+ "XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
54
+ "\n",
55
+ "def figure_path(name):\n",
56
+ " os.makedirs(\"figures\", exist_ok=True)\n",
57
+ " return os.path.join(\"figures\", name)"
58
+ ]
59
+ },
60
+ {
61
+ "cell_type": "code",
62
+ "execution_count": null,
63
+ "id": "0cc06b06",
64
+ "metadata": {},
65
+ "outputs": [],
66
+ "source": [
67
+ "dft = delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False)\n",
68
+ "nn = delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False)"
69
+ ]
70
+ },
71
+ {
72
+ "cell_type": "code",
73
+ "execution_count": null,
74
+ "id": "af66d177",
75
+ "metadata": {},
76
+ "outputs": [],
77
+ "source": [
78
+ "# openMM/Desmond engine hues (used by the scatter/histogram/site/fitting-accuracy panels below)\n",
79
+ "ENGINE_COLORS = {\"openMM\": \"#2E86AB\", \"desmond\": \"#A23B72\"}\n",
80
+ "ENGINE_LABELS = {\"desmond\": \"Desmond\", \"openMM\": \"OpenMM\"}"
81
+ ]
82
+ },
83
+ {
84
+ "cell_type": "markdown",
85
+ "id": "8ddc9cdc",
86
+ "metadata": {},
87
+ "source": [
88
+ "## Panels A and B: MagNET-x vs DFT explicit corrections, Desmond and OpenMM overlaid"
89
+ ]
90
+ },
91
+ {
92
+ "cell_type": "code",
93
+ "execution_count": null,
94
+ "id": "622c1d35",
95
+ "metadata": {},
96
+ "outputs": [],
97
+ "source": [
98
+ "explicit_by_engine = delta22.compare_dft_nn_by_engine(dft, nn, keys=(\"solute\", \"site\", \"nucleus\", \"solvent\"))\n",
99
+ "delta22_plots.plot_dft_nn_scatter_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,\n",
100
+ " save_path=figure_path(\"si_figure_s13a_scatter.png\"))\n",
101
+ "delta22_plots.plot_dft_nn_error_histogram_by_engine(explicit_by_engine, ENGINE_COLORS, ENGINE_LABELS,\n",
102
+ " save_path=figure_path(\"si_figure_s13b_hist.png\"))\n",
103
+ "plt.show()"
104
+ ]
105
+ },
106
+ {
107
+ "cell_type": "markdown",
108
+ "id": "2c511445",
109
+ "metadata": {},
110
+ "source": [
111
+ "## Panel C: every proton/carbon site's explicit correction in chloroform"
112
+ ]
113
+ },
114
+ {
115
+ "cell_type": "code",
116
+ "execution_count": null,
117
+ "id": "ca4e7871",
118
+ "metadata": {},
119
+ "outputs": [],
120
+ "source": [
121
+ "for nucleus, label in [(\"H\", \"Proton\"), (\"C\", \"Carbon\")]:\n",
122
+ " pairs = delta22.explicit_correction_dft_nn_pairs(dft, nn, \"chloroform\", nucleus)\n",
123
+ " delta22_plots.plot_explicit_correction_by_site(\n",
124
+ " pairs, f\"All {label} Sites: DFT vs NN Explicit Corrections (chloroform)\", ENGINE_COLORS,\n",
125
+ " save_path=figure_path(f\"si_figure_s13c_sites_{'1H' if nucleus == 'H' else '13C'}.png\"))\n",
126
+ "plt.show()"
127
+ ]
128
+ },
129
+ {
130
+ "cell_type": "markdown",
131
+ "id": "0813f518",
132
+ "metadata": {},
133
+ "source": [
134
+ "## Panel D: fitting accuracy of the semi-parsimonious composite model"
135
+ ]
136
+ },
137
+ {
138
+ "cell_type": "code",
139
+ "execution_count": null,
140
+ "id": "0ebd8702",
141
+ "metadata": {},
142
+ "outputs": [],
143
+ "source": [
144
+ "# the published panels use 250 seeded train/test splits\n",
145
+ "N_SPLITS = 250"
146
+ ]
147
+ },
148
+ {
149
+ "cell_type": "code",
150
+ "execution_count": null,
151
+ "id": "8ce39a57",
152
+ "metadata": {},
153
+ "outputs": [],
154
+ "source": [
155
+ "SOLVENT_ORDER = [\"chloroform\", \"methanol\", \"TIP4P\", \"benzene\"]\n",
156
+ "solutes = sorted(dft[\"solute\"].unique())\n",
157
+ "for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
158
+ " fitting = delta22.si_s13d_fitting_accuracy(dft, nn, SOLVENT_ORDER, N_SPLITS, solutes, nucleus=nucleus)\n",
159
+ " delta22_plots.plot_fitting_accuracy_boxplot(\n",
160
+ " fitting, SOLVENT_ORDER, label, ENGINE_COLORS,\n",
161
+ " save_path=figure_path(f\"si_figure_s13d_fitting_{'1H' if nucleus == 'H' else '13C'}.png\"))\n",
162
+ "plt.show()"
163
+ ]
164
+ }
165
+ ],
166
+ "metadata": {
167
+ "language_info": {
168
+ "name": "python"
169
+ }
170
+ },
171
+ "nbformat": 4,
172
+ "nbformat_minor": 5
173
+ }
analysis/si_figures/si_figure_s14.ipynb ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "id": "8cd77576",
6
+ "metadata": {},
7
+ "source": [
8
+ "# SI Figure S14: test RMSE with DFT vs MagNET features, by solvent\n",
9
+ "\n",
10
+ "For the four explicit-solvent solvents (chloroform, methanol, TIP4P water, benzene), per-solvent test\n",
11
+ "RMSE of predicting experimental ¹H/¹³C shifts with **DFT** features (solid) vs end-to-end **MagNET/NN**\n",
12
+ "features (lightened), each under three conditions (implicit SOTA, explicit, explicit + vibrations).\n",
13
+ "Two pages, one per MD engine (Desmond, OpenMM); nitromethane dropped."
14
+ ]
15
+ },
16
+ {
17
+ "cell_type": "code",
18
+ "execution_count": null,
19
+ "id": "67f9b9de",
20
+ "metadata": {},
21
+ "outputs": [],
22
+ "source": [
23
+ "import os, sys\n",
24
+ "\n",
25
+ "# make the in-repo modules importable (not pip-installed)\n",
26
+ "REPO = os.path.abspath(\"../..\")\n",
27
+ "for _p in (\"data/delta22\", \"analysis/code\", \"analysis/code/shared\"):\n",
28
+ " sys.path.insert(0, os.path.join(REPO, _p))"
29
+ ]
30
+ },
31
+ {
32
+ "cell_type": "code",
33
+ "execution_count": null,
34
+ "id": "eddfed92",
35
+ "metadata": {},
36
+ "outputs": [],
37
+ "source": [
38
+ "import matplotlib.pyplot as plt\n",
39
+ "\n",
40
+ "import delta22\n",
41
+ "import delta22_plots\n",
42
+ "import paths"
43
+ ]
44
+ },
45
+ {
46
+ "cell_type": "code",
47
+ "execution_count": null,
48
+ "id": "c05cd017",
49
+ "metadata": {},
50
+ "outputs": [],
51
+ "source": [
52
+ "DELTA22_HDF5 = paths.dataset_file(\"delta22\", root=REPO)\n",
53
+ "XLSX = os.path.join(REPO, \"data\", \"delta22\", \"delta22_experimental.xlsx\")\n",
54
+ "\n",
55
+ "def figure_path(name):\n",
56
+ " os.makedirs(\"figures\", exist_ok=True)\n",
57
+ " return os.path.join(\"figures\", name)"
58
+ ]
59
+ },
60
+ {
61
+ "cell_type": "code",
62
+ "execution_count": null,
63
+ "id": "db2e619d",
64
+ "metadata": {},
65
+ "outputs": [],
66
+ "source": [
67
+ "# the published panels use 250 seeded train/test splits\n",
68
+ "N_SPLITS = 250"
69
+ ]
70
+ },
71
+ {
72
+ "cell_type": "code",
73
+ "execution_count": null,
74
+ "id": "b57840e8",
75
+ "metadata": {},
76
+ "outputs": [],
77
+ "source": [
78
+ "dft = delta22.add_composite_columns(delta22.load_query_df_dft(DELTA22_HDF5, XLSX, verbose=False))\n",
79
+ "nn = delta22.add_composite_columns(delta22.load_query_df_nn(DELTA22_HDF5, XLSX, verbose=False))\n",
80
+ "print(len(dft), \"DFT rows;\", len(nn), \"NN rows\")\n",
81
+ "\n",
82
+ "LABELS = [\"Implicit Solvent (SOTA)\", \"Explicit Solvent\", \"Explicit Solvent + Vibrations\"]\n",
83
+ "SS_LABELS = {\"TIP4P\": \"Water (TIP4P)\"}\n",
84
+ "S14_SOLVENTS = [\"chloroform\", \"methanol\", \"TIP4P\", \"benzene\"] # the 4 explicit solvents, SI order"
85
+ ]
86
+ },
87
+ {
88
+ "cell_type": "markdown",
89
+ "id": "a0645b27",
90
+ "metadata": {},
91
+ "source": [
92
+ "## Desmond page (DFT vs NN)"
93
+ ]
94
+ },
95
+ {
96
+ "cell_type": "code",
97
+ "execution_count": null,
98
+ "id": "5a7d9851",
99
+ "metadata": {},
100
+ "outputs": [],
101
+ "source": [
102
+ "# implicit = 2-term (stationary + pcm); explicit = stationary + desmond;\n",
103
+ "# explicit + vibrations = stationary_plus_qcd + desmond (1H) / stationary_plus_des_vib + desmond (13C)\n",
104
+ "DESMOND_FORMULAS = {\n",
105
+ " \"H\": [\"stationary + pcm\", \"stationary + desmond\", \"stationary_plus_qcd + desmond\"],\n",
106
+ " \"C\": [\"stationary + pcm\", \"stationary + desmond\", \"stationary_plus_des_vib + desmond\"],\n",
107
+ "}\n",
108
+ "for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
109
+ " formulas = DESMOND_FORMULAS[nucleus]\n",
110
+ " delta22_plots.plot_ss_boxplot_dft_vs_nn(\n",
111
+ " delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),\n",
112
+ " delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),\n",
113
+ " S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,\n",
114
+ " save_path=figure_path(f\"si_figure_s14_desmond_{label}.png\"))\n",
115
+ "plt.show()"
116
+ ]
117
+ },
118
+ {
119
+ "cell_type": "markdown",
120
+ "id": "c8523187",
121
+ "metadata": {},
122
+ "source": [
123
+ "## OpenMM page (DFT vs NN)"
124
+ ]
125
+ },
126
+ {
127
+ "cell_type": "code",
128
+ "execution_count": null,
129
+ "id": "fc91d9af",
130
+ "metadata": {},
131
+ "outputs": [],
132
+ "source": [
133
+ "# implicit = 1-term composite (stationary_plus_pcm); explicit = stationary + openMM;\n",
134
+ "# explicit + vibrations = stationary_plus_qcd + openMM (1H) / stationary_plus_op_vib + openMM (13C)\n",
135
+ "OPENMM_FORMULAS = {\n",
136
+ " \"H\": [\"stationary_plus_pcm\", \"stationary + openMM\", \"stationary_plus_qcd + openMM\"],\n",
137
+ " \"C\": [\"stationary_plus_pcm\", \"stationary + openMM\", \"stationary_plus_op_vib + openMM\"],\n",
138
+ "}\n",
139
+ "for nucleus, label in [(\"H\", \"1H\"), (\"C\", \"13C\")]:\n",
140
+ " formulas = OPENMM_FORMULAS[nucleus]\n",
141
+ " delta22_plots.plot_ss_boxplot_dft_vs_nn(\n",
142
+ " delta22_plots.ss_fits(dft, nucleus, formulas, S14_SOLVENTS, N_SPLITS, dft=True),\n",
143
+ " delta22_plots.ss_fits(nn, nucleus, formulas, S14_SOLVENTS, N_SPLITS),\n",
144
+ " S14_SOLVENTS, formulas, LABELS, label, solvent_labels=SS_LABELS,\n",
145
+ " save_path=figure_path(f\"si_figure_s14_openmm_{label}.png\"))\n",
146
+ "plt.show()"
147
+ ]
148
+ }
149
+ ],
150
+ "metadata": {
151
+ "language_info": {
152
+ "name": "python"
153
+ }
154
+ },
155
+ "nbformat": 4,
156
+ "nbformat_minor": 5
157
+ }