1pub 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#[derive(Clone, Copy, Debug)]
24pub struct LbfgsConfig {
25 pub fmax: F,
28 pub max_steps: usize,
30 pub max_step: F,
33 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#[derive(Clone, Copy, Debug, PartialEq)]
50pub struct OptReport {
51 pub converged: bool,
53 pub n_steps: usize,
55 pub final_energy: F,
57 pub final_fmax: F,
59}
60
61pub struct LBFGS<'p> {
69 potential: &'p dyn Potential,
70 cfg: LbfgsConfig,
71}
72
73impl<'p> LBFGS<'p> {
74 pub fn new(potential: &'p dyn Potential, cfg: LbfgsConfig) -> Self {
76 Self { potential, cfg }
77 }
78
79 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 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 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 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 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 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 let mut f = vec![0.0; 6];
200 if r > 1e-12 {
201 let coeff = self.k * (r - self.r0) / r; for i in 0..3 {
203 let fi = coeff * d[i]; f[i] = fi; f[3 + i] = -fi; }
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 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 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]; 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 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 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 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]; 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 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}