Skip to content

Commit 1077e63

Browse files
committed
feat: add auto3d models test <engine> health check
Loads an engine and runs one tiny (methane) forward pass to verify it works in the current environment, surfacing the common setup problems up front: a missing torchani for the ANI engines, a failed/blocked aimnet registry download, or a broken custom model file. Exits 0 on success, 3 if a dependency is missing, 5 if the model loads but produces non-finite output. Also fixes the `models info` unknown-engine hint to list the real engine names (ModelFactory.available_models) instead of the upper-cased internal dict keys. Docs + tests included.
1 parent 289463a commit 1077e63

6 files changed

Lines changed: 137 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1414
optimization, thermochemistry, and tautomer ranking from the CLI (previously
1515
Python-only). Each supports `--engine`, `--gpu/--no-gpu`, `--gpu-idx`,
1616
`-o/--output`, and `--json`.
17+
- **`auto3d models test <engine>`** - loads an engine and runs a tiny forward
18+
pass as a health check (catches a missing torchani, a failed aimnet registry
19+
download, or a broken custom model file before a full run).
1720
- **CLI ergonomics** - `auto3d run` gains `--job-name` and `--save-intermediate`;
1821
`config init` gains `--force`; choice flags use enums with shell completion;
1922
input paths are validated up front; and commands return differentiated exit

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ for mol in mols:
9191
| `auto3d config validate <file>` | Validate a configuration file |
9292
| `auto3d models list` | List available NNP models |
9393
| `auto3d models info <engine>` | Show model details |
94+
| `auto3d models test <engine>` | Load an engine and run a forward pass to verify it works |
9495
| `auto3d validate <input>` | Validate input file |
9596

9697
### Common Options

docs/source/cli.rst

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -356,6 +356,30 @@ Show detailed information about a specific engine.
356356
# Get ANI2x details
357357
auto3d models info ANI2x
358358
359+
auto3d models test
360+
^^^^^^^^^^^^^^^^^^
361+
362+
Load an engine and run a single tiny forward pass to confirm it works in the
363+
current environment -- catching a missing ``torchani``, a failed aimnet registry
364+
download, or a broken custom model file up front rather than mid-run.
365+
366+
**Usage:**
367+
368+
.. code:: console
369+
370+
auto3d models test ENGINE [--gpu/--no-gpu] [--gpu-idx N]
371+
372+
**Examples:**
373+
374+
.. code:: console
375+
376+
auto3d models test AIMNET # verify the default engine on GPU
377+
auto3d models test ANI2x --no-gpu # verify ANI2x loads/runs on CPU
378+
auto3d models test ./my_model.pt # verify a custom NNP file
379+
380+
Exits 0 on success; 3 if a dependency is missing, 5 if the model loads but
381+
produces non-finite output.
382+
359383
Shell Completion
360384
----------------
361385

src/Auto3D/cli/app.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,23 @@ def models_info(
235235
execute_models_info(engine=engine)
236236

237237

238+
@models_app.command("test")
239+
def models_test(
240+
engine: Annotated[
241+
str,
242+
typer.Argument(
243+
autocompletion=engine_autocomplete,
244+
help="Engine to health-check: AIMNET, ANI2x, ANI2xt, a registry name, or a model path.",
245+
),
246+
],
247+
gpu: Annotated[bool, typer.Option("--gpu/--no-gpu", help="Use GPU when available.")] = True,
248+
gpu_idx: Annotated[int, typer.Option("--gpu-idx", help="CUDA device index.")] = 0,
249+
) -> None:
250+
"""Load an engine and run a tiny forward pass to verify it works."""
251+
from Auto3D.cli.commands.models import execute_models_test
252+
execute_models_test(engine=engine, gpu=gpu, gpu_idx=gpu_idx)
253+
254+
238255
@app.command()
239256
def validate(
240257
input_file: InputFile,

src/Auto3D/cli/commands/models.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,9 +182,10 @@ def execute_models_info(engine: str) -> None:
182182
engine_upper = "AIMNET"
183183

184184
if engine_upper not in ENGINE_INFO:
185+
from Auto3D.model_factory import ModelFactory
185186
print_error(
186187
f"Unknown engine: {engine}",
187-
hint=f"Available: {', '.join(ENGINE_INFO.keys())}",
188+
hint=f"Available: {', '.join(ModelFactory.available_models())}",
188189
)
189190
raise SystemExit(1)
190191

@@ -207,3 +208,50 @@ def execute_models_info(engine: str) -> None:
207208
content += f" - {note}\n"
208209

209210
console.print(Panel(content, title=f"[cyan]{engine_upper}[/cyan]", border_style="blue"))
211+
212+
213+
def execute_models_test(engine: str, gpu: bool = True, gpu_idx: int = 0) -> None:
214+
"""Health-check an engine: load it and run one tiny forward pass.
215+
216+
Catches the common environment problems up front -- a missing torchani for
217+
ANI, a failed/blocked aimnet registry download, or a broken custom model
218+
file -- instead of having them surface deep inside a run.
219+
"""
220+
from Auto3D.cli.console import print_success
221+
from Auto3D.cli.errors import handle_error
222+
223+
try:
224+
import time
225+
226+
import torch
227+
228+
from Auto3D.exceptions import NumericalError
229+
from Auto3D.model_factory import create_model, get_device
230+
231+
device = get_device(gpu_idx, use_gpu=gpu)
232+
with console.status(f"[bold]Loading {engine} on {device}..."):
233+
t0 = time.time()
234+
adapter = create_model(engine, device)
235+
# A single methane molecule (H, C only -> supported by every engine).
236+
coords = torch.tensor(
237+
[[[0.0, 0.0, 0.0], [0.63, 0.63, 0.63], [-0.63, -0.63, 0.63],
238+
[0.63, -0.63, -0.63], [-0.63, 0.63, -0.63]]],
239+
dtype=torch.float, device=device,
240+
)
241+
species = torch.tensor([[6, 1, 1, 1, 1]], device=device)
242+
charges = torch.tensor([0.0], device=device)
243+
energy, forces = adapter.forward(coords, species, charges)
244+
elapsed = time.time() - t0
245+
246+
if not (torch.isfinite(energy).all() and torch.isfinite(forces).all()):
247+
raise NumericalError(
248+
f"{engine} produced non-finite energy/forces on the test molecule."
249+
)
250+
251+
e_ev = float(energy.reshape(-1)[0])
252+
print_success(
253+
f"{engine} is working on {device} "
254+
f"(methane E = {e_ev:.4f} eV, {elapsed:.1f}s incl. load)."
255+
)
256+
except Exception as e: # noqa: BLE001 - present every failure as a clean panel
257+
handle_error(e)

tests/test_cli_property_commands.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,49 @@ def test_config_init_preset_enum_valid(tmp_path):
164164
assert target.exists()
165165

166166

167+
def test_models_test_success(monkeypatch):
168+
"""`models test` loads the engine and runs a forward; reports success."""
169+
import torch
170+
171+
class _StubAdapter:
172+
def forward(self, coords, species, charges):
173+
return torch.zeros(1), torch.zeros(1, 5, 3)
174+
175+
monkeypatch.setattr("Auto3D.model_factory.get_device", lambda *a, **k: torch.device("cpu"))
176+
monkeypatch.setattr("Auto3D.model_factory.create_model", lambda *a, **k: _StubAdapter())
177+
res = runner.invoke(app, ["models", "test", "AIMNET", "--no-gpu"])
178+
assert res.exit_code == 0, res.output
179+
assert "working" in res.output
180+
181+
182+
def test_models_test_load_failure_exit_code(monkeypatch):
183+
"""A load failure (e.g. missing dependency) exits with the mapped code."""
184+
from Auto3D.exceptions import DependencyError
185+
186+
def _boom(*a, **k):
187+
raise DependencyError("torchani not installed")
188+
189+
monkeypatch.setattr("Auto3D.model_factory.get_device", lambda *a, **k: __import__("torch").device("cpu"))
190+
monkeypatch.setattr("Auto3D.model_factory.create_model", _boom)
191+
res = runner.invoke(app, ["models", "test", "ANI2x", "--no-gpu"])
192+
assert res.exit_code == 3 # DependencyError -> 3
193+
assert "Traceback" not in res.output
194+
195+
196+
def test_models_test_non_finite_exit_code(monkeypatch):
197+
"""Non-finite outputs are reported as a model (numerical) error -> exit 5."""
198+
import torch
199+
200+
class _NanAdapter:
201+
def forward(self, coords, species, charges):
202+
return torch.tensor([float("nan")]), torch.zeros(1, 5, 3)
203+
204+
monkeypatch.setattr("Auto3D.model_factory.get_device", lambda *a, **k: torch.device("cpu"))
205+
monkeypatch.setattr("Auto3D.model_factory.create_model", lambda *a, **k: _NanAdapter())
206+
res = runner.invoke(app, ["models", "test", "AIMNET", "--no-gpu"])
207+
assert res.exit_code == 5 # NumericalError (ModelError) -> 5
208+
209+
167210
def test_api_functions_expose_new_params():
168211
"""calc_spe/opt_geometry/calc_thermo must accept out_path/use_gpu/allow_tf32
169212
so the CLI can drive output location, GPU choice, and TF32 uniformly."""

0 commit comments

Comments
 (0)