Extending the Crate¶
Tutorials for writing your own AtomRestraint / Region / Handler /
Relaxer types. Every extension trait in this crate follows the same
shape (direction-3 rule — see concepts):
pub trait X+ N concretepub structtypes implementing it. User typesimpl Xidentically. No built-in/plugin type-level distinction.
Custom AtomRestraint¶
Goal: pull atoms toward a target plane with a quadratic attractive well.
Step 1 — define the struct¶
use molrs::types::F;
# use molpack::AtomRestraint;
#[derive(Debug, Clone, Copy)]
pub struct PlaneTether {
pub normal: [F; 3],
pub offset: F,
pub k: F,
}
# impl AtomRestraint for PlaneTether {
# fn f(&self, _x: &[F; 3], _s: F, _s2: F) -> F { 0.0 }
# fn fg(&self, _x: &[F; 3], _s: F, _s2: F, _g: &mut [F; 3]) -> F { 0.0 }
# }
pubfields — users construct withPlaneTether { normal, offset, k }, no builder.Debugrequired becauseAtomRestrainthas aDebugsupertrait bound (soTarget's derivedDebugkeeps working).
Step 2 — implement AtomRestraint¶
# use molrs::types::F;
# use molpack::AtomRestraint;
# #[derive(Debug)]
# pub struct PlaneTether { pub normal: [F; 3], pub offset: F, pub k: F }
impl AtomRestraint for PlaneTether {
fn f(&self, pos: &[F; 3], _scale: F, _scale2: F) -> F {
let d = self.normal[0] * pos[0]
+ self.normal[1] * pos[1]
+ self.normal[2] * pos[2]
- self.offset;
0.5 * self.k * d * d
}
fn fg(&self, pos: &[F; 3], scale: F, scale2: F, g: &mut [F; 3]) -> F {
let d = self.normal[0] * pos[0]
+ self.normal[1] * pos[1]
+ self.normal[2] * pos[2]
- self.offset;
g[0] += self.k * d * self.normal[0];
g[1] += self.k * d * self.normal[1];
g[2] += self.k * d * self.normal[2];
self.f(pos, scale, scale2)
}
}
Three contracts that all restraints must obey:
- Gradient accumulates with
+=. Multiple restraints may touch the same atom. fgreturns the value. The hot path uses the returned value for thefdist/frestaccumulation — don't return0.0just because the caller might discard it.- Scale/scale2 usage is your choice. Linear-penalty restraints
typically use
scale; quadratic-penalty ones usescale2. Your tether uses its ownk— ignore both knobs if you prefer.
Step 3 — write a gradient test¶
# use molrs::types::F;
# use molpack::AtomRestraint;
# #[derive(Debug)] pub struct PlaneTether { pub normal: [F; 3], pub offset: F, pub k: F }
# impl AtomRestraint for PlaneTether {
# fn f(&self, _x: &[F; 3], _s: F, _s2: F) -> F { 0.0 }
# fn fg(&self, _x: &[F; 3], _s: F, _s2: F, _g: &mut [F; 3]) -> F { 0.0 }
# }
#[test]
fn plane_tether_gradient_matches_fd() {
let r = PlaneTether { normal: [0.0, 0.0, 1.0], offset: 5.0, k: 2.0 };
let x = [1.0, 2.0, 7.0];
let mut g = [0.0; 3];
let _ = r.fg(&x, 1.0, 1.0, &mut g);
let h: F = 1e-5;
for k in 0..3 {
let mut xp = x; xp[k] += h;
let mut xm = x; xm[k] -= h;
let fd = (r.f(&xp, 1.0, 1.0) - r.f(&xm, 1.0, 1.0)) / (2.0 * h);
assert!(
(g[k] - fd).abs() < 1e-4,
"axis {k}: analytic={}, fd={}", g[k], fd,
);
}
}
Convention: ε = 1e-5, tolerance 1e-3 (looser if the restraint has
kinks).
Step 4 — use it¶
# use molrs::types::F;
# use molpack::AtomRestraint;
# #[derive(Debug, Clone, Copy)] pub struct PlaneTether { pub normal: [F; 3], pub offset: F, pub k: F }
# impl AtomRestraint for PlaneTether {
# fn f(&self, _x: &[F; 3], _s: F, _s2: F) -> F { 0.0 }
# fn fg(&self, _x: &[F; 3], _s: F, _s2: F, _g: &mut [F; 3]) -> F { 0.0 }
# }
use molpack::{InsideBoxRestraint, Target};
# let (pos, rad) = (&[[0.0; 3]][..], &[1.0][..]);
let target = Target::from_coords(pos, rad, 100)
.with_restraint(InsideBoxRestraint::new([0.0; 3], [40.0; 3], [false; 3]))
.with_restraint(PlaneTether { normal: [0.0, 0.0, 1.0], offset: 20.0, k: 1.0 });
Built-in InsideBoxRestraint and user PlaneTether take the same
code path — direction-3 in action.
In Python — the same restraint, duck-typed¶
The interfaces are also exposed to Python through a duck-typed protocol: a
restraint is any object exposing f and fg, and the packer consumes it on the
same code path as a built-in. That makes Python the natural place to prototype
a restraint the input grammar can't express — a few lines, no recompile — and
then, once it earns its place, swap in the native equivalent with the driver
script unchanged.
A duck-typed restraint is any object exposing f and fg. The Python protocol
differs from the Rust trait in exactly one way: fg returns
(energy, (gx, gy, gz)) instead of accumulating into a &mut [F; 3] — the
binding does the += for you. The contracts are otherwise identical: fg
returns the energy, the gradient is the chain rule (dU/dξ)·∇ξ, a linear-energy
penalty rides scale (a quadratic one scale2). A missing f or fg is
rejected at attach time with a TypeError, and an exception raised inside fg
propagates back out of pack. The PlaneTether above, duck-typed in Python:
import numpy as np
class PlaneTether:
"""Pull a site toward the plane z = offset (a per-site geometric bias)."""
def __init__(self, normal, offset, k):
n = np.asarray(normal, float)
self.n, self.offset, self.k = n / np.linalg.norm(n), offset, k
def f(self, x, scale, scale2):
d = float(self.n @ np.asarray(x, float)) - self.offset
return scale * 0.5 * self.k * d * d
def fg(self, x, scale, scale2):
d = float(self.n @ np.asarray(x, float)) - self.offset
g = scale * self.k * d * self.n
return scale * 0.5 * self.k * d * d, (float(g[0]), float(g[1]), float(g[2]))
Attach it with with_atom_restraint (a site subset) or with_restraint (every
copy), and pack.
A per-site field cannot reproduce a distribution. It is tempting to build a per-site penalty from a target density
ρ*(e.g. Boltzmann inversionU = −kT·ln ρ*) to make sites followρ*. This fails under packing's energy minimisation:∑ᵢ U(xᵢ)is minimised by driving every site to the single minimum ofU— the mode ofρ*— so the sites collapse onto the peak instead of spreading over the distribution. To drive a whole species onto a target profile, use a collective restraint (Target.with_collective_restraint, e.g. the built-inProfileMatch), whose penalty is a function of the entire group and whose gradient couples the copies, so the fixed point is empirical distribution = target.
Custom Region¶
Goal: a conical region with apex at origin, axis along +z, half-angle 30°.
use molrs::types::F;
# use molpack::Region;
#[derive(Debug, Clone, Copy)]
pub struct ConeRegion {
pub apex: [F; 3],
pub axis: [F; 3],
pub half_angle_cos: F,
}
impl Region for ConeRegion {
fn contains(&self, x: &[F; 3]) -> bool {
self.signed_distance(x) <= 0.0
}
fn signed_distance(&self, x: &[F; 3]) -> F {
let dx = x[0] - self.apex[0];
let dy = x[1] - self.apex[1];
let dz = x[2] - self.apex[2];
let r = (dx * dx + dy * dy + dz * dz).sqrt();
if r < 1e-12 { return 0.0; }
let axis_dot =
(dx * self.axis[0] + dy * self.axis[1] + dz * self.axis[2]) / r;
self.half_angle_cos - axis_dot
}
// Default FD gradient is OK for prototypes. Override analytically
// for hot-path use — see below.
}
Compose with built-ins¶
# use molrs::types::F;
# use molpack::Region;
# #[derive(Debug, Clone, Copy)]
# pub struct ConeRegion { pub apex: [F; 3], pub axis: [F; 3], pub half_angle_cos: F }
# impl Region for ConeRegion {
# fn contains(&self, _x: &[F; 3]) -> bool { true }
# fn signed_distance(&self, _x: &[F; 3]) -> F { 0.0 }
# }
use molpack::{InsideSphereRegion, RegionExt, RegionRestraint, Target};
# let (pos, rad) = (&[[0.0; 3]][..], &[1.0][..]);
let cone = ConeRegion {
apex: [0.0; 3],
axis: [0.0, 0.0, 1.0],
half_angle_cos: (std::f64::consts::PI / 6.0).cos(),
};
let sphere = InsideSphereRegion::new([0.0; 3], 10.0);
let region = cone.and(sphere);
let target = Target::from_coords(pos, rad, 100)
.with_restraint(RegionRestraint(region));
RegionExt::and / or / not come from a
blanket impl on every Region. The resulting type
And<ConeRegion, InsideSphereRegion> is static-dispatch — no heap.
Analytic gradient override¶
For hot-path use, override signed_distance_grad analytically. The
cone above:
# use molrs::types::F;
# use molpack::Region;
# #[derive(Debug, Clone, Copy)]
# pub struct ConeRegion { pub apex: [F; 3], pub axis: [F; 3], pub half_angle_cos: F }
# impl Region for ConeRegion {
# fn contains(&self, _x: &[F; 3]) -> bool { true }
# fn signed_distance(&self, _x: &[F; 3]) -> F { 0.0 }
fn signed_distance_grad(&self, x: &[F; 3]) -> [F; 3] {
let dx = x[0] - self.apex[0];
let dy = x[1] - self.apex[1];
let dz = x[2] - self.apex[2];
let r2 = dx * dx + dy * dy + dz * dz;
let r = r2.sqrt();
if r < 1e-12 { return [0.0; 3]; }
let axis_dot = (dx * self.axis[0] + dy * self.axis[1] + dz * self.axis[2]) / r;
let inv_r = 1.0 / r;
// signed_distance = cos(α) - axis_dot ⇒ grad = -∂axis_dot/∂x
[
-(self.axis[0] * inv_r - axis_dot * dx * inv_r * inv_r),
-(self.axis[1] * inv_r - axis_dot * dy * inv_r * inv_r),
-(self.axis[2] * inv_r - axis_dot * dz * inv_r * inv_r),
]
}
# }
Then finite-difference check it — same pattern as the AtomRestraint
test.
Custom Handler¶
Goal: a handler that writes a CSV row per step so you can plot the objective evolution.
use std::fs::File;
use std::io::{BufWriter, Write};
use molpack::{F, Handler, PackContext, StepInfo};
pub struct CsvHandler { writer: BufWriter<File> }
impl CsvHandler {
pub fn new(path: &str) -> std::io::Result<Self> {
let mut w = BufWriter::new(File::create(path)?);
writeln!(w, "phase,loop_idx,fdist,frest,improvement_pct")?;
Ok(Self { writer: w })
}
}
impl Handler for CsvHandler {
fn on_step(&mut self, info: &StepInfo, _sys: &PackContext) {
let _ = writeln!(
self.writer,
"{},{},{},{},{}",
info.phase.phase,
info.loop_idx,
info.fdist,
info.frest,
info.improvement_pct,
);
}
}
Handler notes:
on_stepis the only required method. Everything else has a default no-op.sysis&PackContext, never&mut. Handlers cannot mutate packer state — use aRelaxerif you need to.- Multiple handlers run in registration order. Register your CSV
handler before
ProgressHandlerto get a row on every step, vice-versa otherwise. should_stopis polled every iteration. Returntrueto break the outer loop early. Useful for time budgets or custom convergence criteria.
Custom Relaxer¶
Goal: a relaxer that tries random rigid-body translations and accepts if the objective decreases.
use molrs::Frame;
use molrs::types::F;
use molpack::{Relaxer, RelaxerRunner};
use rand::{Rng, RngCore};
#[derive(Debug, Clone)]
pub struct JiggleRelaxer {
pub steps: usize,
pub max_delta: F,
}
impl Relaxer for JiggleRelaxer {
fn spawn(&self, _frame: Option<&Frame>, _ref_coords: &[[F; 3]]) -> Box<dyn RelaxerRunner> {
Box::new(JiggleRunner {
steps: self.steps,
max_delta: self.max_delta,
accepted: 0,
total: 0,
})
}
}
pub struct JiggleRunner {
steps: usize,
max_delta: F,
accepted: usize,
total: usize,
}
impl RelaxerRunner for JiggleRunner {
fn on_iter(
&mut self,
coords: &[[F; 3]],
f_current: F,
evaluate: &mut dyn FnMut(&[[F; 3]]) -> F,
rng: &mut dyn RngCore,
) -> Option<Vec<[F; 3]>> {
let mut best = coords.to_vec();
let mut best_f = f_current;
let mut accepted_any = false;
for _ in 0..self.steps {
let dx = (rng.next_u32() as f64 / u32::MAX as f64 * 2.0 - 1.0) * self.max_delta;
let dy = (rng.next_u32() as f64 / u32::MAX as f64 * 2.0 - 1.0) * self.max_delta;
let dz = (rng.next_u32() as f64 / u32::MAX as f64 * 2.0 - 1.0) * self.max_delta;
let trial: Vec<[F; 3]> = best.iter()
.map(|p| [p[0] + dx, p[1] + dy, p[2] + dz])
.collect();
let f_trial = evaluate(&trial);
self.total += 1;
if f_trial < best_f {
self.accepted += 1;
best_f = f_trial;
best = trial;
accepted_any = true;
}
}
if accepted_any { Some(best) } else { None }
}
fn acceptance_rate(&self) -> F {
if self.total == 0 { 0.0 } else { self.accepted as F / self.total as F }
}
}
Relaxer notes:
- Two-part design.
Relaxeris the immutable builder;RelaxerRunnerholds per-pack state.spawn()is called once per target type atpack()entry. evaluateclosure tests trial coords against the full objective without mutating the reference — use it as often as you like.- Return
Some(new_coords)only if something changed. The packer skips unnecessary cache invalidation when you returnNone. count == 1required. Multi-copy targets share reference coords; a relaxer that mutates them would silently change all copies.
Testing discipline¶
| Kind | Location | Convention |
|---|---|---|
| Unit test | #[cfg(test)] mod tests in the same file |
One #[test] fn per behavior |
| Integration test | tests/<name>.rs |
use molpack::{…}; only public API |
| Gradient finite-difference | alongside unit test | ε=1e-5, tol=1e-3 |
| Regression vs Packmol | tests/examples_batch.rs (#[ignore]) |
Run with --ignored --release |
Run all:
Rules:
- Every new
AtomRestraintgets an FD gradient test. - Every new
Regiongets a boolean-algebra + signed-distance sign test and (for hot-path use) an analytic-gradient FD test.
Common pitfalls¶
- Gradient sign. Every
AtomRestraintaccumulates∂penalty/∂x. Optimizer negates for descent. If your molecules fly out of the region, the gradient has the wrong sign — penalty should point toward the violation boundary. - Rotation convention. Single-atom tests pass with both LEFT and RIGHT Euler multiplication; multi-atom tests don't. Always test Euler changes with ≥ 2 atoms.
Cell<f64>is notSync. UseAtomicU64+f64::to_bits/from_bitsfor interior mutability inSend + Synccontexts.- 0-based atom indexing.
Target::with_atom_restraintuses Rust-native 0-based indices.&[0, 1]selects the first two atoms. Packmol.inpfiles use 1-based — subtract 1 at the parse boundary. count == 1required for relaxers. Multi-copy targets share reference coords.radscaleis phase-dependent. Don't hard-code atomic radii — always go throughsys.radius[i].evaluate_unscaledtemporarily swapsradiuswithradius_inifor user-facing numbers.- PBC boxes must be valid. Zero-length axis returns
PackError::InvalidPBCBox.
Contributing flow¶
- Write a failing test.
- Implement until it passes.
- Run the full gate: