Hybrid ML/MM energy: how the term arrays line up with PyCHARMM & MLpot¶
Design record
This page mixes accepted architecture decisions with historical migration notes. Unmarked performance and scaling recommendations are hypotheses, not current validation claims; consult the evidence registry before treating them as production guidance.
This page explains how the parameters of the extracted energy terms —
n_core_atoms, group_indices, active_group_slots / active_group_mask, and
the lj_epsilon / lj_rmin_half tables — map onto PyCHARMM (the PSF and its
nonbonded parameters) and onto the MLpot decomposition. It also covers the
JIT-compatibility rules and the performance/scaling behaviour those shapes imply.
Companion to docs/md-cg-unification-design.md (the architecture) — this is the
"why do the arrays look like that" tutorial.
1. Two integration modes (same physics, different driver)¶
There are two ways the ML potential and CHARMM are combined. Both start from the same CHARMM build (PSF + parameters); they differ in who runs the time loop.
flowchart TB
subgraph Build["CHARMM build (once)"]
PSF["PSF: atom order, types,<br/>charges, bonds, exclusions"]
NB["NONBONDED tables:<br/>epsilon (kcal/mol), Rmin/2 (Å)"]
end
Build --> A
Build --> B
subgraph A["Mode A — jax-md (examples/cg_jaxmd.py)"]
direction TB
A1["Extract params to arrays<br/>(NonbondedSystemData → FFParams)"]
A2["hybrid_energy_fn(R, ...) in pure jax"]
A3["jax-md integrator"]
A1 --> A2 --> A3
end
subgraph B["Mode B — PyCHARMM runtime (md-system --backend pycharmm)"]
direction TB
B1["CHARMM runs dynamics (Fortran)"]
B2["Each step: MLpot callback<br/>(pycharmm_calculator.calculate_charmm)"]
B3["CHARMM adds MM; ML adds ML forces"]
B1 --> B2 --> B3 --> B1
end
- Mode A is what
cg_jaxmddoes today, and what the newJaxmdDriverwill do: CHARMM is used only to build. Every energy term (ML monomers, MM nonbonded,vdw_core, restraints, SMD) is evaluated in pure jax inside one jittedenergy_fn. This is the mode the extracted terms target. - Mode B keeps CHARMM's integrator and plugs the ML model in as a
per-step callback (
NmlpML pairs handed tocalculate_charmm). Here CHARMM owns the MM nonbonded and the ML potential only contributes the ML region.
The key point: the arrays in the terms are the "Mode A" view — everything CHARMM knows, flattened into static jax arrays once at build time.
2. What PyCHARMM provides → FFParams¶
Everything the terms need about the force field comes out of the PSF and the live CHARMM NONBONDED tables, via one dataclass:
# mmml/interfaces/pycharmmInterface/mm_system_energy.py
@dataclass(frozen=True, slots=True)
class NonbondedSystemData:
charges: np.ndarray # per-atom partial charge (from PSF)
at_codes: np.ndarray # per-atom type code
epsilon: np.ndarray # per-atom LJ ε (kcal/mol) ← param.get_epsilon()
rmin: np.ndarray # per-atom Rmin/2 (Å) ← param.get_vdwr()
excluded_pairs: frozenset # 1-2 / 1-3 exclusions (from PSF)
e14_pairs: frozenset # 1-4 pairs (scaled) (from PSF)
This maps field-for-field onto FFParams in the new schema (decision A):
resolved once by the builder, carried as data, never recomputed per step.
FFParams.from_nonbonded_system_data(nbdata) does the conversion, and
PsfSystemBuilder calls it. The vdw_core term's lj_epsilon / lj_rmin_half
are exactly FFParams.epsilon / .rmin_half (= NonbondedSystemData.epsilon /
.rmin):
CHARMM NONBONDED ──get_epsilon()/get_vdwr()──► NonbondedSystemData
│ .from_nonbonded_system_data()
▼
FFParams(charges, epsilon, rmin_half, at_codes, exclusions, e14_pairs)
│
RepulsiveCoreVdwTerm(lj_epsilon=…, lj_rmin_half=…)
CHARMM stores ε and Rmin/2 (half the pair minimum-energy distance), so the pair rule is a sum, not the usual σ combining:
ε_ij = sqrt(|ε_i · ε_j|) # geometric mean (CHARMM)
Rmin_ij = (Rmin/2)_i + (Rmin/2)_j # arithmetic SUM of the halves
That is why the term takes lj_rmin_half, and internally
sig = rmin_half[i] + rmin_half[j].
3. The atom-index convention: core group first, then molecule groups¶
The builder lays atoms out in a fixed order in the PSF: the solute / "core"
first, then the solvent molecules. That ordering is what n_core_atoms and
group_indices describe.
atom index: 0 1 2 ... n_core-1 | n_core ............... N-1
┌───────────────────────────────────┬───────────────────────────┐
│ CORE GROUP (e.g. peptide) │ MOLECULE GROUPS (waters) │
│ n_core_atoms atoms │ n_groups × atoms_per_grp │
└───────────────────────────────────┴───────────────────────────┘
\___ water0 ___/\_ water1 _/ ...
[O H H] [O H H]
n_core_atoms = n_core # first-N boundary (the "core")
group_indices = [[n_core+0, n_core+1, n_core+2], # water 0 (O,H,H)
[n_core+3, n_core+4, n_core+5], # water 1
...] # shape (n_groups, 3)
n_core_atomsis just the boundary index — the core occupiesR[:n_core].group_indices[g]are the atom indices of solvent moleculeg. Shape is(n_groups, atoms_per_group)— regular, so it vectorises cleanly.
In cg_jaxmd these were the globals n_trialanine and _pw_water_indices_jax;
the term simply names them generically.
4. The energy decomposition — where each term lives¶
hybrid_energy_fn in cg_jaxmd is the whole picture in one function. Annotated:
E_total =
e_intra # (A) ML monomer energies → ml_intra term
+ e_pw_core # (A) repulsive LJ core wall → RepulsiveCoreVdwTerm ("vdw_core")
+ e_inter # (B) MM nonbonded, padded pairs→ mm_nonbonded term
+ e_restraint # (C) flat-bottom H–X bonds → (restraint term)
+ e_pep_bond # (D) all-bond harmonic → (restraint term)
+ e_dihedral # φ/ψ restraint → DihedralRestraintTerm
+ e_smd # (E) steered-MD moving well → SMDBiasTerm
Why does vdw_core exist at all? Because of double-counting avoidance:
flowchart LR
subgraph Pairs["Core–solvent interactions"]
direction TB
P1["Handled by ML dimers<br/>(core + one molecule)"]
P2["Removed from MM nonbonded<br/>(so not counted twice)"]
end
P1 -->|"ML can be blind<br/>at very short range"| Gap["Repulsion gap → atoms collapse"]
Gap -->|"add back only the<br/>repulsive LJ branch"| VC["vdw_core wall<br/>(smoothly cut off)"]
When core–solvent interactions are given to the ML model, those pairs are
excluded from the MM nonbonded sum (get_intermolecular_pairs(...,
peptide_water_ml=True)). But an ML model trained on reasonable geometries can
under-predict the hard repulsive wall at very short separation, letting atoms
fuse. vdw_core re-adds only the positive (repulsive) part of the CHARMM LJ
term for those pairs, smoothly switched to zero past a short cutoff — a safety
rail, not a full nonbonded term.
5. The two padding schemes (the crux of the params)¶
jax needs static array shapes to compile once, but the number of interacting pairs/molecules changes every step. The fix is pad to a fixed capacity + carry a mask. There are two independent padded lists (decision B — each term owns its own):
5a. MM nonbonded pair list — (pi, pj, mask)¶
MAX_PAIRS (fixed capacity, e.g. 200_000)
┌───────────────────────────────────────────── … ─────────────┐
pi = [ 12, 12, 40, 41, … real i indices … 0, 0, 0, 0 ]
pj = [ 88, 141, 90, 91, … real j indices … 0, 0, 0, 0 ]
mask = [ 1, 1, 1, 1, … 1 … 0, 0, 0, 0 ]
└─ padding ──┘
mask (1 for a real pair, 0 for padding) multiplies each pair energy, so
padding contributes exactly zero. Padding pairs point at atom 0 with a clamped
safe_dist so sqrt/1/r never see a true zero (NaN-gradient guard).
5b. Active molecule groups — active_group_slots / active_group_mask¶
This is the one the term exposes. group_indices lists all solvent
molecules; only those near the core matter each step. active_group_slots
selects rows of group_indices; active_group_mask zeroes the padded tail.
group_indices (all molecules, shape (n_groups, 3))
row 0: [42,43,44] row 1: [45,46,47] row 2: [48,49,50] row 3: …
active_group_slots = [ 2, 0, 17, …, 0, 0 ] # indices INTO group_indices
active_group_mask = [ 1, 1, 1, …, 0, 0 ] # 1 = real, 0 = padded
└ padding ┘
capacity = MAX_ACTIVE_GROUPS (fixed; sized to the cutoff shell)
inside the term:
groups = group_indices[active_group_slots] # (MAX_ACTIVE_GROUPS, 3)
E = Σ mask · switch(dist) · repulsive(dist)
So the mapping is: slots index into group_indices, mask cancels the
padding. When you pass active_group_slots=None, the term uses all groups
(the dense path) — correct but unbounded; the padded path is what keeps a large
box affordable and jit-stable (§7).
6. JIT compatibility — the rules these shapes enforce¶
The whole design exists to make one jax.jit graph that is compiled once and
reused for the entire trajectory. The rules:
| Rule | Why | In the term |
|---|---|---|
| Static shapes | jit specialises on shape; a new shape = recompile | pad to MAX_*; never resize per step |
| Mask, don't index | boolean/dynamic indexing changes shape | mask * energy, not energy[mask] |
| No Python branch on traced values | if x > 0 on a tracer fails/leaks |
branches only on Python constants (n_core, cutoff) |
| NaN-safe kernels | √0, 1/0 give NaN gradients even if masked out |
safe_dist = where(mask, dist, 1.0) before sqrt |
| Closed-over constants are static | baked into the graph | ε/Rmin tables, box, cutoff captured at make() |
| Arrays passed as args | same shape ⇒ no recompile, just new values | R, active_group_slots, active_group_mask |
Recompilation is triggered by: changing MAX_PAIRS / MAX_ACTIVE_GROUPS /
n_atoms, changing dtype (f32↔f64), or a Python-level conditional that depends
on array values. Keep all of those fixed for a run and you compile exactly once.
Term-author checklist
A new EnergyTerm stays jit-friendly if its jax_energy_fn: (1) takes R
plus fixed-shape kwargs, (2) closes over everything static in make(),
(3) uses mask multiplies instead of dynamic slicing, and (4) guards every
sqrt / division against masked-out zeros.
7. Performance & scaling¶
vdw_core is a dense block¶
Its cost is proportional to
cost(vdw_core) ∝ n_core_atoms × n_active_groups × atoms_per_group
i.e. every core atom against every active solvent atom. This is fine when
n_active_groups is the cutoff shell (a few hundred waters), and quadratic
trouble if you accidentally pass all waters (active_group_slots=None) for a
big box.
flowchart LR
subgraph Good["Bounded (recommended)"]
G["MAX_ACTIVE_GROUPS = cutoff-shell size<br/>≈ const · cutoff³ · density"]
end
subgraph Bad["Unbounded"]
Bd["active_group_slots=None<br/>→ n_core × N_solvent (grows with box)"]
end
Good -->|"O(n_core · shell)"| Scale["scales with system size ~linearly"]
Bad -->|"O(n_core · N)"| Blow["super-linear; recompiles if N changes"]
Recommendations¶
These are the accepted decisions; the sizing/overflow/dtype pieces are
implemented in mmml/md/energy/capacity.py.
- Size
MAX_ACTIVE_GROUPSto the cutoff shell, not the box (accepted). Pick it from(4/3)π r_c³ · ρ_solventplus headroom, and rebuild the active list from the neighbor list each step. This keepsvdw_core(and the ML dimer term) scaling ~linearly with system size. Usemmml.md.energy.shell_capacity(cutoff_A, number_density_per_A3, headroom=1.5). - Set capacities with headroom and check for overflow (accepted). If real
pairs exceed
MAX_PAIRS, or active groups exceed capacity, you silently drop interactions.mmml.md.energy.check_capacity(n_required, capacity, name)raisesCapacityOverflow(or warns) instead;pad_indices(indices, capacity)pads and guards in one call. Grow capacity (accept one recompile) rather than truncate. - Rebuild the neighbor list every N steps, not every step (accepted;
driver-level). A Verlet skin lets you reuse
(pi, pj, mask)and the active slots across several steps; rebuild cost (get_intermolecular_pairs) is host-side and not jitted.JaxmdDriveris implemented for minimization, NVE, NVT, and NPT.[evidence: jaxmd_driver_surface] - Prefer masking over resizing (accepted). Growing an array forces recompilation and a pipeline stall; a mask reuses the cached graph.
- Dtype policy: f64 for all float math; low precision only for indices and
masks (decided). Keep every distance, energy, force, and PME-like sum in
float64 — the repulsive
(σ/r)^12branch is stiff and f32 risks energy drift, so we do not trade float precision for speed. The precision we do drop is on the non-float arrays:int32indices (addresses ~2.1e9 atoms — vastly more than any system) andint80/1 masks. The mask is stored int8 (small host→device transfer) and cast toCOMPUTE_DTYPEat the multiply site, so it is numerically identical to a float mask (verified intests/unit/test_md_capacity.py). These are theINDEX_DTYPE/MASK_DTYPE/COMPUTE_DTYPEconstants incapacity.py. vdw_coreprovides no ASE face on purpose (accepted). It is a padded-slot jax term; the ASECalculatorpath would need a different (dense, numpy) formulation. For ASE runs, rely on CHARMM's own nonbonded instead.
How the two modes scale differently¶
- Mode A (jax-md): one compiled graph, all terms on device; cost dominated
by the dense ML dimer +
vdw_coreblocks → keep active-group capacity tight. - Mode B (PyCHARMM): CHARMM's neighbor list and MM are already O(N); the ML
callback cost is set by
Nmlp(number of ML pairs handed over each step). The same "bound the ML region" advice applies, just enforced by CHARMM's pair list instead of your padding.
8. One-line summary¶
n_core_atoms and group_indices are the PSF atom-ordering (solute first,
solvent molecules after); lj_epsilon / lj_rmin_half are CHARMM's NONBONDED
tables (get_epsilon / get_vdwr, ε and Rmin/2); active_group_slots /
active_group_mask are the static-shape padding that lets the dynamic set of
nearby solvent molecules run inside a single compile-once jax graph. vdw_core
itself is the repulsive safety rail that replaces the MM nonbonded for
ML-handled core–solvent pairs.