File size: 16,350 Bytes
64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d fe82c54 64c992d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 | """Tests for analysis/code/scaling_factors.py.
Synthetic tests exercise the two table builders and the prediction equation without any large file,
so the core math runs in CI. The opt-in real-data test reproduces the published SI numbers (Tables
S10 and S11) from the released delta-22 data when it is present.
"""
import os
import sys
import numpy as np
import pandas as pd
import pytest
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
import scaling_factors as S # noqa: E402
import paths as P # noqa: E402
REPO = os.path.abspath(os.path.join(HERE, "..", ".."))
REAL_H5 = P.dataset_file("delta22", root=REPO)
REAL_XLSX = os.path.join(REPO, "data", "delta22", "delta22_experimental.xlsx")
# --------------------------------------------------------------------------- synthetic builders
def _proton_nn(coeffs, n=6, seed=0):
"""Synthetic proton table where experimental = a + b*stationary + c*pcm exactly, per solvent.
coeffs maps solvent -> (a, b, c)."""
rng = np.random.default_rng(seed)
rows = []
for solvent, (a, b, c) in coeffs.items():
for i in range(n):
stat = float(rng.normal(30, 3))
pcm = float(rng.normal(0, 0.5))
rows.append(dict(nucleus="H", solute=f"m{i}", site=f"m{i}_0", solvent=solvent,
stationary=stat, pcm=pcm, experimental=a + b * stat + c * pcm))
return pd.DataFrame(rows)
def _carbon_nn(coeffs, factors, n=6, seed=1):
"""Synthetic carbon table where experimental = a + b*(stationary + factor*pcm) exactly, per
solvent. coeffs maps solvent -> (a, b); factors maps solvent -> conversion factor."""
rng = np.random.default_rng(seed)
rows = []
for solvent, (a, b) in coeffs.items():
factor = factors[solvent]
for i in range(n):
stat = float(rng.normal(100, 20))
pcm = float(rng.normal(0, 0.5))
rows.append(dict(nucleus="C", solute=f"m{i}", site=f"m{i}_0", solvent=solvent,
stationary=stat, pcm=pcm, experimental=a + b * (stat + factor * pcm)))
return pd.DataFrame(rows)
def _nitromethane_rows(nucleus, solvents):
"""One garbage nitromethane row per solvent; the fits must exclude it."""
return pd.DataFrame([dict(nucleus=nucleus, solute="nitromethane", site="x", solvent=s,
stationary=0.0, pcm=0.0, experimental=999.0) for s in solvents])
# --------------------------------------------------------------------------- synthetic tests
def test_recommended_model_forms():
# Proton is the three-parameter model, carbon the two-parameter model (John's fitting trials).
assert S.RECOMMENDED_MODEL == {"H": "three_parameter", "C": "two_parameter"}
def test_predict_shift_equation():
table = pd.DataFrame(
{"intercept": [31.0], "stationary": [-0.95], "pcm": [-0.85]},
index=pd.Index(["chloroform"], name="solvent"),
)
got = S.predict_shift(table, "chloroform", magnet_zero_shielding=25.0,
magnet_pcm_chloroform_correction=-0.1)
assert got == pytest.approx(31.0 - 0.95 * 25.0 - 0.85 * -0.1)
got_vec = S.predict_shift(table, "chloroform", [25.0, 26.0], [-0.1, 0.2])
assert np.allclose(got_vec, [31.0 - 0.95 * 25.0 - 0.85 * -0.1,
31.0 - 0.95 * 26.0 - 0.85 * 0.2])
def test_proton_table_recovers_known_line_and_drops_nitromethane():
coeffs = {"chloroform": (31.0, -0.97, -0.85), "benzene": (32.0, -1.00, 2.20)}
nn = pd.concat([_proton_nn(coeffs), _nitromethane_rows("H", coeffs)], ignore_index=True)
table = S.proton_scaling_table(nn, solvents=list(coeffs))
assert list(table.columns) == ["intercept", "stationary", "pcm"]
assert table.index.name == "solvent"
for solvent, (a, b, c) in coeffs.items():
row = table.loc[solvent]
# exact recovery (and the nitromethane garbage row did not perturb it -> it was excluded)
assert row["intercept"] == pytest.approx(a, abs=1e-6)
assert row["stationary"] == pytest.approx(b, abs=1e-6)
assert row["pcm"] == pytest.approx(c, abs=1e-6)
def test_carbon_table_reconstruction_and_drops_nitromethane():
coeffs = {"chloroform": (171.0, -0.92), "benzene": (172.0, -0.93)}
factors = {"chloroform": 1.0, "benzene": 0.63}
nn = pd.concat([_carbon_nn(coeffs, factors), _nitromethane_rows("C", coeffs)], ignore_index=True)
table = S.carbon_scaling_table(nn, solvents=list(coeffs), conversion_factors=factors)
assert list(table.columns) == ["intercept", "stationary", "pcm"]
for solvent, (a, b) in coeffs.items():
row = table.loc[solvent]
assert row["intercept"] == pytest.approx(a, abs=1e-6)
assert row["stationary"] == pytest.approx(b, abs=1e-6)
# the reported pcm is the shared slope times the conversion factor
assert row["pcm"] == pytest.approx(b * factors[solvent], abs=1e-6)
assert row["pcm"] == pytest.approx(row["stationary"] * factors[solvent], abs=1e-12)
def test_carbon_empty_or_nan_factor_gives_nan_row():
# chloroform has data and a finite factor; benzene has no rows and a NaN factor.
nn = _carbon_nn({"chloroform": (171.0, -0.92)}, {"chloroform": 1.0})
table = S.carbon_scaling_table(nn, solvents=["chloroform", "benzene"],
conversion_factors={"chloroform": 1.0, "benzene": np.nan})
assert np.isfinite(table.loc["chloroform", "intercept"])
# a degenerate solvent must be NaN, not a fake (0, 0, 0) fit
assert table.loc["benzene"].isna().all()
def test_carbon_requires_factors_or_dft():
with pytest.raises(ValueError):
S.carbon_scaling_table(_carbon_nn({"chloroform": (171.0, -0.92)}, {"chloroform": 1.0}),
solvents=["chloroform"])
# --------------------------------------------------------------------------- opt-in real-data test
# Published SI values (reflection-symmetrized Tables S10/S11): parameter tuples are
# (intercept, stationary, pcm) per solvent, all 12 solvents. These pin the shipped
# published_scaling_tables() copy (no-data test below): shipped symmetrized CSV == this dict, and the
# raw-data build lands within ~0.003 ppm of it (real-data test). The chain keeps everything in sync.
PUBLISHED_S10_H = {
"chloroform": (31.291983, -0.975682, -0.851715),
"tetrahydrofuran": (31.319664, -0.980082, -0.781373),
"dichloromethane": (31.374485, -0.979760, -0.806215),
"acetone": (31.510823, -0.987188, -1.229850),
"acetonitrile": (31.494061, -0.985628, -0.969617),
"dimethylsulfoxide": (31.597261, -0.991031, -1.348009),
"trifluoroethanol": (30.873172, -0.959885, -0.955808),
"methanol": (31.253627, -0.976349, -1.246680),
"TIP4P": (31.356622, -0.978710, -1.475907),
"benzene": (31.984616, -1.005170, 2.239317),
"toluene": (31.714067, -0.996613, 1.948607),
"chlorobenzene": (31.677950, -0.993715, 0.830908),
}
PUBLISHED_S11_C = {
"chloroform": (171.726919, -0.924228, -0.936801),
"tetrahydrofuran": (171.052274, -0.918997, -1.069506),
"dichloromethane": (171.507154, -0.921165, -1.115013),
"acetone": (171.305850, -0.919608, -1.239500),
"acetonitrile": (171.877492, -0.922626, -1.287661),
"dimethylsulfoxide": (170.596065, -0.918672, -1.296497),
"trifluoroethanol": (174.235231, -0.939011, -1.290071),
"methanol": (172.423799, -0.927563, -1.288843),
"TIP4P": (173.692750, -0.937848, -1.342659),
"benzene": (171.965715, -0.927029, -0.593714),
"toluene": (171.689276, -0.924983, -0.618407),
"chlorobenzene": (171.073820, -0.921235, -0.997638),
}
def test_shipped_published_tables_match_si_values():
"""The shipped published_scaling_tables() copy (no data download needed) equals the published
(reflection-symmetrized) SI values for all 12 solvents and both nuclei. Runs in CI without
delta-22; the real-data tests below tie those same SI values to a raw-data fit (within the
reflection-parity gap) and to live symmetrized inference (exact), so the shipped copy cannot
drift."""
tables = S.published_scaling_tables()
for nucleus, published in (("H", PUBLISHED_S10_H), ("C", PUBLISHED_S11_C)):
table = tables[nucleus]
assert list(table.columns) == ["intercept", "stationary", "pcm"]
assert sorted(table.index) == sorted(published)
for solvent, (intercept, stationary, pcm) in published.items():
row = table.loc[solvent]
assert row["intercept"] == pytest.approx(intercept, abs=5e-4), f"{nucleus} {solvent} int"
assert row["stationary"] == pytest.approx(stationary, abs=5e-4), f"{nucleus} {solvent} stat"
assert row["pcm"] == pytest.approx(pcm, abs=5e-4), f"{nucleus} {solvent} pcm"
@pytest.mark.skipif(not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX)),
reason="real delta22.hdf5 / experimental xlsx not present")
def test_unsymmetrized_fit_lands_near_published_si():
"""The checkpoint-free raw-data fit (symmetrized=False, from the HDF5's stored single-pass
shieldings) lands within ~0.01 ppm of the published, reflection-symmetrized SI tables for all 12
solvents (the largest gaps are on the polar-solvent pcm coefficients, ~0.009). The gap is the
reflection-parity correction the published tables apply; it is small on delta-22's tiny solutes
but nonzero, so this must NOT match to the encoding floor. The 1.5e-2 bound documents the gap and
still catches a real regression (coefficients are O(1) to O(170))."""
tables = S.build_scaling_tables(REAL_H5, REAL_XLSX) # symmetrized=False
max_abs_delta = 0.0
for nucleus, published in (("H", PUBLISHED_S10_H), ("C", PUBLISHED_S11_C)):
table = tables[nucleus]
assert list(table.columns) == ["intercept", "stationary", "pcm"]
assert sorted(table.index) == sorted(published)
for solvent, (intercept, stationary, pcm) in published.items():
row = table.loc[solvent]
assert row["intercept"] == pytest.approx(intercept, abs=1.5e-2), f"{nucleus} {solvent} int"
assert row["stationary"] == pytest.approx(stationary, abs=1.5e-2), f"{nucleus} {solvent} stat"
assert row["pcm"] == pytest.approx(pcm, abs=1.5e-2), f"{nucleus} {solvent} pcm"
max_abs_delta = max(max_abs_delta, abs(row["intercept"] - intercept),
abs(row["stationary"] - stationary), abs(row["pcm"] - pcm))
# the raw fit is genuinely the unsymmetrized one, not the published values by accident: the
# reflection correction shifts at least one coefficient above the int32-encoding floor
assert max_abs_delta > 1e-4, "raw-data fit is identical to the published SI -- reflection gap missing"
# --------------------------------------------------------------------------- symmetrized-inference smoke test
CKPT_ROOT = P.checkpoints_root() or "" # "" (deposit env unset) -> os.path.exists(...) below is False
CKPT_ZERO = os.path.join(CKPT_ROOT, "MagNET-Zero")
CKPT_PCM = os.path.join(CKPT_ROOT, "MagNET-PCM")
_HAS_CHECKPOINTS = os.path.exists(CKPT_ZERO) and os.path.exists(CKPT_PCM)
@pytest.mark.skipif(not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX) and _HAS_CHECKPOINTS),
reason="real delta22.hdf5 / experimental xlsx / model checkpoints not present")
def test_symmetrized_build_reproduces_published_si_and_differs_from_raw():
"""symmetrized=True re-derives the published SI tables from live, reflection-symmetrized
inference -- the same procedure that generated them -- so it reproduces them (within the
pass-to-pass inference noise of the small n_passes used here). It must also differ from the
checkpoint-free symmetrized=False raw fit, proving the override path actually ran and symmetrized
rather than silently falling back to the HDF5's stored single-pass shieldings."""
pytest.importorskip("torch")
sym = S.build_scaling_tables(REAL_H5, REAL_XLSX, symmetrized=True, n_passes=2)
raw = S.build_scaling_tables(REAL_H5, REAL_XLSX) # symmetrized=False
max_vs_raw = 0.0
for nucleus, published in (("H", PUBLISHED_S10_H), ("C", PUBLISHED_S11_C)):
table = sym[nucleus]
assert list(table.columns) == ["intercept", "stationary", "pcm"]
assert sorted(table.index) == sorted(published)
for solvent, (intercept, stationary, pcm) in published.items():
row = table.loc[solvent]
assert np.isfinite(row["intercept"]) and np.isfinite(row["stationary"]) and np.isfinite(row["pcm"])
# close to the published SI (loose: n_passes=2 here vs 10 for the shipped tables, so
# inference noise dominates the tiny reflection correction)
assert row["intercept"] == pytest.approx(intercept, abs=0.05), f"{nucleus} {solvent} int"
assert row["stationary"] == pytest.approx(stationary, abs=0.01), f"{nucleus} {solvent} stat"
assert row["pcm"] == pytest.approx(pcm, abs=0.1), f"{nucleus} {solvent} pcm"
r = raw[nucleus].loc[solvent]
max_vs_raw = max(max_vs_raw, abs(row["intercept"] - r["intercept"]),
abs(row["stationary"] - r["stationary"]), abs(row["pcm"] - r["pcm"]))
# the override path must actually symmetrize: if nn_shieldings_override_df were silently dropped,
# symmetrized=True would equal the symmetrized=False raw fit exactly
assert max_vs_raw > 1e-4, "symmetrized=True equals the raw unsymmetrized fit -- the override path did not run"
@pytest.mark.skipif(not (os.path.exists(REAL_H5) and os.path.exists(REAL_XLSX) and _HAS_CHECKPOINTS),
reason="real delta22.hdf5 / experimental xlsx / model checkpoints not present")
def test_shipped_symmetrized_csvs_reproduce_from_live_inference():
"""Value-traceability for the published SI tables shipped as CSVs in data/scaling_factors/: they
must reproduce from a live symmetrized build at the n_passes they were generated with (10), not
merely parse. A swapped column, wrong solvent order, or stale hand-edit of the shipped CSVs fails
here. The
tolerance absorbs the model's pass-to-pass inference noise (a single forward pass is not
deterministic) but is far tighter than any structural error."""
pytest.importorskip("torch")
sys.path.insert(0, os.path.join(REPO, "data", "scaling_factors"))
import scaling_factors_reader as R # noqa: E402
shipped = R.load_symmetrized_tables()
fresh = S.build_scaling_tables(REAL_H5, REAL_XLSX, symmetrized=True, n_passes=10)
for nucleus in ("H", "C"):
s, f = shipped[nucleus], fresh[nucleus]
assert list(s.columns) == list(f.columns) == ["intercept", "stationary", "pcm"]
assert sorted(s.index) == sorted(f.index)
for solvent in s.index:
assert s.loc[solvent, "intercept"] == pytest.approx(f.loc[solvent, "intercept"], abs=0.03), f"{nucleus} {solvent} intercept"
assert s.loc[solvent, "stationary"] == pytest.approx(f.loc[solvent, "stationary"], abs=0.01), f"{nucleus} {solvent} stationary"
assert s.loc[solvent, "pcm"] == pytest.approx(f.loc[solvent, "pcm"], abs=0.02), f"{nucleus} {solvent} pcm"
def test_magnet_package_scaling_matches_canonical_tables():
"""The tiny copy shipped in the importable package (magnet.scaling, used internally by
magnet.predict_shifts) must equal the canonical SI tables here, so the two can never drift."""
pytest.importorskip("torch") # importing the magnet package pulls in the torch stack
from magnet import scaling as MS
canonical = S.published_scaling_tables() # DataFrames indexed by solvent
tiny = MS.published_scaling_tables() # {solvent: {column: value}} dicts
for nucleus in ("H", "C"):
assert set(tiny[nucleus]) == set(canonical[nucleus].index)
for solvent, coeffs in tiny[nucleus].items():
for column in ("intercept", "stationary", "pcm"):
assert coeffs[column] == pytest.approx(float(canonical[nucleus].loc[solvent, column]))
# the two predict_shift implementations give the same shift
assert float(MS.predict_shift(tiny["C"], "chloroform", 170.0, -0.3)) == pytest.approx(
float(S.predict_shift(canonical["C"], "chloroform", 170.0, -0.3)))
|