Modelforge ASE Calculator: Simple Walkthrough

This notebook demonstrates how to use a potential trained with modelforge as a calculator in the Atomic Simulation Environment (ASE). It is written for first-time users of both modelforge and ASE.

What you will do in this tutorial:

  1. Load a trained NNP model checkpoint.

  2. Wrap it with ModelForgeCalculator.

  3. Build a molecule from a SMILES string.

  4. Compute single-point energy and forces.

  5. Run geometry optimization and a short molecular dynamics simulation.

[1]:
from modelforge.potential.potential import load_inference_model_from_checkpoint

# Helper utilities to load the example model checkpoint bundled with modelforge.
from modelforge.utils.io import get_path_string
from modelforge.ase.tests import data

checkpoint_file_path = f"{get_path_string(data)}/model.ckpt" # This is an example model used in testing
potential = load_inference_model_from_checkpoint(checkpoint_file_path, jit=False)
print(f"Loaded checkpoint: {checkpoint_file_path}")
2026-04-20 15:24:41.993 | DEBUG    | modelforge.potential.potential:generate_potential:860 - training_parameter=None
2026-04-20 15:24:41.994 | DEBUG    | modelforge.potential.potential:generate_potential:861 - potential_parameter=SchNetParameters(potential_name='SchNet', only_unique_pairs=False, core_parameter=CoreParameter(number_of_radial_basis_functions=128, maximum_interaction_radius=0.49999999999999994, number_of_interaction_modules=9, number_of_filters=256, shared_interactions=True, activation_function_parameter=ActivationFunctionConfig(activation_function_name='ShiftedSoftplus', activation_function_arguments=None, activation_function=ShiftedSoftplus()), featurization=Featurization(properties_to_featurize=['atomic_number', 'per_system_total_charge'], atomic_number=AtomicNumber(maximum_atomic_number=101, number_of_per_atom_features=512), atomic_period=AtomicPeriod(maximum_period=8, number_of_per_period_features=32), atomic_group=AtomicGroup(maximum_group=18, number_of_per_group_features=32)), predicted_properties=['per_atom_energy', 'per_atom_charge'], predicted_dim=[1, 1]), postprocessing_parameter=PostProcessingParameter(properties_to_process=['per_atom_energy'], per_atom_energy=PerAtomEnergy(normalize=False, from_atom_to_system_reduction=True, keep_per_atom_property=True), per_atom_charge=PerAtomCharge(conserve=True, conserve_strategy='default'), per_system_electrostatic_energy=None, per_system_zbl_energy=None, per_system_vdw_energy=None, sum_per_system_energy=None, general_postprocessing_operation=GeneralPostProcessingOperation(calculate_molecular_self_energy=True, calculate_atomic_self_energy=False)), potential_seed=-1)
2026-04-20 15:24:41.994 | DEBUG    | modelforge.potential.potential:generate_potential:862 - dataset_parameter=None
2026-04-20 15:24:41.995 | DEBUG    | modelforge.potential.potential:setup_potential:705 - potential_seed None
2026-04-20 15:24:41.995 | DEBUG    | modelforge.potential.schnet:__init__:64 - Initializing the SchNet architecture.
2026-04-20 15:24:42.008 | DEBUG    | modelforge.potential.potential:setup_potential:724 - Only unique pairs: False
2026-04-20 15:24:42.008 | DEBUG    | modelforge.potential.potential:setup_potential:758 - Cutoffs: local_cutoff=0.49999999999999994, vdw_cutoff=-1, electrostatic_cutoff=-1
2026-04-20 15:24:42.010 | INFO     | modelforge.potential.neighbors:__init__:480 - NeighborlistForInference initialized
2026-04-20 15:24:42.103 | DEBUG    | modelforge.potential.potential:load_state_dict:672 - Removed prefixes: {'potential.'}
Loaded checkpoint: /Users/jenniferclark/bin/modelforge/modelforge-ase/modelforge/ase/tests/data/model.ckpt

To evaluate energies and forces in ASE, create a ModelForgeCalculator with the loaded potential and attach it to an ASE Atoms object.

[2]:
# Install ASE
from modelforge.ase import ModelForgeCalculator

modelforge_calculator = ModelForgeCalculator(potential)

ASE includes tools for building molecules, and modelforge-ase also provides helper functions for a simple SMILES-based workflow.

An ASE Atoms object stores the system definition: element identities, 3D positions, and optional simulation metadata (for example cell vectors and periodic boundary settings). By itself, Atoms is only a container for structure.

To compute potential energy or forces, you must attach a calculator first (here, ModelForgeCalculator). Without a calculator, calls like atoms.get_potential_energy() and atoms.get_forces() cannot run.

[3]:
from modelforge.ase import smiles_to_ase, ase_to_rdkit

# Set optimize=True to run an MMFF94 geometry optimization in RDKit before conversion.
smiles = "NCCCCCCO"
atoms = smiles_to_ase(smiles, optimize=False)

# Attach a calculator before requesting energy/forces from ASE.
atoms.calc = modelforge_calculator

You can view the 3D structure directly in ASE. (A separate RDKit view is also possible using the helper conversion utilities.)

[4]:
from ase.visualize import view
view(atoms, viewer='x3d')
[4]:
ASE atomic visualization
[5]:
# Optional alternative viewer (install nglview first).
# from nglview import show_ase
# show_ase(atoms)

Single-point energy and force computation

With the calculator attached, ASE can evaluate potential energy and per-atom forces. ModelForgeCalculator handles unit conversion from modelforge internal units to ASE units (eV and angstrom).

[6]:
pe = atoms.get_potential_energy()
forces = atoms.get_forces()
print(f"Potential energy: {pe:.6f} eV")
print("Forces (eV/angstrom):")
print(forces)
Potential energy: -87.677811 eV
Forces (eV/angstrom):
[[ 9.35617831e+00 -1.04941746e+01 -2.46965417e+00]
 [-4.73898635e+00  2.68741981e+00 -1.49133123e+00]
 [ 1.76531205e+00 -4.81745170e-01  1.01300176e+00]
 [-6.05733491e-01  6.38226477e-01  7.25054235e-02]
 [-8.13248220e-03 -3.55702940e-01 -7.89400711e-01]
 [ 1.21058832e+00  8.26806163e-01 -6.81625482e-01]
 [ 6.57577191e-01  4.57251602e-01 -7.60445774e-01]
 [-1.75304901e+00  1.35702911e+00 -3.41923599e+00]
 [-8.86226398e+00 -1.88798054e+00  4.35927589e+00]
 [ 3.55498647e+00  7.28130734e+00  4.92473686e-01]
 [ 6.69284696e-01  6.24919797e-01 -1.99190706e+00]
 [ 2.48271806e-01  2.60230954e+00  7.58894359e-01]
 [-2.14171833e-01 -1.01460840e+00 -7.52640876e-01]
 [-9.50183786e-01 -6.46874168e-01  9.01118020e-01]
 [-2.31531027e-01  5.94798309e-01  1.97134656e-01]
 [ 3.06845990e-01  4.83842126e-01 -1.15194275e+00]
 [-2.01529873e-01  8.74319249e-02  1.19573265e-01]
 [-2.83709665e-01  5.89578509e-02  6.02928039e-01]
 [-3.62168497e-01  6.41423562e-01  6.89800633e-01]
 [ 3.00789842e-01 -1.08247846e+00  4.95305264e-01]
 [ 3.53056884e-02 -2.18362045e+00 -1.30540650e+00]
 [-3.37182905e-01 -8.14991585e-03  2.12953493e+00]
 [ 4.43502713e-01 -1.86389677e-01  2.98204480e+00]]

Geometry optimization

ASE offers several structure optimizers. Here we use BFGS and stop when the maximum force falls below 0.05 eV/angstrom.

[7]:
from ase.optimize import BFGS

opt = BFGS(atoms, logfile=f"{smiles}_opt.log", trajectory=f"{smiles}_opt.traj")
opt.run(fmax=0.05)
[7]:
True

Molecular dynamics simulation

You can also run MD with ASE. This example runs short Langevin dynamics at 298 K and writes trajectory/log files.

[8]:
import ase.units as ase_units
from ase.io.trajectory import Trajectory
from ase.md import Langevin
from ase.io import write, read

trajectory_name = f"{smiles}_sim.traj"
write(f"{smiles}_system.pdb", atoms)

traj = Trajectory(trajectory_name, "w", atoms)

dyn = Langevin(
    atoms,
    timestep=1.0 * ase_units.fs,
    temperature_K=298.0,  # Kelvin
    friction=1.0 / ase_units.fs,
    trajectory=traj,
    logfile=f"{smiles}_md.log",
)
dyn.run(100)
/Users/jenniferclark/mamba/envs/modelforge/lib/python3.11/site-packages/ase/md/langevin.py:110: FutureWarning: The implementation of `fixcm=True` in `Langevin` does not strictly sample the correct NVT distributions. The deviations are typically small for large systems but can be more pronounced for small systems. Use `fixcm=False` together with `ase.constraints.FixCom`. `fixcm` is deprecated since ASE 3.28.0 and will be removed in a future release.
  warnings.warn(msg, FutureWarning)
[8]:
True

Convert ASE trajectory to XYZ so it can be opened in many viewers.

[9]:
trj = read(trajectory_name, ":")
write(f"{smiles}_sim.xyz", trj, format="xyz")

Now let’s visualize the trajectory.

First try the nglview widget inline. If widget rendering is unavailable in your frontend, the code falls back to ASE’s inline x3d viewer.

[10]:
view(trj)
[10]:
<Popen: returncode: None args: ['/Users/jenniferclark/mamba/envs/modelforge/...>
[11]:
## Notebook widget viewer (requires nglview + ipywidgets).
#from nglview import show_asetraj
#show_asetraj(trj)
2026-04-20 15:24:48.133 python[1375:35253137] +[IMKClient subclass]: chose IMKClient_Modern