| """Adversarial edge-case tests for the magnet public API (magnet/api.py). |
| |
| Pure-logic tests (single-vs-list detection, validation, solvent checking, solute selection) run |
| whenever the torch stack is installed. Tests marked @needs_model load real checkpoints and are |
| skipped unless the weights are present; they stay few and fast (n_passes=1, symmetrize=False, tiny |
| molecules). Run the whole suite with `pytest`; to include the real-model tests, check out the |
| Hugging Face copy so the weights land in model_checkpoints/. |
| """ |
| import os |
| import warnings |
|
|
| import numpy as np |
| import pytest |
|
|
| warnings.filterwarnings("ignore") |
|
|
| import torch |
|
|
| import magnet.api as api |
| from magnet import scaling |
| from magnet.eqV2.edge_rot_mat import init_edge_rot_mat |
|
|
| _HERE = os.path.dirname(os.path.abspath(__file__)) |
| _REPO = os.path.dirname(_HERE) |
| _CKPT_REL = os.path.join("model_checkpoints", "MagNET-Zero", "MagNET-Zero_1H.ckpt") |
| _HAVE_MODELS = os.path.exists(os.path.join(_REPO, _CKPT_REL)) |
| needs_model = pytest.mark.skipif(not _HAVE_MODELS, reason="checkpoints not available") |
|
|
| |
| CH4_Z = np.array([6, 1, 1, 1, 1]) |
| CH4_XYZ = np.array([[0., 0., 0.], |
| [0.629, 0.629, 0.629], |
| [-0.629, -0.629, 0.629], |
| [-0.629, 0.629, -0.629], |
| [0.629, -0.629, -0.629]]) |
| |
| CH2O_Z = np.array([6, 8, 1, 1]) |
| CH2O_XYZ = np.array([[0., 0., 0.], [1.2, 0., 0.], [-0.5, 0.94, 0.], [-0.5, -0.94, 0.]]) |
| |
| CHCL3_Z = np.array([6, 1, 17, 17, 17]) |
| CHCL3_XYZ = np.array([[0., 0., 0.], [0., 0., 1.1], [1.7, 0., -0.4], |
| [-0.85, 1.47, -0.4], [-0.85, -1.47, -0.4]]) |
|
|
|
|
| |
| |
| |
|
|
| def test_single_ndarray_is_single(): |
| an, xyz, single = api._as_batch(CH4_Z, CH4_XYZ) |
| assert single is True and len(an) == 1 and an[0].shape == (5,) and xyz[0].shape == (5, 3) |
|
|
| def test_single_pythonlist_is_single(): |
| an, xyz, single = api._as_batch([6, 1, 1, 1, 1], CH4_XYZ.tolist()) |
| assert single is True and len(xyz) == 1 |
|
|
| def test_one_atom_molecule_is_single(): |
| an, xyz, single = api._as_batch(np.array([1]), np.array([[0., 0., 0.]])) |
| assert single is True and xyz[0].shape == (1, 3) |
|
|
| def test_list_of_molecules_is_batch(): |
| an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ]) |
| assert single is False and len(an) == 2 |
|
|
| def test_batch_size_one_list_is_batch(): |
| an, xyz, single = api._as_batch([CH4_Z], [CH4_XYZ]) |
| assert single is False and len(an) == 1 |
|
|
| def test_3d_ndarray_is_batch(): |
| Z = np.stack([CH4_Z, CH4_Z]); X = np.stack([CH4_XYZ, CH4_XYZ]) |
| an, xyz, single = api._as_batch(Z, X) |
| assert single is False and len(xyz) == 2 and xyz[0].shape == (5, 3) |
|
|
| def test_ragged_batch_ok(): |
| an, xyz, single = api._as_batch([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ]) |
| assert xyz[0].shape == (5, 3) and xyz[1].shape == (4, 3) |
|
|
| def test_empty_list_raises(): |
| with pytest.raises(ValueError, match="empty input"): |
| api._as_batch([], []) |
|
|
| def test_empty_ndarray_raises(): |
| with pytest.raises(ValueError, match="empty input"): |
| api._as_batch(np.array([]), np.zeros((0, 3))) |
|
|
| def test_mismatched_atom_counts_single_raises(): |
| with pytest.raises(ValueError): |
| api._as_batch(np.array([6, 1, 1]), np.zeros((4, 3))) |
|
|
| def test_wrong_last_dim_raises(): |
| with pytest.raises(ValueError): |
| api._as_batch(np.array([6, 1]), np.zeros((2, 2))) |
|
|
| def test_mismatched_list_lengths_raises(): |
| with pytest.raises(ValueError): |
| api._as_batch([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ, CH4_XYZ]) |
|
|
| def test_coords_1d_single_atom_flat_is_misread(): |
| |
| |
| with pytest.raises(ValueError): |
| api._as_batch(np.array([6]), np.array([0., 0., 0.])) |
|
|
|
|
| |
| |
| |
|
|
| @pytest.mark.parametrize("bad", [0, -1, 1.5, "3", None, 2.0]) |
| def test_check_passes_rejects(bad): |
| with pytest.raises(ValueError, match="n_passes"): |
| api._check_passes(bad) |
|
|
| @pytest.mark.parametrize("good", [1, 10, np.int64(5)]) |
| def test_check_passes_accepts(good): |
| api._check_passes(good) |
|
|
| def test_check_passes_bool_quirk(): |
| |
| api._check_passes(True) |
| with pytest.raises(ValueError): |
| api._check_passes(False) |
|
|
|
|
| |
| |
| |
|
|
| def test_predict_shieldings_bad_model(): |
| with pytest.raises(ValueError, match="model must be one of"): |
| api.predict_shieldings(CH4_Z, CH4_XYZ, model="bogus") |
|
|
| @pytest.mark.parametrize("m,fn", [("MagNET-PCM", "implicit_solvent_correction"), |
| ("MagNET-x", "explicit_solvent_correction")]) |
| def test_predict_shieldings_correction_redirect(m, fn): |
| with pytest.raises(ValueError, match=fn): |
| api.predict_shieldings(CH4_Z, CH4_XYZ, model=m) |
|
|
| def test_predict_shieldings_bad_passes(): |
| with pytest.raises(ValueError, match="n_passes"): |
| api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=0) |
|
|
|
|
| |
| |
| |
|
|
| def test_predict_shifts_unknown_solvent(): |
| with pytest.raises(ValueError, match="unknown solvent"): |
| api.predict_shifts(CH4_Z, CH4_XYZ, solvent="ethanol") |
|
|
| def test_predict_shifts_solvent_case_sensitive(): |
| with pytest.raises(ValueError, match="unknown solvent"): |
| api.predict_shifts(CH4_Z, CH4_XYZ, solvent="Chloroform") |
|
|
| def test_predict_shifts_water_not_rejected_by_validation(): |
| |
| tables = scaling.published_scaling_tables() |
| assert "TIP4P" in tables["C"] and "TIP4P" in tables["H"] |
| |
| options = ["water" if s == "TIP4P" else s for s in tables["C"]] |
| assert "water" in options and "TIP4P" not in options |
|
|
|
|
| |
| |
| |
|
|
| def test_validate_chloroform(): |
| api._validate_solvent("chloroform", CHCL3_Z) |
|
|
| def test_validate_two_chloroforms(): |
| api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z])) |
|
|
| def test_validate_water(): |
| api._validate_solvent("water", np.array([8, 1, 1])) |
|
|
| def test_validate_benzene(): |
| api._validate_solvent("benzene", np.array([6] * 6 + [1] * 6)) |
|
|
| def test_validate_methanol(): |
| api._validate_solvent("methanol", np.array([6, 8, 1, 1, 1, 1])) |
|
|
| def test_validate_order_within_block_independent(): |
| |
| api._validate_solvent("water", np.array([1, 8, 1])) |
|
|
| def test_validate_empty_raises(): |
| with pytest.raises(ValueError, match="no solvent atoms"): |
| api._validate_solvent("chloroform", np.array([], dtype=int)) |
|
|
| def test_validate_wrong_count_raises(): |
| |
| with pytest.raises(ValueError, match="not a whole number"): |
| api._validate_solvent("chloroform", np.array([6, 6, 1, 1, 17, 17, 17])) |
|
|
| def test_validate_wrong_composition_raises(): |
| |
| with pytest.raises(ValueError, match="not whole chloroform"): |
| api._validate_solvent("chloroform", np.array([6, 6, 6, 6, 6])) |
|
|
| def test_validate_grouped_by_element_raises(): |
| |
| |
| grouped = np.array([6, 6, 1, 1, 17, 17, 17, 17, 17, 17]) |
| with pytest.raises(ValueError, match="not whole chloroform"): |
| api._validate_solvent("chloroform", grouped) |
|
|
| def test_validate_two_chloroforms_contiguous_ok(): |
| |
| api._validate_solvent("chloroform", np.concatenate([CHCL3_Z, CHCL3_Z])) |
|
|
| def test_validate_mismatched_solvent_raises(): |
| |
| with pytest.raises(ValueError, match="not whole methanol"): |
| api._validate_solvent("methanol", np.array([6, 6, 6, 6, 6, 6])) |
|
|
|
|
| |
| |
| |
|
|
| EXPLICIT_SOLVENTS = ["chloroform", "benzene", "methanol", "water"] |
| PREDICT_SHIFTS_SOLVENTS = [ |
| "tetrahydrofuran", "dichloromethane", "chloroform", "toluene", "benzene", "chlorobenzene", |
| "acetone", "dimethylsulfoxide", "acetonitrile", "trifluoroethanol", "methanol", "water", |
| ] |
|
|
| def _one_molecule(solvent): |
| """Build one molecule of `solvent` as an atomic-number array from the composition table.""" |
| return np.array([element for element, count in api._SOLVENT_COMPOSITION[solvent].items() |
| for _ in range(count)]) |
|
|
| def test_explicit_solvent_set_matches_run_magnet(): |
| |
| from magnet import run_magnet |
| assert sorted(api._SOLVENT_COMPOSITION) == sorted(run_magnet.N_ATOMS_PER_SOLVENT) |
| assert sorted(api._SOLVENT_COMPOSITION) == sorted(EXPLICIT_SOLVENTS) |
|
|
| def test_solvent_composition_sums_to_block_size(): |
| from magnet import run_magnet |
| for solvent, comp in api._SOLVENT_COMPOSITION.items(): |
| assert sum(comp.values()) == run_magnet.N_ATOMS_PER_SOLVENT[solvent] |
|
|
| @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS) |
| def test_validate_accepts_one_molecule(solvent): |
| api._validate_solvent(solvent, _one_molecule(solvent)) |
|
|
| @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS) |
| def test_validate_accepts_two_contiguous_molecules(solvent): |
| one = _one_molecule(solvent) |
| api._validate_solvent(solvent, np.concatenate([one, one])) |
|
|
| @pytest.mark.parametrize("solvent", EXPLICIT_SOLVENTS) |
| def test_validate_rejects_dropping_one_atom(solvent): |
| |
| short = _one_molecule(solvent)[:-1] |
| with pytest.raises(ValueError, match="not a whole number"): |
| api._validate_solvent(solvent, short) |
|
|
| def test_predict_shifts_supports_the_12_documented_solvents(): |
| tables = scaling.published_scaling_tables() |
| for nucleus in ("H", "C"): |
| keys = {"water" if s == "TIP4P" else s for s in tables[nucleus]} |
| assert keys == set(PREDICT_SHIFTS_SOLVENTS) |
|
|
| def test_explicit_rejects_predict_only_solvent(): |
| |
| atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z]) |
| xyz = np.zeros((len(atoms), 3)) |
| with pytest.raises(ValueError, match="solvent must be one of"): |
| api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="acetone", |
| n_passes=1, symmetrize=False) |
|
|
|
|
| |
| |
| |
|
|
| def _system(): |
| atoms = np.concatenate([np.array([6, 1, 1, 1]), CHCL3_Z]) |
| xyz = np.concatenate([CH4_XYZ[:4] + 10.0, CHCL3_XYZ]) |
| return atoms, xyz |
|
|
| def test_explicit_one_basic(): |
| atoms, xyz = _system() |
| solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [0, 1, 2, 3], "chloroform") |
| assert np.array_equal(solute_an, np.array([6, 1, 1, 1])) |
| assert np.array_equal(full_an, atoms) |
|
|
| def test_explicit_one_reorders_solvent_first_input(): |
| |
| atoms = np.concatenate([CHCL3_Z, np.array([6, 1, 1, 1])]) |
| xyz = np.concatenate([CHCL3_XYZ, CH4_XYZ[:4] + 10.0]) |
| solute_an, full_an, full_xyz = api._explicit_one(atoms, xyz, [5, 6, 7, 8], "chloroform") |
| assert np.array_equal(solute_an, np.array([6, 1, 1, 1])) |
| |
| assert np.array_equal(full_an[:4], np.array([6, 1, 1, 1])) |
| assert np.array_equal(np.sort(full_an[4:]), np.sort(CHCL3_Z)) |
|
|
| def test_explicit_one_output_order_follows_solute_atoms(): |
| atoms, xyz = _system() |
| solute_an, full_an, _ = api._explicit_one(atoms, xyz, [3, 2, 1, 0], "chloroform") |
| assert np.array_equal(solute_an, np.array([1, 1, 1, 6])) |
| assert np.array_equal(full_an[:4], np.array([1, 1, 1, 6])) |
|
|
| def test_explicit_one_negative_indices(): |
| atoms, xyz = _system() |
| solute_an, _, _ = api._explicit_one(atoms, xyz, [-9, -8, -7, -6], "chloroform") |
| assert np.array_equal(solute_an, np.array([6, 1, 1, 1])) |
|
|
| def test_explicit_one_wrong_solvent_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="not .* benzene"): |
| api._explicit_one(atoms, xyz, [0, 1, 2, 3], "benzene") |
|
|
| def test_explicit_one_duplicate_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="duplicate"): |
| api._explicit_one(atoms, xyz, [0, 0, 1, 2], "chloroform") |
|
|
| def test_explicit_one_negative_normalizes_to_duplicate_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="duplicate"): |
| api._explicit_one(atoms, xyz, [2, -7], "chloroform") |
|
|
| def test_explicit_one_out_of_range_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="out of range"): |
| api._explicit_one(atoms, xyz, [0, 1, 2, 9], "chloroform") |
|
|
| def test_explicit_one_very_negative_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="out of range"): |
| api._explicit_one(atoms, xyz, [-100], "chloroform") |
|
|
| def test_explicit_one_all_atoms_solute_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="no solvent atoms"): |
| api._explicit_one(atoms, xyz, list(range(len(atoms))), "chloroform") |
|
|
| def test_explicit_one_empty_solute_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="empty"): |
| api._explicit_one(atoms, xyz, [], "chloroform") |
|
|
|
|
| |
| |
| |
|
|
| def test_explicit_unknown_solvent_raises(): |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="solvent must be one of"): |
| api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="ethanol", |
| n_passes=1, symmetrize=False) |
|
|
| def test_explicit_solvent_mismatch_raises(): |
| |
| atoms, xyz = _system() |
| with pytest.raises(ValueError, match="not .* benzene"): |
| api.explicit_solvent_correction(atoms, xyz, solute_atoms=[0, 1, 2, 3], solvent="benzene", |
| n_passes=1, symmetrize=False) |
|
|
|
|
| |
| |
| |
|
|
| @needs_model |
| def test_real_predict_shieldings_single_returns_array(): |
| out = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False) |
| assert isinstance(out, np.ndarray) and out.shape == (5,) |
| assert np.all(np.isfinite(out)) |
|
|
| @needs_model |
| def test_real_predict_shieldings_list_returns_list(): |
| out = api.predict_shieldings([CH4_Z], [CH4_XYZ], n_passes=1, symmetrize=False) |
| assert isinstance(out, list) and len(out) == 1 and out[0].shape == (5,) |
|
|
| @needs_model |
| def test_real_predict_shieldings_batch_shapes(): |
| out = api.predict_shieldings([CH4_Z, CH2O_Z], [CH4_XYZ, CH2O_XYZ], |
| n_passes=1, symmetrize=False) |
| assert isinstance(out, list) and out[0].shape == (5,) and out[1].shape == (4,) |
|
|
| @needs_model |
| def test_real_unsupported_element_rejected(): |
| |
| Z = np.array([15, 1, 1, 1]) |
| X = np.array([[0., 0., 0.], [1., 0., 0.], [0., 1., 0.], [0., 0., 1.]]) |
| with pytest.raises(ValueError, match="unsupported atomic numbers"): |
| api.predict_shieldings(Z, X, n_passes=1, symmetrize=False) |
|
|
| @needs_model |
| def test_real_predict_shifts_components_reproduce(): |
| d = api.predict_shifts(CH2O_Z, CH2O_XYZ, solvent="chloroform", n_passes=1, |
| symmetrize=False, return_components=True) |
| assert set(d) == {"shifts", "zero_shielding", "pcm_correction", "coefficients"} |
| shifts, sigma, delta = d["shifts"], d["zero_shielding"], d["pcm_correction"] |
| coef = d["coefficients"] |
| |
| tbl = scaling.published_scaling_tables() |
| assert coef["H"] == tbl["H"]["chloroform"] and coef["C"] == tbl["C"]["chloroform"] |
| |
| for idx, nuc in [(0, "C"), (2, "H"), (3, "H")]: |
| expect = coef[nuc]["intercept"] + coef[nuc]["stationary"] * sigma[idx] \ |
| + coef[nuc]["pcm"] * delta[idx] |
| assert np.isclose(shifts[idx], expect, atol=1e-6), (idx, nuc, shifts[idx], expect) |
| |
| assert np.isnan(shifts[1]) |
| assert np.all(np.isfinite([shifts[0], shifts[2], shifts[3]])) |
|
|
| @needs_model |
| def test_real_predict_shifts_acetone_matches_readme(): |
| |
| |
| Z = np.array([6, 6, 8, 6, 1, 1, 1, 1, 1, 1]) |
| X = np.array([ |
| [1.2913, -0.5947, -0.0016], [0.0029, 0.1931, -0.0010], [-0.0174, 1.3994, -0.0003], |
| [-1.2743, -0.6189, 0.0004], [-1.0822, -1.6899, 0.0014], [-1.8597, -0.3513, 0.8791], |
| [-1.8605, -0.3530, -0.8783], [1.3415, -1.2208, 0.8916], [1.3170, -1.2669, -0.8614], |
| [2.1415, 0.0788, -0.0298], |
| ]) |
| shifts = api.predict_shifts(Z, X, solvent="chloroform") |
| carbonyl = shifts[[1]].mean() |
| methyl_C = shifts[[0, 3]].mean() |
| methyl_H = shifts[[4, 5, 6, 7, 8, 9]].mean() |
| assert np.isclose(carbonyl, 207.6, atol=1.0), carbonyl |
| assert np.isclose(methyl_C, 30.7, atol=0.5), methyl_C |
| assert np.isclose(methyl_H, 2.20, atol=0.15), methyl_H |
|
|
| @needs_model |
| def test_real_predict_shifts_water_maps_to_tip4p(): |
| d = api.predict_shifts(CH4_Z, CH4_XYZ, solvent="water", n_passes=1, |
| symmetrize=False, return_components=True) |
| tbl = scaling.published_scaling_tables() |
| assert d["coefficients"]["H"] == tbl["H"]["TIP4P"] |
| assert d["coefficients"]["C"] == tbl["C"]["TIP4P"] |
|
|
| @needs_model |
| def test_real_single_vs_batch_same_shapes(): |
| single = api.predict_shieldings(CH4_Z, CH4_XYZ, n_passes=1, symmetrize=False) |
| batch = api.predict_shieldings([CH4_Z, CH4_Z], [CH4_XYZ, CH4_XYZ], |
| n_passes=1, symmetrize=False) |
| assert single.shape == batch[0].shape == batch[1].shape |
| |
| assert np.allclose(single, batch[0], atol=0.5) |
|
|
| @needs_model |
| @pytest.mark.xfail(reason="known: zero-edge graph (lone atom) gives a cryptic torch error, not a " |
| "clean ValueError; a lone atom is not a valid NMR input", |
| raises=RuntimeError) |
| def test_real_single_atom_solute(): |
| |
| api.predict_shieldings(np.array([6]), np.array([[0., 0., 0.]]), |
| n_passes=1, symmetrize=False) |
|
|
| @needs_model |
| @pytest.mark.xfail(reason="known: atoms all beyond the model cutoff -> zero edges -> torch " |
| "RuntimeError, not a clean ValueError", |
| raises=RuntimeError) |
| def test_real_atoms_beyond_cutoff_crash(): |
| |
| api.predict_shieldings(np.array([6, 1]), np.array([[0., 0., 0.], [0., 0., 50.]]), |
| n_passes=1, symmetrize=False) |
|
|
|
|
| |
| def test_edge_rot_mat_rejects_coincident_atoms(): |
| |
| |
| |
| edge_vecs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 0.0, 0.0]]) |
| with pytest.raises(ValueError, match="overlapping atoms"): |
| init_edge_rot_mat(edge_vecs) |
|
|
|
|
| def test_edge_rot_mat_accepts_separated_atoms(): |
| edge_vecs = torch.tensor([[1.0, 0.0, 0.0], [0.0, 1.5, 0.0], [0.0, 0.0, 2.0]]) |
| rot = init_edge_rot_mat(edge_vecs) |
| assert rot.shape == (3, 3, 3) |
| |
| identity = torch.eye(3).expand(3, 3, 3) |
| assert torch.allclose(torch.bmm(rot, rot.transpose(1, 2)), identity, atol=1e-4) |
|
|