| """Recommended Linear Scaling Parameters for MagNET-Zero / MagNET-PCM (SI Tables S10 and S11). |
| |
| Turns MagNET-Zero shieldings into chemical shifts via a per-solvent linear model: |
| |
| shift = c_intercept + c_stationary * sigma_zero + c_pcm * delta_pcm |
| |
| where sigma_zero is the MagNET-Zero gas-phase shielding and delta_pcm the MagNET-PCM chloroform |
| correction. Proton (S10) uses a THREE-parameter model (all coefficients free) because for benzene and |
| toluene the PCM correction points the wrong way and only a free c_pcm can go positive. Carbon (S11) |
| uses a TWO-parameter model: the gas and PCM terms share one per-solvent slope (PCM scaled to solvent |
| by a conversion factor first), which fit held-out carbons slightly better; the reported `pcm` is that |
| slope times the conversion factor. |
| |
| The published tables are REFLECTION-SYMMETRIZED: each shielding is averaged over 20 forward passes |
| with the geometry mirrored for half (n_passes=10, symmetrize=True), cancelling the SO(3)-only model's |
| reflection-parity error. They ship verbatim in |
| data/scaling_factors/scaling_factors_symmetrized_{H,C}.csv; published_scaling_tables() returns them, |
| and build_scaling_tables(symmetrized=True) reproduces them from live inference (needs checkpoints). |
| build_scaling_tables(symmetrized=False) fits the raw single-pass shieldings in delta22.hdf5 instead |
| and lands ~0.01 ppm off. test_scaling_factors.py locks this down. |
| """ |
| import os |
| import sys |
|
|
| import numpy as np |
| import pandas as pd |
|
|
| from paths import repo_root, ensure_on_path, dataset_file |
|
|
| |
| |
| |
| |
| _HERE = os.path.dirname(os.path.abspath(__file__)) |
| _REPO = repo_root(__file__) |
| if _HERE not in sys.path: |
| sys.path.insert(0, _HERE) |
| ensure_on_path("analysis", "code", "shared", file=__file__) |
| ensure_on_path("data", "delta22", file=__file__) |
|
|
| from stats import linear_fit_1d |
| import delta22 as D |
|
|
| |
| SOLVENTS = tuple(D.DESMOND_SOLVENTS) |
|
|
| |
| |
| EXCLUDE_SOLUTES = ("nitromethane",) |
|
|
| |
| RECOMMENDED_MODEL = {"H": "three_parameter", "C": "two_parameter"} |
|
|
| _COLUMNS = ["intercept", "stationary", "pcm"] |
|
|
| |
| _SYMMETRIZED_CSV_DIR = os.path.join(_REPO, "data", "scaling_factors") |
|
|
|
|
| def published_scaling_tables(): |
| """The published SI scaling tables, {"H": Table S10, "C": Table S11}, as solvent-indexed |
| DataFrames (columns intercept / stationary / pcm). Read from the shipped CSVs, so no delta-22 |
| download is needed; pass to predict_shift to turn MagNET-Zero/PCM shieldings into shifts. See the |
| module docstring for provenance.""" |
| tables = {} |
| for nucleus in ("H", "C"): |
| path = os.path.join(_SYMMETRIZED_CSV_DIR, f"scaling_factors_symmetrized_{nucleus}.csv") |
| tables[nucleus] = pd.read_csv(path).set_index("solvent")[_COLUMNS] |
| return tables |
|
|
|
|
| def proton_scaling_table(query_df_nn, solvents=SOLVENTS, exclude_solutes=EXCLUDE_SOLUTES): |
| """SI Table S10: per-solvent [intercept, stationary, pcm] for the proton three-parameter model. |
| |
| Fits experimental ~ stationary + pcm freely for each solvent on the MagNET-Zero shielding and the |
| MagNET-PCM (chloroform) correction. Returns a DataFrame indexed by solvent. |
| """ |
| nn = query_df_nn[(query_df_nn["nucleus"] == "H") |
| & ~query_df_nn["solute"].isin(set(exclude_solutes))] |
| coeffs = D.full_fit_coefficients(nn, list(solvents), "stationary + pcm") |
| table = coeffs.T.rename(columns={"Intercept": "intercept"})[_COLUMNS] |
| table.index.name = "solvent" |
| return table |
|
|
|
|
| def carbon_conversion_factors(query_df_dft): |
| """The per-solvent PCM conversion factor for carbon (delta22.pcm_conversion_factors): for each |
| solvent, the through-the-origin slope of that solvent's PCM correction (at the level MagNET-Zero |
| reproduces, wB97X-D) against chloroform's B3LYP-D3(BJ) correction (the level MagNET-PCM |
| reproduces), from the delta-22 DFT PCM data. So one factor converts both chloroform to the solvent |
| and B3LYP to wB97X-D. A Series indexed by solvent.""" |
| return D.pcm_conversion_factors(query_df_dft, "C") |
|
|
|
|
| def carbon_scaling_table(query_df_nn, query_df_dft=None, solvents=SOLVENTS, |
| exclude_solutes=EXCLUDE_SOLUTES, conversion_factors=None): |
| """SI Table S11: per-solvent [intercept, stationary, pcm] for the carbon two-parameter model. |
| |
| For each solvent, fits experimental ~ (stationary + factor * pcm) with a single shared slope, |
| where `factor` is the per-solvent PCM conversion factor. The reported `stationary` is that slope |
| and the reported `pcm` is slope * factor, so the prediction equation in the module docstring |
| applies with MagNET-PCM's chloroform correction. The factors come from query_df_dft |
| (carbon_conversion_factors) unless passed in as `conversion_factors` (a Series or dict). A solvent |
| with fewer than two usable sites, or an undefined conversion factor, gets a row of NaN rather than |
| a fake zero fit. Returns a DataFrame indexed by solvent. |
| """ |
| if conversion_factors is None: |
| if query_df_dft is None: |
| raise ValueError("provide query_df_dft or conversion_factors") |
| conversion_factors = carbon_conversion_factors(query_df_dft) |
| nn = query_df_nn[(query_df_nn["nucleus"] == "C") |
| & ~query_df_nn["solute"].isin(set(exclude_solutes))] |
| rows = {} |
| for solvent in solvents: |
| factor = float(conversion_factors[solvent]) |
| sub = nn[nn["solvent"] == solvent] |
| y = pd.to_numeric(sub["experimental"], errors="coerce").to_numpy(float) |
| x = sub["stationary"].to_numpy(float) + factor * sub["pcm"].to_numpy(float) |
| keep = np.isfinite(x) & np.isfinite(y) |
| if not np.isfinite(factor) or keep.sum() < 2: |
| rows[solvent] = {"intercept": np.nan, "stationary": np.nan, "pcm": np.nan} |
| continue |
| intercept, slope = linear_fit_1d(x[keep], y[keep]) |
| rows[solvent] = {"intercept": intercept, "stationary": slope, "pcm": slope * factor} |
| table = pd.DataFrame.from_dict(rows, orient="index")[_COLUMNS] |
| table.index.name = "solvent" |
| return table |
|
|
|
|
| def build_scaling_tables(delta22_path, experimental_path, symmetrized=False, n_passes=10): |
| """Re-derive both recommended-scaling tables from the released delta-22 data. |
| |
| symmetrized=True reproduces the published SI Tables S10/S11 the way they were generated: live, |
| reflection-symmetrized inference (scaling_factors_symmetrized.py). Needs the magnet package and |
| model checkpoints, and is slow (re-runs inference on all 22 solutes). |
| |
| symmetrized=False (default) fits the raw single-pass MagNET-Zero/PCM shieldings stored in the |
| HDF5, which carry the reflection-parity error the published tables correct, so it lands ~0.01 ppm |
| off the published SI. Use it for a checkpoint-free re-derivation. |
| |
| Returns {"H": Table S10 DataFrame, "C": Table S11 DataFrame}. |
| """ |
| if symmetrized: |
| import scaling_factors_symmetrized as _S |
| override_df = _S.compute_symmetrized_nn_shieldings_df(delta22_path, n_passes=n_passes, |
| verbose=False) |
| nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False, |
| nn_shieldings_override_df=override_df) |
| else: |
| nn = D.load_query_df_nn(delta22_path, experimental_path, verbose=False) |
| dft = D.load_query_df_dft(delta22_path, experimental_path, verbose=False) |
| return {"H": proton_scaling_table(nn), "C": carbon_scaling_table(nn, dft)} |
|
|
|
|
| def predict_shift(table, solvent, magnet_zero_shielding, magnet_pcm_chloroform_correction): |
| """Apply a scaling table to MagNET-Zero / MagNET-PCM outputs to predict a chemical shift. |
| |
| table: a proton or carbon scaling table (DataFrame indexed by solvent). Get one from |
| published_scaling_tables()[nucleus] (no data download) or build_scaling_tables(...)[nucleus] |
| (re-derived from delta-22). |
| solvent: the solvent name (water is "TIP4P"). |
| magnet_zero_shielding: the MagNET-Zero gas-phase shielding (scalar or array). |
| magnet_pcm_chloroform_correction: MagNET-PCM's chloroform correction (scalar or array). |
| |
| The same equation serves both nuclei because the stored `pcm` coefficient already folds in the |
| per-solvent scaling, so you always pass the chloroform correction. |
| |
| End to end, from a geometry (the H/C shieldings each model returns are per-atom arrays): |
| |
| from magnet.run_magnet import compute_MagNET_Zero_shieldings, compute_MagNET_PCM_corrections |
| zero = compute_MagNET_Zero_shieldings([Z], [xyz])[0] # MagNET-Zero shieldings |
| pcm = compute_MagNET_PCM_corrections([Z], [xyz])[0] # MagNET-PCM chloroform correction |
| tables = published_scaling_tables() |
| carbons = Z == 6 |
| shifts_13C = predict_shift(tables["C"], "benzene", zero[carbons], pcm[carbons]) |
| """ |
| row = table.loc[solvent] |
| return (row["intercept"] |
| + row["stationary"] * np.asarray(magnet_zero_shielding, dtype=float) |
| + row["pcm"] * np.asarray(magnet_pcm_chloroform_correction, dtype=float)) |
|
|
|
|
| if __name__ == "__main__": |
| h5 = dataset_file("delta22", root=_REPO) |
| xlsx = os.path.join(_REPO, "data", "delta22", "delta22_experimental.xlsx") |
| |
| |
| tables = build_scaling_tables(h5, xlsx) |
| for nucleus, label in (("H", "Table S10 (1H)"), ("C", "Table S11 (13C)")): |
| print(f"\n=== {label} (unsymmetrized re-derivation): {RECOMMENDED_MODEL[nucleus]} model ===") |
| print(tables[nucleus].round(6).to_string()) |
|
|