pub mod lbfgs;
pub trait Potential: Send + Sync {
fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>);
fn calc_energy(&self, coords: &[F]) -> F {
self.calc_energy_forces(coords).0
}
fn calc_forces(&self, coords: &[F]) -> Vec<F> {
self.calc_energy_forces(coords).1
}
}
use lbfgs::{Converge, fmax_from_grad, minimize_core};
use molrs::types::F;
pub use lbfgs::{MinResult, minimize_lbfgs_rms};
#[derive(Clone, Copy, Debug)]
pub struct LbfgsConfig {
pub fmax: F,
pub max_steps: usize,
pub max_step: F,
pub memory: usize,
}
impl Default for LbfgsConfig {
fn default() -> Self {
Self {
fmax: 0.05,
max_steps: 500,
max_step: 0.2,
memory: 8,
}
}
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct OptReport {
pub converged: bool,
pub n_steps: usize,
pub final_energy: F,
pub final_fmax: F,
}
pub struct LBFGS<'p> {
potential: &'p dyn Potential,
cfg: LbfgsConfig,
}
impl<'p> LBFGS<'p> {
pub fn new(potential: &'p dyn Potential, cfg: LbfgsConfig) -> Self {
Self { potential, cfg }
}
pub fn run(&self, coords: &mut [F]) -> Result<OptReport, String> {
if coords.is_empty() {
return Ok(OptReport {
converged: true,
n_steps: 0,
final_energy: 0.0,
final_fmax: 0.0,
});
}
if !coords.len().is_multiple_of(3) {
return Err(format!(
"coords length {} is not a multiple of 3 (expected 3·n_atoms)",
coords.len()
));
}
Ok(self.run_one(coords))
}
pub fn run_batch(
&self,
coords: &mut [F],
n_atoms: usize,
n_structs: usize,
) -> Result<Vec<OptReport>, String> {
let stride = n_atoms * 3;
let expected = n_structs * stride;
if coords.len() != expected {
return Err(format!(
"coords length {} != n_structs ({}) · n_atoms ({}) · 3 = {}",
coords.len(),
n_structs,
n_atoms,
expected
));
}
if n_structs == 0 {
return Ok(Vec::new());
}
if stride == 0 {
return Err(format!(
"n_atoms must be > 0 for a batch of {n_structs} structures"
));
}
#[cfg(feature = "rayon")]
{
use rayon::prelude::*;
Ok(coords
.par_chunks_mut(stride)
.map(|block| self.run_one(block))
.collect())
}
#[cfg(not(feature = "rayon"))]
{
Ok(coords
.chunks_mut(stride)
.map(|block| self.run_one(block))
.collect())
}
}
fn run_one(&self, coords: &mut [F]) -> OptReport {
let (final_energy, grad, n_steps, converged) = minimize_core(
coords,
self.cfg.max_steps,
Converge::Fmax(self.cfg.fmax),
self.cfg.max_step,
self.cfg.memory,
|c| self.potential.calc_energy_forces(c),
);
OptReport {
converged,
n_steps,
final_energy,
final_fmax: fmax_from_grad(&grad),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
struct HarmonicBond {
k: F,
r0: F,
}
impl Potential for HarmonicBond {
fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
let d = [
coords[3] - coords[0],
coords[4] - coords[1],
coords[5] - coords[2],
];
let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let e = 0.5 * self.k * (r - self.r0) * (r - self.r0);
let mut f = vec![0.0; 6];
if r > 1e-12 {
let coeff = self.k * (r - self.r0) / r; for i in 0..3 {
let fi = coeff * d[i]; f[i] = fi; f[3 + i] = -fi; }
}
(e, f)
}
}
fn opts() -> LbfgsConfig {
LbfgsConfig::default()
}
#[test]
fn relaxes_harmonic_bond_to_equilibrium() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords = vec![0.0, 0.0, 0.0, 1.5, 0.0, 0.0];
let report = LBFGS::new(&pot, opts()).run(&mut coords).unwrap();
assert!(report.converged, "should converge: {report:?}");
let r = coords[3] - coords[0];
assert!(
(r.abs() - 1.0).abs() < 1e-6,
"bond length should reach r0, got {r}"
);
assert!(
report.final_energy < 1e-9,
"energy ~0, got {}",
report.final_energy
);
assert!(report.final_fmax <= opts().fmax, "fmax satisfied");
}
#[test]
fn fmax_convergence_semantics() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
let o = LbfgsConfig {
max_steps: 1,
..opts()
};
let r = LBFGS::new(&pot, o).run(&mut coords).unwrap();
assert!(!r.converged);
assert_eq!(r.n_steps, 1);
}
#[test]
fn idempotent_at_minimum() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0]; let r = LBFGS::new(&pot, opts()).run(&mut coords).unwrap();
assert!(r.converged);
assert!(
r.n_steps <= 1,
"already-minimized takes <=1 step, took {}",
r.n_steps
);
}
#[test]
fn single_atom_converges_immediately() {
struct Free;
impl Potential for Free {
fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
(0.0, vec![0.0; coords.len()])
}
}
let mut coords = vec![0.3, -0.2, 0.1];
let r = LBFGS::new(&Free, opts()).run(&mut coords).unwrap();
assert!(r.converged);
assert!(r.n_steps <= 1);
}
#[test]
fn rejects_non_multiple_of_three() {
struct Free;
impl Potential for Free {
fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
(0.0, vec![0.0; coords.len()])
}
}
let mut coords = vec![0.0, 0.0, 0.0, 1.0];
assert!(LBFGS::new(&Free, opts()).run(&mut coords).is_err());
}
#[test]
fn empty_coords_is_converged_noop() {
struct Free;
impl Potential for Free {
fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
(0.0, vec![0.0; coords.len()])
}
}
let mut coords: Vec<F> = vec![];
let r = LBFGS::new(&Free, opts()).run(&mut coords).unwrap();
assert!(r.converged);
assert_eq!(r.n_steps, 0);
}
#[test]
fn trust_region_caps_step() {
let pot = HarmonicBond { k: 500.0, r0: 1.0 };
let mut coords = vec![0.0, 0.0, 0.0, 3.0, 0.0, 0.0];
let before = coords.clone();
let o = LbfgsConfig {
max_steps: 1,
max_step: 0.01,
..opts()
};
LBFGS::new(&pot, o).run(&mut coords).unwrap();
for (a, b) in coords.iter().zip(&before) {
assert!(
(a - b).abs() <= 0.01 + 1e-12,
"component moved {} > max_step",
(a - b).abs()
);
}
}
#[test]
fn batch_equals_serial() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let single_start = vec![0.0, 0.0, 0.0, 1.4, 0.0, 0.0];
let mut single = single_start.clone();
let single_report = LBFGS::new(&pot, opts()).run(&mut single).unwrap();
let b = 4;
let mut batch: Vec<F> = Vec::new();
for _ in 0..b {
batch.extend_from_slice(&single_start);
}
let reports = LBFGS::new(&pot, opts())
.run_batch(&mut batch, 2, b)
.unwrap();
assert_eq!(reports.len(), b);
for (i, rep) in reports.iter().enumerate() {
assert!((rep.final_energy - single_report.final_energy).abs() < 1e-10);
assert!((rep.final_fmax - single_report.final_fmax).abs() < 1e-10);
let block = &batch[i * 6..i * 6 + 6];
for (a, s) in block.iter().zip(&single) {
assert!((a - s).abs() < 1e-9, "batch block {i} diverged from serial");
}
}
}
#[test]
fn batch_rejects_size_mismatch() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords = vec![0.0; 6 * 3 + 1]; assert!(
LBFGS::new(&pot, opts())
.run_batch(&mut coords, 2, 3)
.is_err()
);
}
#[test]
fn batch_zero_structs_is_empty() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords: Vec<F> = vec![];
let reports = LBFGS::new(&pot, opts())
.run_batch(&mut coords, 2, 0)
.unwrap();
assert!(reports.is_empty());
}
#[test]
fn batch_zero_atoms_errors_not_panics() {
let pot = HarmonicBond { k: 100.0, r0: 1.0 };
let mut coords: Vec<F> = vec![];
assert!(
LBFGS::new(&pot, opts())
.run_batch(&mut coords, 0, 3)
.is_err()
);
}
}