Argon density from MD: xnn vs the original CACE#

The end goal of an interatomic potential is simulation, so the sharpest test is a thermodynamic observable. This notebook trains CACE on the Argon data and runs NPT molecular dynamics at 85 K / 1 bar with both codes (the paper validates CACE stability the same way, with MD of water up to 2000 K):

  • track (a), same potential: the trained original-CACE weights are copied into xnn; the two MD engines must produce the same density.

  • track (b), independently trained models: train each code separately; densities should agree within thermal fluctuations, near the experimental liquid-Ar value (~1.41 g/cm³).

Same protocol as examples/gnn/{mace,nequip,allegro}/03_*.

0. Setup: train float32, run MD float64#

# silence the expected warnings
import logging, warnings
logging.disable(logging.WARNING)
warnings.filterwarnings("ignore", category=UserWarning)
warnings.filterwarnings("ignore", category=FutureWarning,
                        message="You are using `torch.load` with `weights_only=False`")

import time
import numpy as np
import torch
import matplotlib.pyplot as plt
import ase.io
import ase.units as u

torch.set_default_dtype(torch.float32)
torch.manual_seed(0)
DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
CUTOFF, SPECIES = 6.0, [18]
DATA = "../../../datasets/argon_md"
import xnn, cace
print("xnn:", xnn.__version__, "| cace (original): 0.1.0 | device:", DEVICE)
xnn: 0.1.0 | cace (original): 0.1.0 | device: cuda

1. Load data and build both data pipelines#

from ase import Atoms
def _to_atoms(s):  # rebuild an ASE Atoms for the upstream (CACE) side
    a = Atoms(numbers=s["atomic_numbers"], positions=s["pos"],
              cell=s["cell"], pbc=[True, True, True])
    a.info["REF_energy"] = float(s["energy"])
    a.arrays["REF_forces"] = np.asarray(s["forces"])
    return a

from xnn.common.data import AtomicDataset, load_dataset
from cace.data import AtomicData as CaceAtomicData
from cace.tools import torch_geometric as cace_tg

DATA_KEY = {"energy": "REF_energy", "forces": "REF_forces"}

E0 = {18: 0.0}   # argon isolated-atom reference energy
train_structs = load_dataset("argon_md", split="train")
train_atoms = [_to_atoms(s) for s in train_structs]
ds_all = AtomicDataset(train_structs, CUTOFF)
keep = [i for i in range(len(train_structs)) if ds_all[i].num_edges > 0]
train_structs = [train_structs[i] for i in keep]
train_atoms = [train_atoms[i] for i in keep]

xnn_train = AtomicDataset(train_structs, CUTOFF)
LAMBDA = float(sum(xnn_train[i].num_edges for i in range(len(xnn_train))) /
               sum(xnn_train[i].num_nodes for i in range(len(xnn_train))))
up_train = [CaceAtomicData.from_atoms(a, cutoff=CUTOFF, data_key=dict(DATA_KEY),
                                      atomic_energies=E0) for a in train_atoms]

g = torch.Generator().manual_seed(0)
perm = torch.randperm(len(train_structs), generator=g).tolist()
n_val = max(1, int(0.1 * len(train_structs)))
val_idx, train_idx = perm[:n_val], perm[n_val:]
print(f"{len(train_structs)} configs (train {len(train_idx)} / val {len(val_idx)}) "
      f"| lambda = {LAMBDA:.2f} | E0 = {E0}")
193 configs (train 174 / val 19) | lambda = 17.78 | E0 = {18: 0.0}

2. Train the two models#

from cace.modules import BesselRBF as UpBessel, PolynomialCutoff as UpPoly
from cace.modules.atomwise import Atomwise
from cace.modules.forces import Forces
from cace.representations import Cace as UpCace
from cace.models.atomistic import NeuralNetworkPotential

from xnn.common.config import from_dict
from xnn.common.train import Trainer
from torch.utils.data import Subset

NAB, NRBF, NRB, LMAX, NU, T = 2, 6, 8, 3, 3, 1
TYPES = ["M", "Ar", "Bchi"]
EW, FW, LR, WD, BS, EPOCHS = 1.0, 100.0, 0.01, 5e-7, 10, 60

def make_upstream(calc_stress):
    rep = UpCace(zs=SPECIES, n_atom_basis=NAB, cutoff=CUTOFF,
                 radial_basis=UpBessel(cutoff=CUTOFF, n_rbf=NRBF, trainable=True),
                 cutoff_fn=UpPoly(cutoff=CUTOFF, p=6),
                 max_l=LMAX, max_nu=NU, num_message_passing=T,
                 type_message_passing=TYPES, n_radial_basis=NRB,
                 avg_num_neighbors=LAMBDA, embed_receiver_nodes=True)
    atomwise = Atomwise(n_layers=3, n_hidden=[32, 16], output_key="CACE_energy",
                        add_linear_nn=True)
    forces = Forces(calc_forces=True, calc_stress=calc_stress,
                    energy_key="CACE_energy", forces_key="CACE_forces")
    return NeuralNetworkPotential(representation=rep,
                                  output_modules=[atomwise, forces])

# ---------- 2.1 train the ORIGINAL CACE (native loop) ----------
torch.manual_seed(0)
cace_nnp = make_upstream(calc_stress=False).to(DEVICE)
b0 = next(iter(cace_tg.dataloader.DataLoader(dataset=up_train[:2], batch_size=2))).to(DEVICE)
_ = cace_nnp(b0.to_dict(), training=True)          # lazy init

tr_loader = cace_tg.dataloader.DataLoader(dataset=[up_train[i] for i in train_idx],
                                          batch_size=BS, shuffle=True)
va_loader = cace_tg.dataloader.DataLoader(dataset=[up_train[i] for i in val_idx],
                                          batch_size=BS, shuffle=False)
opt = torch.optim.Adam(cace_nnp.parameters(), lr=LR, weight_decay=WD)
sched = torch.optim.lr_scheduler.ReduceLROnPlateau(opt, patience=10)

def up_loss(pred, b):
    n = torch.bincount(b.batch).to(pred["CACE_energy"].dtype)
    return EW * (((pred["CACE_energy"] - b.energy) / n) ** 2).mean() \
         + FW * ((pred["CACE_forces"] - b.forces) ** 2).mean()

t0 = time.time()
for epoch in range(EPOCHS):
    cace_nnp.train()
    for b in tr_loader:
        b = b.to(DEVICE)
        loss = up_loss(cace_nnp(b.to_dict(), training=True), b)
        opt.zero_grad(); loss.backward(); opt.step()
    cace_nnp.eval(); vl = 0.0
    for b in va_loader:
        b = b.to(DEVICE)
        vl += float(up_loss(cace_nnp(b.to_dict(), training=False), b))
    sched.step(vl / len(va_loader))
print(f"original CACE trained {EPOCHS} epochs in {time.time()-t0:.0f} s "
      f"(final val {vl/len(va_loader):.3e})")

# ---------- 2.2 train xnn INDEPENDENTLY (Trainer) ----------
core = from_dict({
    "model": {"name": "cace", "cutoff": CUTOFF, "n_interactions": T, "n_rbf": NRBF,
              "species": SPECIES, "n_atom_basis": NAB, "n_radial_basis": NRB,
              "max_l": LMAX, "max_nu": NU, "message_types": TYPES,
              "embed_receiver_nodes": True, "avg_num_neighbors": LAMBDA,
              "atomic_energies": [E0[18]]},
    "data": {"batch_size": BS},
    "optim": {"lr": LR, "weight_decay": WD, "epochs": EPOCHS, "energy_weight": EW,
              "force_weight": FW, "scheduler": "plateau"},
    "device": DEVICE, "seed": 0, "output_dir": "runs/argon_md_indep",
})
t0 = time.time()
trainer = Trainer(core, Subset(xnn_train, train_idx), Subset(xnn_train, val_idx))
trainer.fit()
print(f"xnn (independent) trained {EPOCHS} epochs in {time.time()-t0:.0f} s")
xnn_indep_base = trainer.model.model
original CACE trained 60 epochs in 748 s (final val 5.116e-04)
epoch    0 | train loss 8.0363e-01 | val loss 1.5225e-01
epoch    1 | train loss 7.0898e-02 | val loss 7.2190e-03
epoch    2 | train loss 2.1769e-02 | val loss 4.7038e-03
epoch    3 | train loss 1.4422e-02 | val loss 3.9868e-03
epoch    4 | train loss 1.0941e-02 | val loss 3.3670e-03
epoch    5 | train loss 7.7384e-03 | val loss 2.2328e-03
epoch    6 | train loss 6.4776e-03 | val loss 2.3081e-03
epoch    7 | train loss 4.7837e-03 | val loss 2.1634e-03
epoch    8 | train loss 4.0802e-03 | val loss 1.9508e-03
epoch    9 | train loss 3.6429e-03 | val loss 1.6529e-03
epoch   10 | train loss 3.2452e-03 | val loss 1.5608e-03
epoch   11 | train loss 2.9435e-03 | val loss 1.3839e-03
epoch   12 | train loss 2.7130e-03 | val loss 1.3390e-03
epoch   13 | train loss 2.4838e-03 | val loss 1.1608e-03
epoch   14 | train loss 2.4167e-03 | val loss 1.2059e-03
epoch   15 | train loss 3.0544e-03 | val loss 1.2990e-03
epoch   16 | train loss 2.8776e-03 | val loss 1.2534e-03
epoch   17 | train loss 2.1314e-03 | val loss 9.1301e-04
epoch   18 | train loss 1.7308e-03 | val loss 8.9006e-04
epoch   19 | train loss 1.8098e-03 | val loss 8.2352e-04
epoch   20 | train loss 1.5195e-03 | val loss 8.4284e-04
epoch   21 | train loss 1.3834e-03 | val loss 7.3705e-04
epoch   22 | train loss 1.3261e-03 | val loss 7.0285e-04
epoch   23 | train loss 1.2889e-03 | val loss 6.9000e-04
epoch   24 | train loss 1.1883e-03 | val loss 6.7604e-04
epoch   25 | train loss 1.1700e-03 | val loss 6.6523e-04
epoch   26 | train loss 1.1512e-03 | val loss 5.9796e-04
epoch   27 | train loss 1.1420e-03 | val loss 6.8749e-04
epoch   28 | train loss 1.2094e-03 | val loss 7.8841e-04
epoch   29 | train loss 1.5012e-03 | val loss 5.9808e-04
epoch   30 | train loss 9.4884e-04 | val loss 6.3126e-04
epoch   31 | train loss 8.5811e-04 | val loss 5.4355e-04
epoch   32 | train loss 8.9869e-04 | val loss 4.9025e-04
epoch   33 | train loss 7.8336e-04 | val loss 4.8524e-04
epoch   34 | train loss 8.2078e-04 | val loss 7.1318e-04
epoch   35 | train loss 1.0372e-03 | val loss 1.4541e-03
epoch   36 | train loss 3.2866e-03 | val loss 4.5918e-03
epoch   37 | train loss 3.2036e-03 | val loss 8.9568e-04
epoch   38 | train loss 1.5704e-03 | val loss 8.4324e-04
epoch   39 | train loss 1.2979e-03 | val loss 6.3215e-04
epoch   40 | train loss 1.1012e-03 | val loss 4.8147e-04
epoch   41 | train loss 9.0507e-04 | val loss 5.2171e-04
epoch   42 | train loss 8.2821e-04 | val loss 5.4909e-04
epoch   43 | train loss 8.5960e-04 | val loss 4.6530e-04
epoch   44 | train loss 7.8841e-04 | val loss 4.2920e-04
epoch   45 | train loss 8.4063e-04 | val loss 7.0295e-04
epoch   46 | train loss 8.2159e-04 | val loss 4.7510e-04
epoch   47 | train loss 6.9754e-04 | val loss 4.8852e-04
epoch   48 | train loss 6.7046e-04 | val loss 4.0414e-04
epoch   49 | train loss 6.5078e-04 | val loss 4.4652e-04
epoch   50 | train loss 6.3985e-04 | val loss 3.9209e-04
epoch   51 | train loss 6.6274e-04 | val loss 4.6944e-04
epoch   52 | train loss 8.0179e-04 | val loss 6.8823e-04
epoch   53 | train loss 8.3657e-04 | val loss 6.0100e-04
epoch   54 | train loss 7.5767e-04 | val loss 3.6173e-04
epoch   55 | train loss 6.0461e-04 | val loss 3.6775e-04
epoch   56 | train loss 5.9049e-04 | val loss 4.3890e-04
epoch   57 | train loss 5.9111e-04 | val loss 3.4077e-04
epoch   58 | train loss 7.5536e-04 | val loss 3.9526e-04
epoch   59 | train loss 8.5891e-04 | val loss 5.2345e-04
xnn (independent) trained 60 epochs in 93 s

Common MD utilities#

Switch to float64; rebuild the trained original CACE in double precision with a stress-capable Forces module and load the trained weights. The original code’s own CACECalculator (which adds \(E_0\) back and converts the stress to Voigt form) drives its MD; XNNCalculator drives the xnn side.

torch.set_default_dtype(torch.float64)
from ase import Atoms
from ase.calculators.calculator import all_changes
from ase.md.nptberendsen import NPTBerendsen
from ase.md.velocitydistribution import MaxwellBoltzmannDistribution, Stationary
from xnn.common.deploy import XNNCalculator
from xnn.common.models import build_model, ForceStressOutput
from cace.calculators import CACECalculator

class PatchedCACECalculator(CACECalculator):
    '''Upstream stores energy as a shape-(1,) array; float() of it raises on numpy>=2.'''
    def calculate(self, atoms=None, properties=None, system_changes=all_changes):
        try:
            super().calculate(atoms, properties, system_changes)
        except TypeError:   # forces/stress are already in results at this point
            self.results["energy"] = float(np.asarray(self.results["energy"]).reshape(-1)[0])
            self.results["forces"] = np.asarray(self.results["forces"], dtype=np.float64)
        return self.results

cace_md_model = make_upstream(calc_stress=True)
b64 = next(iter(cace_tg.dataloader.DataLoader(
    dataset=[CaceAtomicData.from_atoms(train_atoms[0], cutoff=CUTOFF)], batch_size=1)))
_ = cace_md_model(b64.to_dict(), training=True)     # lazy-init in float64
cace_md_model.load_state_dict({k: v.double() for k, v in cace_nnp.state_dict().items()})
cace_md_model = cace_md_model.to(DEVICE).eval()
xnn_indep_base = xnn_indep_base.double().eval()

def cace_calc():
    return PatchedCACECalculator(cace_md_model, device=DEVICE, compute_stress=True,
                                 energy_key="CACE_energy", forces_key="CACE_forces",
                                 atomic_energies=E0)

T_K, P_BAR, DT = 85.0, 1.0, 5 * u.fs
N_EQUIL, N_PROD = 300, 700
AMU_A3_TO_G_CM3 = 1.6605390666
a0 = train_structs[0]

def density(atoms):
    return atoms.get_masses().sum() / atoms.get_volume() * AMU_A3_TO_G_CM3

def compare_calcs(make_x, make_u):
    at = Atoms(numbers=a0["atomic_numbers"], positions=a0["pos"], cell=a0["cell"], pbc=True)
    ax = at.copy(); ax.calc = make_x(); au = at.copy(); au.calc = make_u()
    return (abs(ax.get_potential_energy() - au.get_potential_energy()),
            np.abs(ax.get_forces() - au.get_forces()).max(),
            np.abs(ax.get_stress() - au.get_stress()).max())

def run_npt(make_calc, label):
    at = Atoms(numbers=a0["atomic_numbers"], positions=a0["pos"], cell=a0["cell"], pbc=True)
    at.calc = make_calc()
    MaxwellBoltzmannDistribution(at, temperature_K=T_K, rng=np.random.default_rng(0)); Stationary(at)
    dyn = NPTBerendsen(at, timestep=DT, temperature_K=T_K, pressure_au=P_BAR * u.bar,
                       taut=100 * u.fs, taup=1000 * u.fs, compressibility_au=2e-4 / u.bar)
    rho = np.empty(N_EQUIL + N_PROD)
    t0 = time.time()
    for k in range(N_EQUIL + N_PROD):
        dyn.run(1); rho[k] = density(at)
    print(f"{label}: {N_EQUIL+N_PROD} steps in {time.time()-t0:.0f} s | "
          f"rho_eq = {rho[N_EQUIL:].mean():.4f} g/cm3")
    return rho

RHO_EXP = 1.41
print(f"initial density = {density(Atoms(numbers=a0['atomic_numbers'], positions=a0['pos'], cell=a0['cell'], pbc=True)):.4f} g/cm3 "
      f"| target T={T_K} K, P={P_BAR} bar | exp ~{RHO_EXP} g/cm3")
initial density = 1.7910 g/cm3 | target T=85.0 K, P=1.0 bar | exp ~1.41 g/cm3

Track (a): same potential (weights copied CACE → xnn)#

3a. Copy the trained original-CACE weights into xnn#

def copy_cace_into_xnn(x, rep, readout):
    with torch.no_grad():
        x.embed_sender.copy_(rep.node_embedding_sender.embedding_weights.double())
        x.embed_receiver.copy_(rep.node_embedding_receiver.embedding_weights.double())
        x.rbf.freqs.copy_(rep.radial_basis.bessel_weights.double() * float(rep.cutoff))
        x.radial_transform.weight.copy_(
            torch.stack([w.double() for w in rep.radial_transform.weights]))
        for t, (nm, ar, bchi) in enumerate(rep.message_passing_list):
            xi = x.interactions[t]
            if nm is not None:
                xi.memory.memory_coef.copy_(torch.stack([w.double() for w in nm.memory_coef]))
            if ar is not None:
                xi.message_ar.prefactor.copy_(torch.stack([w.double() for w in ar.prefactor]))
                xi.message_ar.inv_r0.copy_(torch.stack([w.double() for w in ar.invr0]))
            if bchi is not None:
                xi.message_bchi.h.weight.copy_(bchi.hnet[0].linear.weight.double())
                xi.message_bchi.h.bias.copy_(bchi.hnet[0].linear.bias.double())
        for j, dense in enumerate(readout.outnet):
            x.readout_mlp[2 * j].weight.copy_(dense.linear.weight.double())
            x.readout_mlp[2 * j].bias.copy_(dense.linear.bias.double())
        x.readout_linear.weight.copy_(readout.linear_nn.linear.weight.double())
        x.readout_linear.bias.copy_(readout.linear_nn.linear.bias.double())
        x.atom_ref.weight[18] = E0[18]

xnn_shared_base = build_model(core.model)          # fresh float64 model
copy_cace_into_xnn(xnn_shared_base, cace_md_model.representation,
                    cace_md_model.output_modules[0])
xnn_shared = ForceStressOutput(xnn_shared_base, compute_forces=True,
                                compute_stress=True).to(DEVICE).double().eval()

def xnn_a():  return XNNCalculator(xnn_shared, cutoff=CUTOFF, device=DEVICE)

dE, dF, dS = compare_calcs(xnn_a, cace_calc)
print("track (a) calculators on one Argon config (SAME potential):")
print(f"  dE = {dE:.2e} eV | dF = {dF:.2e} eV/A | ds = {dS:.2e} eV/A3  -> identical")
track (a) calculators on one Argon config (SAME potential):
  dE = 4.80e-07 eV | dF = 3.09e-08 eV/A | ds = 1.06e-10 eV/A3  -> identical

4a/5a. NPT MD: same potential through both codes#

rho_xa = run_npt(xnn_a, "xnn (a)")
rho_ua = run_npt(cace_calc, "cace (a)")
da_x, da_u = rho_xa[N_EQUIL:].mean(), rho_ua[N_EQUIL:].mean()
print(f"track (a)  rho_xnn = {da_x:.4f}   rho_cace = {da_u:.4f}   "
      f"|diff| = {abs(da_x-da_u):.2e} g/cm3")
xnn (a): 1000 steps in 72 s | rho_eq = 1.5085 g/cm3
cace (a): 1000 steps in 269 s | rho_eq = 1.5085 g/cm3
track (a)  rho_xnn = 1.5085   rho_cace = 1.5085   |diff| = 1.11e-08 g/cm3
/tmp/ipykernel_1130747/2712809382.py:51: DeprecationWarning: Use thermalize_momenta
  MaxwellBoltzmannDistribution(at, temperature_K=T_K, rng=np.random.default_rng(0)); Stationary(at)

Track (b): independently trained models#

3b/4b/5b. Two independent potentials → two densities#

xnn_indep = ForceStressOutput(xnn_indep_base, compute_forces=True,
                               compute_stress=True).to(DEVICE).double().eval()
def xnn_b():  return XNNCalculator(xnn_indep, cutoff=CUTOFF, device=DEVICE)

dE, dF, dS = compare_calcs(xnn_b, cace_calc)
print("track (b) calculators on one Argon config (INDEPENDENT models):")
print(f"  dE = {dE:.3e} eV | dF = {dF:.3e} eV/A | ds = {dS:.3e} eV/A3 "
      "(training-level differences)")

rho_xb = run_npt(xnn_b, "xnn (b)")
rho_ub = rho_ua                                  # same trained original potential
db_x, db_u = rho_xb[N_EQUIL:].mean(), rho_ub[N_EQUIL:].mean()
print(f"track (b)  rho_xnn = {db_x:.4f} ± {rho_xb[N_EQUIL:].std():.3f}   "
      f"rho_cace = {db_u:.4f} ± {rho_ub[N_EQUIL:].std():.3f}")
print(f"           |diff| = {abs(db_x-db_u):.2e} g/cm3 (within thermal fluctuations; exp ~{RHO_EXP})")
track (b) calculators on one Argon config (INDEPENDENT models):
  dE = 2.370e+00 eV | dF = 4.795e-02 eV/A | ds = 1.307e-04 eV/A3 (training-level differences)
xnn (b): 1000 steps in 75 s | rho_eq = 1.4798 g/cm3
track (b)  rho_xnn = 1.4798 ± 0.004   rho_cace = 1.5085 ± 0.003
           |diff| = 2.87e-02 g/cm3 (within thermal fluctuations; exp ~1.41)
/tmp/ipykernel_1130747/2712809382.py:51: DeprecationWarning: Use thermalize_momenta
  MaxwellBoltzmannDistribution(at, temperature_K=T_K, rng=np.random.default_rng(0)); Stationary(at)

6. Overview: both tracks#

t_ps = np.arange(N_EQUIL + N_PROD) * (DT / u.fs) / 1000.0
xc = N_EQUIL * (DT / u.fs) / 1000.0
fig, ax = plt.subplots(1, 2, figsize=(12, 4.4), sharey=True)
ax[0].plot(t_ps, rho_xa, label="xnn", lw=1)
ax[0].plot(t_ps, rho_ua, label="original CACE", lw=1, ls="--")
ax[0].set_title(f"(a) same potential  |Δρ|={abs(da_x-da_u):.1e} g/cm³")
ax[1].plot(t_ps, rho_xb, label="xnn (independent)", lw=1)
ax[1].plot(t_ps, rho_ub, label="original CACE (independent)", lw=1, ls="--")
ax[1].set_title(f"(b) independently trained  |Δρ|={abs(db_x-db_u):.1e} g/cm³")
for a in ax:
    a.axvline(xc, color="gray", ls=":", lw=1)
    a.axhline(RHO_EXP, color="k", ls="-.", lw=1, label=f"exp ≈ {RHO_EXP}")
    a.set_xlabel("time [ps]"); a.legend(fontsize=8)
ax[0].set_ylabel("density [g/cm³]")
plt.tight_layout(); plt.savefig("argon_density_md.png", dpi=120); plt.show()

print(f"{'':<20}{'xnn':>12}{'original CACE':>16}{'|diff|':>12}")
print("-" * 60)
print(f"{'(a) same PES':<20}{da_x:>12.4f}{da_u:>16.4f}{abs(da_x-da_u):>12.1e}")
print(f"{'(b) independent':<20}{db_x:>12.4f}{db_u:>16.4f}{abs(db_x-db_u):>12.1e}")
print(f"{'experiment':<20}{RHO_EXP:>12.2f}")
../../../../_images/d11d5f023ad276fa09d5dbfd15ae21250f59ae4bcc3086d97dfe396f96dd02c2.png
                            xnn   original CACE      |diff|
------------------------------------------------------------
(a) same PES              1.5085          1.5085     1.1e-08
(b) independent           1.4798          1.5085     2.9e-02
experiment                  1.41

Summary#

  • Track (a): copying the trained original-CACE weights into xnn gives identical energy/forces/stress and identical NPT densities; the xnn inference/MD path reproduces the original CACE exactly.

  • Track (b): an independently trained xnn CACE lands within the thermal fluctuations of the original, both near the experimental liquid-Ar density (~1.41 g/cm³).

Same conclusions, same pipeline, fourth model family: CACE joins MACE, NequIP and Allegro as a faithful member of the xnn GNN family (shared GNNPotential base, featurizers, ForceStressOutput, ASE deploy), and the only one that needs no e3nn.