File size: 6,952 Bytes
23c6f41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Tests for analysis/code/delta50_residuals.py.

Synthetic tests check residual selection and statistics on a hand-built shieldings dict. Opt-in
tests run against the real delta50.hdf5 and reproduce the paper's DELTA50 observation: MagNET-Zero
tracks its DFT reference to well under 0.5 ppm (1H) and a few ppm (13C), with nitromethane,
nitroethane, and 2-methyl-2-nitropropane the significant 13C outliers (up to ~7 ppm).
"""
import os
import sys

import numpy as np
import pytest

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "delta50"))  # decode_delta50
sys.path.insert(0, HERE)
import paths  # noqa: E402

import delta50_residuals as D  # noqa: E402


def make_shieldings():
    """Flat shieldings dict like the reader's all_atoms: H, C, C, H, N. MagNET-Zero is one array
    (WP04 quality at H, wB97X-D quality at C); the DFT reference is split by nucleus."""
    return {
        "atomic_numbers": np.array([1, 6, 6, 1, 7]),
        "shielding_wp04_pcSseg2": np.array([30.5, 150.5, 151.5, 31.5, 200.5]),
        "shielding_wb97xd_pcSseg2": np.array([30.7, 150.7, 151.7, 31.7, 200.7]),
        "nn_magnet_zero": np.array([30.6, 150.6, 151.6, 31.3, np.nan]),
    }


def test_residuals_select_nucleus_against_the_right_dft_level():
    s = make_shieldings()
    h = D.residuals(s, "H")  # DFT WP04 minus MagNET at the two H atoms (0, 3)
    np.testing.assert_allclose(sorted(h), sorted([30.5 - 30.6, 31.5 - 31.3]))
    c = D.residuals(s, "C")  # DFT wB97X-D minus MagNET at the two C atoms (1, 2)
    np.testing.assert_allclose(sorted(c), sorted([150.7 - 150.6, 151.7 - 151.6]))
    assert len(h) == 2 and len(c) == 2


def test_residual_stats():
    errors = np.array([-0.1, 0.2, 0.05, -0.05])
    st = D.residual_stats(errors)
    assert st["n"] == 4
    assert st["rmse"] == pytest.approx(np.sqrt(np.mean(errors ** 2)))
    assert st["mae"] == pytest.approx(np.mean(np.abs(errors)))
    assert st["max_ae"] == pytest.approx(0.2)


def test_residual_stats_empty():
    st = D.residual_stats(np.array([]))
    assert st["n"] == 0 and np.isnan(st["rmse"])


# --- opt-in: reproduce the DELTA50 residual numbers from the released data -----------------------

REAL = paths.dataset_file("delta50", file=__file__)


@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
def test_reproduces_delta50_magnet_zero_vs_dft_residuals():
    summary = D.residual_summary(REAL)
    # 1H: MagNET-Zero tracks WP04 to ~0.05 ppm RMSE, max residual ~0.47 ppm ("up to 0.4 ppm")
    assert summary["H"]["rmse"] == pytest.approx(0.055, abs=0.01)
    assert summary["H"]["max_ae"] == pytest.approx(0.469, abs=0.02)
    # 13C: RMSE ~0.61 ppm, max residual ~6.65 ppm ("up to 7 ppm")
    assert summary["C"]["rmse"] == pytest.approx(0.607, abs=0.03)
    assert summary["C"]["max_ae"] == pytest.approx(6.654, abs=0.05)


@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
def test_named_13c_outliers_are_the_three_nitroalkanes():
    per_mol = D.per_molecule_max_residual(REAL)
    top3 = sorted(per_mol, key=lambda nm: -per_mol[nm]["C"])[:3]
    assert set(top3) == {"nitromethane", "nitroethane", "2-methyl-2-nitropropane"}
    # nitromethane is the single largest 13C outlier, ~6.65 ppm
    assert per_mol["nitromethane"]["C"] == pytest.approx(6.654, abs=0.05)
    # nitrobenzene, by contrast, is accurately predicted (paper's explicit contrast)
    assert per_mol["nitrobenzene"]["C"] < 0.5


# Golden experimental shifts (ppm) for the molecules whose atom order is most likely to be mismapped:
# the three symmetry-fallback cases and the near-degenerate distinct environments (amide N-methyls,
# diastereotopic methyls). Values are the published DELTA50 shifts; a mapping regression changes them.
_GOLDEN_MULTISET = {
    "cyclohexanone": {"C": [25.02, 27.03, 27.03, 42.0, 42.0, 212.15]},
    "methyl acetate": {"C": [20.71, 51.62, 171.55]},
    "2-methyl-2-nitropropane": {"C": [27.87, 27.87, 27.87, 85.06]},
    "n,n-dimethylformamide": {"C": [31.44, 36.48, 162.52]},
    "n,n-dimethylacetamide": {"C": [21.58, 35.2, 38.05, 170.66]},
    "2-methyl-2-butene": {"C": [13.41, 17.32, 25.63, 118.44, 132.1]},
}
# (0-based carbon index -> shift): pins each shift to a specific atom, so a swap of two same-value-set
# environments (e.g. the two DMF N-methyls) fails even though the multiset is unchanged.
_GOLDEN_ATOM = {
    "n,n-dimethylformamide": {0: 162.52, 3: 31.44, 4: 36.48},
    "n,n-dimethylacetamide": {1: 170.66, 3: 35.2, 4: 38.05, 5: 21.58},
    "2-methyl-2-butene": {0: 17.32, 1: 132.1, 2: 118.44, 3: 13.41, 4: 25.63},
    "cyclohexanone": {1: 212.15, 2: 42.0, 3: 42.0, 4: 27.03, 5: 27.03, 6: 25.02},
}


@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
def test_experimental_multiset_matches_published_delta50():
    from decode_delta50 import Delta50
    with Delta50(REAL) as ds:
        for name, expected in _GOLDEN_MULTISET.items():
            m = ds.molecule_by_name(name)
            got = sorted(round(float(x), 2) for x in m["experimental_shift"][m["atomic_numbers"] == 6]
                         if np.isfinite(x))
            assert got == sorted(expected["C"]), name


@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
def test_experimental_swap_sensitive_carbons_pinned_to_atoms():
    from decode_delta50 import Delta50
    with Delta50(REAL) as ds:
        for name, atoms in _GOLDEN_ATOM.items():
            m = ds.molecule_by_name(name)
            for idx, shift in atoms.items():
                assert m["atomic_numbers"][idx] == 6, (name, idx)
                assert m["experimental_shift"][idx] == pytest.approx(shift, abs=1e-3), (name, idx)


@pytest.mark.skipif(not os.path.exists(REAL), reason="real delta50.hdf5 not present")
def test_experimental_shift_present_and_agrees_with_dft():
    from decode_delta50 import Delta50
    with Delta50(REAL) as ds:
        atoms = ds.all_atoms()
    z = atoms["atomic_numbers"]
    exp = atoms["experimental_shift"]
    # experimental is present for every H and C atom, absent for heteroatoms
    assert np.isfinite(exp[(z == 1) | (z == 6)]).all()
    assert np.isnan(exp[(z != 1) & (z != 6)]).all()
    # A correct atom mapping makes scaled DFT track experiment; a scrambled one would not. These
    # tolerances are loose (our DFT is gas phase on AIMNet2 geometries, not DELTA50's PCM level) but
    # far below the ppm-scale RMSE a wrong assignment would produce.
    for zz, key, tol in ((1, "shielding_wp04_pcSseg2", 0.5), (6, "shielding_wb97xd_pcSseg2", 5.0)):
        m = (z == zz) & np.isfinite(exp)
        sigma = atoms[key][m]
        A = np.vstack([sigma, np.ones(m.sum())]).T
        coef, *_ = np.linalg.lstsq(A, exp[m], rcond=None)
        rmse = np.sqrt(np.mean((A @ coef - exp[m]) ** 2))
        assert rmse < tol, f"Z={zz} scaled-DFT-vs-exp RMSE {rmse:.3f} too large"