Skip to main content

molrs_ff/optimize/
mod.rs

1//! Force-field-agnostic geometry optimization (energy minimization).
2//!
3//! The [`LBFGS`] class relaxes a single structure ([`run`](LBFGS::run)) or a
4//! homogeneous batch ([`run_batch`](LBFGS::run_batch)) using the shared L-BFGS
5//! core in [`lbfgs`]. It consumes only the `(energy, forces = -grad)` contract
6//! of [`Potential`](crate::potential::Potential), so it works with any force
7//! field — MMFF94, the harmonic/LJ kernels, or a user-supplied potential.
8//!
9//! Coordinates use the same flat layout as the rest of `molrs-ff`:
10//! `[x0,y0,z0, x1,y1,z1, ...]` (`3·n_atoms` elements, `f64`). Convergence is on
11//! `fmax` — the maximum per-atom force magnitude `max_i ‖F_i‖` (kcal/mol/Å) —
12//! matching the ASE / molpy convention.
13
14pub mod lbfgs;
15
16use crate::potential::Potential;
17use lbfgs::{Converge, fmax_from_grad, minimize_core};
18use molrs::types::F;
19
20pub use lbfgs::{MinResult, minimize_lbfgs_rms};
21
22/// Convergence and step controls for the [`LBFGS`] optimizer.
23#[derive(Clone, Copy, Debug)]
24pub struct LbfgsConfig {
25    /// Maximum per-atom force magnitude for convergence (kcal/mol/Å).
26    /// Optimization stops when `max_i ‖F_i‖ < fmax`. Default `0.05`.
27    pub fmax: F,
28    /// Outer-iteration cap. Default `500`.
29    pub max_steps: usize,
30    /// Per-step displacement cap in Å (trust region). Default `0.2`. Use
31    /// `f64::INFINITY` to disable the trust region (unbounded line search).
32    pub max_step: F,
33    /// L-BFGS correction-pair history size. Default `8`.
34    pub memory: usize,
35}
36
37impl Default for LbfgsConfig {
38    fn default() -> Self {
39        Self {
40            fmax: 0.05,
41            max_steps: 500,
42            max_step: 0.2,
43            memory: 8,
44        }
45    }
46}
47
48/// Outcome of a single minimization.
49#[derive(Clone, Copy, Debug, PartialEq)]
50pub struct OptReport {
51    /// Whether `fmax` convergence was reached within `max_steps`.
52    pub converged: bool,
53    /// Number of outer L-BFGS iterations performed.
54    pub n_steps: usize,
55    /// Potential energy at the returned point (kcal/mol).
56    pub final_energy: F,
57    /// Maximum per-atom force magnitude at the returned point (kcal/mol/Å).
58    pub final_fmax: F,
59}
60
61/// Limited-memory BFGS geometry optimizer over a molecule-bound [`Potential`].
62///
63/// Mirrors molpy's `LBFGS(potential, …).run(structure)`: hold the potential and
64/// config, then [`run`](LBFGS::run) a single structure or [`run_batch`] a
65/// homogeneous set. Coordinates are flat `[x0,y0,z0, …]` and relaxed in place.
66///
67/// [`run_batch`]: LBFGS::run_batch
68pub struct LBFGS<'p> {
69    potential: &'p dyn Potential,
70    cfg: LbfgsConfig,
71}
72
73impl<'p> LBFGS<'p> {
74    /// Create an optimizer bound to `potential` with the given config.
75    pub fn new(potential: &'p dyn Potential, cfg: LbfgsConfig) -> Self {
76        Self { potential, cfg }
77    }
78
79    /// Relax a single structure in place. `coords` is the flat `3·n_atoms`
80    /// buffer, updated to the located minimizer.
81    ///
82    /// # Errors
83    /// Returns `Err` if `coords.len()` is not a multiple of three.
84    pub fn run(&self, coords: &mut [F]) -> Result<OptReport, String> {
85        if coords.is_empty() {
86            return Ok(OptReport {
87                converged: true,
88                n_steps: 0,
89                final_energy: 0.0,
90                final_fmax: 0.0,
91            });
92        }
93        if !coords.len().is_multiple_of(3) {
94            return Err(format!(
95                "coords length {} is not a multiple of 3 (expected 3·n_atoms)",
96                coords.len()
97            ));
98        }
99        Ok(self.run_one(coords))
100    }
101
102    /// Relax a homogeneous batch in place, one [`OptReport`] per structure.
103    ///
104    /// All `n_structs` structures share this potential. `coords` is the
105    /// concatenation of `n_structs` flat blocks each of length `3·n_atoms`
106    /// (row-major over the conceptual `(B, N, 3)` array). Each block is optimized
107    /// independently; with the `rayon` feature the blocks run in parallel.
108    ///
109    /// # Errors
110    /// Returns `Err` if `coords.len() != n_structs · n_atoms · 3`, or a
111    /// zero-atom batch.
112    pub fn run_batch(
113        &self,
114        coords: &mut [F],
115        n_atoms: usize,
116        n_structs: usize,
117    ) -> Result<Vec<OptReport>, String> {
118        let stride = n_atoms * 3;
119        let expected = n_structs * stride;
120        if coords.len() != expected {
121            return Err(format!(
122                "coords length {} != n_structs ({}) · n_atoms ({}) · 3 = {}",
123                coords.len(),
124                n_structs,
125                n_atoms,
126                expected
127            ));
128        }
129        if n_structs == 0 {
130            return Ok(Vec::new());
131        }
132        // `chunks_mut(0)` / `par_chunks_mut(0)` panic, so reject a zero-atom
133        // batch explicitly rather than letting it reach the split.
134        if stride == 0 {
135            return Err(format!(
136                "n_atoms must be > 0 for a batch of {n_structs} structures"
137            ));
138        }
139
140        #[cfg(feature = "rayon")]
141        {
142            use rayon::prelude::*;
143            Ok(coords
144                .par_chunks_mut(stride)
145                .map(|block| self.run_one(block))
146                .collect())
147        }
148        #[cfg(not(feature = "rayon"))]
149        {
150            Ok(coords
151                .chunks_mut(stride)
152                .map(|block| self.run_one(block))
153                .collect())
154        }
155    }
156
157    /// Single-structure minimization without the length check — the batch path
158    /// already guarantees `block.len() == 3·n_atoms`.
159    fn run_one(&self, coords: &mut [F]) -> OptReport {
160        let (final_energy, grad, n_steps, converged) = minimize_core(
161            coords,
162            self.cfg.max_steps,
163            Converge::Fmax(self.cfg.fmax),
164            self.cfg.max_step,
165            self.cfg.memory,
166            |c| self.potential.calc_energy_forces(c),
167        );
168        OptReport {
169            converged,
170            n_steps,
171            final_energy,
172            final_fmax: fmax_from_grad(&grad),
173        }
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    /// Two atoms joined by a single harmonic bond `E = ½k(r − r0)²` along x.
182    /// Force on each atom is along the bond; the system relaxes to `r == r0`.
183    struct HarmonicBond {
184        k: F,
185        r0: F,
186    }
187
188    impl Potential for HarmonicBond {
189        fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
190            // atoms 0 and 1, vector from 0 -> 1.
191            let d = [
192                coords[3] - coords[0],
193                coords[4] - coords[1],
194                coords[5] - coords[2],
195            ];
196            let r = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
197            let e = 0.5 * self.k * (r - self.r0) * (r - self.r0);
198            // dE/dr = k(r - r0); force on atom 1 = -dE/dr * d/r, atom 0 opposite.
199            let mut f = vec![0.0; 6];
200            if r > 1e-12 {
201                let coeff = self.k * (r - self.r0) / r; // dE/dr / r
202                for i in 0..3 {
203                    let fi = coeff * d[i]; // d(E)/d(x1_i) = coeff * d_i ... gradient
204                    f[i] = fi; // force on atom 0 = -grad_0 = +coeff*d  (since grad_0 = -coeff*d)
205                    f[3 + i] = -fi; // force on atom 1
206                }
207            }
208            (e, f)
209        }
210    }
211
212    fn opts() -> LbfgsConfig {
213        LbfgsConfig::default()
214    }
215
216    #[test]
217    fn relaxes_harmonic_bond_to_equilibrium() {
218        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
219        // start stretched to 1.5 Å along x.
220        let mut coords = vec![0.0, 0.0, 0.0, 1.5, 0.0, 0.0];
221        let report = LBFGS::new(&pot, opts()).run(&mut coords).unwrap();
222        assert!(report.converged, "should converge: {report:?}");
223        let r = coords[3] - coords[0];
224        assert!(
225            (r.abs() - 1.0).abs() < 1e-6,
226            "bond length should reach r0, got {r}"
227        );
228        assert!(
229            report.final_energy < 1e-9,
230            "energy ~0, got {}",
231            report.final_energy
232        );
233        assert!(report.final_fmax <= opts().fmax, "fmax satisfied");
234    }
235
236    #[test]
237    fn fmax_convergence_semantics() {
238        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
239        // max_steps = 1 from far away -> not converged, exactly one step.
240        let mut coords = vec![0.0, 0.0, 0.0, 2.0, 0.0, 0.0];
241        let o = LbfgsConfig {
242            max_steps: 1,
243            ..opts()
244        };
245        let r = LBFGS::new(&pot, o).run(&mut coords).unwrap();
246        assert!(!r.converged);
247        assert_eq!(r.n_steps, 1);
248    }
249
250    #[test]
251    fn idempotent_at_minimum() {
252        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
253        let mut coords = vec![0.0, 0.0, 0.0, 1.0, 0.0, 0.0]; // already at r0
254        let r = LBFGS::new(&pot, opts()).run(&mut coords).unwrap();
255        assert!(r.converged);
256        assert!(
257            r.n_steps <= 1,
258            "already-minimized takes <=1 step, took {}",
259            r.n_steps
260        );
261    }
262
263    #[test]
264    fn single_atom_converges_immediately() {
265        // A lone atom with no forces: fmax is already 0.
266        struct Free;
267        impl Potential for Free {
268            fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
269                (0.0, vec![0.0; coords.len()])
270            }
271        }
272        let mut coords = vec![0.3, -0.2, 0.1];
273        let r = LBFGS::new(&Free, opts()).run(&mut coords).unwrap();
274        assert!(r.converged);
275        assert!(r.n_steps <= 1);
276    }
277
278    #[test]
279    fn rejects_non_multiple_of_three() {
280        struct Free;
281        impl Potential for Free {
282            fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
283                (0.0, vec![0.0; coords.len()])
284            }
285        }
286        let mut coords = vec![0.0, 0.0, 0.0, 1.0];
287        assert!(LBFGS::new(&Free, opts()).run(&mut coords).is_err());
288    }
289
290    #[test]
291    fn empty_coords_is_converged_noop() {
292        struct Free;
293        impl Potential for Free {
294            fn calc_energy_forces(&self, coords: &[F]) -> (F, Vec<F>) {
295                (0.0, vec![0.0; coords.len()])
296            }
297        }
298        let mut coords: Vec<F> = vec![];
299        let r = LBFGS::new(&Free, opts()).run(&mut coords).unwrap();
300        assert!(r.converged);
301        assert_eq!(r.n_steps, 0);
302    }
303
304    #[test]
305    fn trust_region_caps_step() {
306        // A stiff bond far from equilibrium would take a large step without a
307        // trust region; with a tiny max_step every component move is bounded.
308        let pot = HarmonicBond { k: 500.0, r0: 1.0 };
309        let mut coords = vec![0.0, 0.0, 0.0, 3.0, 0.0, 0.0];
310        let before = coords.clone();
311        let o = LbfgsConfig {
312            max_steps: 1,
313            max_step: 0.01,
314            ..opts()
315        };
316        LBFGS::new(&pot, o).run(&mut coords).unwrap();
317        for (a, b) in coords.iter().zip(&before) {
318            assert!(
319                (a - b).abs() <= 0.01 + 1e-12,
320                "component moved {} > max_step",
321                (a - b).abs()
322            );
323        }
324    }
325
326    #[test]
327    fn batch_equals_serial() {
328        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
329        let single_start = vec![0.0, 0.0, 0.0, 1.4, 0.0, 0.0];
330
331        let mut single = single_start.clone();
332        let single_report = LBFGS::new(&pot, opts()).run(&mut single).unwrap();
333
334        // 4 identical copies stacked.
335        let b = 4;
336        let mut batch: Vec<F> = Vec::new();
337        for _ in 0..b {
338            batch.extend_from_slice(&single_start);
339        }
340        let reports = LBFGS::new(&pot, opts())
341            .run_batch(&mut batch, 2, b)
342            .unwrap();
343        assert_eq!(reports.len(), b);
344        for (i, rep) in reports.iter().enumerate() {
345            assert!((rep.final_energy - single_report.final_energy).abs() < 1e-10);
346            assert!((rep.final_fmax - single_report.final_fmax).abs() < 1e-10);
347            let block = &batch[i * 6..i * 6 + 6];
348            for (a, s) in block.iter().zip(&single) {
349                assert!((a - s).abs() < 1e-9, "batch block {i} diverged from serial");
350            }
351        }
352    }
353
354    #[test]
355    fn batch_rejects_size_mismatch() {
356        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
357        let mut coords = vec![0.0; 6 * 3 + 1]; // not 3 structs * 2 atoms * 3
358        assert!(
359            LBFGS::new(&pot, opts())
360                .run_batch(&mut coords, 2, 3)
361                .is_err()
362        );
363    }
364
365    #[test]
366    fn batch_zero_structs_is_empty() {
367        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
368        let mut coords: Vec<F> = vec![];
369        let reports = LBFGS::new(&pot, opts())
370            .run_batch(&mut coords, 2, 0)
371            .unwrap();
372        assert!(reports.is_empty());
373    }
374
375    #[test]
376    fn batch_zero_atoms_errors_not_panics() {
377        // A (B, 0, 3) batch: n_atoms == 0 -> stride 0 would panic chunks_mut.
378        let pot = HarmonicBond { k: 100.0, r0: 1.0 };
379        let mut coords: Vec<F> = vec![];
380        assert!(
381            LBFGS::new(&pot, opts())
382                .run_batch(&mut coords, 0, 3)
383                .is_err()
384        );
385    }
386}