Skip to content

Commit 7f460cf

Browse files
authored
Enhance tests for DGL and PyG backends, including new mock implementations and improved coverage for MolecularDynamics. Update cache handling tests and ensure proper error handling for model loading. Refactor model prediction logic in ModelLightningModule to support both single and batched inputs. (#764)
1 parent 2e56818 commit 7f460cf

7 files changed

Lines changed: 351 additions & 20 deletions

File tree

src/matgl/utils/_training_pyg.py

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -206,20 +206,37 @@ def forward(
206206
l_g: Data | None = None,
207207
state_attr: torch.Tensor | None = None,
208208
):
209-
"""Args:
210-
g: dgl Graph
211-
lat: lattice
212-
l_g: Line graph
213-
state_attr: State attribute.
209+
"""Compute model predictions.
210+
211+
Attaches per-node ``pos`` and per-edge ``pbc_offshift`` tensors derived from
212+
``frac_coords``/``pbc_offset`` and the supplied lattice(s), then delegates to
213+
the wrapped model. Works for both single-graph (``lat`` shape ``(3, 3)``) and
214+
batched (``lat`` shape ``(B, 3, 3)``) inputs.
215+
216+
Args:
217+
g: PyG graph (single ``Data`` or batched ``Batch``).
218+
lat: Lattice tensor.
219+
l_g: Optional line graph.
220+
state_attr: Optional state attribute.
214221
215222
Returns:
216223
Model prediction.
217224
"""
218-
g.edata["lattice"] = torch.repeat_interleave(lat, g.batch_num_edges(), dim=0) # type:ignore[arg-type]
219-
g.edata["pbc_offshift"] = (g.edata["pbc_offset"].unsqueeze(dim=-1) * g.edata["lattice"]).sum(dim=1)
220-
g.ndata["pos"] = (
221-
g.ndata["frac_coords"].unsqueeze(dim=-1) * torch.repeat_interleave(lat, g.batch_num_nodes(), dim=0) # type:ignore[arg-type]
222-
).sum(dim=1)
225+
if lat is not None:
226+
# Normalize lat to (B, 3, 3) regardless of whether the caller supplied a
227+
# single-graph (3, 3) tensor or a (B, 3, 3) batched tensor.
228+
if lat.dim() == 2:
229+
lat = lat.unsqueeze(0)
230+
# PyG batches assign each node a graph index via ``g.batch``; for a
231+
# non-batched ``Data`` this attribute is missing/None, in which case
232+
# every node belongs to graph 0.
233+
batch = getattr(g, "batch", None)
234+
if batch is None:
235+
batch = torch.zeros(g.num_nodes, dtype=torch.long, device=g.frac_coords.device)
236+
node_lat = lat[batch]
237+
g.pos = (g.frac_coords.unsqueeze(dim=-1) * node_lat).sum(dim=1)
238+
edge_lat = lat[batch[g.edge_index[0]]]
239+
g.pbc_offshift = (g.pbc_offset.unsqueeze(dim=-1) * edge_lat).sum(dim=1)
223240
if self.include_line_graph:
224241
return self.model(g=g, l_g=l_g, state_attr=state_attr)
225242
return self.model(g, state_attr=state_attr)

tests/ext/test_ase_dgl.py

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
from __future__ import annotations
22

33
import os.path
4+
from unittest.mock import MagicMock, patch
45

56
import numpy as np
67
import pytest
78
import torch
89
from ase.build import molecule
10+
from ase.calculators.calculator import Calculator
911
from pymatgen.io.ase import AseAtomsAdaptor
1012

1113
import matgl
1214
from matgl import load_model
1315

1416
if matgl.config.BACKEND != "DGL":
1517
pytest.skip("Skipping DGL tests", allow_module_level=True)
18+
from matgl.ext import _ase_dgl as ase_mod
1619
from matgl.ext.ase import Atoms2Graph, M3GNetCalculator, MolecularDynamics, PESCalculator, Relaxer
1720

1821

@@ -220,7 +223,7 @@ def test_get_graph_from_atoms_mol():
220223
assert np.allclose(state, [0.0, 0.0])
221224

222225

223-
@pytest.mark.skipif(True, reason="Too slow.")
226+
@pytest.mark.skipif(True, reason="Slow; superseded by test_molecular_dynamics_branches which mocks the calculator.")
224227
def test_molecular_dynamics(MoS2):
225228
pot = load_model("TensorNetDGL-PES-MatPES-PBE-2025.2")
226229
for ensemble in [
@@ -243,3 +246,72 @@ def test_molecular_dynamics(MoS2):
243246
md.run(1)
244247
with pytest.raises(ValueError, match="Ensemble not supported"):
245248
MolecularDynamics(MoS2, potential=pot, ensemble="notanensemble")
249+
250+
251+
def _fake_pes_calculator():
252+
"""Build a stand-in for ``PESCalculator`` that satisfies ASE's ``set_calculator``.
253+
254+
The real ``PESCalculator`` requires a trained Potential. We avoid downloading a
255+
pretrained model in tests by substituting a minimal ASE ``Calculator`` whose
256+
``calculate`` is never invoked because the tests never run MD steps.
257+
"""
258+
calc = MagicMock(spec=Calculator)
259+
calc.results = {}
260+
calc.parameters = {}
261+
return calc
262+
263+
264+
def test_molecular_dynamics_branches(MoS2):
265+
"""Exercise every supported MD ensemble branch (and the invalid one) without
266+
requiring a pretrained potential.
267+
268+
The MD constructors don't run any forces at init time, so a mock PESCalculator
269+
is sufficient to hit all branches in ``MolecularDynamics.__init__`` and the
270+
``set_atoms`` helper.
271+
"""
272+
fake_potential = MagicMock(spec=torch.nn.Module)
273+
274+
ensembles = [
275+
"nvt",
276+
"nve",
277+
"nvt_langevin",
278+
"nvt_andersen",
279+
"nvt_bussi",
280+
"nvt_nose_hoover_chain",
281+
"npt",
282+
"npt_berendsen",
283+
"npt_nose_hoover",
284+
"npt_nose_hoover_chain",
285+
]
286+
287+
with patch.object(ase_mod, "PESCalculator", side_effect=lambda **_kw: _fake_pes_calculator()):
288+
for ensemble in ensembles:
289+
md = MolecularDynamics(
290+
MoS2,
291+
potential=fake_potential,
292+
ensemble=ensemble,
293+
taut=0.1,
294+
taup=0.1,
295+
compressibility_au=10,
296+
)
297+
assert md.dyn is not None
298+
# Re-wire calculator/atoms via the public helper.
299+
md.set_atoms(MoS2)
300+
301+
# ``taut`` / ``taup`` defaults branch.
302+
md_default = MolecularDynamics(
303+
MoS2,
304+
potential=fake_potential,
305+
ensemble="npt_nose_hoover_chain",
306+
taut=None,
307+
taup=None,
308+
compressibility_au=10,
309+
)
310+
assert md_default.dyn is not None
311+
312+
with pytest.raises(ValueError, match="Ensemble not supported"):
313+
MolecularDynamics(MoS2, potential=fake_potential, ensemble="notanensemble")
314+
315+
# ``stress_weight`` is rejected with a warning.
316+
with pytest.warns(UserWarning, match="Relaxer does not support user-defined stress_weight"):
317+
MolecularDynamics(MoS2, potential=fake_potential, ensemble="nve", stress_weight=0.1)

tests/graph/test_converters.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
"""Tests for the backend-dispatching ``matgl.graph.converters`` shim."""
2+
3+
from __future__ import annotations
4+
5+
import matgl
6+
from matgl.graph.converters import GraphConverter
7+
8+
9+
def test_converters_shim_dispatches_to_active_backend():
10+
"""``matgl.graph.converters.GraphConverter`` must alias the active backend's
11+
converter base class.
12+
13+
The shim is a thin re-export and is imported by other modules (notably for
14+
type-checking), so we just need to make sure the alias works on either backend.
15+
"""
16+
if matgl.config.BACKEND == "DGL":
17+
from matgl.graph._converters_dgl import GraphConverter as Expected
18+
else:
19+
from matgl.graph._converters_pyg import GraphConverter as Expected
20+
21+
assert GraphConverter is Expected

tests/test_config.py

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,55 @@
11
from __future__ import annotations
22

3+
import importlib.util
34
import os.path
5+
from unittest.mock import patch
46

57
import pytest
68

79
import matgl
8-
from matgl.config import MATGL_CACHE, clear_cache
10+
from matgl.config import MATGL_CACHE, clear_cache, ensure_backend
911

1012

1113
def test_clear_cache():
1214
clear_cache(False)
1315
assert not os.path.exists(MATGL_CACHE)
1416

1517

18+
def test_clear_cache_missing_dir(capsys):
19+
"""A second ``clear_cache`` call after the cache was already deleted must not raise."""
20+
clear_cache(False)
21+
clear_cache(False)
22+
captured = capsys.readouterr()
23+
assert "not found" in captured.out
24+
25+
26+
def test_clear_cache_no_when_user_says_no(monkeypatch):
27+
"""If the user answers 'n', the cache directory must remain untouched."""
28+
os.makedirs(MATGL_CACHE, exist_ok=True)
29+
answers = iter(["n"])
30+
monkeypatch.setattr("builtins.input", lambda _prompt: next(answers))
31+
clear_cache(confirm=True)
32+
assert os.path.exists(MATGL_CACHE)
33+
34+
35+
def test_ensure_backend_dgl_missing_raises_runtime_error():
36+
"""Mocked-missing DGL must raise a ``RuntimeError`` from ``ensure_backend('DGL')``."""
37+
with (
38+
patch.object(importlib.util, "find_spec", side_effect=ImportError("nope")),
39+
pytest.raises(RuntimeError, match="Please install DGL"),
40+
):
41+
ensure_backend("DGL")
42+
43+
44+
def test_ensure_backend_pyg_missing_raises_runtime_error():
45+
"""Mocked-missing PyG must raise a ``RuntimeError`` from ``ensure_backend('PYG')``."""
46+
with (
47+
patch.object(importlib.util, "find_spec", side_effect=ImportError("nope")),
48+
pytest.raises(RuntimeError, match="Please install torch_geometric"),
49+
):
50+
ensure_backend("PYG")
51+
52+
1653
def test_set_backend():
1754
with pytest.raises(ValueError, match="Invalid backend"):
1855
matgl.set_backend("nonsense")

tests/utils/test_io.py

Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,3 +217,149 @@ def test_load_bad_model():
217217
load_model("bad_serialized_model")
218218
finally:
219219
shutil.rmtree("bad_serialized_model")
220+
221+
222+
def test_load_model_wraps_unknown_error_as_runtime_error(tmp_path):
223+
"""Non-(ImportError, ValueError) exceptions should be re-raised as ``RuntimeError``."""
224+
serialized_dir = tmp_path / "weird_model"
225+
serialized_dir.mkdir()
226+
for fn in ("model.pt", "state.pt", "model.json"):
227+
(serialized_dir / fn).write_text("doesn't matter")
228+
229+
def boom(*_args, **_kwargs):
230+
raise KeyboardInterrupt("boom")
231+
232+
with (
233+
patch.object(matgl_io, "_get_file_paths", side_effect=boom),
234+
pytest.raises(RuntimeError, match="Unknown error occurred while loading model"),
235+
):
236+
load_model(serialized_dir)
237+
238+
239+
def test_get_file_paths_malformed_identifier_raises_value_error(tmp_path):
240+
"""An identifier that contains ``/`` but doesn't match a valid HF repo id must
241+
fail with a clear ``ValueError`` rather than attempting a network call."""
242+
from matgl.utils.io import _get_file_paths
243+
244+
# Doesn't exist locally and doesn't match ``owner/name`` (leading slash, double slash).
245+
bogus = "//bad//repo//id"
246+
with pytest.raises(ValueError, match=r"No valid model found locally or at Hugging Face Hub"):
247+
_get_file_paths(tmp_path / bogus, str_path=bogus)
248+
249+
250+
def test_get_file_paths_bare_name_hub_failure_raises_value_error(tmp_path):
251+
"""When a bare name fails to download from the materialyze HF org, raise ``ValueError``."""
252+
from matgl.utils.io import _get_file_paths
253+
254+
def boom(*_args, **_kwargs):
255+
raise RuntimeError("simulated hub failure")
256+
257+
with (
258+
patch.object(matgl_io, "_download_from_hf_hub", side_effect=boom),
259+
pytest.raises(ValueError, match=r"No valid model found locally or at Hugging Face repo"),
260+
):
261+
_get_file_paths(tmp_path / "BareName", str_path="BareName")
262+
263+
264+
def test_iomixin_load_dgl_class_under_pyg_warns():
265+
"""Loading a model whose nested kwargs reference a DGL-only class under PYG must warn.
266+
267+
Triggers the branch that auto-flips the backend to DGL when a serialized model has a
268+
nested component class name containing ``m3gnet`` / ``megnet`` / ``chgnet`` / ``qet``.
269+
"""
270+
import matgl as _matgl
271+
272+
if _matgl.config.BACKEND != "PYG":
273+
pytest.skip("Only meaningful on the PyG backend.")
274+
275+
# IOMixIn.load expects a dict-of-paths or a Path. Pre-build the artifacts on disk.
276+
import json as _json
277+
import tempfile
278+
279+
with tempfile.TemporaryDirectory() as tmpdir:
280+
tmp_path = Path(tmpdir)
281+
nested = {
282+
"@class": "M3GNet",
283+
"@module": "definitely.not.a.real.module.at.all",
284+
"@model_version": 1,
285+
"init_args": {},
286+
}
287+
init_args = {"n": 1, "submodel": nested}
288+
289+
torch.save(init_args, tmp_path / "model.pt")
290+
torch.save({}, tmp_path / "state.pt")
291+
(tmp_path / "model.json").write_text(
292+
_json.dumps(
293+
{
294+
"@class": "OldModel",
295+
"@module": "tests.utils.test_io",
296+
"@model_version": 1,
297+
"metadata": None,
298+
"kwargs": init_args,
299+
}
300+
)
301+
)
302+
303+
# ``matgl.set_backend("DGL")`` would mutate global state and may fail if DGL
304+
# isn't installed; patch it to a no-op so the test is self-contained.
305+
with (
306+
patch.object(_matgl, "set_backend") as mock_set_backend,
307+
pytest.warns(UserWarning, match=r"Setting the backend to DGL"),
308+
pytest.raises((ImportError, ValueError, ModuleNotFoundError)),
309+
):
310+
OldModel.load(tmp_path)
311+
312+
mock_set_backend.assert_called_with("DGL")
313+
314+
315+
def test_generate_hf_model_card_with_unserializable_metadata():
316+
"""``_generate_hf_model_card`` must swallow ``TypeError`` from non-serializable metadata.
317+
318+
Forces the ``json.dumps`` fallback to fail even with ``default=str`` by using an
319+
object whose ``__repr__`` raises (and therefore so does ``str(obj)``).
320+
"""
321+
from matgl.utils.io import _generate_hf_model_card
322+
323+
class Unserializable:
324+
def __repr__(self):
325+
raise TypeError("repr exploded")
326+
327+
model = OldModel(1)
328+
card = _generate_hf_model_card(model, metadata={"oops": Unserializable()})
329+
330+
assert "## Metadata" not in card
331+
assert "OldModel" in card
332+
333+
334+
def test_get_available_pretrained_models_handles_hub_errors():
335+
"""If the HF hub call fails, ``get_available_pretrained_models`` returns an empty list."""
336+
337+
class _BoomApi:
338+
def list_models(self, **_kwargs):
339+
raise RuntimeError("network is down")
340+
341+
with patch.object(matgl_io, "HfApi", return_value=_BoomApi()):
342+
names = get_available_pretrained_models()
343+
344+
assert names == []
345+
346+
347+
def test_get_available_pretrained_models_strips_owner_prefix():
348+
"""Returned names should be bare (no ``"owner/"`` prefix) and sorted."""
349+
350+
class _FakeModelInfo:
351+
def __init__(self, repo_id: str):
352+
self.id = repo_id
353+
354+
class _FakeApi:
355+
def list_models(self, **_kwargs):
356+
return [
357+
_FakeModelInfo("materialyze/Zeta"),
358+
_FakeModelInfo("materialyze/Alpha"),
359+
_FakeModelInfo("no-slash-id"), # malformed entries are silently skipped
360+
]
361+
362+
with patch.object(matgl_io, "HfApi", return_value=_FakeApi()):
363+
names = get_available_pretrained_models()
364+
365+
assert names == ["Alpha", "Zeta"]

0 commit comments

Comments
 (0)