Extending the force-field IR¶
The force-field IR (it adopts the LAMMPS standard, see
Force-field IR) is a protocol. Its shape is data —
a category (how many atoms a term has, which Frame block holds its terms,
which coordinate its energy is a function of) and a style (its ordered
parameters, each with a dimension, and its energy) — and anything that has
that shape extends it: from Rust, from Python, from molpy, with nothing in
molrs rebuilt. A registered style prices at both compile doors and in MD,
persists in a .mrec record, is written to every engine that can hold it
and refused by name by every engine that cannot, and joins the form
conversions — exactly as a built-in does. The built-ins are themselves
registrations of the same form, sealed.
The form — categories, styles, parameters, refusals — is molrs::ff::ir
(Rust) and molrs.ff.ir (Python); registering into it is
molrs::ff::style_registry and molrs.ff.style_registry; compiling a force
field against it is molrs::ff::compile and molrs.ff.compile. molpy
re-exports the Python modules. The engine files a registered
style is read from and written to are molrs.io's (LammpsForcefieldReader,
OpenmmXmlWriter, write_mrec, …; the same names in Rust's molrs::io), the
one owner of every file reader and writer.
The form¶
Categories¶
pub struct CategorySpec {
pub name: Cow<'static, str>, // ^[a-z][a-z0-9_]*$
pub arity: Arity, // Exact(0..=5) or SelfOrPair (a pair)
pub block: Cow<'static, str>, // the Frame block it prices
pub coordinate: Coordinate, // None, Distance, Angle, Dihedral, Improper, Compound
pub order: EndpointOrder, // Reversible, Ordered, Unordered
pub excludes: bool, // always false
}
| name | arity | block | coordinate | order | priced |
|---|---|---|---|---|---|
atom |
0 | atoms |
none | – | no |
bond |
2 | bonds |
r |
reversible | yes |
angle |
3 | angles |
theta |
reversible | yes |
dihedral |
4 | dihedrals |
phi |
reversible | yes |
improper |
4 | impropers |
phi, chi = abs(phi) |
ordered | yes |
pair |
self or pair | atoms (by type) |
r |
unordered | yes: a pairs list or a neighbour search |
cmap |
5 | cmaps |
compound | ordered (φ then ψ) | yes |
drude |
2 | drudes |
r |
ordered | yes, drude harmonic by its expression |
constraint |
2 | constraints |
none | reversible | no |
virtual_site |
0 | virtual_sites |
none | – | no |
A custom category has 2 to 5 atoms, its block is exactly <name>s (so
a record alone locates it), and its coordinate is Compound — a function of
the atoms' positions — unless it asks for the geometric variable its arity
has (Distance for 2, Angle for 3, Dihedral / Improper for 4).
CategorySpec::custom(name, arity, coordinate, order) builds one; anything
else is refused (BadName, Arity, BlockName). The only special case the
compiler keys on a category is SelfOrPair (neighbour-driven pricing); it
gates on CategorySpec.block, never on a name.
Styles and parameters¶
pub struct StyleSpec {
pub category, name,
pub params: Vec<ParamSpec>, // ordered: LAMMPS's *_coeff order where LAMMPS has the style
pub style_params: Vec<ParamSpec>, // cutoff, mixing, special keep their reserved meanings
pub source: ParamSource, // TypeRows or PerInstance (Frame columns)
pub special: Option<SpecialClass>, // a pair style's special-bonds weights (default lj)
pub expression: Option<String>, // the energy, a Lepton expression, kept byte for byte
pub force_is_gradient: bool, // false only for coul/charmm
pub unindexed_one_term: bool,
pub lammps: LammpsForm, // None, Positional, Custom(codec)
pub samples: Vec<ConformanceSample>, // registration check points
}
pub struct ParamSpec { name, dim: ParamDimension, kind: ParamKind, default: Option<ParamValue>, mix: ParamCombination, indexed: bool }
ParamDimensionis a dimension in five exponents, energyE, lengthL, angleA, chargeQ, massM, writtennum ("/" factor)*—1,E,E/L^2,1/L,E*L^6,E*L/Q^2,A,E/A^2,M.Aalone is an angle value, stored in degrees and never converted; a negative angle exponent is per radian; any other positive angle exponent is refused. A unit conversion multiplies a value byf_E^e · f_L^l · f_Q^q · f_M^m.- Names are Lepton identifiers. Reserved:
name,type,style, the endpoint columns (itom…mtom,atomi…atomm), the annotation columns (class,element,smarts,smirks,overrides,desc,doi), the geometric variablesr theta phi chi, the pointsp1…p5, and in a pair styleq,q1,q2andx1/x2for another parameterx.cutoff(L),mixing(arithmetic | geometric | sixthpower) andspecial(lj | coul) are style parameters with fixed meanings. - Kinds:
Scalar(one number per row),Array { rank }(anf64[T, S…]column, priced by a Tier-2 or Tier-3 kernel, never an expression variable),Text { choices }.indexedmakes a familyk1 … kM, contiguous, oneMper row shared by the style's indexed parameters. - Combining rules (pair styles): a pair's value is the cross row's, else the
ParamCombinationof the two self rows —Arithmetic,Geometric, or the joint (ε, σ) rule the style'smixingnames (LjEpsilon/LjSigma);ParamCombination::Nonemakes an unlike pair without a cross rowNoMixing. - Defaults are applied in one place (
StyleSpec::gather) before any kernel sees a parameter; a missing one isMissingParam, a value of the wrong kind or outside its choicesBadValue.
Kernels: three tiers¶
| Tier | Rust | Python | What it is |
|---|---|---|---|
| 1 | Kernel::Expression, or register_style(spec, None) with spec.expression |
expression= |
the expression, compiled with exact (dual-number) derivatives |
| 2 | Kernel::Scalar(Arc<dyn ScalarForm>), Kernel::Compound(Arc<dyn CompoundForm>) |
kernel= (a numpy callable; compound=True for positions) |
a batch function of the coordinate, or of the atoms' positions |
| 3 | Kernel::Constructor { compiled, typed, rows } |
– | a constructor that builds the whole kernel (every native built-in) |
The kernel contract, every tier:
qper term is the category's coordinate:rin the field's length unit,theta∈ [0, π],phi∈ (−π, π] (IUPAC sign, atoms in row order) — radians. A compound form getsx,n_terms × aritypositions, minimum-imaged relative to each term's first atom.- Parameters arrive exactly as stored: IR units, angle values in
degrees. A Tier-2 form reads each per-type parameter as an
n_termscolumn (ParamColumns::get), arrays with a leadingn_termsaxis, text per term, numeric style parameters broadcast to a column; a pair form reads the resolved pair value of each parameter (cross row, else its mixing rule), andq1,q2when the frame hasatoms.charge. - A form writes the unweighted energy of each term and its
derivative
de_dq(a compound form:∂E/∂x, not the force). The generic kernels (ff::potential::form_kernel:ScalarBonded,ScalarPair,CompoundTerms) apply the pair special-bonds weight,r < cutoffat both compile doors (the style'scutoff, ∞ when it states none — LAMMPS truncates every pair style, 1-4 pairs included, and so does every built-in; a form is never called past the cutoff), the chain rule onto Cartesian forces and the virial.
LAMMPS's pair_style lj/smooth/linear as a Tier-2 form, from
molrs-ext-example (a crate outside molrs, its pub API only):
pub struct LjSmoothLinear;
impl ScalarForm for LjSmoothLinear {
fn eval(&self, r: &[f64], p: &ParamColumns<'_>, e: &mut [f64], de_dr: &mut [f64]) {
let (eps, sigma, rc) = (col(p, "epsilon"), col(p, "sigma"), col(p, "cutoff"));
for t in 0..r.len() {
let lj = |x: f64| {
let s6 = (sigma[t] / x).powi(6);
(4.0 * eps[t] * (s6 * s6 - s6), 24.0 * eps[t] * (s6 - 2.0 * s6 * s6) / x)
};
let ((u, du), (uc, duc)) = (lj(r[t]), lj(rc[t]));
e[t] = u - uc - (r[t] - rc[t]) * duc;
de_dr[t] = du - duc;
}
}
fn inputs(&self) -> Vec<String> {
vec!["epsilon".into(), "sigma".into(), "cutoff".into()]
}
}
let spec = StyleSpec::new("pair", "lj/smooth/linear")
.params(vec![
ParamSpec::new("epsilon", ParamDimension::ENERGY).mix(ParamCombination::LjEpsilon { sigma: "sigma".into() }),
ParamSpec::new("sigma", ParamDimension::LENGTH).mix(ParamCombination::LjSigma { epsilon: "epsilon".into() }),
])
.style_params(vec![ParamSpec::new("cutoff", ParamDimension::LENGTH)])
.special(SpecialClass::Vdw)
.lammps(LammpsForm::positional());
registry.register_style(spec, Some(Kernel::Scalar(Arc::new(LjSmoothLinear))))?;
The same contract from Python — one call per style per evaluation, every term at once:
import numpy as np
from molrs.ff import ir, style_registry
def fene(r, k, r0, epsilon, sigma):
x = (r / r0) ** 2
s6 = (sigma / r) ** 6
inner = r < 2 ** (1 / 6) * sigma
e = -0.5 * k * r0**2 * np.log(1 - x) + np.where(inner, 4 * epsilon * (s6 * s6 - s6) + epsilon, 0.0)
de = k * r / (1 - x) + np.where(inner, 4 * epsilon * (-12 * s6 * s6 + 6 * s6) / r, 0.0)
return e, de
style_registry.register_style("bond", "fene/np",
params={"k": "E/L^2", "r0": "L", "epsilon": "E", "sigma": "L"},
kernel=fene)
A kernel of the wrong shape or dtype, or one that raises, is KernelShape
(the Python exception chained as __cause__), at compile or at the
evaluation that met it.
PotentialCompiler::with_registry(ff, ®istry) compiles against a
registry of one's own; PotentialCompiler::new reads the process-wide one
(molrs::ff::style_registry::register_style, Python style_registry.register_style).
Without a force field, molrs.ff.compile.ExplicitTerms(category, style, atoms, *,
charges=None, **params).compile() builds the kernel of any style the IR prices — a
built-in, a registered one, a custom category's — over explicit terms: an
(n, arity) array of atom indices and each parameter as stored, one number
or one value per term. It is built exactly as PotentialCompiler.compile
builds it and returns a Potentials; there is no per-style Python class.
New categories¶
registry.register_category(CategorySpec::custom(
"urey_bradley", 3, Coordinate::Compound, EndpointOrder::Reversible,
))?;
registry.register_style(
StyleSpec::new("urey_bradley", "harmonic").params(vec![
ParamSpec::new("k_ub", "E/L^2".parse()?),
ParamSpec::new("r_ub", ParamDimension::LENGTH),
]),
Some(Kernel::Compound(Arc::new(UreyBradley))),
)?;
style_registry.register_category("urey_bradley", 3) # block "urey_bradleys", coordinate compound
style_registry.register_style("urey_bradley", "spring", params={"k_ub": "E/L^2", "r_ub": "L"},
expression="k_ub*(distance(p1,p3)-r_ub)^2")
ff.def_style("urey_bradley", "spring").def_type("A-B-A", a, b, a, k_ub=20.0, r_ub=2.4)
ForceField.def_style on a custom category returns a RelationStyle whose
def_type(name, *endpoints, **params) takes exactly the category's arity
(Arity otherwise); the terms are the rows of the Frame block <name>s. A
typifier's TypeAssignment(links={kind: rows}) fills any registered relation kind
(by MolGraph kind name or class); assign_terms matches endpoints by the
category's EndpointOrder and molrec's wildcard rule.
A category nobody registered — one a record brought in — behaves as
CategorySpec::custom(name, arity of its endpoint columns, Compound,
Reversible): kept always, priced when its style has an expression,
NoKernel otherwise.
Expressions¶
The grammar, the functions, the definitions and the variable binding are
molrec's (docs/spec/forcefield.md, Expressions),
a Lepton subset:
+ - * / ^, parentheses, numbers;^binds tighter than unary minus (-a^2 = -(a^2)) and is right-associative;exp log sqrt sin cos tan asin acos atan abs,min max,step(x ≥ 0 → 1),delta(x = 0 → 1),select(x, y, z)(x ≠ 0 → y); no named constants.- Definitions follow the energy,
; name=formula, each using only names to its right. - Point functions
distance(pa, pb),angle(pa, pb, pc),dihedral(pa, pb, pc, pd)overp1…pAin every category with points (notpair). - Variables: bond / drude
r, angletheta, dihedralphi, improperphiandchi, compound categories points only, pairr,q1,q2, a bare parameter (the pair value) andx1/x2(the self rows); every numeric parameter by name, as stored — an angle value in degrees, which the expression converts:theta0*(pi/180), withpidefined as the last sub-definition,; pi=3.141592653589793(Lepton has no named constants; parenthesised,(pi/180)isf64::to_radians's factor to the bit, wheretheta0*pi/180rounds differently). - The expression is the unweighted energy of one term, inside the cutoff
(the form pair kernel truncates at
r < cutoff; a shift or switch to zero there is the expression's own, aslj/cut'sshiftand the CHARMM switch are); a pair expression must be symmetric under exchanging the atoms.
Derivatives are exact (forward-mode duals: one for q, 3·arity for the
points). A style with no registered kernel whose instance carries an
expression is priced by it (the compile fallback); a registered kernel
takes priority, and an instance expression that differs from the
registry's is checked for agreement with it at first compile.
Persistence¶
A custom style persists as its molrec style entry: its style parameters, its
expression (the instance's, else the registry's: ForceFieldSection.from_forcefield writes the
registry expression of a registered custom style, so a process that
registered nothing can price it), and its table (array columns included,
f64[T, S…]). A category beyond the built-ins is kept with the arity of
its endpoint columns. Reading never evaluates an expression: a style with
no expression reads whole, and compiling it is NoKernel — "no kernel for
<category> `<style>`: register it (molrs.ff.style_registry.register_style) or
give it an expression". Parameter dimensions are not persisted: a style read
from a record has no ParamDimensions until it is registered again.
Engines and refusals¶
- LAMMPS.
LammpsForm::Positionalis derived from the spec:<category>_style <name>,<category>_coeff <type> v₁ … vₙinparamsorder, each value converted by itsParamDimension,mixingaspair_modify mix. It refuses, at registration, a Text, Array or indexed parameter, another style parameter thancutoff/mixing, and a category without a LAMMPS*_style.LammpsForm::Custom(codec)writes a line that is not positional. An expression-only style is refused (the installed LAMMPS has noLEPTONpackage); give it a form withregister_engine_form. - OpenMM XML. An expression style is written as its category's
Custom*Force(CustomBondForce,CustomAngleForce,CustomTorsionForce, a<Script>-builtCustomCompoundBondForce,CustomNonbondedForce), parameters in IR units and the expression rewritten exactly into OpenMM's units. - GROMACS, AMBER prmtop and frcmod hold the built-in styles only.
Every engine refusal is IrError::NoEngineForm { engine, category, style,
reason }; a writer returns it typed (ForceFieldWriteError::ir() in Rust, the
molrs.ff.ir.NoEngineFormError class in Python). See
Engine codecs.
Conversions¶
A style of a form family registers a FormCodec — its family, whether it
is the family's canonical style, and two exact maps, embed (this style →
the canonical style's parameters) and project (canonical → this style,
exact on its image, else a FormRefusal naming the condition):
ForceField::canonical() maps every style of a family in the canonical
style's category onto it; to_form(category, style) converts within a
category through the canonical parameters, exactly or OutOfImage;
fit_form(category, style, metric) is the least-squares projection with a
residual, which needs only each style's energy and so works for an
expression, a Python and a native style alike. A family with no canonical
style (or two) is FormConflict; a style without a codec NoForm. See
Converting between forms.
Conformance¶
A registration is checked before it lands; nothing is left behind by a
refusal. Each refusal is one IrError variant (Python: a subclass of
molrs.ff.ir.IrError, itself a ValueError, named after the variant with
an Error suffix: Sealed is SealedError, Dimension is
DimensionError) naming the offending item.
| Variant | When |
|---|---|
UnknownCategory |
a style in a category neither built in nor registered |
BadName |
a category or parameter name outside its pattern |
Arity |
a custom arity outside 2..=5; a type with the wrong number of endpoints |
BlockName |
a custom category whose block is not <name>s |
ReservedParam, DuplicateParam |
a reserved or repeated parameter name |
Dimension |
an unparsable or forbidden dimension |
Parse, UnknownFunction, FunctionArity |
an expression that does not parse |
UnboundVariable |
a free name that is neither a variable of the category nor a numeric parameter (theta in a bond) |
Point |
pk beyond the arity, or any point in a pair |
CoordinateMismatch |
a scalar form on a compound category, a compound form on a pair |
Derivative |
a Tier-2 derivative against a central difference of its own energy, beyond 1e-6 |
Disagree |
an expression against the kernel beside it, beyond 1e-10 |
Asymmetric |
a pair energy that changes when its atoms are exchanged (1e-12) |
Sealed |
re-registering or unregistering a built-in |
Conflict |
a different registration under a taken name (an identical one is a no-op; Python replace=True overrides a custom one) |
NoKernel |
nothing can price the style |
NoMixing |
an unlike pair, a parameter that does not mix, no cross row |
MissingParam, BadValue |
a value a kernel needs and no default; a value of the wrong kind or outside its choices |
KernelShape |
a Python kernel's output of the wrong shape or dtype, or a kernel that raised |
NoEngineForm |
an engine that cannot hold the style |
FormConflict, NoForm |
a form family without exactly one canonical style; a style without a codec |
OutOfImage |
an exact form conversion (to_form) of a row outside the target style's image; a fit_form that cannot evaluate the category |
Malformed |
a spec whose declarations contradict each other (a mixing rule on a bonded parameter, an ε without its σ) |
The derivative and agreement checks run at registration on the style's
samples (16 seeded points each); a style registered without samples is
checked once per process at its first compile, on up to 8 real terms.
Relative errors are measured against max(|value|, RMS over the sample
set). The checks never run per evaluation.
From molpy¶
molpy keeps no IR of its own: molpy.ff.style_registry re-exports
molrs.ff.style_registry, so molpy.ff.style_registry.StyleDeclaration is
molrs.ff.style_registry.StyleDeclaration, and a style declared from either
package is the same registration. molpy's own guide
(docs/developer/extending-forcefield.md, "A new style in 30 lines") declares
bond fene by its expression, types a bead-spring chain with it and prices
it, and its tests/test_ff/test_user_style.py runs that snippet as written.
How this is checked¶
The Rust proof — molrs-ext-example/, a standalone crate depending on
molrs as any user's crate does (pub API only; scripts/check.sh ext, a
pre-push hook and a CI job). It adds pair lj/smooth/linear (a Tier-2
ScalarForm), a category urey_bradley with a native CompoundForm,
bond fene by its expression and class2's bond-angle term as a category
bond_angle. The LAMMPS numbers are pinned by
scripts/ff_ir_extension_lammps_check.sh --pin (LAMMPS run 0 on decks
molrs's own LAMMPS writer wrote from the specs; %.17g energy and forces;
real units, two configurations each):
| Test | Criterion | Measured worst |
|---|---|---|
pair_style_matches_lammps |
E and every force component = LAMMPS lj/smooth/linear (cutoff 5 Å straddling the 15 pairs: ten inside, five beyond), rel ≤ 1e-10; compile_typed = compile, rel ≤ 1e-12 |
1.7·10⁻¹⁵; doors bit for bit |
new_category_matches_lammps |
urey_bradley = LAMMPS angle_style charmm with K = 0, rel ≤ 1e-10 |
bit for bit |
fene_matches_lammps |
bond fene by expression = LAMMPS bond_style fene, rel ≤ 1e-10, (r/R0)² < 0.9 |
6.8·10⁻¹⁶ |
mrec_round_trip |
.mrec write/read prices bit for bit; with Registry::builtin() only, fene prices bit for bit, bond_angle by its expression (≤ 1e-10 of its native form), urey_bradley and lj/smooth/linear are NoKernel naming them |
as stated |
nonconforming_refused |
every protocol refusal reachable from Rust (registration, compile, writers, forms), by variant and named item | 25 variants (KernelShape: Python) |
bond_angle_cross_term |
hand value −π/60, central differences, expression = native form, rel ≤ 1e-12 | 5.8·10⁻¹⁵ |
The same script prices bond_angle against angle_style class2 (every
cross term but ba at zero) when MOLRS_LMP_CLASS2 names a LAMMPS built
with CLASS2 (scripts/build_lammps_class2.sh): compared, not pinned, worst
rel 2.2·10⁻¹⁶.
The Python proof — molrs-python/tests/test_ff_ir_extension.py, LAMMPS
numbers pinned in ff_ir_extension_lammps.tsv by the same script:
| Row | Criterion | Measured worst |
|---|---|---|
fene by expression = the analytic formula |
rel ≤ 1e-12 | 2.1·10⁻¹⁶ |
| a numpy kernel = the expression | E rel ≤ 1e-12, F rel ≤ 1e-10 | 1.1·10⁻¹⁶, 2.1·10⁻¹⁶ |
both = LAMMPS bond_style fene (deck by LammpsForcefieldWriter, lj units) |
rel ≤ 1e-10 | 7.0·10⁻¹⁶ (expression), 5.2·10⁻¹⁶ (numpy) |
urey_bradley from Python = LAMMPS (angle_style charmm, K = 0) |
rel ≤ 1e-10 | 1.6·10⁻¹⁶ |
pair lj/smooth/linear by expression and by a numpy kernel = LAMMPS, its 5 Å cutoff straddling the pairs; compile_typed (an integrator's first force call) = compile |
rel ≤ 1e-10; doors rel ≤ 1e-12 | 2.5·10⁻¹⁵ (expression), 2.9·10⁻¹⁵ (numpy); doors bit for bit |
.mrec round trip; a subprocess that registered nothing |
bit for bit, expression byte for byte | bit for bit |
| a callable-only style in a fresh process | NoKernel naming the style and molrs.ff.style_registry.register_style |
as stated |
refusals: unknown function, unbound variable, sealed bond harmonic, wrong def_type arity, kernel of the wrong shape, kernel raising, GromacsTopForcefieldWriter.write, missing parameter |
each its IrError subclass naming the item |
as stated |
dihedral table/linear (table: f64[N]) by a numpy kernel = hand linear interpolation; round trip |
rel ≤ 1e-12; bits | 0 |
| class2 bond-angle: expression = numpy = −π/60 (= the Rust form) | rel ≤ 1e-12 | 2.4·10⁻¹⁵ |
molpy — tests/test_ff/test_user_style.py: its guide's snippet, run as written,
energy = the analytic FENE sum to 1.1·10⁻¹⁶ relative, forces to 2.1·10⁻¹⁶ of
the largest, and a fresh process that registered nothing prices the saved
.mrec bit for bit.
The built-ins conform — ff::ir::builtin_conformance:
- Every built-in with an Appendix-A expression prices identically by its
native kernel and by the expression registered as a style of its own, on
64 seeded configurations × parameter sets per style (a pair style at both
compile doors;
dihedral periodicwith 1–3 terms andnharmonicof order 2–5 by their table-generated expressions;dihedral rb, priced by its expression alone, againstmulti/harmonic's native kernel): energy and forces to 1e-10 relative; measured worst 1.5·10⁻¹⁵ (energy) and 3.6·10⁻¹⁵ (forces), over 24 styles. - Every built-in constructor's
ParamSourceis what it reads: 37 constructors; aTypeRowsone prices another row differently and no row not at all, aPerInstanceone prices the same bits with or without a row. - Every positional LAMMPS codec writes the pinned includes
(
ff/testdata/builtin_conformance/) for the LAMMPS-read hand molecule, the five cross-engine sources (ff14sb,gaff2,chamber,charmm36,oplsaa) and one field per positional built-in inrealandmetal: 16 of 22 files byte for byte, the rest to ≤ 2 ulps (the pinned includes were converted through LAMMPS'sljunits by a unit expression,real→realincluded; the codec multiplies by one exact factor per dimension).bond class2,pair buckandpair morsehave no pinned include; LAMMPS prices them inff::engine_codec_check.
The engine codecs of extension styles against LAMMPS and OpenMM (fene,
lj/smooth/linear, a compound urey_bradley as OpenMM's
CustomCompoundBondForce) are Checked against the
engines.