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}