sync code from github@e853181b4c59e84169cc94394b0c9bbe8a58b077
Browse files- conftest.py +8 -8
- tests/test_api.py +485 -0
- tests/test_data/magnet_zero_and_pcm_shieldings.h5 +3 -0
- tests/test_magnet.py +262 -0
conftest.py
CHANGED
|
@@ -1,11 +1,11 @@
|
|
| 1 |
-
# The magnet/
|
| 2 |
# (see magnet/requirements.txt), which the default dataset-reader test environment does not
|
| 3 |
-
# install. When torch is absent, skip collecting
|
| 4 |
-
#
|
| 5 |
#
|
| 6 |
-
# This is needed (not just the importorskip guards inside
|
| 7 |
-
#
|
| 8 |
-
#
|
| 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
|