QM/MM — Region Partitioning, Embedding, Forces¶
Everything QM/MM in this library, in one place (issue #283): real
region partitioning around a reactive bond, real coordinate slicing, a
Diffuse2Seg-derived relevance propagation, and the real Hellmann-Feynman
forces + Velocity-Verlet MD this repo already had (moved here from
dashboard_core.qmmm, which still re-exports them for compatibility).
Region partitioning and propagation need the qmmm extra
(pip install dense-evolution[qmmm], installs RDKit); forces/MD need no
extra dependency beyond what dashboard_core already requires.
from rdkit import Chem
from dense_evolution.qmmm import partition_qm_mm_region, sliced_geometry
mol = Chem.AddHs(Chem.MolFromSmiles("OCCCCCC")) # 1-hexanol
qm_atoms, boundary_pairs = partition_qm_mm_region(mol, {0, 1}, radius=1)
print(sorted(qm_atoms)) # [0, 1, 2, 7, 8, 9, 10, 11]
print(boundary_pairs) # [(2, 3)] -- one bond crosses the boundary
partition_qm_mm_region walks outward from the reactive bond's atoms in
heavy-atom hops. Hydrogens always follow their own heavy atom (never
independently walked -- that spuriously cuts a terminal C-H bond and
leaves an empty MM fragment). A boundary bond that would cut into an
aromatic ring pulls the whole ring into the QM region first: cutting a
lone aromatic atom out of its ring and capping it with hydrogen is not a
valid molecule.
import numpy as np
atoms, geom_bohr = sliced_geometry(atomic_numbers, geom_bohr, qm_atoms, boundary_pairs)
sliced_geometry takes a coordinate SUBSET of one whole-molecule
conformer -- never an independently re-embedded fragment, which gives
unrelated 3D structures across fragments and was the real cause behind
an MMFF94 mechanical correction that looked like it helped but turned
out to be compensating for that geometry choice instead of truncation
itself (see Dense-Evolution-Discovery's
docs/qmmm_region_partitioning_mmff_correction.md for the full
retraction).
from dense_evolution.qmmm import propagate_relevance
relevance = propagate_relevance(bond_order_matrix, seed_idx=[0, 1], n_nodes=n_atoms)
region = {i for i in range(n_atoms) if relevance[i] > threshold}
propagate_relevance is Algorithm 1 of Hümmer, Sicking, Hüger &
Gottschalk 2026 ("Diffuse2Seg", arXiv:2609.06491) -- non-linear
p-Laplacian graph propagation, solved by Gauss-Jacobi iteration,
originally built to spread point prompts through a diffusion model's
self-attention for image segmentation. Here "affinity" can be any
node-graph weight, e.g. real Mayer/Wiberg bond order between atoms
instead of self-attention between image patches. Measured, not
assumed: on a real branched-aromatic molecule, this does NOT reproduce
a naive "stronger bond propagates further" result at the paper's own
calibrated hyperparameters (tuned for a dense multi-prompt image
pipeline, not a single molecular seed) -- see
Dense-Evolution-Discovery's docs/qmmm_utils.md for the full lambda
sweep. Included here as a real, correctly-implemented primitive; not a
proven QM/MM region-selection win yet.
from ase import Atoms
from dense_evolution.qmmm.ase_bridge import DenseEvolutionCalculator
h2 = Atoms("H2", positions=[[0, 0, 0], [0, 0, 0.7414]])
h2.calc = DenseEvolutionCalculator(atomic_numbers=[1, 1], nuclear_charges=[1.0, 1.0],
n_electrons=2, basis_name="sto-3g")
print(h2.get_potential_energy()) # -30.39 eV
DenseEvolutionCalculator (issue #288, needs the ase extra:
pip install dense-evolution[ase]) is an ASE Calculator backed by
native_hf's own differentiable energy (build_energy_fn) -- real
Obara-Saika integrals and SCF, not a stub, for interop with ASE's
optimizers/MD drivers and other engines' Atoms representations.
h2.calc = DenseEvolutionCalculator(atomic_numbers=[1, 1], nuclear_charges=[1.0, 1.0],
n_electrons=2, basis_name="6-31g*")
print(h2.get_potential_energy()) # -30.66 eV
basis_name is a plain string -- "sto-3g", "6-31g", "6-31g*",
anything basis_set_exchange has data for -- passed straight through to
native_hf, which already supported arbitrary bases before this bridge
existed (nothing here adds new basis-set capability; this is purely the
ASE-interop layer). Swapping the basis on the SAME geometry is the whole
point: 6-31G gives a lower (better, more variational freedom) energy
than STO-3G for real hydrogen, exactly as physics requires. 6-31G and
6-31G* give the IDENTICAL energy for H2 specifically -- not a bug: *
adds polarization d-functions to heavy atoms only, and hydrogen has none
to add here.
Only energy is implemented (implemented_properties = ["energy"]) --
native_hf's own forces come from a separate, already-real
implementation, compute_hellmann_feynman_forces above, with its own
finite-difference derivative and its own calling convention (a molecule
name from MOLECULE_CATALOG, not a bare ASE Atoms object). This
bridge does not wrap that here, so ASE's gradient-based optimizers
(BFGS, FIRE, ...) cannot be driven by it yet -- only single-point
energies at any geometry/basis you construct directly.
qmmm ¶
compute_hellmann_feynman_forces ¶
compute_hellmann_feynman_forces(
name: str,
statevector=None,
mapping: str = "jordan_wigner",
geometry=None,
fd_step_angstrom: float = 0.001,
)
Real Hellmann-Feynman forces (Hartree/Angstrom) on every nucleus of
MOLECULE_CATALOG[name]: F = -d
Cost (prog.txt, dashboard_core audit point 4a): the central-difference derivative evaluates energy_at 6n_atoms+1 times (H2's 2 atoms -> 13 Hamiltonian builds per call, each at a genuinely different geometry). This looks like it should be cacheable -- build_molecular_hamiltonian already caches by exact geometry -- but it isn't in practice: every one of the 13 geometries differs by fd_step_angstrom, so every call is a cache miss. Measured directly on H2 (dhf/PennyLane path) before deciding not to add a "cache the Pauli-term basis" layer here: HF + fermion-to-qubit mapping took 0.130s, Pauli-term extraction 0.0005s, dense matrix assembly 0.0031s -- the Hartree-Fock solve itself is 96%+ of the cost, not the bookkeeping after it, so caching the Pauli-term structure would save a few percent at best, not the 6n_atoms multiplier prog.txt's framing suggests. A real geometry change requires a real HF re-solve regardless of how its output gets packaged afterward -- see the module docstring's own account of why analytic differentiation (which WOULD avoid re-solving HF this many times) was tried and dropped for a real cross-platform PennyLane/ autograd bug, not reattempted here.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
name
|
str
|
A key from |
required |
statevector
|
see description above.
|
|
None
|
mapping
|
see description above.
|
|
None
|
geometry
|
see description above.
|
|
None
|
fd_step_angstrom
|
see description above.
|
|
None
|
Returns:
| Type | Description |
|---|---|
dict
|
|
Examples:
>>> from dense_evolution.qmmm import compute_hellmann_feynman_forces
>>> from dashboard_core.hamiltonians import MOLECULE_CATALOG
>>> h2 = [k for k in MOLECULE_CATALOG if k.startswith("H2 ")][0]
>>> result = compute_hellmann_feynman_forces(h2)
>>> round(result['energy_hartree'], 4)
-1.1373
>>> 0.01 < result['force_norm'] < 0.02 # small residual at equilibrium, not exactly zero
True
Source code in dense_evolution/qmmm/forces.py
172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 | |
md_step ¶
md_step(
positions_angstrom,
velocities_angstrom_per_fs,
forces_hartree_per_angstrom,
symbols,
dt_fs: float = 0.5,
)
One real Velocity-Verlet half-step (v(t+dt/2) = v(t) + a(t)dt/2, r(t+dt) = r(t) + v(t+dt/2)dt) using the real Hellmann-Feynman forces above and each atom's real atomic mass -- ordinary classical Newtonian mechanics (F=ma), nothing invented. Positions in Angstrom, velocities in Angstrom/fs, forces in Hartree/Angstrom, dt in femtoseconds.
Examples:
>>> import numpy as np
>>> from dense_evolution.qmmm import md_step
>>> positions = np.array([[0, 0, 0.0], [0, 0, 0.7414]]) # H2 at equilibrium
>>> velocities = np.zeros_like(positions)
>>> forces = np.array([[0, 0, 0.0109], [0, 0, -0.0109]]) # restoring force, pulling atoms together
>>> new_pos, new_vel, accel = md_step(positions, velocities, forces, ['H', 'H'], dt_fs=0.5)
>>> bool(new_pos[1, 2] < 0.7414) # the second atom moved toward the first
True
Source code in dense_evolution/qmmm/forces.py
run_md_trajectory ¶
run_md_trajectory(
name: str,
n_steps: int,
dt_fs: float = 0.5,
mapping: str = "jordan_wigner",
recompute_electronic_state: bool = False,
fd_step_angstrom: float = 0.001,
)
Real, minimal ab-initio-forces MD trajectory: at each step, real Hellmann-Feynman forces (compute_hellmann_feynman_forces) move the real nuclear positions/velocities via real Velocity-Verlet (md_step). Starts from rest (zero initial velocities) at the catalog's real equilibrium geometry.
fd_step_angstrom: forwarded to compute_hellmann_feynman_forces at every step -- previously not exposed here at all, silently using that function's own default (0.001 A) with no way for a caller to ask for a different finite-difference step (e.g. a molecule with an unusually steep energy landscape, where the default step size isn't the one already verified converged for H2).
recompute_electronic_state=False (default) holds the electronic state fixed at the initial Hartree-Fock reference through the whole trajectory -- forces stay exact only close to the starting geometry (a real, explicitly-stated approximation, not a fabricated one). True ab-initio MD (re-solving Hartree-Fock at every step's new geometry) is available by setting this True, at real, substantial extra cost per step.
Examples:
>>> from dense_evolution.qmmm import run_md_trajectory
>>> from dashboard_core.hamiltonians import MOLECULE_CATALOG
>>> h2 = [k for k in MOLECULE_CATALOG if k.startswith("H2 ")][0]
>>> traj = run_md_trajectory(h2, n_steps=3, dt_fs=0.5)
>>> traj['step']
[0, 1, 2]
>>> len(traj['force_norm'])
3
Source code in dense_evolution/qmmm/forces.py
partition_qm_mm_region ¶
BFS outward from seed_heavy_atoms (a set/list of atom indices)
across radius heavy-atom hops. Hydrogens always follow their own
heavy atom afterward (never independently expanded -- see module
docstring). Any boundary bond that would cut into an aromatic ring
pulls that whole ring into the QM region first, iterating until
stable, before hydrogens are added and the boundary is finalized.
Returns (qm_atoms: set[int], boundary_pairs: list[(kept_idx, cut_idx)]),
one pair per bond crossing the QM/MM boundary, kept_idx on the QM
side.
Examples:
>>> from rdkit import Chem
>>> from dense_evolution.qmmm import partition_qm_mm_region
>>> mol = Chem.AddHs(Chem.MolFromSmiles("OCCCCCC"))
>>> qm_atoms, boundary_pairs = partition_qm_mm_region(mol, {0, 1}, radius=1)
>>> len(boundary_pairs)
1
Source code in dense_evolution/qmmm/region.py
sliced_geometry ¶
A coordinate SUBSET of one whole-molecule conformer (never an independently re-embedded fragment -- that would give unrelated 3D structures across fragments, and was the real cause of the MMFF94 correction's apparent benefit turning out to be a geometry artifact, see module docstring). Boundary bonds get a capping H placed along the kept->cut bond direction at a standard C-H bond length.
Examples:
>>> import numpy as np
>>> from dense_evolution.qmmm import sliced_geometry
>>> numbers = [8, 6, 6]
>>> geom = np.array([[0.0, 0.0, 0.0], [1.4, 0.0, 0.0], [2.8, 0.0, 0.0]])
>>> new_numbers, new_geom = sliced_geometry(numbers, geom, {0, 1}, [(1, 2)])
>>> new_numbers
[8, 6, 1]
Source code in dense_evolution/qmmm/region.py
propagate_relevance ¶
propagate_relevance(
affinity,
seed_idx,
n_nodes,
p=1.6,
lam=1e-05,
tau_prop=0.0001,
max_iter=500,
)
Algorithm 1 of Hummer, Sicking, Huger & Gottschalk 2026 (arXiv:2609.06491, "Diffuse2Seg", Sec. 3.4/A.1), read directly from the paper and implemented verbatim.
Non-linear p-Laplacian graph-regularized smoothing (Elmoataz et al.
2008), solved by Gauss-Jacobi iteration: propagates a one-hot seed
vector over any node-affinity graph affinity (self-attention in the
original paper; Mayer/Wiberg bond order in Dense-Evolution-Discovery's
QM/MM experiments) into a soft relevance map that stays smooth within
high-affinity regions and is throttled across low-affinity (edge)
ones. p, lam, tau_prop are the paper's own final values
(Sec. 4.2).
Measured, not assumed (Dense-Evolution-Discovery's
qmmm_diffuse2seg_propagation_lambda_sweep.py, on the real Mayer
bond-order graph of a branched-aromatic molecule,
OCC(c1ccccc1)CCC): at the paper's own lam=1e-5 -- tuned for a dense
grid of prompts later merged together, not a single isolated seed --
a stronger real bond (aromatic ring, bond order 1.412) actually
propagates LESS relevance than a weaker one (alkyl chain, bond order
0.991), the opposite of the naive expectation. The ratio crosses 1.0
only around lam~0.5-1, and only reaches a large expected-direction
differentiation (ratio 1.49) at lam=10, two orders of magnitude above
the paper's own calibrated value. This is reported as the real,
measured lam-dependence of this algorithm in a single-seed molecular
setting, distinct from the many-prompt image setting it was designed
and calibrated for -- lam is not silently retuned to whatever value
looks best.
Degenerate-case handling: at any node i where g_i = sqrt(sum_j A_ij(f_j-f_i)^2) is exactly 0 (every affinity-neighbor already equals f_i), the formula's g_i^(p-2) term diverges for p<2. An earlier version clamped g_i away from 0 with an epsilon -- the same category of shortcut already rejected elsewhere in this project for degenerate eigenvalues (see dense_evolution.physics.spectral, Kato's divided- difference formula) in favor of the real mathematical limit. Worked out directly here: as g_i -> 0, every A_ij-connected f_j equals f_i by definition of g_i=0, so the g_i^(p-2)-weighted terms in both the numerator and denominator of the update come to dominate and cancel to exactly f_i -- i.e. a node already consistent with its whole affinity-neighborhood is unchanged by an edge-preserving smoothing step, exactly as expected. Implemented as an explicit special case below, not an epsilon.
Examples:
>>> import numpy as np
>>> from dense_evolution.qmmm import propagate_relevance
>>> affinity = np.array([[0, 1.0, 0], [1.0, 0, 1.0], [0, 1.0, 0]])
>>> rel = propagate_relevance(affinity, [0], 3, lam=1.0)
>>> bool(rel[0] > rel[1] > rel[2])
True
Source code in dense_evolution/qmmm/propagation.py
See also: dense_evolution.native_hf for the
Hartree-Fock engine these region functions feed into;
dashboard_core.hamiltonians for
MOLECULE_CATALOG and build_molecular_hamiltonian, which
compute_hellmann_feynman_forces/run_md_trajectory are built from.