ekwan16 commited on
Commit
e16c7ac
·
verified ·
1 Parent(s): 997ca82

sync code from github@e853181b4c59e84169cc94394b0c9bbe8a58b077

Browse files
conftest.py CHANGED
@@ -1,11 +1,11 @@
1
- # The magnet/ model package needs the heavy PyTorch / PyTorch Geometric stack
2
  # (see magnet/requirements.txt), which the default dataset-reader test environment does not
3
- # install. When torch is absent, skip collecting the magnet tests entirely so the lightweight
4
- # CI stays green. They run locally once the torch stack from magnet/requirements.txt is present.
5
  #
6
- # This is needed (not just the importorskip guards inside test_magnet.py) because that test file
7
- # lives inside the magnet package, so pytest imports magnet/__init__.py during collection, which
8
- # imports torch before any in-test skip can run.
9
  import os
10
  import sys
11
 
@@ -23,8 +23,8 @@ collect_ignore_glob = []
23
  try:
24
  import torch # noqa: F401
25
  except ImportError:
26
- collect_ignore = ["magnet"]
27
- collect_ignore_glob = ["magnet/*"]
28
 
29
 
30
  # The reproduce tests (test_reproduce.py) execute every notebook and take minutes; they are opt-in so
 
1
+ # The magnet/ package tests live in tests/ and need the heavy PyTorch / PyTorch Geometric stack
2
  # (see magnet/requirements.txt), which the default dataset-reader test environment does not
3
+ # install. When torch is absent, skip collecting tests/ entirely so the lightweight CI stays green.
4
+ # They run locally once the torch stack from magnet/requirements.txt is present.
5
  #
6
+ # This is needed (not just the importorskip guards inside the test files) because tests/test_api.py
7
+ # and tests/test_magnet.py import torch (and magnet, which imports torch) at module scope, before
8
+ # any in-test skip can run.
9
  import os
10
  import sys
11
 
 
23
  try:
24
  import torch # noqa: F401
25
  except ImportError:
26
+ collect_ignore = ["magnet", "tests"]
27
+ collect_ignore_glob = ["magnet/*", "tests/*"]
28
 
29
 
30
  # The reproduce tests (test_reproduce.py) execute every notebook and take minutes; they are opt-in so
tests/test_api.py ADDED
@@ -0,0 +1,485 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Adversarial edge-case tests for the magnet public API (magnet/api.py).
2
+
3
+ Pure-logic tests (single-vs-list detection, validation, solvent checking, solute selection) run
4
+ whenever the torch stack is installed. Tests marked @needs_model load real checkpoints and are
5
+ skipped unless the weights are present; they stay few and fast (n_passes=1, symmetrize=False, tiny
6
+ molecules). Run the whole suite with `pytest`; to include the real-model tests, check out the
7
+ Hugging Face copy so the weights land in model_checkpoints/.
8
+ """
9
+ import os
10
+ import warnings
11
+
12
+ import numpy as np
13
+ import pytest
14
+
15
+ warnings.filterwarnings("ignore")
16
+
17
+ import torch
18
+
19
+ import magnet.api as api
20
+ from magnet import scaling
21
+ from magnet.eqV2.edge_rot_mat import init_edge_rot_mat
22
+
23
+ _HERE = os.path.dirname(os.path.abspath(__file__))
24
+ _REPO = os.path.dirname(_HERE) # magnet/ -> repo root
25
+ _CKPT_REL = os.path.join("model_checkpoints", "MagNET-Zero", "MagNET-Zero_1H.ckpt")
26
+ _HAVE_MODELS = os.path.exists(os.path.join(_REPO, _CKPT_REL))
27
+ needs_model = pytest.mark.skipif(not _HAVE_MODELS, reason="checkpoints not available")
28
+
29
+ # ---- tiny geometries (validity irrelevant for API-contract tests) ----
30
+ CH4_Z = np.array([6, 1, 1, 1, 1])
31
+ CH4_XYZ = np.array([[0., 0., 0.],
32
+ [0.629, 0.629, 0.629],
33
+ [-0.629, -0.629, 0.629],
34
+ [-0.629, 0.629, -0.629],
35
+ [0.629, -0.629, -0.629]])
36
+ # formaldehyde CH2O: exercises H, C, and an O (should be NaN in shifts)
37
+ CH2O_Z = np.array([6, 8, 1, 1])
38
+ CH2O_XYZ = np.array([[0., 0., 0.], [1.2, 0., 0.], [-0.5, 0.94, 0.], [-0.5, -0.94, 0.]])
39
+ # one chloroform molecule, atoms in molecule order C H Cl Cl Cl
40
+ CHCL3_Z = np.array([6, 1, 17, 17, 17])
41
+ CHCL3_XYZ = np.array([[0., 0., 0.], [0., 0., 1.1], [1.7, 0., -0.4],
42
+ [-0.85, 1.47, -0.4], [-0.85, -1.47, -0.4]])
43
+
44
+
45
+ # =====================================================================
46
+ # _as_batch: single-vs-list detection and validation
47
+ # =====================================================================
48
+
49
+ def test_single_ndarray_is_single():
50
+ an, xyz, single = api._as_batch(CH4_Z, CH4_XYZ)
51
+ assert single is True and len(an) == 1 and an[0].shape == (5,) and xyz[0].shape == (5, 3)
52
+
53
+ def test_single_pythonlist_is_single():
54
+ an, xyz, single = api._as_batch([6, 1, 1, 1, 1], CH4_XYZ.tolist())
55
+ assert single is True and len(xyz) == 1
56
+
57
+ def test_one_atom_molecule_is_single():
58
+ an, xyz, single = api._as_batch(np.array([1]), np.array([[0., 0., 0.]]))
59
+ assert single is True and xyz[0].shape == (1, 3)
60
+
61
+ def test_list_of_molecules_is_batch():
62
+ an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ])
63
+ assert single is False and len(an) == 2
64
+
65
+ def test_batch_size_one_list_is_batch():
66
+ an, xyz, single = api._as_batch([CH4_Z], [CH4_XYZ])
67
+ assert single is False and len(an) == 1
68
+
69
+ def test_3d_ndarray_is_batch():
70
+ Z = np.stack([CH4_Z, CH4_Z]); X = np.stack([CH4_XYZ, CH4_XYZ]) # (2,5) and (2,5,3)
71
+ an, xyz, single = api._as_batch(Z, X)
72
+ assert single is False and len(xyz) == 2 and xyz[0].shape == (5, 3)
73
+
74
+ def test_ragged_batch_ok():
75
+ an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ])
76
+ assert xyz[0].shape == (5, 3) and xyz[1].shape == (4, 3)
77
+
78
+ def test_empty_list_raises():
79
+ with pytest.raises(ValueError, match="empty input"):
80
+ api._as_batch([], [])
81
+
82
+ def test_empty_ndarray_raises():
83
+ with pytest.raises(ValueError, match="empty input"):
84
+ api._as_batch(np.array([]), np.zeros((0, 3)))
85
+
86
+ def test_mismatched_atom_counts_single_raises():
87
+ with pytest.raises(ValueError):
88
+ api._as_batch(np.array([6, 1, 1]), np.zeros((4, 3)))
89
+
90
+ def test_wrong_last_dim_raises():
91
+ with pytest.raises(ValueError):
92
+ api._as_batch(np.array([6, 1]), np.zeros((2, 2)))
93
+
94
+ def test_mismatched_list_lengths_raises():
95
+ with pytest.raises(ValueError):
96
+ api._as_batch([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ, CH4_XYZ])
97
+
98
+ def test_coords_1d_single_atom_flat_is_misread():
99
+ # coordinates=(3,) flat for one atom: coordinates[0] is a scalar (ndim 0) -> not single ->
100
+ # treated as a 3-molecule batch of 0-d coords -> raises. Documents the (N,3) requirement.
101
+ with pytest.raises(ValueError):
102
+ api._as_batch(np.array([6]), np.array([0., 0., 0.]))
103
+
104
+
105
+ # =====================================================================
106
+ # _check_passes
107
+ # =====================================================================
108
+
109
+ @pytest.mark.parametrize("bad", [0, -1, 1.5, "3", None, 2.0])
110
+ def test_check_passes_rejects(bad):
111
+ with pytest.raises(ValueError, match="n_passes"):
112
+ api._check_passes(bad)
113
+
114
+ @pytest.mark.parametrize("good", [1, 10, np.int64(5)])
115
+ def test_check_passes_accepts(good):
116
+ api._check_passes(good) # no raise
117
+
118
+ def test_check_passes_bool_quirk():
119
+ # bool is a subclass of int in Python; True passes as "1 pass", False is rejected.
120
+ api._check_passes(True) # accepted (quirk, not a real bug)
121
+ with pytest.raises(ValueError):
122
+ api._check_passes(False)
123
+
124
+
125
+ # =====================================================================
126
+ # predict_shieldings argument validation (pure logic, before model load)
127
+ # =====================================================================
128
+
129
+ def test_predict_shieldings_bad_model():
130
+ with pytest.raises(ValueError, match="model must be one of"):
131
+ api.predict_shieldings(CH4_Z, CH4_XYZ, model="bogus")
132
+
133
+ @pytest.mark.parametrize("m,fn", [("MagNET-PCM", "implicit_solvent_correction"),
134
+ ("MagNET-x", "explicit_solvent_correction")])
135
+ def test_predict_shieldings_correction_redirect(m, fn):
136
+ with pytest.raises(ValueError, match=fn):
137
+ api.predict_shieldings(CH4_Z, CH4_XYZ, model=m)
138
+
139
+ def test_predict_shieldings_bad_passes():
140
+ with pytest.raises(ValueError, match="n_passes"):
141
+ api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=0)
142
+
143
+
144
+ # =====================================================================
145
+ # predict_shifts solvent validation (pure logic, before model load)
146
+ # =====================================================================
147
+
148
+ def test_predict_shifts_unknown_solvent():
149
+ with pytest.raises(ValueError, match="unknown solvent"):
150
+ api.predict_shifts(CH4_Z, CH4_XYZ, solvent="ethanol")
151
+
152
+ def test_predict_shifts_solvent_case_sensitive():
153
+ with pytest.raises(ValueError, match="unknown solvent"):
154
+ api.predict_shifts(CH4_Z, CH4_XYZ, solvent="Chloroform")
155
+
156
+ def test_predict_shifts_water_not_rejected_by_validation():
157
+ # "water" must map to TIP4P and NOT trip the unknown-solvent guard.
158
+ tables = scaling.published_scaling_tables()
159
+ assert "TIP4P" in tables["C"] and "TIP4P" in tables["H"]
160
+ # the error message offers "water" (not "TIP4P") as an option:
161
+ options = ["water" if s == "TIP4P" else s for s in tables["C"]]
162
+ assert "water" in options and "TIP4P" not in options
163
+
164
+
165
+ # =====================================================================
166
+ # _validate_solvent: atom-type-count check against a named solvent
167
+ # =====================================================================
168
+
169
+ def test_validate_chloroform():
170
+ api._validate_solvent("chloroform", CHCL3_Z) # no raise
171
+
172
+ def test_validate_two_chloroforms():
173
+ api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z]))
174
+
175
+ def test_validate_water():
176
+ api._validate_solvent("water", np.array([8, 1, 1]))
177
+
178
+ def test_validate_benzene():
179
+ api._validate_solvent("benzene", np.array([6] * 6 + [1] * 6))
180
+
181
+ def test_validate_methanol():
182
+ api._validate_solvent("methanol", np.array([6, 8, 1, 1, 1, 1]))
183
+
184
+ def test_validate_order_within_block_independent():
185
+ # order within one molecule's block does not matter
186
+ api._validate_solvent("water", np.array([1, 8, 1]))
187
+
188
+ def test_validate_empty_raises():
189
+ with pytest.raises(ValueError, match="no solvent atoms"):
190
+ api._validate_solvent("chloroform", np.array([], dtype=int))
191
+
192
+ def test_validate_wrong_count_raises():
193
+ # 7 atoms is not a whole number of chloroforms (5 atoms each)
194
+ with pytest.raises(ValueError, match="not a whole number"):
195
+ api._validate_solvent("chloroform", np.array([6, 6, 1, 1, 17, 17, 17]))
196
+
197
+ def test_validate_wrong_composition_raises():
198
+ # 5 atoms (one chloroform-sized block) but the wrong elements
199
+ with pytest.raises(ValueError, match="not whole chloroform"):
200
+ api._validate_solvent("chloroform", np.array([6, 6, 6, 6, 6]))
201
+
202
+ def test_validate_grouped_by_element_raises():
203
+ # two chloroforms' worth of atoms (correct TOTALS: 2 C, 2 H, 6 Cl) but grouped by element, so the
204
+ # contiguous 5-atom blocks are not whole molecules. Total-count check would miss this; block does not.
205
+ grouped = np.array([6, 6, 1, 1, 17, 17, 17, 17, 17, 17])
206
+ with pytest.raises(ValueError, match="not whole chloroform"):
207
+ api._validate_solvent("chloroform", grouped)
208
+
209
+ def test_validate_two_chloroforms_contiguous_ok():
210
+ # same atoms as above but laid out as two whole contiguous molecules: fine
211
+ api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z]))
212
+
213
+ def test_validate_mismatched_solvent_raises():
214
+ # a benzene block passed as methanol (both divisible cases would still fail composition)
215
+ with pytest.raises(ValueError, match="not whole methanol"):
216
+ api._validate_solvent("methanol", np.array([6, 6, 6, 6, 6, 6]))
217
+
218
+
219
+ # =====================================================================
220
+ # supported-solvent tables stay in sync with the code (validation)
221
+ # =====================================================================
222
+
223
+ EXPLICIT_SOLVENTS = ["chloroform", "benzene", "methanol", "water"]
224
+ PREDICT_SHIFTS_SOLVENTS = [
225
+ "tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene",
226
+ "acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "water",
227
+ ]
228
+
229
+ def _one_molecule(solvent):
230
+ """Build one molecule of `solvent` as an atomic-number array from the composition table."""
231
+ return np.array([element for element, count in api._SOLVENT_COMPOSITION[solvent].items()
232
+ for _ in range(count)])
233
+
234
+ def test_explicit_solvent_set_matches_run_magnet():
235
+ # composition table, the model's block-size table, and the documented list all agree
236
+ from magnet import run_magnet
237
+ assert sorted(api._SOLVENT_COMPOSITION) == sorted(run_magnet.N_ATOMS_PER_SOLVENT)
238
+ assert sorted(api._SOLVENT_COMPOSITION) == sorted(EXPLICIT_SOLVENTS)
239
+
240
+ def test_solvent_composition_sums_to_block_size():
241
+ from magnet import run_magnet
242
+ for solvent, comp in api._SOLVENT_COMPOSITION.items():
243
+ assert sum(comp.values()) == run_magnet.N_ATOMS_PER_SOLVENT[solvent]
244
+
245
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
246
+ def test_validate_accepts_one_molecule(solvent):
247
+ api._validate_solvent(solvent, _one_molecule(solvent)) # must not raise
248
+
249
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
250
+ def test_validate_accepts_two_contiguous_molecules(solvent):
251
+ one = _one_molecule(solvent)
252
+ api._validate_solvent(solvent, np.concatenate([one, one])) # must not raise
253
+
254
+ @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS)
255
+ def test_validate_rejects_dropping_one_atom(solvent):
256
+ # one atom short of a whole molecule -> wrong count
257
+ short = _one_molecule(solvent)[:-1]
258
+ with pytest.raises(ValueError, match="not a whole number"):
259
+ api._validate_solvent(solvent, short)
260
+
261
+ def test_predict_shifts_supports_the_12_documented_solvents():
262
+ tables = scaling.published_scaling_tables()
263
+ for nucleus in ("H", "C"):
264
+ keys = {"water" if s == "TIP4P" else s for s in tables[nucleus]}
265
+ assert keys == set(PREDICT_SHIFTS_SOLVENTS)
266
+
267
+ def test_explicit_rejects_predict_only_solvent():
268
+ # acetone is valid for predict_shifts but not for explicit corrections
269
+ atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z])
270
+ xyz = np.zeros((len(atoms), 3))
271
+ with pytest.raises(ValueError, match="solvent must be one of"):
272
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="acetone",
273
+ n_passes=1, symmetrize=False)
274
+
275
+
276
+ # =====================================================================
277
+ # _explicit_one: solute selection, reordering, negative/dup/range
278
+ # =====================================================================
279
+
280
+ def _system(): # methane solute (0..3) + one chloroform (4..8)
281
+ atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z])
282
+ xyz = np.concatenate([CH4_XYZ[:4] + 10.0, CHCL3_XYZ]) # solute far, solvent near origin
283
+ return atoms, xyz
284
+
285
+ def test_explicit_one_basic():
286
+ atoms, xyz = _system()
287
+ solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [0, 1, 2, 3], "chloroform")
288
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
289
+ assert np.array_equal(full_an, atoms) # already solute-first
290
+
291
+ def test_explicit_one_reorders_solvent_first_input():
292
+ # input has chloroform first, methane second; solute indices point at the methane
293
+ atoms = np.concatenate([CHCL3_Z, np.array([6, 1, 1, 1])])
294
+ xyz = np.concatenate([CHCL3_XYZ, CH4_XYZ[:4] + 10.0])
295
+ solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [5, 6, 7, 8], "chloroform")
296
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
297
+ # reordered: solute first, then the solvent block
298
+ assert np.array_equal(full_an[:4], np.array([6, 1, 1, 1]))
299
+ assert np.array_equal(np.sort(full_an[4:]), np.sort(CHCL3_Z))
300
+
301
+ def test_explicit_one_output_order_follows_solute_atoms():
302
+ atoms, xyz = _system()
303
+ solute_an, full_an, _ = api._explicit_one(atoms, xyz, [3, 2, 1, 0], "chloroform") # reversed
304
+ assert np.array_equal(solute_an, np.array([1, 1, 1, 6])) # matches requested order
305
+ assert np.array_equal(full_an[:4], np.array([1, 1, 1, 6]))
306
+
307
+ def test_explicit_one_negative_indices():
308
+ atoms, xyz = _system() # n=9
309
+ solute_an, _, _ = api._explicit_one(atoms, xyz, [-9, -8, -7, -6], "chloroform") # -> 0,1,2,3
310
+ assert np.array_equal(solute_an, np.array([6, 1, 1, 1]))
311
+
312
+ def test_explicit_one_wrong_solvent_raises():
313
+ atoms, xyz = _system() # solvent is chloroform
314
+ with pytest.raises(ValueError, match="not .* benzene"):
315
+ api._explicit_one(atoms, xyz, [0, 1, 2, 3], "benzene")
316
+
317
+ def test_explicit_one_duplicate_raises():
318
+ atoms, xyz = _system()
319
+ with pytest.raises(ValueError, match="duplicate"):
320
+ api._explicit_one(atoms, xyz, [0, 0, 1, 2], "chloroform")
321
+
322
+ def test_explicit_one_negative_normalizes_to_duplicate_raises():
323
+ atoms, xyz = _system() # n=9; index 2 and -7 both -> 2
324
+ with pytest.raises(ValueError, match="duplicate"):
325
+ api._explicit_one(atoms, xyz, [2, -7], "chloroform")
326
+
327
+ def test_explicit_one_out_of_range_raises():
328
+ atoms, xyz = _system()
329
+ with pytest.raises(ValueError, match="out of range"):
330
+ api._explicit_one(atoms, xyz, [0, 1, 2, 9], "chloroform")
331
+
332
+ def test_explicit_one_very_negative_raises():
333
+ atoms, xyz = _system()
334
+ with pytest.raises(ValueError, match="out of range"):
335
+ api._explicit_one(atoms, xyz, [-100], "chloroform")
336
+
337
+ def test_explicit_one_all_atoms_solute_raises():
338
+ atoms, xyz = _system()
339
+ with pytest.raises(ValueError, match="no solvent atoms"):
340
+ api._explicit_one(atoms, xyz, list(range(len(atoms))), "chloroform")
341
+
342
+ def test_explicit_one_empty_solute_raises():
343
+ atoms, xyz = _system()
344
+ with pytest.raises(ValueError, match="empty"):
345
+ api._explicit_one(atoms, xyz, [], "chloroform")
346
+
347
+
348
+ # =====================================================================
349
+ # explicit_solvent_correction: pure-logic validation before any model call
350
+ # =====================================================================
351
+
352
+ def test_explicit_unknown_solvent_raises():
353
+ atoms, xyz = _system()
354
+ with pytest.raises(ValueError, match="solvent must be one of"):
355
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="ethanol",
356
+ n_passes=1, symmetrize=False)
357
+
358
+ def test_explicit_solvent_mismatch_raises():
359
+ # declare benzene but the snapshot has chloroform -> count check fails before any model load
360
+ atoms, xyz = _system()
361
+ with pytest.raises(ValueError, match="not .* benzene"):
362
+ api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="benzene",
363
+ n_passes=1, symmetrize=False)
364
+
365
+
366
+ # =====================================================================
367
+ # REAL MODEL TESTS (few, tiny, n_passes=1, symmetrize=False)
368
+ # =====================================================================
369
+
370
+ @needs_model
371
+ def test_real_predict_shieldings_single_returns_array():
372
+ out = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False)
373
+ assert isinstance(out, np.ndarray) and out.shape == (5,)
374
+ assert np.all(np.isfinite(out)) # H and C all predicted, none NaN
375
+
376
+ @needs_model
377
+ def test_real_predict_shieldings_list_returns_list():
378
+ out = api.predict_shieldings([CH4_Z], [CH4_XYZ], n_passes=1, symmetrize=False)
379
+ assert isinstance(out, list) and len(out) == 1 and out[0].shape == (5,)
380
+
381
+ @needs_model
382
+ def test_real_predict_shieldings_batch_shapes():
383
+ out = api.predict_shieldings([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ],
384
+ n_passes=1, symmetrize=False)
385
+ assert isinstance(out, list) and out[0].shape == (5,) and out[1].shape == (4,)
386
+
387
+ @needs_model
388
+ def test_real_unsupported_element_rejected():
389
+ # PH3-like: phosphorus (15) is out of vocab
390
+ Z = np.array([15, 1, 1, 1])
391
+ X = np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]])
392
+ with pytest.raises(ValueError, match="unsupported atomic numbers"):
393
+ api.predict_shieldings(Z, X, n_passes=1, symmetrize=False)
394
+
395
+ @needs_model
396
+ def test_real_predict_shifts_components_reproduce():
397
+ d = api.predict_shifts(CH2O_Z, CH2O_XYZ, solvent="chloroform", n_passes=1,
398
+ symmetrize=False, return_components=True)
399
+ assert set(d) == {"shifts", "zero_shielding", "pcm_correction", "coefficients"}
400
+ shifts, sigma, delta = d["shifts"], d["zero_shielding"], d["pcm_correction"]
401
+ coef = d["coefficients"]
402
+ # coefficients must match chloroform rows
403
+ tbl = scaling.published_scaling_tables()
404
+ assert coef["H"] == tbl["H"]["chloroform"] and coef["C"] == tbl["C"]["chloroform"]
405
+ # reproduce shift = intercept + stationary*zero + pcm*pcm for H (idx 2,3) and C (idx 0)
406
+ for idx, nuc in [(0, "C"), (2, "H"), (3, "H")]:
407
+ expect = coef[nuc]["intercept"] + coef[nuc]["stationary"] * sigma[idx] \
408
+ + coef[nuc]["pcm"] * delta[idx]
409
+ assert np.isclose(shifts[idx], expect, atol=1e-6), (idx, nuc, shifts[idx], expect)
410
+ # O atom (idx 1) must be NaN, H/C finite
411
+ assert np.isnan(shifts[1])
412
+ assert np.all(np.isfinite([shifts[0], shifts[2], shifts[3]]))
413
+
414
+ @needs_model
415
+ def test_real_predict_shifts_acetone_matches_readme():
416
+ # the README "Your First Prediction" worked example: acetone in chloroform on an AIMNet2
417
+ # geometry, default n_passes/symmetrize. Guards the documented numbers against model drift.
418
+ Z = np.array([6, 6, 8, 6, 1, 1, 1, 1, 1, 1])
419
+ X = np.array([
420
+ [1.2913, -0.5947, -0.0016], [0.0029, 0.1931, -0.0010], [-0.0174, 1.3994, -0.0003],
421
+ [-1.2743, -0.6189, 0.0004], [-1.0822, -1.6899, 0.0014], [-1.8597, -0.3513, 0.8791],
422
+ [-1.8605, -0.3530, -0.8783], [1.3415, -1.2208, 0.8916], [1.3170, -1.2669, -0.8614],
423
+ [2.1415, 0.0788, -0.0298],
424
+ ])
425
+ shifts = api.predict_shifts(Z, X, solvent="chloroform")
426
+ carbonyl = shifts[[1]].mean()
427
+ methyl_C = shifts[[0, 3]].mean()
428
+ methyl_H = shifts[[4, 5, 6, 7, 8, 9]].mean()
429
+ assert np.isclose(carbonyl, 207.6, atol=1.0), carbonyl
430
+ assert np.isclose(methyl_C, 30.7, atol=0.5), methyl_C
431
+ assert np.isclose(methyl_H, 2.20, atol=0.15), methyl_H
432
+
433
+ @needs_model
434
+ def test_real_predict_shifts_water_maps_to_tip4p():
435
+ d = api.predict_shifts(CH4_Z, CH4_XYZ, solvent="water", n_passes=1,
436
+ symmetrize=False, return_components=True)
437
+ tbl = scaling.published_scaling_tables()
438
+ assert d["coefficients"]["H"] == tbl["H"]["TIP4P"]
439
+ assert d["coefficients"]["C"] == tbl["C"]["TIP4P"]
440
+
441
+ @needs_model
442
+ def test_real_single_vs_batch_same_shapes():
443
+ single = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False)
444
+ batch = api.predict_shieldings([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ],
445
+ n_passes=1, symmetrize=False)
446
+ assert single.shape == batch[0].shape == batch[1].shape
447
+ # values are close (stochastic frames -> not identical); documents non-determinism
448
+ assert np.allclose(single, batch[0], atol=0.5)
449
+
450
+ @needs_model
451
+ @pytest.mark.xfail(reason="known: zero-edge graph (lone atom) gives a cryptic torch error, not a "
452
+ "clean ValueError; a lone atom is not a valid NMR input",
453
+ raises=RuntimeError)
454
+ def test_real_single_atom_solute():
455
+ # isolated single atom: no neighbors -> empty edge tensor -> deep torch.min() crash.
456
+ api.predict_shieldings(np.array([6]), np.array([[0., 0., 0.]]),
457
+ n_passes=1, symmetrize=False)
458
+
459
+ @needs_model
460
+ @pytest.mark.xfail(reason="known: atoms all beyond the model cutoff -> zero edges -> torch "
461
+ "RuntimeError, not a clean ValueError",
462
+ raises=RuntimeError)
463
+ def test_real_atoms_beyond_cutoff_crash():
464
+ # two atoms 50 A apart: no edges within cutoff -> same zero-edge crash as one atom.
465
+ api.predict_shieldings(np.array([6, 1]), np.array([[0., 0., 0.], [0., 0., 50.]]),
466
+ n_passes=1, symmetrize=False)
467
+
468
+
469
+ # ---- degenerate-geometry guard in the local-frame construction (no weights needed) ----
470
+ def test_edge_rot_mat_rejects_coincident_atoms():
471
+ # an edge between two atoms at the same position has ~zero length; the old code printed a
472
+ # warning and then divided by ~zero, silently producing NaN frames that poison the whole
473
+ # forward pass. It must raise instead.
474
+ edge_vecs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]])
475
+ with pytest.raises(ValueError, match="overlapping atoms"):
476
+ init_edge_rot_mat(edge_vecs)
477
+
478
+
479
+ def test_edge_rot_mat_accepts_separated_atoms():
480
+ edge_vecs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.5, 0.0], [0.0, 0.0, 2.0]])
481
+ rot = init_edge_rot_mat(edge_vecs)
482
+ assert rot.shape == (3, 3, 3)
483
+ # each frame is a proper rotation: R @ R^T == I
484
+ identity = torch.eye(3).expand(3, 3, 3)
485
+ assert torch.allclose(torch.bmm(rot, rot.transpose(1, 2)), identity, atol=1e-4)
tests/test_data/magnet_zero_and_pcm_shieldings.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:32963207bef34c0ea4246042d9f8d8377d934654c20fdcdd10700643382cfa0c
3
+ size 55384
tests/test_magnet.py ADDED
@@ -0,0 +1,262 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests for the magnet package.
2
+
3
+ The synthetic tests build a tiny EquiformerV2 model (no checkpoint, no dataset) and run a
4
+ forward pass, so they exercise the whole package wiring and run in CI. The tests that need the
5
+ released checkpoints / reference data are gated with skipif and run only when those files are
6
+ present in the repo (model_checkpoints/ and data/<name>/, carried by the Hugging Face checkout).
7
+ """
8
+ import os
9
+ import numpy as np
10
+ import pytest
11
+
12
+ # The model needs the heavy PyTorch / PyG stack (see requirements.txt). When it is not
13
+ # installed (e.g. the dataset-reader CI, which installs only numpy/h5py/pandas), skip the whole
14
+ # module cleanly instead of erroring at import.
15
+ pytest.importorskip("torch")
16
+ pytest.importorskip("torch_scatter")
17
+ pytest.importorskip("torch_cluster")
18
+ pytest.importorskip("torch_geometric")
19
+ pytest.importorskip("magnet")
20
+
21
+ from magnet.model import MagNET_Lightning
22
+ from magnet.inference import predict_shieldings
23
+
24
+ _HERE = os.path.dirname(os.path.abspath(__file__))
25
+ _REPO = os.path.abspath(os.path.join(_HERE, ".."))
26
+ _CKPT_ROOT = os.path.join(_REPO, "model_checkpoints") # absent on GitHub (git-ignored); every skipif below is then False
27
+ CKPT = os.path.join(_CKPT_ROOT, "MagNET-Zero")
28
+ # The natural-products reference shieldings are a tiny (54 KB) committed test fixture (test_data/), so
29
+ # the reproduce/symmetrize tests need only the released checkpoints, not a separate reference download.
30
+ NP_H5 = os.path.join(_HERE, "test_data", "magnet_zero_and_pcm_shieldings.h5")
31
+
32
+
33
+ def _dataset_file(name):
34
+ """Resolve a released dataset hdf5 the way analysis/code/paths.py does, but without importing it
35
+ (this package is standalone): the in-repo data/<name>/<name>.hdf5, carried by the Hugging Face
36
+ checkout via Git LFS."""
37
+ return os.path.join(_REPO, "data", name, name + ".hdf5")
38
+
39
+ TINY_PARAMS = dict(
40
+ model_type="EquiformerV2", max_neighbors=20, max_radius=5.0, max_num_elements=20,
41
+ num_layers=1, sphere_channels=8, attn_hidden_channels=8, num_heads=1,
42
+ attn_alpha_channels=8, attn_value_channels=8, ffn_hidden_channels=16,
43
+ lmax_list=[2], mmax_list=[2], grid_resolution=14, weight_init="uniform", lr=1e-4,
44
+ )
45
+
46
+ # a small tetrahedral-ish CH4 so the graph has edges
47
+ METHANE_Z = np.array([6, 1, 1, 1, 1])
48
+ METHANE_XYZ = np.array([[0, 0, 0], [0.63, 0.63, 0.63], [-0.63, -0.63, 0.63],
49
+ [-0.63, 0.63, -0.63], [0.63, -0.63, -0.63]], dtype=float)
50
+
51
+
52
+ def test_predict_rejects_unsupported_elements():
53
+ """MagNET only knows H, C, N, O, F, S, Cl; predicting on anything else must raise, not guess.
54
+ The check runs before the model, so dummy models are fine."""
55
+ # phosphorus (15) in the solute
56
+ with pytest.raises(ValueError, match="unsupported atomic numbers"):
57
+ predict_shieldings(None, None, np.array([6, 1, 1, 15]), np.zeros((4, 3)))
58
+ # an unsupported atom in the solvent block (the full atomic_numbers, MagNET-x path)
59
+ with pytest.raises(ValueError, match="unsupported"):
60
+ predict_shieldings(None, None, np.array([6, 1]), np.zeros((4, 3)),
61
+ atomic_numbers=np.array([6, 1, 35, 1])) # bromine
62
+ # a supported-only molecule passes the element check (it may fail later for lack of a real
63
+ # model, but not with an "unsupported" element error)
64
+ try:
65
+ predict_shieldings(None, None, METHANE_Z, METHANE_XYZ)
66
+ except ValueError as e:
67
+ assert "unsupported" not in str(e)
68
+ except Exception:
69
+ pass
70
+
71
+
72
+ def test_resolve_checkpoint_honors_checkpoints_dir(tmp_path):
73
+ """checkpoints_dir points at the released weights when they live outside the repo. It may be the
74
+ model_checkpoints/ folder itself or a directory that contains one, and takes precedence."""
75
+ from magnet.run_magnet import _resolve_checkpoint
76
+ rel = "model_checkpoints/MagNET-Zero/MagNET-Zero_1H.ckpt"
77
+
78
+ # (a) checkpoints_dir IS the model_checkpoints/ folder -> the leading path segment is stripped
79
+ mc = tmp_path / "mc"
80
+ (mc / "MagNET-Zero").mkdir(parents=True)
81
+ inside = mc / "MagNET-Zero" / "MagNET-Zero_1H.ckpt"
82
+ inside.write_bytes(b"")
83
+ assert _resolve_checkpoint(rel, checkpoints_dir=str(mc)) == str(inside)
84
+
85
+ # (b) checkpoints_dir CONTAINS a model_checkpoints/ folder (an `hf download --local-dir` target)
86
+ parent = tmp_path / "download"
87
+ contained = parent / "model_checkpoints" / "MagNET-Zero" / "MagNET-Zero_1H.ckpt"
88
+ contained.parent.mkdir(parents=True)
89
+ contained.write_bytes(b"")
90
+ assert _resolve_checkpoint(rel, checkpoints_dir=str(parent)) == str(contained)
91
+
92
+ # (c) a checkpoints_dir without the file is not fabricated (falls through to the defaults)
93
+ empty = tmp_path / "empty"
94
+ empty.mkdir()
95
+ assert not _resolve_checkpoint(rel, checkpoints_dir=str(empty)).startswith(str(empty))
96
+
97
+ # (d) an absolute path resolves regardless of checkpoints_dir
98
+ assert _resolve_checkpoint(str(inside), checkpoints_dir=str(empty)) == str(inside)
99
+
100
+
101
+ @pytest.fixture(scope="module")
102
+ def tiny_model():
103
+ return MagNET_Lightning(TINY_PARAMS).eval()
104
+
105
+
106
+ def test_predict_shape_and_finite(tiny_model):
107
+ out = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
108
+ geometry=METHANE_XYZ, device="cpu")
109
+ out = np.asarray(out)
110
+ assert out.shape == (METHANE_Z.shape[0],)
111
+ assert np.isfinite(out).all()
112
+
113
+
114
+ def test_n_passes_averages(tiny_model):
115
+ out = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
116
+ geometry=METHANE_XYZ, device="cpu", n_passes=3)
117
+ assert np.asarray(out).shape == (METHANE_Z.shape[0],)
118
+ assert np.isfinite(out).all()
119
+
120
+
121
+ def test_symmetrize_runs_and_is_reflection_consistent(tiny_model):
122
+ # the symmetrized prediction averages over G and its mirror image, so evaluating it on G
123
+ # and on reflect(G) must give the same per-atom values (up to per-pass frame noise).
124
+ refl = METHANE_XYZ.copy(); refl[:, 0] = -refl[:, 0]
125
+ a = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
126
+ geometry=METHANE_XYZ, device="cpu", n_passes=8, symmetrize=True)
127
+ b = predict_shieldings(tiny_model, tiny_model, solute_atomic_numbers=METHANE_Z,
128
+ geometry=refl, device="cpu", n_passes=8, symmetrize=True)
129
+ # both are reflection-symmetrized estimates of the same molecule
130
+ assert np.allclose(np.asarray(a), np.asarray(b), atol=0.5)
131
+
132
+
133
+ def test_solvent_path_runs():
134
+ """Regression test for the explicit-solvent (MagNET-x) code path. That branch runs only when
135
+ filter_solvent_edges=True (the MagNET-x checkpoints set it), so the gas-phase tests never reach
136
+ it; a torch_geometric >= 2.6 change (data.keys became a method) once broke it. Build a tiny
137
+ solvent-aware model and feed a solute plus two solvent molecules."""
138
+ model = MagNET_Lightning(dict(TINY_PARAMS, filter_solvent_edges=True)).eval()
139
+ solute_Z = np.array([6, 1, 1, 1, 1])
140
+ solvent_mol = np.array([[0.0, 0.0, 0.0], [0.0, 0.96, 0.0], [0.93, -0.24, 0.0]]) # 3-atom solvent
141
+ full_Z = np.concatenate([solute_Z, [8, 1, 1], [8, 1, 1]])
142
+ geom = np.concatenate([METHANE_XYZ, solvent_mol + [3.5, 0, 0], solvent_mol + [0, 3.5, 0]])
143
+ out = np.asarray(predict_shieldings(model, model, solute_atomic_numbers=solute_Z, geometry=geom,
144
+ atomic_numbers=full_Z, N_atoms_per_solvent=3,
145
+ solvent_distance_threshold=12.0, device="cpu"))
146
+ assert out.shape == (solute_Z.shape[0],)
147
+ assert np.isfinite(out).all()
148
+
149
+
150
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
151
+ def test_real_magnet_zero_reproduces_reference():
152
+ import h5py
153
+ dev = "cpu"
154
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
155
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
156
+ with h5py.File(NP_H5, "r") as f:
157
+ name = list(f.keys())[0]
158
+ g = f[name]
159
+ z = g["atomic_numbers"][:]; xyz = g["geometry"][:].astype(np.float64)
160
+ # This reference file predates the MagNET casing standardization, so its dataset is stored
161
+ # under the old "MagNet_..." spelling. Read whichever spelling the file actually uses.
162
+ ref_key = "MagNET_Zero_shieldings" if "MagNET_Zero_shieldings" in g else "MagNet_Zero_shieldings"
163
+ ref = g[ref_key][:]
164
+ pred = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=20)
165
+ tgt = (z == 1) | (z == 6)
166
+ # un-symmetrized 20-pass reproduces the (un-symmetrized) released numbers well under 0.1 ppm
167
+ assert np.abs(pred[tgt] - ref[tgt]).mean() < 0.05
168
+
169
+
170
+ @pytest.mark.skipif(not os.path.exists(CKPT), reason="released MagNET-Zero checkpoints not present")
171
+ def test_real_symmetrize_changes_13C_on_large_molecule():
172
+ import h5py
173
+ dev = "cpu"
174
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_1H.ckpt"), map_location=dev).eval()
175
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT, "MagNET-Zero_13C.ckpt"), map_location=dev).eval()
176
+ with h5py.File(NP_H5, "r") as f:
177
+ # pick a large molecule (>=50 atoms) where the parity error is significant
178
+ big = [k for k in f.keys() if f[k]["atomic_numbers"].shape[0] >= 50][0]
179
+ g = f[big]
180
+ z = g["atomic_numbers"][:]; xyz = g["geometry"][:].astype(np.float64)
181
+ plain = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=12)
182
+ sym = predict_shieldings(mH, mC, solute_atomic_numbers=z, geometry=xyz, device=dev, n_passes=12, symmetrize=True)
183
+ c = z == 6
184
+ # symmetrization removes a real 13C parity error: it should shift carbons by > 0.05 ppm RMS
185
+ assert np.sqrt(((plain[c] - sym[c]) ** 2).mean()) > 0.05
186
+
187
+
188
+ CKPT_FOUNDATION = os.path.join(_CKPT_ROOT, "MagNET")
189
+ CKPT_PCM = os.path.join(_CKPT_ROOT, "MagNET-PCM")
190
+
191
+ # a small, valid molecule (methyl acetate) to smoke-run the foundation and PCM checkpoints
192
+ _SMOKE_Z = np.array([6, 6, 6, 8, 8, 6, 1, 1, 1, 1, 1, 1])
193
+ _SMOKE_XYZ = np.array([
194
+ [-2.07273936, 0.71782219, -0.27169511], [-1.51154256, -0.47834334, -0.05051489],
195
+ [-0.06133485, -0.72471339, 0.10392854], [0.38785097, -1.84332836, 0.30459866],
196
+ [0.65684897, 0.41875002, -0.00514369], [2.0648613, 0.23182768, 0.13713977],
197
+ [-1.48494101, 1.6266042, -0.3569932], [-3.14927745, 0.81001383, -0.37304503],
198
+ [-2.12480974, -1.36963022, 0.03013895], [2.44585443, -0.42836204, -0.64841408],
199
+ [2.30088663, -0.16782625, 1.12843716], [2.5483427, 1.20718563, 0.03357037]])
200
+
201
+
202
+ @pytest.mark.skipif(not os.path.exists(CKPT_FOUNDATION),
203
+ reason="released MagNET (foundation) checkpoints not present")
204
+ def test_real_foundation_loads_and_runs():
205
+ """The foundation checkpoints load and produce finite per-atom shieldings of the right shape.
206
+ Regression for the checkpoint-family load/run path (the MagNET-X case bug was this class)."""
207
+ dev = "cpu"
208
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_FOUNDATION, "MagNET_1H.ckpt"), map_location=dev).eval()
209
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_FOUNDATION, "MagNET_13C.ckpt"), map_location=dev).eval()
210
+ pred = predict_shieldings(mH, mC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=2)
211
+ assert pred.shape == (_SMOKE_Z.shape[0],)
212
+ assert np.isfinite(pred[(_SMOKE_Z == 1) | (_SMOKE_Z == 6)]).all()
213
+
214
+
215
+ @pytest.mark.skipif(not os.path.exists(CKPT_PCM),
216
+ reason="released MagNET-PCM checkpoints not present")
217
+ def test_real_pcm_correction_is_finite_and_nonzero():
218
+ """The MagNET-PCM gas and with-PCM checkpoints load, run, and genuinely differ: the chloroform
219
+ correction (with-PCM minus gas) is finite and not identically zero over the H/C atoms."""
220
+ dev = "cpu"
221
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withoutPCM_1H.ckpt"), map_location=dev).eval()
222
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withoutPCM_13C.ckpt"), map_location=dev).eval()
223
+ pH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withPCM_1H.ckpt"), map_location=dev).eval()
224
+ pC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_PCM, "MagNET-PCM-withPCM_13C.ckpt"), map_location=dev).eval()
225
+ gas = predict_shieldings(mH, mC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=4)
226
+ pcm = predict_shieldings(pH, pC, solute_atomic_numbers=_SMOKE_Z, geometry=_SMOKE_XYZ, device=dev, n_passes=4)
227
+ correction = pcm - gas
228
+ hc = (_SMOKE_Z == 1) | (_SMOKE_Z == 6)
229
+ assert np.isfinite(correction[hc]).all()
230
+ assert np.abs(correction[hc]).max() > 1e-3 # the two checkpoints are not the same model
231
+
232
+
233
+ CKPT_X = os.path.join(_CKPT_ROOT, "MagNET-x")
234
+ SIGMA_FRESH = _dataset_file("sigma-fresh") # in-repo data/sigma-fresh/, git-ignored, via the Hugging Face checkout
235
+
236
+
237
+ @pytest.mark.skipif(not (os.path.exists(CKPT_X) and os.path.exists(SIGMA_FRESH)),
238
+ reason="MagNET-x checkpoints or sigma-fresh data not present")
239
+ def test_real_magnet_x_reproduces_dft_correction():
240
+ """The MagNET-x explicit-solvent correction (solvated minus isolated) should approximate the DFT
241
+ correction stored in sigma-fresh for a single solute+solvent pose."""
242
+ import sys
243
+ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "data", "sigma-fresh"))
244
+ from decode_sigma_fresh import SigmaFresh
245
+ dev = "cpu"
246
+ mH = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_X, "MagNET-x-chloroform_1H.ckpt"), map_location=dev).eval()
247
+ mC = MagNET_Lightning.load_from_checkpoint(os.path.join(CKPT_X, "MagNET-x-chloroform_13C.ckpt"), map_location=dev).eval()
248
+ with SigmaFresh(SIGMA_FRESH) as ds:
249
+ p = ds.pose("chloroform", 3, 1)
250
+ ns = p["n_solute_atoms"]; nper = 5; cut = ns + p["n_solvents_partial"] * nper
251
+ Zf = p["atomic_numbers"][:cut]; geom = p["coordinates"][:cut]; Zs = p["atomic_numbers"][:ns]
252
+ ref = (p["shielding_solvated"] - p["shielding_isolated"])[:ns]
253
+
254
+ def run(threshold):
255
+ return np.asarray(predict_shieldings(
256
+ mH, mC, solute_atomic_numbers=Zs, geometry=geom, atomic_numbers=Zf,
257
+ N_atoms_per_solvent=nper, solvent_distance_threshold=threshold, device=dev, n_passes=8)).squeeze()
258
+
259
+ correction = run(12.0) - run(0.0)
260
+ keep = ((Zs == 1) | (Zs == 6)) & np.isfinite(ref)
261
+ rms = np.sqrt(np.mean((correction[keep] - ref[keep]) ** 2))
262
+ assert rms < 0.3 # measured ~0.085 ppm on this pose; model error, not exact