Skip to main content
← OpenMECP Documentation

omecp/
gediis.rs

1//! GEDIIS (Geometry Energy Direct Inversion in Iterative Subspace) implementation.
2//!
3//! This module implements the experimental GEDIIS algorithm 
4//!
5//! # Algorithm Overview
6//!
7//! GEDIIS extends GDIIS by incorporating energy information into the DIIS matrix.
8//! Three variants are available:
9//!
10//! - **RFO-DIIS**: Uses quadratic step overlaps A[i,j] = <q_i, q_j>
11//! - **Energy-DIIS**: Uses energy-based metric A[i,j] = g_i·R_i + g_j·R_j - g_i·R_j - g_j·R_i
12//! - **Simultaneous-DIIS**: Combines Energy-DIIS with quadratic terms
13//!
14//! # References
15//!
16//! - Li, X.; Frisch, M. J. J. Chem. Theory Comput. 2006, 2, 835-839.
17//! - Kudin, K. N.; Scuseria, G. E.; Cancès, E. J. Chem. Phys. 2002, 116, 8255.
18
19use nalgebra::{DMatrix, DVector};
20use std::collections::VecDeque;
21
22/// GEDIIS matrix variant selection.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum GediisVariant {
25    /// RFO-DIIS: A[i,j] = <q_i, q_j> where q = quadratic step
26    #[default]
27    RfoDiis,
28    /// Energy-DIIS: A[i,j] = g_i·R_i + g_j·R_j - g_i·R_j - g_j·R_i
29    EnergyDiis,
30    /// Simultaneous: EnDIS + 0.5*(g_i·g_i/H + g_j·g_j/H)
31    SimultaneousDiis,
32}
33
34/// Tracks energy rises during optimization.
35#[derive(Debug, Clone, Default)]
36pub struct EnergyRiseTracker {
37    /// Number of consecutive energy rises
38    pub n_rises: usize,
39    /// Maximum allowed consecutive rises before interpolation
40    pub max_rises: usize,
41    /// Previous energy for comparison
42    prev_energy: Option<f64>,
43    /// Whether to force interpolation
44    pub do_interpolate: bool,
45}
46
47impl EnergyRiseTracker {
48    /// Creates a new tracker with specified max rises.
49    pub fn new(max_rises: usize) -> Self {
50        Self {
51            n_rises: 0,
52            max_rises,
53            prev_energy: None,
54            do_interpolate: false,
55        }
56    }
57
58
59    /// Updates tracker with new energy value.
60    ///
61    /// # Arguments
62    ///
63    /// * `energy` - Current energy (or energy difference for MECP)
64    /// * `tolerance` - Tolerance for energy rise detection
65    pub fn update(&mut self, energy: f64, tolerance: f64) {
66        if let Some(prev) = self.prev_energy {
67            if energy > prev + tolerance {
68                self.n_rises += 1;
69            } else {
70                self.n_rises = 0;
71            }
72        }
73        self.prev_energy = Some(energy);
74        self.do_interpolate = self.n_rises > self.max_rises;
75    }
76
77    /// Resets the tracker.
78    pub fn reset(&mut self) {
79        self.n_rises = 0;
80        self.prev_energy = None;
81        self.do_interpolate = false;
82    }
83}
84
85/// Configuration for GEDIIS optimizer.
86#[derive(Debug, Clone)]
87pub struct GediisConfig {
88    /// Maximum number of vectors to use
89    pub max_vectors: usize,
90    /// GEDIIS variant to use
91    pub variant: GediisVariant,
92    /// Switching threshold for RMS error (SimSw in Old Code)
93    pub sim_switch: f64,
94    /// Maximum consecutive energy rises before interpolation
95    pub max_rises: usize,
96    /// Whether to automatically switch variants
97    pub auto_switch: bool,
98    /// Scaling factor for TS search (SclDIS in Old Code)
99    pub ts_scale: f64,
100    /// Number of negative eigenvalues (0=min, 1=TS)
101    pub n_neg: usize,
102}
103
104impl Default for GediisConfig {
105    fn default() -> Self {
106        Self {
107            max_vectors: 5,
108            variant: GediisVariant::RfoDiis,
109            sim_switch: 0.005,
110            max_rises: 1,
111            auto_switch: true,
112            ts_scale: 1.0,
113            n_neg: 0,
114        }
115    }
116}
117
118/// Main GEDIIS optimizer.
119pub struct GediisOptimizer {
120    /// Configuration
121    pub config: GediisConfig,
122    /// Energy rise tracker
123    pub rise_tracker: EnergyRiseTracker,
124}
125
126impl GediisOptimizer {
127    /// Creates a new GEDIIS optimizer with default configuration.
128    pub fn new() -> Self {
129        Self {
130            config: GediisConfig::default(),
131            rise_tracker: EnergyRiseTracker::new(1),
132        }
133    }
134
135    /// Creates a new GEDIIS optimizer with specified configuration.
136    pub fn with_config(config: GediisConfig) -> Self {
137        let rise_tracker = EnergyRiseTracker::new(config.max_rises);
138        Self { config, rise_tracker }
139    }
140
141    /// Selects the appropriate GEDIIS variant based on optimization state.
142    ///
143    /// Implements the decision logic from Old Code GeoDIS:
144    /// - OKEnD requires NGDIIS >= 4 for TS search, >= 2 for minimum
145    /// - OKEnD also requires |DC| >= SmlDif where DC = E(1) - E(2)
146    /// - EnDIS used when RMSErr > SimSw or energy rises
147    pub fn select_variant(
148        &self,
149        rms_error: f64,
150        energy_rises: bool,
151        n_points: usize,
152        energies: Option<&VecDeque<f64>>,
153    ) -> GediisVariant {
154        if !self.config.auto_switch {
155            return self.config.variant;
156        }
157
158        // Check if Energy-DIIS is appropriate (OKEnD in Old Code)
159        let mut ok_en_diis = if self.config.n_neg > 0 {
160            n_points >= 4  // TS search needs more points
161        } else {
162            n_points >= 2  // Minimum search
163        };
164
165        // Additional check: energy difference must be significant
166        // Old Code: OKEnD = OKEnD.and.(Abs(DC).ge.SmlDif.or.Rises)
167        const SML_DIF: f64 = 1e-5;
168        if let Some(e) = energies {
169            if e.len() >= 2 {
170                let dc = e[0] - e[1];
171                ok_en_diis = ok_en_diis && (dc.abs() >= SML_DIF || energy_rises);
172            }
173        }
174
175        let use_energy = rms_error > self.config.sim_switch || energy_rises;
176
177        if use_energy && ok_en_diis {
178            GediisVariant::EnergyDiis
179        } else {
180            GediisVariant::RfoDiis
181        }
182    }
183
184    /// Computes a GEDIIS step.
185    ///
186    /// # Arguments
187    ///
188    /// * `coords` - History of coordinate vectors (Angstrom)
189    /// * `grads` - History of gradient vectors (Ha/Å)
190    /// * `energies` - History of energies (Ha)
191    /// * `quad_steps` - History of quadratic steps (optional, for RFO-DIIS)
192    ///
193    /// # Returns
194    ///
195    /// Tuple of (new_coords, coefficients) or None if failed.
196    pub fn compute_step(
197        &mut self,
198        coords: &VecDeque<DVector<f64>>,
199        grads: &VecDeque<DVector<f64>>,
200        energies: &VecDeque<f64>,
201        quad_steps: Option<&VecDeque<DVector<f64>>>,
202    ) -> Option<(DVector<f64>, Vec<f64>)> {
203        let n = coords.len();
204        if n < 2 {
205            return None;
206        }
207
208        let n_use = n.min(self.config.max_vectors);
209        let dim = coords[0].len();
210
211        // Compute RMS error for variant selection
212        let rms_error = if !grads.is_empty() {
213            let g_norm_sq: f64 = grads[0].norm_squared();
214            (g_norm_sq / dim as f64).sqrt()
215        } else {
216            0.0
217        };
218
219        // Check for energy rises
220        if !energies.is_empty() {
221            self.rise_tracker.update(energies[0], 1e-6);
222        }
223
224        // Select variant
225        let variant = self.select_variant(
226            rms_error,
227            self.rise_tracker.do_interpolate,
228            n_use,
229            Some(energies),
230        );
231
232        // Build GEDIIS matrix based on variant
233        let b_matrix = match variant {
234            GediisVariant::RfoDiis => {
235                if let Some(steps) = quad_steps {
236                    self.build_rfo_diis_matrix(steps, n_use)
237                } else {
238                    // Fallback: use gradients as pseudo-steps
239                    self.build_rfo_diis_matrix(grads, n_use)
240                }
241            }
242            GediisVariant::EnergyDiis => {
243                self.build_energy_diis_matrix(grads, coords, n_use)
244            }
245            GediisVariant::SimultaneousDiis => {
246                self.build_sim_diis_matrix(grads, coords, energies, n_use, quad_steps)
247            }
248        };
249
250        // Build RHS vector
251        // For RFO-DIIS and Sim-DIIS: only constraint equation (matching Old Code)
252        // For Energy-DIIS: energies are used in the EnCoef solver differently
253        let mut rhs = DVector::zeros(n_use + 1);
254        if variant == GediisVariant::EnergyDiis {
255            // Energy-DIIS uses energies to bias toward lower energy points
256            for i in 0..n_use {
257                rhs[i] = -energies.get(i).copied().unwrap_or(0.0);
258            }
259        }
260        // All variants: constraint that sum of coefficients = 1
261        rhs[n_use] = 1.0;
262
263        // Solve for coefficients
264        let solution = b_matrix.lu().solve(&rhs)?;
265
266        // Extract coefficients (exclude Lagrange multiplier)
267        let coeffs: Vec<f64> = solution.rows(0, n_use).iter().copied().collect();
268
269        // Validate coefficients
270        if coeffs.iter().any(|c| !c.is_finite()) {
271            return None;
272        }
273
274        // Interpolate geometry
275        let mut x_interp = DVector::zeros(dim);
276        for (i, coord) in coords.iter().take(n_use).enumerate() {
277            x_interp += coord * coeffs[i];
278        }
279
280        // Interpolate gradient
281        let mut g_interp = DVector::zeros(dim);
282        for (i, grad) in grads.iter().take(n_use).enumerate() {
283            g_interp += grad * coeffs[i];
284        }
285
286        // Compute new geometry: x_new = x_interp - g_interp
287        let x_new = &x_interp - &g_interp;
288
289        Some((x_new, coeffs))
290    }
291
292    /// Builds RFO-DIIS matrix from quadratic steps.
293    ///
294    /// A[i,j] = <q_i, q_j>
295    /// For TS search, first coordinate is weighted by ts_scale.
296    fn build_rfo_diis_matrix(
297        &self,
298        steps: &VecDeque<DVector<f64>>,
299        n: usize,
300    ) -> DMatrix<f64> {
301        let mut b = DMatrix::zeros(n + 1, n + 1);
302
303        for i in 0..n {
304            for j in 0..n {
305                let dot = if self.config.n_neg > 0 && steps[i].len() > 0 {
306                    // TS search: weight first coordinate
307                    self.config.ts_scale * steps[i][0] * steps[j][0]
308                        + steps[i].rows(1, steps[i].len() - 1)
309                            .dot(&steps[j].rows(1, steps[j].len() - 1))
310                } else {
311                    steps[i].dot(&steps[j])
312                };
313                b[(i, j)] = dot;
314            }
315        }
316
317        // Tikhonov regularization
318        let mut diag_sum = 0.0_f64;
319        for i in 0..n {
320            diag_sum += b[(i, i)];
321        }
322        let reg = 1e-6 * (diag_sum / (n as f64)).max(1e-10);
323        for i in 0..n {
324            b[(i, i)] += reg;
325        }
326
327        // Add constraint row/column
328        for i in 0..n {
329            b[(i, n)] = 1.0;
330            b[(n, i)] = 1.0;
331        }
332        b[(n, n)] = 0.0;
333
334        b
335    }
336
337    /// Builds Energy-DIIS matrix.
338    ///
339    /// Trace: AMTrc[i,j] = -<g_i, x_j>
340    /// A[i,j] = AMTrc[i,i] + AMTrc[j,j] - AMTrc[i,j] - AMTrc[j,i]
341    ///
342    /// For TS search (n_neg > 0), the first coordinate is weighted by ts_scale
343    /// to emphasize the reaction coordinate, matching Old Code:
344    /// `AMTrc(I+1,J+1) = -SclDIS*ANFF(1,I)*ANCC(1,J) - SProd(NVarT-1,ANFF(2,I),ANCC(2,J))`
345    fn build_energy_diis_matrix(
346        &self,
347        grads: &VecDeque<DVector<f64>>,
348        coords: &VecDeque<DVector<f64>>,
349        n: usize,
350    ) -> DMatrix<f64> {
351        let mut b = DMatrix::zeros(n + 1, n + 1);
352
353        // Build trace matrix: AMTrc[i,j] = -<g_i, x_j>
354        // For TS search, weight first coordinate by ts_scale
355        let mut trace = DMatrix::zeros(n, n);
356        for i in 0..n {
357            for j in 0..n {
358                if self.config.n_neg > 0 && !grads[i].is_empty() && !coords[j].is_empty() {
359                    // TS search: weight first coordinate
360                    let first_term = -self.config.ts_scale * grads[i][0] * coords[j][0];
361                    let rest_term = if grads[i].len() > 1 {
362                        -grads[i].rows(1, grads[i].len() - 1)
363                            .dot(&coords[j].rows(1, coords[j].len() - 1))
364                    } else {
365                        0.0
366                    };
367                    trace[(i, j)] = first_term + rest_term;
368                } else {
369                    trace[(i, j)] = -grads[i].dot(&coords[j]);
370                }
371            }
372        }
373
374        // Build Energy-DIIS matrix: A[i,j] = trace[i,i] + trace[j,j] - trace[i,j] - trace[j,i]
375        for i in 0..n {
376            for j in 0..n {
377                b[(i, j)] = trace[(i, i)] + trace[(j, j)] - trace[(i, j)] - trace[(j, i)];
378            }
379        }
380
381        // Tikhonov regularization: add 1e-6 × mean diagonal to prevent
382        // ill-conditioned matrices when gradient-geometry pairs are nearly
383        // colinear (common with capped steps in MECP).
384        let mut diag_sum = 0.0_f64;
385        for i in 0..n {
386            diag_sum += b[(i, i)];
387        }
388        let reg = 1e-6 * (diag_sum / (n as f64)).max(1e-10);
389        for i in 0..n {
390            b[(i, i)] += reg;
391        }
392
393        // Add constraint row/column (sum of coefficients = 1)
394        for i in 0..n {
395            b[(i, n)] = 1.0;
396            b[(n, i)] = 1.0;
397        }
398        b[(n, n)] = 0.0;
399
400        b
401    }
402
403    /// Builds Simultaneous-DIIS matrix.
404    ///
405    /// Combines Energy-DIIS with quadratic energy approximation.
406    /// Formula: A[i,j] = EnDIS[i,j] + 0.5*(q_i·f_i + q_j·f_j)
407    /// where q = quadratic step and f = forces (negative gradient).
408    ///
409    /// This matches the Old Code GeoDIS implementation:
410    /// `Half*(SProd(NVarT,AQuad(1,I),ANFF(1,I)) + SProd(NVarT,AQuad(1,J),ANFF(1,J)))`
411    fn build_sim_diis_matrix(
412        &self,
413        grads: &VecDeque<DVector<f64>>,
414        coords: &VecDeque<DVector<f64>>,
415        _energies: &VecDeque<f64>,
416        n: usize,
417        quad_steps: Option<&VecDeque<DVector<f64>>>,
418    ) -> DMatrix<f64> {
419        // Start with Energy-DIIS matrix
420        let mut b = self.build_energy_diis_matrix(grads, coords, n);
421
422        // Add quadratic energy approximation: 0.5*(q_i·f_i + q_j·f_j)
423        // where f = -grad (forces are negative gradients)
424        // If quad_steps not available, use grads as approximation
425        for i in 0..n {
426            for j in 0..n {
427                let quad_term = if let Some(steps) = quad_steps {
428                    // Correct formula: 0.5*(q_i·(-g_i) + q_j·(-g_j))
429                    // Since forces = -grads, this is -0.5*(q_i·g_i + q_j·g_j)
430                    -0.5 * (steps[i].dot(&grads[i]) + steps[j].dot(&grads[j]))
431                } else {
432                    // Fallback: use gradient norm squared as approximation
433                    // This is less accurate but provides a reasonable estimate
434                    0.5 * (grads[i].norm_squared() + grads[j].norm_squared())
435                };
436                b[(i, j)] += quad_term;
437            }
438        }
439
440        b
441    }
442}
443
444impl Default for GediisOptimizer {
445    fn default() -> Self {
446        Self::new()
447    }
448}
449
450/// Computes dynamic GEDIIS weight based on energy trend.
451///
452/// Returns weight in [0.0, 0.98] for blending GDIIS and GEDIIS.
453pub fn compute_dynamic_gediis_weight(
454    energies: &VecDeque<f64>,
455    displacements: &VecDeque<f64>,
456) -> f64 {
457    let n = energies.len();
458    if n < 5 {
459        return 0.0;  // Not enough history
460    }
461
462    // Check for stuck optimizer
463    let recent_displacements: Vec<f64> = displacements.iter().take(3).copied().collect();
464    let avg_disp = recent_displacements.iter().sum::<f64>() / recent_displacements.len() as f64;
465    if avg_disp < 1e-8 {
466        return 0.0;  // Stuck, use pure GDIIS
467    }
468
469    // Count uphill steps
470    let mut uphill_count = 0;
471    for i in 0..(n - 1).min(5) {
472        if energies[i] > energies[i + 1] + 1e-8 {
473            uphill_count += 1;
474        }
475    }
476
477    // If too many uphill steps, reduce GEDIIS weight
478    if uphill_count >= 2 {
479        return 0.0;
480    }
481
482    // Compute energy trend using linear regression
483    let x_mean = (n - 1) as f64 / 2.0;
484    let y_mean: f64 = energies.iter().sum::<f64>() / n as f64;
485
486    let mut num = 0.0;
487    let mut den = 0.0;
488    for (i, &e) in energies.iter().enumerate() {
489        let x = i as f64;
490        num += (x - x_mean) * (e - y_mean);
491        den += (x - x_mean) * (x - x_mean);
492    }
493
494    let slope = if den.abs() > 1e-14 { num / den } else { 0.0 };
495
496    // Compute deviation from trend
497    let mut max_dev: f64 = 0.0;
498    for (i, &e) in energies.iter().enumerate() {
499        let predicted = y_mean + slope * (i as f64 - x_mean);
500        let dev = (e - predicted).abs();
501        max_dev = max_dev.max(dev);
502    }
503
504    // Map deviation to weight
505    let scale = y_mean.abs().max(1e-6);
506    let rel_dev = max_dev / scale;
507
508    let weight = if rel_dev < 0.01 {
509        0.98  // Very smooth, use mostly GEDIIS
510    } else if rel_dev < 0.05 {
511        0.8
512    } else if rel_dev < 0.1 {
513        0.5
514    } else if rel_dev < 0.2 {
515        0.3
516    } else {
517        0.0  // Too noisy, use pure GDIIS
518    };
519
520    // Apply uphill penalty
521    let penalty = 1.0 - 0.3 * uphill_count as f64;
522    (weight * penalty).clamp(0.0, 0.98)
523}