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)))