Skip to main content

molpack/
handler.rs

1//! Handler trait and built-in handlers for packing progress callbacks.
2
3use molrs::types::F;
4use std::io::BufWriter;
5use std::path::PathBuf;
6use std::time::Instant;
7
8use crate::context::PackContext;
9use crate::frame::compute_mol_ids;
10use crate::numerics::objective_small_floor;
11
12// ── Info structs ─────────────────────────────────────────────────────────────
13
14/// Information about the current packing phase.
15#[derive(Debug, Clone, Copy)]
16pub struct PhaseInfo {
17    /// 0-based phase index.
18    pub phase: usize,
19    /// Total number of phases (ntype + 1).
20    pub total_phases: usize,
21    /// If `Some(itype)`, this is a per-type compaction phase.
22    /// If `None`, this is the final all-types phase.
23    pub molecule_type: Option<usize>,
24}
25
26/// Summary report emitted at the end of each packing phase.
27///
28/// Passed to [`Handler::on_phase_end`]. Fields mirror the counters
29/// reported by `ProgressHandler::on_phase_start`; when no summary is
30/// needed (e.g. early termination), callers may omit the handler hook.
31#[derive(Debug, Clone, Copy, Default)]
32pub struct PhaseReport {
33    /// Number of outer iterations actually run in this phase.
34    pub iterations: usize,
35    /// Final `fdist` (max inter-molecular overlap) at phase exit.
36    pub fdist: F,
37    /// Final `frest` (max restraint violation) at phase exit.
38    pub frest: F,
39    /// Whether the phase converged under `precision`.
40    pub converged: bool,
41}
42
43/// Screen-log detail level for LAMMPS-style packer output.
44#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
45pub enum MolpackLogLevel {
46    /// Print nothing.
47    #[default]
48    Quiet,
49    /// Print system setup, phase summaries, and final summary.
50    Summary,
51    /// Print per-step thermo-style progress lines.
52    Progress,
53    /// Print the same progress lines plus extra diagnostic columns.
54    Verbose,
55}
56
57impl MolpackLogLevel {
58    #[inline]
59    pub const fn is_enabled(self) -> bool {
60        !matches!(self, Self::Quiet)
61    }
62}
63
64/// Per-iteration progress snapshot.
65#[derive(Debug, Clone)]
66pub struct StepInfo {
67    /// 0-based loop iteration within the current phase.
68    pub loop_idx: usize,
69    /// Maximum loops for this phase.
70    pub max_loops: usize,
71    /// Current phase info.
72    pub phase: PhaseInfo,
73    /// Max inter-molecular overlap violation (0.0 = no overlap).
74    pub fdist: F,
75    /// Max constraint violation (0.0 = all constraints satisfied).
76    pub frest: F,
77    /// Improvement from last iteration, as percentage (positive = improving).
78    pub improvement_pct: F,
79    /// Current radius scaling factor (starts at discale, decays to 1.0).
80    pub radscale: F,
81    /// Convergence precision target.
82    pub precision: F,
83    /// Relaxer acceptance rates: `(type_index, acceptance_rate)`.
84    pub relaxer_acceptance: Vec<(usize, F)>,
85}
86
87// ── Trait ─────────────────────────────────────────────────────────────────────
88
89/// Callback interface called by [`crate::packer::Molpack`] during packing.
90pub trait Handler: Send {
91    /// Called immediately at the start of [`pack`][crate::packer::Molpack::pack],
92    /// before any computation. Use this for immediate user feedback.
93    fn on_start(&mut self, _ntotat: usize, _ntotmol: usize) {}
94
95    /// Called once after initialization completes, with valid `xcart` positions.
96    /// Use this to write the initial conformation (e.g. [`XYZHandler`]).
97    fn on_initialized(&mut self, _sys: &PackContext) {}
98
99    /// Called after each outer optimization loop iteration.
100    fn on_step(&mut self, info: &StepInfo, sys: &PackContext);
101
102    /// Called at the start of each packing phase (per-type and all-types).
103    /// Allows stateful handlers to reset between phases.
104    fn on_phase_start(&mut self, _info: &PhaseInfo) {}
105
106    /// Called once after the packing loop finishes (convergence or max loops).
107    fn on_finish(&mut self, _sys: &PackContext) {}
108
109    /// Return `true` to request early termination of the packing loop.
110    fn should_stop(&self) -> bool {
111        false
112    }
113
114    // ── v2 additions (spec §6.6) — default no-op, backward compatible ──
115
116    /// Called after each inner GENCAN iteration (more granular than [`on_step`]).
117    ///
118    /// Default: no-op. Implement when you need per-inner-iteration feedback
119    /// (e.g. plotting objective evolution, adaptive stop criteria).
120    ///
121    /// - `iter` — 0-based inner iteration counter within the current outer step
122    /// - `f` — current objective value (pair + restraint)
123    /// - `sys` — read-only view of the packing context
124    ///
125    /// [`on_step`]: Handler::on_step
126    fn on_inner_iter(&mut self, _iter: u32, _f: F, _sys: &PackContext) {}
127
128    /// Called at the end of each packing phase, with a summary report.
129    ///
130    /// Default: no-op. Paired with [`on_phase_start`] for symmetric
131    /// setup / teardown hooks.
132    ///
133    /// [`on_phase_start`]: Handler::on_phase_start
134    fn on_phase_end(&mut self, _info: &PhaseInfo, _report: &PhaseReport) {}
135}
136
137// ── NullHandler ───────────────────────────────────────────────────────────────
138
139/// A no-op handler.
140pub struct NullHandler;
141
142impl Handler for NullHandler {
143    fn on_step(&mut self, _info: &StepInfo, _sys: &PackContext) {}
144}
145
146// ── XYZHandler ────────────────────────────────────────────────────────────────
147
148/// Writes packing snapshots as a multi-frame XYZ trajectory.
149///
150/// Writes a frame on every `every`-th step (starting from step 0).
151/// No automatic initial or final writes.
152pub struct XYZHandler {
153    path: PathBuf,
154    /// Write every `n` steps (must be >= 1).
155    every: usize,
156    file: Option<BufWriter<std::fs::File>>,
157    /// Precomputed global molecule ID per atom (constant across all frames).
158    mol_ids: Vec<usize>,
159}
160
161impl XYZHandler {
162    /// Create a new XYZ trajectory handler.
163    ///
164    /// `every` controls writing frequency: a frame is written on every step
165    /// where `loop_idx % every == 0` (step 0 is always included).
166    /// `every` must be >= 1.
167    pub fn new(path: impl Into<PathBuf>, every: usize) -> Self {
168        assert!(every >= 1, "every must be >= 1");
169        Self {
170            path: path.into(),
171            every,
172            file: None,
173            mol_ids: Vec::new(),
174        }
175    }
176
177    fn open(&mut self) {
178        if self.file.is_some() {
179            return;
180        }
181        match std::fs::OpenOptions::new()
182            .write(true)
183            .create(true)
184            .truncate(true)
185            .open(&self.path)
186        {
187            Ok(f) => self.file = Some(BufWriter::new(f)),
188            Err(e) => log::warn!("XYZHandler: cannot open {}: {e}", self.path.display()),
189        }
190    }
191
192    fn write_frame(&mut self, comment: &str, sys: &PackContext) {
193        self.open();
194        let Some(ref mut w) = self.file else { return };
195        use std::io::Write;
196        let nat = sys.xcart.len();
197        let _ = writeln!(w, "{nat}");
198        let _ = writeln!(w, "Properties=species:S:1:pos:R:3:mol:I:1  {comment}");
199        for (icart, pos) in sys.xcart.iter().enumerate() {
200            let elem = sys
201                .elements
202                .get(icart)
203                .and_then(|e| *e)
204                .map(|e| e.symbol())
205                .unwrap_or("X");
206            let mol_id = self.mol_ids.get(icart).copied().unwrap_or(0);
207            let _ = writeln!(
208                w,
209                "{elem}  {:.6}  {:.6}  {:.6}  {mol_id}",
210                pos[0], pos[1], pos[2]
211            );
212        }
213        let _ = w.flush();
214    }
215}
216
217impl Handler for XYZHandler {
218    fn on_initialized(&mut self, sys: &PackContext) {
219        self.mol_ids = compute_mol_ids(sys);
220    }
221
222    fn on_step(&mut self, info: &StepInfo, sys: &PackContext) {
223        if info.loop_idx.is_multiple_of(self.every) {
224            self.write_frame(&format!("step {}", info.loop_idx), sys);
225        }
226    }
227}
228
229// ── ProgressHandler ───────────────────────────────────────────────────────────
230
231/// Prints human-readable progress lines to `stderr`.
232///
233/// Added as a default handler by [`crate::packer::Molpack::new`].
234pub struct ProgressHandler {
235    start: Option<Instant>,
236}
237
238impl ProgressHandler {
239    pub fn new() -> Self {
240        Self { start: None }
241    }
242}
243
244impl Default for ProgressHandler {
245    fn default() -> Self {
246        Self::new()
247    }
248}
249
250impl Handler for ProgressHandler {
251    fn on_start(&mut self, ntotat: usize, ntotmol: usize) {
252        self.start = Some(Instant::now());
253        eprintln!("Packing {ntotmol} molecules ({ntotat} atoms)...");
254    }
255
256    fn on_initialized(&mut self, sys: &PackContext) {
257        let elapsed = self.start.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0);
258        eprintln!(
259            "  Initializing... done ({:.1}s)  overlap: {:.4e}  constraints: {:.4e}",
260            elapsed, sys.fdist, sys.frest
261        );
262    }
263
264    fn on_phase_start(&mut self, info: &PhaseInfo) {
265        let desc = match info.molecule_type {
266            Some(itype) => format!("Compacting type {itype}"),
267            None => "Optimizing all types together".to_string(),
268        };
269        eprintln!("  Phase [{}/{}] {desc}", info.phase + 1, info.total_phases,);
270    }
271
272    fn on_step(&mut self, info: &StepInfo, _sys: &PackContext) {
273        let elapsed = self.start.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0);
274        eprintln!(
275            "    Step [{}/{}]  overlap: {:.2e}  constraints: {:.2e}  improved {:.1}%  ({:.1}s)",
276            info.loop_idx + 1,
277            info.max_loops,
278            info.fdist,
279            info.frest,
280            info.improvement_pct,
281            elapsed,
282        );
283    }
284
285    fn on_finish(&mut self, sys: &PackContext) {
286        let elapsed = self.start.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0);
287        if sys.fdist < 0.01 && sys.frest < 0.01 {
288            eprintln!(
289                "  Converged in {:.1}s — overlap: {:.2e}  constraints: {:.2e}",
290                elapsed, sys.fdist, sys.frest,
291            );
292        } else {
293            eprintln!(
294                "  Did not converge ({:.1}s) — overlap: {:.2e}  constraints: {:.2e}",
295                elapsed, sys.fdist, sys.frest,
296            );
297        }
298    }
299}
300
301// ── LammpsLogHandler ─────────────────────────────────────────────────────────
302
303/// LAMMPS-style screen log for packing runs.
304///
305/// Most users should enable this through
306/// [`Molpack::with_lammps_output`][crate::packer::Molpack::with_lammps_output]
307/// or [`Molpack::with_log_level`][crate::packer::Molpack::with_log_level]
308/// instead of attaching the handler manually.
309pub struct LammpsLogHandler {
310    level: MolpackLogLevel,
311    every: usize,
312    tolerance: F,
313    precision: F,
314    seed: u64,
315    max_loops: usize,
316    ntypes: usize,
317    periodic_box: Option<([F; 3], [F; 3], [bool; 3])>,
318    start: Option<Instant>,
319    phase_start: Option<Instant>,
320}
321
322impl LammpsLogHandler {
323    #[allow(clippy::too_many_arguments)]
324    pub fn new(
325        level: MolpackLogLevel,
326        every: usize,
327        tolerance: F,
328        precision: F,
329        seed: u64,
330        max_loops: usize,
331        ntypes: usize,
332        periodic_box: Option<([F; 3], [F; 3], [bool; 3])>,
333    ) -> Self {
334        Self {
335            level,
336            every: every.max(1),
337            tolerance,
338            precision,
339            seed,
340            max_loops,
341            ntypes,
342            periodic_box,
343            start: None,
344            phase_start: None,
345        }
346    }
347
348    fn elapsed(&self) -> F {
349        self.start.map(|t| t.elapsed().as_secs_f64()).unwrap_or(0.0)
350    }
351
352    fn phase_elapsed(&self) -> F {
353        self.phase_start
354            .map(|t| t.elapsed().as_secs_f64())
355            .unwrap_or(0.0)
356    }
357}
358
359impl Handler for LammpsLogHandler {
360    fn on_start(&mut self, ntotat: usize, ntotmol: usize) {
361        if !self.level.is_enabled() {
362            return;
363        }
364
365        let now = Instant::now();
366        self.start = Some(now);
367        self.phase_start = Some(now);
368
369        eprintln!("Molpack screen log");
370        eprintln!("System information:");
371        eprintln!("  molecule types = {}", self.ntypes);
372        eprintln!("  molecules      = {ntotmol}");
373        eprintln!("  atoms          = {ntotat}");
374        eprintln!("Settings:");
375        eprintln!("  tolerance      = {:.6} A", self.tolerance);
376        eprintln!("  precision      = {:.6}", self.precision);
377        eprintln!("  seed           = {}", self.seed);
378        eprintln!("  nloop          = {}", self.max_loops);
379        match self.periodic_box {
380            Some((min, max, flags)) => eprintln!(
381                "  pbc            = [{:.6}, {:.6}, {:.6}] -> [{:.6}, {:.6}, {:.6}]  flags={:?}",
382                min[0], min[1], min[2], max[0], max[1], max[2], flags
383            ),
384            None => eprintln!("  pbc            = off"),
385        }
386    }
387
388    fn on_initialized(&mut self, sys: &PackContext) {
389        if !self.level.is_enabled() {
390            return;
391        }
392        eprintln!(
393            "Initialization: time={:.3}s overlap={:.4e} restraints={:.4e}",
394            self.elapsed(),
395            sys.fdist,
396            sys.frest
397        );
398    }
399
400    fn on_phase_start(&mut self, info: &PhaseInfo) {
401        if !self.level.is_enabled() {
402            return;
403        }
404        self.phase_start = Some(Instant::now());
405        let desc = match info.molecule_type {
406            Some(itype) => format!("type {itype} compaction"),
407            None => "all-type optimization".to_string(),
408        };
409        eprintln!("Phase {}/{}: {desc}", info.phase + 1, info.total_phases);
410        if self.level >= MolpackLogLevel::Progress {
411            if self.level >= MolpackLogLevel::Verbose {
412                eprintln!(
413                    "{:>8} {:>14} {:>14} {:>10} {:>10} {:>10}",
414                    "Step", "Overlap", "Restraint", "Improve%", "RadScale", "Time"
415                );
416            } else {
417                eprintln!(
418                    "{:>8} {:>14} {:>14} {:>10} {:>10}",
419                    "Step", "Overlap", "Restraint", "Improve%", "Time"
420                );
421            }
422        }
423    }
424
425    fn on_step(&mut self, info: &StepInfo, _sys: &PackContext) {
426        if self.level < MolpackLogLevel::Progress || !info.loop_idx.is_multiple_of(self.every) {
427            return;
428        }
429        if self.level >= MolpackLogLevel::Verbose {
430            eprintln!(
431                "{:>8} {:>14.6e} {:>14.6e} {:>10.3} {:>10.4} {:>10.3}",
432                info.loop_idx + 1,
433                info.fdist,
434                info.frest,
435                info.improvement_pct,
436                info.radscale,
437                self.elapsed(),
438            );
439        } else {
440            eprintln!(
441                "{:>8} {:>14.6e} {:>14.6e} {:>10.3} {:>10.3}",
442                info.loop_idx + 1,
443                info.fdist,
444                info.frest,
445                info.improvement_pct,
446                self.elapsed(),
447            );
448        }
449    }
450
451    fn on_phase_end(&mut self, info: &PhaseInfo, report: &PhaseReport) {
452        if !self.level.is_enabled() {
453            return;
454        }
455        eprintln!(
456            "Phase {}/{} summary: steps={} converged={} overlap={:.6e} restraints={:.6e} time={:.3}s",
457            info.phase + 1,
458            info.total_phases,
459            report.iterations,
460            report.converged,
461            report.fdist,
462            report.frest,
463            self.phase_elapsed(),
464        );
465    }
466
467    fn on_finish(&mut self, sys: &PackContext) {
468        if !self.level.is_enabled() {
469            return;
470        }
471        let converged = sys.fdist < self.precision && sys.frest < self.precision;
472        eprintln!(
473            "Final summary: converged={} overlap={:.6e} restraints={:.6e} elapsed={:.3}s",
474            converged,
475            sys.fdist,
476            sys.frest,
477            self.elapsed(),
478        );
479    }
480}
481
482// ── EarlyStopHandler ──────────────────────────────────────────────────────────
483
484/// Requests early termination when improvement stalls.
485///
486/// Tracks `fdist + frest` total violation. After `warmup` iterations, if the
487/// relative improvement drops below `threshold` for `patience` consecutive
488/// steps, sets the stop flag.
489///
490/// Added as a default handler by [`crate::packer::Molpack::new`].
491pub struct EarlyStopHandler {
492    /// Relative improvement threshold.
493    pub threshold: F,
494    /// Iterations to skip before tracking. Default: `5`.
495    pub warmup: usize,
496    /// Consecutive stall iterations before stopping. Default: `3`.
497    pub patience: usize,
498    prev_violation: F,
499    stall_count: usize,
500    stop: bool,
501}
502
503impl EarlyStopHandler {
504    pub fn new(threshold: F) -> Self {
505        Self {
506            threshold,
507            warmup: 5,
508            patience: 3,
509            prev_violation: F::INFINITY,
510            stall_count: 0,
511            stop: false,
512        }
513    }
514
515    pub fn with_warmup(mut self, warmup: usize) -> Self {
516        self.warmup = warmup;
517        self
518    }
519
520    pub fn with_patience(mut self, patience: usize) -> Self {
521        self.patience = patience.max(1);
522        self
523    }
524}
525
526impl Default for EarlyStopHandler {
527    /// Default is intentionally conservative (effectively disabled),
528    /// so Packmol-sized examples are not stopped before convergence.
529    fn default() -> Self {
530        Self::new(F::NEG_INFINITY)
531    }
532}
533
534impl Handler for EarlyStopHandler {
535    fn on_initialized(&mut self, _sys: &PackContext) {
536        self.prev_violation = F::INFINITY;
537        self.stall_count = 0;
538        self.stop = false;
539    }
540
541    fn on_phase_start(&mut self, _info: &PhaseInfo) {
542        self.prev_violation = F::INFINITY;
543        self.stall_count = 0;
544        self.stop = false;
545    }
546
547    fn on_step(&mut self, info: &StepInfo, _sys: &PackContext) {
548        let v = info.fdist + info.frest;
549        if info.loop_idx <= self.warmup {
550            self.prev_violation = v;
551            return;
552        }
553        let rel_change = if self.prev_violation > 0.0 {
554            (self.prev_violation - v) / self.prev_violation
555        } else if v < objective_small_floor() {
556            1.0 // already converged
557        } else {
558            F::INFINITY
559        };
560
561        if rel_change < self.threshold {
562            self.stall_count += 1;
563            if self.stall_count >= self.patience {
564                log::debug!(
565                    "EarlyStop: stalled for {} iters (rel_change={:.2e})",
566                    self.stall_count,
567                    rel_change
568                );
569                self.stop = true;
570            }
571        } else {
572            self.stall_count = 0;
573        }
574        self.prev_violation = v;
575    }
576
577    fn should_stop(&self) -> bool {
578        self.stop
579    }
580}