File size: 25,178 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
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
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
"""Tests for analysis/code/applications.py (the no-plotting analysis module).

Builds a tiny synthetic applications.hdf5 + spreadsheet so the correction math can be checked
in CI without the multi-hundred-megabyte release files.
"""
import os
import sys

import numpy as np
import pandas as pd
import h5py
import pytest

HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, os.path.join(HERE, "..", "..", "data", "applications"))
sys.path.insert(0, HERE)
import paths

from applications_reader import Applications, SHELL_SIZES, SOLVENTS  # noqa: E402
import applications as A  # noqa: E402

SCALE = 1e4
MISS = np.int32(-2147483648)


def test_distribution_shift_by_solvent_table_averages_per_solvent():
    """distribution_shift_by_solvent_table averages the three coefficient-choice RMSEs per solvent,
    and surfaces the water column as the biggest extrapolation gap (the S15 story)."""
    remapped = A.FORMULA_REMAP["stationary_plus_qcd + openMM"]
    per_solute = {"H": pd.DataFrame([
        {"formula": remapped, "solvent": "chloroform", "solute": "a", "rmse": 0.10},
        {"formula": remapped, "solvent": "chloroform", "solute": "b", "rmse": 0.20},
        {"formula": remapped, "solvent": "TIP4P", "solute": "a", "rmse": 0.30},
        {"formula": remapped, "solvent": "TIP4P", "solute": "b", "rmse": 0.50},
    ])}
    per_solvent = {"H": pd.DataFrame([
        {"formula": remapped, "solvent": "chloroform", "rmse": 0.16},
        {"formula": remapped, "solvent": "TIP4P", "rmse": 0.45},
    ])}
    bootstrap = pd.DataFrame([
        {"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.14},
        {"nucleus": "H", "formula": remapped, "solvent": "chloroform", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.18},
        {"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "a", "seed": 0, "Bootstrap_RMSE": 0.60},
        {"nucleus": "H", "formula": remapped, "solvent": "TIP4P", "solute": "b", "seed": 0, "Bootstrap_RMSE": 0.80},
    ])
    table = A.distribution_shift_by_solvent_table(per_solute, per_solvent, bootstrap, "H",
                                                  "stationary_plus_qcd + openMM")
    assert list(table.index) == ["TIP4P", "chloroform"]  # sorted; uppercase sorts before lowercase
    assert list(table.columns) == ["Extrapolated from delta22", "Scaled to Test Set", "Scaled to Solute"]
    assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.15)
    assert table.loc["chloroform", "Scaled to Test Set"] == pytest.approx(0.16)
    assert table.loc["chloroform", "Extrapolated from delta22"] == pytest.approx(0.16)
    assert table.loc["TIP4P", "Scaled to Solute"] == pytest.approx(0.40)
    assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.70)
    chl_gap = table.loc["chloroform", "Extrapolated from delta22"] - table.loc["chloroform", "Scaled to Solute"]
    water_gap = table.loc["TIP4P", "Extrapolated from delta22"] - table.loc["TIP4P", "Scaled to Solute"]
    assert water_gap > chl_gap


def _enc(a):
    a = np.asarray(a, dtype=np.float64)
    nan = np.isnan(a)
    out = np.round(np.where(nan, 0.0, a) * SCALE).astype(np.int32)
    out[nan] = MISS
    return out


@pytest.fixture
def loader(tmp_path):
    """One solute (vomicine), 3 atoms, one H site over atoms 1+2, one C site over atom 3.
    magnet_x is set so the solvated-minus-isolated correction is a known constant per shell."""
    h5 = tmp_path / "applications.hdf5"
    xlsx = tmp_path / "exp.xlsx"
    with h5py.File(h5, "w") as f:
        sg = f.create_group("solvents")
        for sv in SOLVENTS:
            sg.create_group(sv).create_dataset("atomic_numbers", data=np.array([6], np.int32))
        g = f.create_group("vomicine")
        g.create_dataset("atomic_numbers", data=np.array([1, 1, 6], np.int32))
        mx = g.create_group("magnet_x")
        for sv in SOLVENTS:
            svg = mx.create_group(sv)
            for k, shell in enumerate(SHELL_SIZES):
                arr = np.zeros((4, 3, 2), dtype=np.float64)
                arr[:, :, 0] = 10.0                 # isolated
                arr[:, :, 1] = 10.0 + 0.1 * (k + 1)  # solvated -> correction = 0.1*(k+1)
                svg.create_dataset(f"shell_{shell}", data=_enc(arr))
            svg.create_dataset("stationary", data=_enc(np.full(3, 10.0)))
        mz = g.create_group("magnet_zero")
        mz.create_dataset("stationary", data=_enc(np.array([100.0, 101.0, 50.0])))
        mz.create_dataset("pcm_correction", data=_enc(np.array([-1.0, -1.0, -2.0])))
        q = g.create_group("qcd")
        q.create_dataset("stationary", data=_enc(np.zeros((3, 4))))
        q.create_dataset("trajectories", data=_enc(np.zeros((2, 3, 3, 4))))  # corr -> 0
        cm = f.create_group("composite_model")
        cmc = cm.create_group("pcm_conversion_factors")
        # the real conversion table uses 'water' (renamed to TIP4P by build_query_df_nn), never both
        rows = "solvent,pcm_conversion_factor\n" + "".join(
            f"{s},1.0\n" for s in ["chloroform", "benzene", "methanol", "water"])
        cmc.create_dataset("H", data=rows)
        cmc.create_dataset("C", data=rows)
    with pd.ExcelWriter(xlsx) as w:
        pd.DataFrame({
            "site": ["H", "1 C"], "nucleus": ["H", "C"],
            "atom_numbers": ["1,2", "3"],
            "chloroform": [1.0, 2.0], "benzene": [1.0, 2.0],
            "methanol": [1.0, 2.0], "water": [1.0, 2.0],
        }).to_excel(w, sheet_name="vomicine", index=False)
    return Applications(h5, xlsx)


def test_shell_convergence_values(loader):
    df = A.shell_convergence_corrections(loader, "vomicine", "H")
    # one H site (uuid-prefixed) x 4 solvents
    assert df.index.get_level_values("solvent").nunique() == len(SOLVENTS)
    assert df.index.get_level_values("site").unique().tolist() == ["01 H"]
    # correction for shell k (0-based) is exactly 0.1*(k+1) within the int32 quantum
    row = df.xs("chloroform", level="solvent").iloc[0]
    for k, shell in enumerate(SHELL_SIZES):
        assert row[f"shell_{shell}"] == pytest.approx(0.1 * (k + 1), abs=5e-5)


def test_only_requested_nucleus(loader):
    df = A.shell_convergence_corrections(loader, "vomicine", "H")
    # C site must not appear in an H-nucleus convergence table
    sites = df.index.get_level_values("site").unique().tolist()
    assert all("C" not in s for s in sites)


def test_site_atom_indices():
    assert A.site_atom_indices("1,2") == [0, 1]
    assert A.site_atom_indices("21") == [20]


def test_fit_recovers_known_line():
    # experimental = 2*stationary + 5 exactly -> intercept ~5, slope ~2, RMSE ~0
    df = pd.DataFrame({"stationary": [1.0, 2.0, 3.0, 4.0], "experimental": [7.0, 9.0, 11.0, 13.0]})
    rmse, params = A.fit(df, "stationary")
    assert rmse < 1e-9
    assert params["Intercept"] == pytest.approx(5.0, abs=1e-6)
    assert params["stationary"] == pytest.approx(2.0, abs=1e-6)


def test_fit_ignores_rows_with_no_experimental_value():
    # one site has no experimental measurement (common in the real natural-products spreadsheet);
    # it must not turn the whole RMSE into NaN, only be left out of the score
    df = pd.DataFrame({
        "stationary": [1.0, 2.0, 3.0, 4.0],
        "experimental": [7.0, 9.0, 11.0, np.nan],
    })
    rmse, params = A.fit(df, "stationary")
    assert rmse < 1e-9
    assert params["stationary"] == pytest.approx(2.0, abs=1e-6)


def test_fit_matches_statsmodels():
    # the numpy fit must be numerically identical to the original statsmodels OLS oracle, across
    # multi-term formulas and with missing values present (rows dropped identically by both).
    rng = np.random.default_rng(7)
    n = 60
    df = pd.DataFrame({
        "stationary": rng.normal(100, 20, n), "pcm": rng.normal(0, 2, n),
        "qcd": rng.normal(0, 1, n), "openMM": rng.normal(0, 2, n),
    })
    df["experimental"] = (2.0 * df["stationary"] + 0.5 * df["pcm"] - 0.3 * df["qcd"]
                          + 1.0 + rng.normal(0, 1, n))
    df.loc[[3, 17, 42], "experimental"] = np.nan   # missing experimental values
    df.loc[[8, 25], "pcm"] = np.nan                # missing predictor values
    for formula in ["stationary", "stationary + pcm", "stationary + pcm + qcd",
                    "stationary + pcm + qcd + openMM"]:
        rmse, params = A.fit(df, formula)
        s_rmse, s_params = A._fit_statsmodels(df, formula)
        assert rmse == pytest.approx(s_rmse, abs=1e-8), formula
        for name in s_params.index:
            assert params[name] == pytest.approx(s_params[name], abs=1e-6), f"{formula}:{name}"


def test_build_query_df_nn(loader):
    q = A.build_query_df_nn(loader)
    expected = {"solute", "nucleus", "site", "solvent", "experimental", "stationary", "pcm",
                "openMM", "openMM_vib", "qcd", "stationary_plus_pcm", "stationary_plus_qcd",
                "stationary_plus_op_vib"}
    assert expected.issubset(q.columns)
    # H site covers atoms 1+2: stationary = mean(100, 101) = 100.5; pcm = mean(-1, -1) = -1;
    # conversion factor 1.0 -> stationary_plus_pcm = 99.5
    h = q[(q["nucleus"] == "H") & (q["solvent"] == "chloroform")].iloc[0]
    assert h["stationary"] == pytest.approx(100.5, abs=1e-3)
    assert h["stationary_plus_pcm"] == pytest.approx(99.5, abs=1e-3)
    assert h["qcd"] == pytest.approx(0.0, abs=1e-3)


def test_apply_bootstrap_nan_handling():
    # a missing coeff (openMM=NaN) contributes nothing; a present coeff with missing data -> NaN
    coeffs = pd.DataFrame([{"solvent": "chloroform", "formula": "pcm2", "seed": 0,
                            "Intercept": 5.0, "stationary": 2.0, "openMM": np.nan}])
    data = pd.DataFrame([
        {"solute": "m", "nucleus": "H", "site": "a", "solvent": "chloroform",
         "experimental": 7.0, "stationary": 1.0, "openMM": 9.0},                 # -> 5 + 2*1 = 7
        {"solute": "m", "nucleus": "H", "site": "b", "solvent": "chloroform",
         "experimental": np.nan, "stationary": np.nan, "openMM": 9.0},           # stationary data NaN -> NaN
    ])
    out = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H").set_index("site")
    assert out.loc["a", "predicted"] == pytest.approx(7.0)
    assert np.isnan(out.loc["b", "predicted"])


def test_apply_bootstrap_and_solute_rmse():
    # one formula/seed/solvent: predicted = 5 + 2*stationary; experimental matches -> RMSE 0
    coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": ["pcm2"], "seed": [0],
                           "Intercept": [5.0], "stationary": [2.0]})
    data = pd.DataFrame({
        "solute": ["m", "m"], "nucleus": ["H", "H"], "site": ["01 H", "02 H"],
        "solvent": ["chloroform", "chloroform"], "experimental": [7.0, 9.0], "stationary": [1.0, 2.0],
    })
    preds = A.apply_bootstrap_params_to_full_dataset(data, coeffs, nucleus="H")
    assert np.allclose(preds["predicted"], preds["experimental"])
    rmses = A.compute_solute_rmses(preds)
    assert rmses["Bootstrap_RMSE"].iloc[0] == pytest.approx(0.0, abs=1e-9)


# --- SI Figure S15's "Fitting RMSE Comparisons" -----------------------------------------------

def _rmse_comparison_fixture():
    """Two test-set solutes ("m", "n"), one solvent, formula "stationary + openMM". Each solute has
    only 2 points against 3 free parameters, so a per-solute-only fit is always exact (RMSE 0) --
    that part needs no special data. The 4 points are deliberately NOT co-planar in (stationary,
    openMM, experimental) space (verified: an earlier version of this fixture picked co-planar
    points by accident, which made the pooled "Scaled to Test Set" fit ALSO exact and silently
    hid the "formula" column bug this fixture now catches -- see the FORMULA_REMAP note below), so
    the pooled fit has nonzero RMSE and "Scaled to Test Set" != "Scaled to Solute". The bootstrap
    coefficients are also chosen far from both fits, so all three columns differ."""
    query_df_nn = pd.DataFrame([
        {"solute": "m", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 10.0, "stationary": 5.0, "openMM": 1.0},
        {"solute": "m", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 12.0, "stationary": 6.0, "openMM": 1.0},
        {"solute": "n", "nucleus": "H", "site": "01", "solvent": "chloroform", "experimental": 20.0, "stationary": 5.0, "openMM": 2.0},
        {"solute": "n", "nucleus": "H", "site": "02", "solvent": "chloroform", "experimental": 23.0, "stationary": 7.0, "openMM": 2.0},
    ])
    formula = "stationary + openMM"
    per_solute = {"H": A.fit_formulas_per_solvent_and_solute(query_df_nn, [formula], ["chloroform"],
                                                             ["m", "n"], A.FORMULA_REMAP)}
    all_solute = {"H": A.fit_formulas_per_solvent(query_df_nn, [formula], ["chloroform"], A.FORMULA_REMAP)}
    # The real pipeline (applications.build_bootstrap_seed_coeffs) always stores the REMAPPED
    # formula name in its "formula" column, same as per_solute_fits/per_solvent_fits above --
    # fitting_rmse_comparison_table looks up bootstrap_rmses by the remapped name, so a coeffs
    # frame built with the raw formula string would never match and "Extrapolated from delta22"
    # would silently come out all-NaN instead of raising. Match that convention here.
    remapped = A.FORMULA_REMAP[formula]
    coeffs = pd.DataFrame({"solvent": ["chloroform"], "formula": [remapped], "seed": [0],
                           "Intercept": [0.0], "stationary": [1.0], "openMM": [1.0]})
    preds = A.apply_bootstrap_params_to_full_dataset(query_df_nn, coeffs, nucleus="H")
    bootstrap_rmses = A.compute_solute_rmses(preds)
    return query_df_nn, per_solute, all_solute, bootstrap_rmses, formula


def test_scaled_to_test_set_per_solute_rmse_applies_one_pooled_fit_per_solute():
    query_df_nn, _, all_solute, _, formula = _rmse_comparison_fixture()
    out = A.scaled_to_test_set_per_solute_rmse(query_df_nn, all_solute, "H", "chloroform", formula)
    assert set(out["solute"]) == {"m", "n"}
    # both solutes score under the SAME pooled coefficients, so their RMSEs need not be equal
    assert out["Bootstrap_RMSE"].notna().all()


def test_fitting_rmse_comparison_table_has_all_three_columns_and_solutes():
    query_df_nn, per_solute, all_solute, bootstrap_rmses, formula = _rmse_comparison_fixture()
    table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses,
                                            "H", "chloroform", formula)
    assert set(table.columns) == {"Scaled to Solute", "Scaled to Test Set", "Extrapolated from delta22"}
    assert set(table.index) == {"m", "n"}
    assert table.notna().all().all()   # a formula-remapping mismatch would leave a column all-NaN
    # "Scaled to Solute" fits each solute alone with 2 free params on 2 points -> exact, RMSE 0
    assert table["Scaled to Solute"].max() < 1e-8
    # the fixture's points are deliberately non-coplanar (see _rmse_comparison_fixture), so the
    # pooled fit is NOT exact and genuinely differs from the per-solute fit
    assert table["Scaled to Test Set"].max() > 1e-3
    # the bootstrap coefficients are far from either fit, so this column differs from both
    assert (table["Extrapolated from delta22"] - table["Scaled to Solute"]).abs().min() > 0.1


@pytest.mark.skipif(
    not os.path.exists(paths.dataset_file("applications", file=__file__)),
    reason="real applications.hdf5 not present")
def test_fitting_rmse_comparison_table_reproduces_published_chloroform_values():
    """Every published "Fitting RMSE Comparisons (chloroform, H)" value (SI Figure S15), read
    directly off the SI, reproduced from the released data. Cross-checks the delta-22-bootstrap
    ("Extrapolated") column at a slightly looser tolerance since it averages over random seeds."""
    real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
    loader = Applications(paths.dataset_file("applications", file=__file__),
                          os.path.join(real_data_dir, "applications_experimental.xlsx"))
    query_df_nn = A.build_query_df_nn(loader)
    seed = A.build_bootstrap_seed_coeffs(loader)
    per_solute = A.per_solute_fits(query_df_nn)
    all_solute = A.per_solvent_fits(query_df_nn)
    preds_h = A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H")
    bootstrap_rmses_h = A.compute_solute_rmses(preds_h)
    table = A.fitting_rmse_comparison_table(query_df_nn, per_solute, all_solute, bootstrap_rmses_h,
                                            "H", "chloroform", "stationary_plus_qcd + openMM")
    # (solute, column) -> published value, read off the SI Figure S15 bar chart
    published = {
        ("isomer_1E", "Scaled to Solute"): 0.138, ("isomer_1E", "Scaled to Test Set"): 0.138,
        ("vomicine", "Scaled to Solute"): 0.173, ("vomicine", "Scaled to Test Set"): 0.183,
        ("prednisone", "Scaled to Solute"): 0.130, ("prednisone", "Scaled to Test Set"): 0.153,
        ("flavone", "Scaled to Solute"): 0.021, ("flavone", "Scaled to Test Set"): 0.062,
    }
    for (solute, col), expected in published.items():
        assert table.loc[solute, col] == pytest.approx(expected, abs=3e-3), f"{solute}/{col}"
    published_extrapolated = {"isomer_1E": 0.146, "vomicine": 0.180, "prednisone": 0.166, "flavone": 0.060}
    for solute, expected in published_extrapolated.items():
        assert table.loc[solute, "Extrapolated from delta22"] == pytest.approx(expected, abs=0.02), solute


@pytest.mark.skipif(
    not os.path.exists(paths.dataset_file("applications", file=__file__)),
    reason="real applications.hdf5 not present")
def test_distribution_shift_by_solvent_table_flags_water_on_real_data():
    """The per-solvent distribution-shift check on real data: for protons, TIP4P (water) must show
    the largest 'Extrapolated from delta22' minus 'Scaled to Solute' gap -- the exact claim the
    function's docstring and SI Figure S15's narrative make (the peptide dominates the water column).
    Also spot-checks two published per-solvent RMSE values."""
    real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
    loader = Applications(paths.dataset_file("applications", file=__file__),
                          os.path.join(real_data_dir, "applications_experimental.xlsx"))
    query_df_nn = A.build_query_df_nn(loader)
    seed = A.build_bootstrap_seed_coeffs(loader)
    per_solute = A.per_solute_fits(query_df_nn)
    all_solute = A.per_solvent_fits(query_df_nn)
    bootstrap_h = A.compute_solute_rmses(
        A.apply_bootstrap_params_to_full_dataset(query_df_nn, seed["H"], nucleus="H"))
    table = A.distribution_shift_by_solvent_table(per_solute, all_solute, bootstrap_h, "H",
                                                  "stationary_plus_qcd + openMM")
    gap = table["Extrapolated from delta22"] - table["Scaled to Solute"]
    assert gap.idxmax() == "TIP4P", f"expected water to have the largest extrapolation gap, got {gap.idxmax()}"
    assert table.loc["TIP4P", "Extrapolated from delta22"] == pytest.approx(0.336, abs=0.01)
    assert table.loc["chloroform", "Scaled to Solute"] == pytest.approx(0.107, abs=0.01)


# --- SI Figure S15's "Feature Space Coverage by Solvent" / "Residuals for Delta22 Fitting Coefficients" ---

def _feature_space_fixture():
    """A test-set solute ("m") and a delta-22 solute, both nucleus H, in chloroform and benzene
    (TIP4P/methanol deliberately absent from "m" to check the solvent filter drops rows cleanly).
    stationary_plus_qcd/openMM are chosen so delta-22's chloroform plane is exact-fittable
    (3 points, 3 free parameters -> zero residual)."""
    query_df_nn = pd.DataFrame([
        {"solute": "m", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 10.0, "openMM": 1.0, "experimental": 12.0},
        {"solute": "m", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 11.0, "openMM": 2.0, "experimental": 15.0},
        {"solute": "m", "nucleus": "C", "solvent": "chloroform", "stationary_plus_op_vib": 100.0, "openMM": 3.0, "experimental": 120.0},
    ])
    delta22_query_df_nn = pd.DataFrame([
        {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 12.0, "openMM": 0.0, "experimental": 12.0},
        {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 14.0, "openMM": 1.0, "experimental": 15.0},
        {"solute": "delta22", "nucleus": "H", "solvent": "chloroform", "stationary_plus_qcd": 16.0, "openMM": 2.0, "experimental": 18.0},
        {"solute": "delta22", "nucleus": "H", "solvent": "benzene", "stationary_plus_qcd": 9.0, "openMM": 1.5, "experimental": 13.0},
    ])
    return query_df_nn, delta22_query_df_nn


def test_feature_space_coverage_table_combines_and_centers_both_datasets():
    query_df_nn, delta22_query_df_nn = _feature_space_fixture()
    out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
    assert set(out["dataset"]) == {"Test Set", "Delta22"}
    assert set(out["solvent"]) == {"chloroform", "benzene"}
    # global centering: the pooled x column (both datasets, both solvents) averages to 0
    assert out["x"].mean() == pytest.approx(0.0, abs=1e-9)
    assert out["y"].mean() == pytest.approx(0.0, abs=1e-9)


def test_feature_space_coverage_table_drops_solvents_test_set_lacks():
    query_df_nn, delta22_query_df_nn = _feature_space_fixture()
    out = A.feature_space_coverage_table(query_df_nn, delta22_query_df_nn, "H",
                                         solvents=("chloroform", "benzene", "methanol", "TIP4P"))
    # "m" (the only test-set H solute) has no methanol/TIP4P rows, so those solvents drop out
    assert set(out["solvent"]) == {"chloroform", "benzene"}


def test_delta22_plane_residuals_table_delta22_residuals_are_near_zero():
    # delta-22's own 3 chloroform points exactly determine a 3-parameter plane, so delta-22's own
    # residuals against that plane must be ~0 (an OLS fit's residuals average to 0 with an
    # intercept; with exactly as many points as parameters, every residual is individually ~0)
    query_df_nn, delta22_query_df_nn = _feature_space_fixture()
    out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform",))
    d22 = out[out["dataset"] == "Delta22"]
    assert len(d22) == 3
    assert np.allclose(d22["residual"], 0.0, atol=1e-6)
    # the test-set row scores against that SAME plane and is not forced to be exact
    test_row = out[out["dataset"] == "Test Set"]
    assert len(test_row) == 1


def test_delta22_plane_residuals_table_skips_solvents_with_too_few_delta22_points():
    # benzene has only 1 delta-22 point (< 3 needed to fit a plane) -> dropped entirely, no crash
    query_df_nn, delta22_query_df_nn = _feature_space_fixture()
    out = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, "H", solvents=("chloroform", "benzene"))
    assert set(out["solvent"]) == {"chloroform"}


@pytest.mark.skipif(
    not os.path.exists(paths.dataset_file("applications", file=__file__)),
    reason="real applications.hdf5 not present")
def test_feature_space_and_residuals_tables_sane_on_real_data():
    """Not an exact-number reproduction (these are scatter plots, not summary statistics) --
    structural sanity checks: delta-22's own residuals against its own fitted plane average to
    (near) zero every solvent, and the known H/TIP4P data-scarcity caveat from the SI text ("the
    available experimental data for water in the test set is limited to one solute") shows up as a
    small test-set count for that one solvent."""
    real_data_dir = os.path.join(HERE, "..", "..", "data", "applications")
    delta22_dir = os.path.join(HERE, "..", "..", "data", "delta22")
    sys.path.insert(0, delta22_dir)
    import delta22 as D
    loader = Applications(paths.dataset_file("applications", file=__file__),
                          os.path.join(real_data_dir, "applications_experimental.xlsx"))
    query_df_nn = A.build_query_df_nn(loader)
    delta22_query_df_nn = D.add_composite_columns(D.load_query_df_nn(
        paths.dataset_file("delta22", file=__file__), os.path.join(delta22_dir, "delta22_experimental.xlsx"),
        verbose=False))
    for nucleus in ["H", "C"]:
        residuals = A.delta22_plane_residuals_table(query_df_nn, delta22_query_df_nn, nucleus)
        d22_means = residuals[residuals["dataset"] == "Delta22"].groupby("solvent")["residual"].mean()
        assert (d22_means.abs() < 1e-6).all()
        test_counts = residuals[residuals["dataset"] == "Test Set"].groupby("solvent").size()
        assert test_counts["chloroform"] > 50   # plenty of chloroform test-set data
        # SI-documented data scarcity: "the available experimental data for water in the test set
        # is limited to one solute" -- the residuals table (which requires an experimental value,
        # unlike the feature-space-coverage table) sees far fewer TIP4P test-set rows than chloroform
        assert 0 < test_counts.get("TIP4P", 0) < test_counts["chloroform"] / 5