| """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 |
| import paths as P |
|
|
| 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") |
|
|
|
|
| |
|
|
| 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]) |
|
|
|
|
| |
|
|
| def test_recommended_model_forms(): |
| |
| 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] |
| |
| 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) |
| |
| 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(): |
| |
| 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"]) |
| |
| 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"]) |
|
|
|
|
| |
|
|
| |
| |
| |
| |
| 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) |
| 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)) |
| |
| |
| assert max_abs_delta > 1e-4, "raw-data fit is identical to the published SI -- reflection gap missing" |
|
|
|
|
| |
|
|
| CKPT_ROOT = P.checkpoints_root() or "" |
| 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) |
| 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"]) |
| |
| |
| 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"])) |
| |
| |
| 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 |
| 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") |
| from magnet import scaling as MS |
| canonical = S.published_scaling_tables() |
| tiny = MS.published_scaling_tables() |
| 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])) |
| |
| 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))) |
|
|