File size: 10,160 Bytes
ef53368
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""
Tests for the leveling-effect analysis core (leveling.py).

Most tests build tiny synthetic inputs in the exact on-disk formats (so the fixtures
double as a spec of what the loaders expect) and need no large data. Two opt-in tests
run against the real delta22.hdf5 and the Kaupp NS372 spreadsheet if they are present.

Run:  pytest test_leveling.py -q
Requires: pytest, numpy, h5py, pandas, openpyxl, scikit-learn.
"""
import os
import numpy as np
import h5py
import openpyxl
import pytest

from fixed_point import MISSING_MARKER as _MISSING_MARKER
from leveling import (
    _decode_fixed_point,
    analyze_nucleus,
    read_nucleus,
    load_delta22,
    load_ns372,
    load_ns372_1h_convergence,
    convergence_statistics,
    DELTA22_REF,
)

SCALE = 1e4


def _enc(arr):
    """Encode floats the way the real file does: int32 = round(value * 1e4), NaN -> marker."""
    a = np.asarray(arr, dtype=np.float64)
    mask = np.isnan(a)
    out = np.zeros(a.shape, dtype=np.int32)
    out[~mask] = np.rint(a[~mask] * SCALE).astype(np.int32)
    out[mask] = _MISSING_MARKER
    return out


# ---------------------------------------------------------------------------
# fixed-point decoding
# ---------------------------------------------------------------------------
def test_decode_round_trip_and_marker():
    values = np.array([0.0, 207.07, -3.5, np.nan, 12345.6789], dtype=np.float64)
    decoded = _decode_fixed_point(_enc(values))
    assert np.isnan(decoded[3])
    np.testing.assert_allclose(decoded[~np.isnan(decoded)],
                               values[~np.isnan(values)], atol=1e-4)


def test_decode_passes_through_float():
    # an older plain-decimal copy must read unchanged (decode is a no-op on floats)
    values = np.array([1.0, 2.5, -7.25], dtype=np.float64)
    np.testing.assert_array_equal(_decode_fixed_point(values), values)


def test_decode_handles_int64_and_no_marker():
    # a re-encode at int64 must decode like int32 (any integer dtype), and an
    # integer array with no missing marker must round-trip with no spurious blanks
    vals = np.array([10000, 20000, -35000], dtype=np.int64)   # = 1.0, 2.0, -3.5
    out = _decode_fixed_point(vals)
    np.testing.assert_allclose(out, [1.0, 2.0, -3.5])
    assert not np.isnan(out).any()


# ---------------------------------------------------------------------------
# leveling analysis on a synthetic, perfectly-leveled matrix
# ---------------------------------------------------------------------------
def test_analyze_nucleus_detects_leveling():
    # build methods that are affine transforms of one base signal plus tiny noise:
    # after scaling they are interchangeable, so PC1 should dominate and every
    # pairwise correlation should be near 1.
    rng = np.random.RandomState(0)
    base = rng.normal(size=60)
    slopes = np.linspace(0.8, 1.3, 8)
    offsets = np.linspace(-5, 5, 8)
    cols = [a * base + b + 0.01 * rng.normal(size=60)
            for a, b in zip(slopes, offsets)]
    M = np.column_stack(cols)
    methods = [f"m{i}" for i in range(8)]
    res = analyze_nucleus(M, methods, ref=M[:, 0])
    assert res['ev'][0] > 0.99            # PC1 carries essentially all variance
    assert res['r_min'] > 0.99            # worst pairwise correlation still near 1
    assert set(res['scaled_rmse']) == set(methods)
    assert 'r2' in res['par'] and 'theta' in res['par']


# ---------------------------------------------------------------------------
# delta22 loader against a synthetic int32 HDF5 in the real layout
# ---------------------------------------------------------------------------
DELTA22_FUNCS = ['hf', 'blyp_d3bj', 'bp86_d3bj', 'b97d3_d3bj', 'tpsstpss_d3bj',
                 'b3lyp_d3bj', 'pbe0_d3bj', 'm062x_d3', 'wb97xd', 'wp04', 'wc04',
                 'B2PLYP', 'mPW2PLYP', 'B2GP_PLYP', 'dsd_pbep86', 'revdsd_pbep86',
                 'mp2', 'dlpno_mp2']


def _make_synthetic_delta22(path):
    # one gas method label per functional. most sit at pcSseg3, but mp2 is only at
    # pcSseg2 and hf only at pcSseg1, so the loader's largest-basis fallback ladder
    # (pcSseg3 then pcSseg2 then pcSseg1) is exercised.
    basis_for = {f: "pcSseg3" for f in DELTA22_FUNCS}
    basis_for["mp2"] = "pcSseg2"
    basis_for["hf"] = "pcSseg1"
    names = [f"{f},{basis_for[f]},gas,none" for f in DELTA22_FUNCS]
    n_methods = len(names)
    with h5py.File(path, "w") as f:
        f.create_dataset("conventional_nmr_method_names",
                         data=np.array(names, dtype=h5py.string_dtype()))
        # one solute, atoms H, C, H, C, N  (N is ignored: not 1H/13C)
        z = np.array([1, 6, 1, 6, 7], np.uint8)
        g = f.create_group("solutes/mol1")
        g.create_dataset("atomic_numbers", data=z)
        # conventional_shieldings[method, geometry, atom]; geometry 1 = PBE0/cc-pVTZ
        base = np.array([30.0, 150.0, 31.0, 151.0, 200.0])
        cs = np.zeros((n_methods, 2, len(z)))
        for mi in range(n_methods):
            cs[mi, 0] = base + 0.5 * mi          # aimnet2 geometry (unused)
            cs[mi, 1] = base + 0.01 * mi         # pbe0_tz geometry (used)
        g.create_dataset("stationary_and_pcm/conventional_shieldings", data=_enc(cs))
    return base


def test_load_delta22_synthetic(tmp_path):
    p = tmp_path / "delta22.hdf5"
    base = _make_synthetic_delta22(str(p))
    out = load_delta22(str(p))
    assert set(out) == {"1H", "13C"}
    # 1 solute has two H sites (atoms 0,2) and two C sites (atoms 1,3)
    assert out["1H"]["M"].shape == (2, len(DELTA22_FUNCS))
    assert out["13C"]["M"].shape == (2, len(DELTA22_FUNCS))
    assert DELTA22_REF in out["1H"]["methods"]
    # every functional was selected via the basis-fallback ladder, so no method is missing
    assert not np.isnan(out["1H"]["M"]).any()
    assert not np.isnan(out["13C"]["M"]).any()
    # decoded values must match what we wrote on the pbe0_tz geometry; pick the
    # DSD-PBEP86 method column for the first H site (atom 0)
    ref_method_index = DELTA22_FUNCS.index("dsd_pbep86")
    expected = base[0] + 0.01 * ref_method_index
    col = out["1H"]["methods"].index(DELTA22_REF)
    np.testing.assert_allclose(out["1H"]["M"][0, col], expected, atol=1e-4)


# ---------------------------------------------------------------------------
# NS372 sheet parsing against a synthetic Kaupp-style worksheet
# ---------------------------------------------------------------------------
def _make_synthetic_kaupp_sheet(path):
    wb = openpyxl.Workbook()
    ws = wb.active
    ws.title = "S6 - 1H Shieldings"
    # row 1: functional headers from column 7; row 2: tau sub-labels
    # two methods: SVWN (no tau) and TPSS (tau = tauC), matching leveling.METHODS keys
    ws.cell(1, 7, "SVWN")
    ws.cell(1, 8, "TPSS"); ws.cell(2, 8, "τC")
    # data rows from row 4: col1 nucleus (sticky), col2 molecule, col4 CCSD(T) ref
    rows = [("1H", "molA", 30.0, 30.1, 30.2),
            ("1H", "molB", 28.0, 28.2, 28.1),
            ("1H", "molC", 25.0, 25.3, 25.1)]
    for i, (nuc, mol, ref, svwn, tpss) in enumerate(rows):
        r = 4 + i
        ws.cell(r, 1, nuc)
        ws.cell(r, 2, mol)
        ws.cell(r, 4, ref)
        ws.cell(r, 7, svwn)
        ws.cell(r, 8, tpss)
    wb.save(path)


def test_read_nucleus_synthetic(tmp_path):
    p = tmp_path / "kaupp.xlsx"
    _make_synthetic_kaupp_sheet(str(p))
    wb = openpyxl.load_workbook(str(p), data_only=True)
    ref, data = read_nucleus(wb, "S6 - 1H Shieldings")
    np.testing.assert_allclose(ref, [30.0, 28.0, 25.0])
    assert ("SVWN", None) in data
    assert ("TPSS", "τC") in data
    np.testing.assert_allclose(data[("SVWN", None)], [30.1, 28.2, 25.3])
    np.testing.assert_allclose(data[("TPSS", "τC")], [30.2, 28.1, 25.1])


# ---------------------------------------------------------------------------
# opt-in tests against the real files (skipped when absent, e.g. in CI)
# ---------------------------------------------------------------------------
def _find(*candidates):
    for c in candidates:
        if c and os.path.exists(c):
            return c
    return None

_HERE = os.path.dirname(os.path.abspath(__file__))
import paths
_REAL_DELTA22 = _find(
    paths.dataset_file("delta22", file=__file__),   # deposit copy, then in-repo data/delta22/
    os.path.join(_HERE, "delta22.hdf5"),
    os.path.join(_HERE, "delta22", "delta22.hdf5"),
)
_REAL_KAUPP = _find(
    os.path.join(_HERE, "..", "..", "data", "ns372", "ct1c00919_si_002.xlsx"),
)


@pytest.mark.skipif(_REAL_DELTA22 is None, reason="real delta22.hdf5 not present")
def test_real_delta22_reproduces_leveling():
    out = load_delta22(_REAL_DELTA22)
    assert out["1H"]["M"].shape == (145, 18)
    assert out["13C"]["M"].shape == (76, 18)
    res = analyze_nucleus(out["1H"]["M"], out["1H"]["methods"], out["1H"]["ref"])
    assert res['ev'][0] > 0.99            # PC1 dominance on the real proton data


@pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
def test_real_ns372_loads_all_nuclei():
    out = load_ns372(_REAL_KAUPP)
    assert set(out) == {"1H", "11B", "13C", "15N", "17O", "19F", "31P", "33S"}
    res = analyze_nucleus(out["13C"]["M"], out["13C"]["methods"], out["13C"]["ref"])
    assert res['ev'][0] > 0.99


@pytest.mark.skipif(_REAL_KAUPP is None, reason="real Kaupp NS372 spreadsheet not present")
def test_real_ns372_1h_convergence_stats():
    # Figure 2D input: the 1H slice reduces to 44 methods vs the CCSD(T) reference,
    # and each method's slope/scaled RMSE lands in the expected physical range.
    cc = load_ns372_1h_convergence(_REAL_KAUPP)
    assert cc.shape == (124, 45)                       # 44 methods + CCSD(T)
    assert "CCSD(T)" in cc.columns and "HF" in cc.columns   # 'Hartree-Fock' header renamed
    res = convergence_statistics(cc).set_index("Method")
    assert len(res) == 44
    assert (res["Slope"].between(0.9, 1.15)).all()     # near the y=x limit
    assert (res["RMSE (scaled)"] > 0).all()
    # the double hybrid sits closest to the electronic-structure limit
    assert res["RMSE (scaled)"].idxmin() == "DSD-PBEP86"
    assert res.loc["DSD-PBEP86", "RMSE (scaled)"] < 0.1