//! Eigenvalues: the dense symmetric eigenproblem of the Lanczos projection, and the shifted //! block Lanczos method for the lowest modes of K φ = λ M φ. //! //! **Dense.** Householder reduction to tridiagonal form, then the implicit symmetric QR //! algorithm with Wilkinson shifts, accumulating the transformations (G. H. Golub, C. F. Van //! Loan, *Matrix Computations*, 4th ed., Johns Hopkins 2013, §8.3, algorithms 8.3.1 and 8.3.3). //! //! **Sparse.** The spectral transformation (T. Ericsson, A. Ruhe, *The spectral transformation //! Lanczos method for the numerical solution of large sparse generalized symmetric eigenvalue //! problems*, Math. Comp. 35(152):1251–1268, 1980): with a shift σ below the wanted //! eigenvalues, the operator (K − σM)⁻¹M is symmetric in the M inner product and has eigenvalues //! θ = 1/(λ − σ), so the lowest λ become the largest, well separated θ. A block of vectors //! (R. G. Grimes, J. G. Lewis, H. D. Simon, *A shifted block Lanczos algorithm for solving //! sparse symmetric generalized eigenproblems*, SIAM J. Matrix Anal. Appl. 15(1):228–272, 1994) //! finds repeated eigenvalues up to the block size, such as the six rigid-body modes of a free //! body. Every new block is reorthogonalized against all earlier ones, twice (full //! reorthogonalization, B. N. Parlett, *The Symmetric Eigenvalue Problem*, SIAM 1998, ch. 13), //! so no spurious copies appear. A Ritz pair has converged when its residual bound //! ‖B_{j+1} s‖ is below a relative tolerance. The start block is pseudo-random from a fixed seed, //! so results are reproducible. use crate::{Error, Result}; /// Eigenvalues (ascending) and eigenvectors (column k of the row-major `vectors`) of a dense /// symmetric matrix (row-major, n × n). pub fn symmetric_eigen(a: &[f64], n: usize) -> Result<(Vec, Vec)> { if a.len() != n * n { return Err(Error::Internal("dense eigenproblem size".into())); } let mut t = a.to_vec(); let mut q = vec![0.0; n * n]; for i in 0..n { q[i * n + i] = 1.0; } // Householder tridiagonalization: for each column k, reflect A[k+1.., k] onto e1. let mut v = vec![0.0; n]; let mut p = vec![0.0; n]; for k in 0..n.saturating_sub(2) { let m = k + 1; let norm: f64 = (m..n).map(|i| t[i * n + k] * t[i * n + k]).sum::().sqrt(); if norm == 0.0 { continue; } let x0 = t[m * n + k]; let alpha = if x0 >= 0.0 { -norm } else { norm }; for i in m..n { v[i] = t[i * n + k]; } v[m] -= alpha; let vnorm: f64 = (m..n).map(|i| v[i] * v[i]).sum::().sqrt(); if vnorm == 0.0 { continue; } for i in m..n { v[i] /= vnorm; } // p = A22 v, w = p − (vᵀp) v, A22 −= 2 (v wᵀ + w vᵀ). for i in m..n { p[i] = (m..n).map(|j| t[i * n + j] * v[j]).sum(); } let kk: f64 = (m..n).map(|i| v[i] * p[i]).sum(); for i in m..n { p[i] -= kk * v[i]; } for i in m..n { for j in m..n { t[i * n + j] -= 2.0 * (v[i] * p[j] + p[i] * v[j]); } } t[m * n + k] = alpha; t[k * n + m] = alpha; for i in m + 1..n { t[i * n + k] = 0.0; t[k * n + i] = 0.0; } // Q = Q (I − 2 v vᵀ). for r in 0..n { let s: f64 = (m..n).map(|j| q[r * n + j] * v[j]).sum(); for j in m..n { q[r * n + j] -= 2.0 * s * v[j]; } } } let mut d: Vec = (0..n).map(|i| t[i * n + i]).collect(); let mut e: Vec = (0..n.saturating_sub(1)).map(|i| t[(i + 1) * n + i]).collect(); tridiagonal_qr(&mut d, &mut e, &mut q, n)?; // Sort ascending, carrying the vectors. let mut order: Vec = (0..n).collect(); order.sort_by(|&a, &b| d[a].total_cmp(&d[b]).then(a.cmp(&b))); let values = order.iter().map(|&i| d[i]).collect(); let mut vectors = vec![0.0; n * n]; for (new, &old) in order.iter().enumerate() { for r in 0..n { vectors[r * n + new] = q[r * n + old]; } } Ok((values, vectors)) } /// Diagonalize a symmetric tridiagonal matrix (diagonal d, sub-diagonal e) by implicit QR steps /// with Wilkinson shifts, applying the rotations to the columns of q. fn tridiagonal_qr(d: &mut [f64], e: &mut [f64], q: &mut [f64], n: usize) -> Result<()> { let small = |e: f64, a: f64, b: f64| e.abs() <= f64::EPSILON * (a.abs() + b.abs()); let mut h = n.saturating_sub(1); let mut iterations = 0usize; let cap = 30 * n.max(1) + 30; while h > 0 { if small(e[h - 1], d[h - 1], d[h]) { e[h - 1] = 0.0; h -= 1; continue; } let mut l = h - 1; while l > 0 && !small(e[l - 1], d[l - 1], d[l]) { l -= 1; } if l > 0 { e[l - 1] = 0.0; } iterations += 1; if iterations > cap { return Err(Error::NotConverged("the tridiagonal QR iteration did not converge".into())); } // Wilkinson shift from the trailing 2 × 2 block. let dd = (d[h - 1] - d[h]) / 2.0; let eh = e[h - 1]; let sign = if dd >= 0.0 { 1.0 } else { -1.0 }; let mu = d[h] - eh * eh / (dd + sign * dd.hypot(eh)); let mut x = d[l] - mu; let mut z = e[l]; for k in l..h { let r = x.hypot(z); let (c, s) = if r == 0.0 { (1.0, 0.0) } else { (x / r, -z / r) }; if k > l { e[k - 1] = c * e[k - 1] - s * z; } let (a, b, dq) = (d[k], e[k], d[k + 1]); d[k] = c * c * a - 2.0 * c * s * b + s * s * dq; d[k + 1] = s * s * a + 2.0 * c * s * b + c * c * dq; e[k] = c * s * (a - dq) + (c * c - s * s) * b; if k + 1 < h { z = -s * e[k + 1]; e[k + 1] *= c; x = e[k]; } for row in 0..n { let (qp, qq) = (q[row * n + k], q[row * n + k + 1]); q[row * n + k] = c * qp - s * qq; q[row * n + k + 1] = s * qp + c * qq; } } } Ok(()) } /// A small deterministic pseudo-random generator (xorshift64*, S. Vigna, *An experimental /// exploration of Marsaglia's xorshift generators, scrambled*, ACM TOMS 42(4), 2016) for /// Lanczos start vectors. pub struct Random(u64); impl Random { pub fn new(seed: u64) -> Random { Random(seed.max(1)) } /// Uniform in [−1, 1). pub fn uniform(&mut self) -> f64 { self.0 ^= self.0 >> 12; self.0 ^= self.0 << 25; self.0 ^= self.0 >> 27; let v = self.0.wrapping_mul(0x2545_f491_4f6c_dd1d); (v >> 11) as f64 / (1u64 << 52) as f64 - 1.0 } } /// What the Lanczos iteration looks for, in terms of λ = σ + 1/θ. #[derive(Debug, Clone, Copy)] pub struct Wanted { /// Modes to return within the range. pub count: usize, /// Lower and upper bounds on λ (eigenvalues outside are skipped). pub min: f64, pub max: f64, } /// The operators the Lanczos iteration needs. pub trait Operator: Sync { fn n(&self) -> usize; /// y = M x. fn mass(&self, x: &[f64], y: &mut [f64]); /// x ← (K − σM)⁻¹ x. fn solve(&self, x: &mut [f64]) -> Result<()>; /// The right-hand side the operator solves for, given a basis vector `x` and its M product /// `mx`: M x for the spectral transformation (the default); another matrix's product for /// other pencils (buckling, [`crate::buckle`]). fn operand(&self, _x: &[f64], mx: &[f64]) -> Vec { mx.to_vec() } /// The eigenvalue of a Ritz value θ of the operator: σ + 1/θ for the spectral /// transformation. fn eigenvalue(&self, shift: f64, theta: f64) -> f64 { shift + 1.0 / theta } } /// Converged eigenpairs: λ ascending, vectors M-orthonormal. #[derive(Debug, Clone)] pub struct Eigenpairs { pub values: Vec, pub vectors: Vec>, /// Converged eigenvalues of negative Ritz values θ (outside what the transformation looks /// for: negative buckling load factors), largest |θ| first. pub negative: Vec, /// Lanczos vectors used. pub basis: usize, } fn dot(a: &[f64], b: &[f64]) -> f64 { a.iter().zip(b).map(|(x, y)| x * y).sum() } /// Orthonormalize a block against the basis and itself in the M inner product (classical /// Gram–Schmidt, twice). A column that vanishes is replaced by a fresh random direction and its /// diagonal coefficient stays zero. Returns the new vectors, their M products and the /// upper-triangular coefficients `r[j][i]` of column i on new vector j. fn orthonormalize( op: &impl Operator, w: Vec>, q: &[Vec], mq: &[Vec], rng: &mut Random, scratch: &mut [f64], ) -> (Vec>, Vec>, Vec>) { let n = op.n(); let k = w.len(); let mut r = vec![vec![0.0; k]; k]; let mut out: Vec> = Vec::with_capacity(k); let mut mout: Vec> = Vec::with_capacity(k); for (i, mut v) in w.into_iter().enumerate() { let mut fresh = false; for _attempt in 0..4 { op.mass(&v, scratch); let before = dot(&v, scratch).max(0.0).sqrt(); for _ in 0..2 { for (qj, mqj) in q.iter().zip(mq) { let c = dot(mqj, &v); v.iter_mut().zip(qj).for_each(|(x, y)| *x -= c * y); } for (j, (qj, mqj)) in out.iter().zip(&mout).enumerate() { let c = dot(mqj, &v); v.iter_mut().zip(qj).for_each(|(x, y)| *x -= c * y); if !fresh && let Some(x) = r.get_mut(j).and_then(|row| row.get_mut(i)) { *x += c; } } } op.mass(&v, scratch); let norm = dot(&v, scratch).max(0.0).sqrt(); if norm > 1e-8 * before && norm > 0.0 { if !fresh && let Some(x) = r.get_mut(i).and_then(|row| row.get_mut(i)) { *x = norm; } v.iter_mut().for_each(|x| *x /= norm); mout.push(scratch.iter().map(|x| x / norm).collect()); out.push(v); break; } fresh = true; v = (0..n).map(|_| rng.uniform()).collect(); } } (out, mout, r) } /// Shift-invert block Lanczos with full reorthogonalization. pub fn lanczos(op: &impl Operator, shift: f64, wanted: Wanted, block: usize, max_basis: usize, tolerance: f64) -> Result { let n = op.n(); if n == 0 { return Err(Error::Invalid("there are no free degrees of freedom".into())); } let b = block.clamp(1, n); let max_basis = max_basis.min(n).max(b); let mut rng = Random::new(0x9e37_79b9_7f4a_7c15); let mut q: Vec> = Vec::new(); let mut mq: Vec> = Vec::new(); // The projected matrix T, grown block by block (row-major in a growing square). let mut t: Vec> = Vec::new(); let mut scratch = vec![0.0; n]; // Start block. let start: Vec> = (0..b).map(|_| (0..n).map(|_| rng.uniform()).collect()).collect(); let (mut cur, mut mcur, _) = orthonormalize(op, start, &q, &mq, &mut rng, &mut scratch); if cur.len() < b { return Err(Error::NotConverged("could not build a Lanczos start block".into())); } let mut prev_b: Option>> = None; let mut next_check = (wanted.count + b).max(2 * b); loop { fieldcraft_model::progress::check()?; let j0 = q.len(); // W = Op Q_j. let mut w: Vec> = Vec::with_capacity(b); // The block's solves are independent: one task each. for y in fieldcraft_parallel::map(mcur.len(), |i| { let mut y = op.operand(cur.get(i).map(Vec::as_slice).unwrap_or_default(), mcur.get(i).map(Vec::as_slice).unwrap_or_default()); op.solve(&mut y).map(|()| y) }) { w.push(y?); } // A_j = Q_jᵀ M W (symmetrized), W −= Q_j A_j (the earlier blocks go in the full // reorthogonalization below). let mut a = vec![vec![0.0; b]; b]; for (i, mqi) in mcur.iter().enumerate() { for (j, wj) in w.iter().enumerate() { a[i][j] = dot(mqi, wj); } } for i in 0..b { for j in i + 1..b { let s = 0.5 * (a[i][j] + a[j][i]); a[i][j] = s; a[j][i] = s; } } q.append(&mut cur); mq.append(&mut mcur); let m = q.len(); for row in &mut t { row.resize(m, 0.0); } t.resize(m, vec![0.0; m]); for i in 0..b { for j in 0..b { t[j0 + i][j0 + j] = a[i][j]; } } if let Some(bp) = &prev_b { // T[block j][block j−1] = B_j, T[block j−1][block j] = B_jᵀ. for i in 0..b { for j in 0..b { t[j0 + i][j0 - b + j] = bp[i][j]; t[j0 - b + j][j0 + i] = bp[i][j]; } } } // Full reorthogonalization happens inside the QR against the whole basis. let full = m >= max_basis || m + b > n; let (next, mnext, bnext) = if full { (Vec::new(), Vec::new(), vec![vec![0.0; b]; b]) } else { orthonormalize(op, w, &q, &mq, &mut rng, &mut scratch) }; // Convergence check on a schedule (the dense eigenproblem costs m³). if m >= next_check || full || next.len() < b { next_check = m + (m / 4).max(b); let dense: Vec = t.iter().flatten().copied().collect(); let (theta, s) = symmetric_eigen(&dense, m)?; // Order by λ ascending = θ descending (θ > 0 below the spectrum). let mut idx: Vec = (0..m).collect(); idx.sort_by(|&x, &y| theta[y].total_cmp(&theta[x]).then(x.cmp(&y))); let mut done = false; let mut accepted = Vec::new(); let mut found = 0; for &i in &idx { let th = theta[i]; if !(th > 0.0) { break; } // Residual bound ‖B_{j+1} s_last‖ with s_last the last block of the Ritz vector. let mut res2 = 0.0; if !full { for r in 0..b { let v: f64 = (0..b).map(|c| bnext[r][c] * s[(m - b + c) * m + i]).sum(); res2 += v * v; } } if res2.sqrt() > tolerance * th { break; } let lambda = op.eigenvalue(shift, th); if lambda > wanted.max { done = true; break; } if lambda >= wanted.min { accepted.push(i); found += 1; if found == wanted.count { done = true; break; } } } if full && !done { if m >= n { done = true; } else { return Err(Error::NotConverged(format!( "found {found} of {} modes after {m} Lanczos vectors (the cap); raise the iteration limit or ask for fewer modes", wanted.count ))); } } if done { // Negative Ritz values that have converged, largest magnitude first. let mut negative = Vec::new(); for &i in idx.iter().rev() { let th = theta[i]; if !(th < 0.0) { break; } let res2: f64 = if full { 0.0 } else { (0..b).map(|r| (0..b).map(|c| bnext[r][c] * s[(m - b + c) * m + i]).sum::().powi(2)).sum() }; if res2.sqrt() > tolerance * th.abs() { break; } negative.push(op.eigenvalue(shift, th)); } let mut values = Vec::new(); let mut vectors = Vec::new(); for &i in &accepted { values.push(op.eigenvalue(shift, theta[i])); let mut x = vec![0.0; n]; for (k, qk) in q.iter().enumerate() { let c = s[k * m + i]; x.iter_mut().zip(qk).for_each(|(a, b)| *a += c * b); } op.mass(&x, &mut scratch); let norm = dot(&x, &scratch).max(0.0).sqrt(); if norm > 0.0 { x.iter_mut().for_each(|a| *a /= norm); } // Deterministic sign: the largest component positive. let big = x.iter().copied().fold(0.0f64, |m, v| if v.abs() > m.abs() { v } else { m }); if big < 0.0 { x.iter_mut().for_each(|a| *a = -*a); } vectors.push(x); } return Ok(Eigenpairs { values, vectors, negative, basis: m }); } } if full { return Err(Error::NotConverged("the Lanczos basis is exhausted".into())); } cur = next; mcur = mnext; if cur.len() < b { return Err(Error::NotConverged("the Lanczos block lost rank".into())); } prev_b = Some(bnext); } } #[cfg(test)] mod tests { use super::*; #[test] fn dense_symmetric_eigen() { let n = 9; let mut rng = Random::new(7); let mut a = vec![0.0; n * n]; for i in 0..n { for j in 0..=i { let v = rng.uniform(); a[i * n + j] = v; a[j * n + i] = v; } } let (values, vectors) = symmetric_eigen(&a, n).unwrap(); assert!(values.windows(2).all(|w| w[0] <= w[1])); for k in 0..n { for i in 0..n { let av: f64 = (0..n).map(|j| a[i * n + j] * vectors[j * n + k]).sum(); assert!((av - values[k] * vectors[i * n + k]).abs() < 1e-12, "pair {k}"); } let norm: f64 = (0..n).map(|i| vectors[i * n + k].powi(2)).sum(); assert!((norm - 1.0).abs() < 1e-12); } assert_eq!(symmetric_eigen(&[3.0], 1).unwrap().0, vec![3.0]); } /// K and M diagonal. struct Diagonal { k: Vec, m: Vec, shift: f64, } impl Operator for Diagonal { fn n(&self) -> usize { self.k.len() } fn mass(&self, x: &[f64], y: &mut [f64]) { for i in 0..x.len() { y[i] = self.m[i] * x[i]; } } fn solve(&self, x: &mut [f64]) -> Result<()> { for i in 0..x.len() { x[i] /= self.k[i] - self.shift * self.m[i]; } Ok(()) } } #[test] fn lanczos_finds_repeated_and_zero_eigenvalues() { // Six zero eigenvalues, then 1 twice, then distinct values; M not the identity. let n = 200; let lambdas: Vec = (0..n) .map(|i| { if i < 6 { 0.0 } else if i < 8 { 1.0 } else { 1.0 + (i - 7) as f64 * 0.5 } }) .collect(); let m: Vec = (0..n).map(|i| 1.0 + (i % 7) as f64 * 0.1).collect(); let k: Vec = lambdas.iter().zip(&m).map(|(l, m)| l * m).collect(); let shift = -1e-3; let op = Diagonal { k, m, shift }; let wanted = Wanted { count: 10, min: f64::NEG_INFINITY, max: f64::INFINITY }; let pairs = lanczos(&op, shift, wanted, 6, n, 1e-9).unwrap(); assert_eq!(pairs.values.len(), 10); let want = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 1.0, 1.0, 1.5, 2.0]; for (got, w) in pairs.values.iter().zip(want) { assert!((got - w).abs() < 1e-8, "{:?}", pairs.values); } // M-orthonormal vectors. let mut y = vec![0.0; n]; for a in &pairs.vectors { for b in &pairs.vectors { op.mass(b, &mut y); let d = dot(a, &y); assert!((d - if std::ptr::eq(a, b) { 1.0 } else { 0.0 }).abs() < 1e-8); } } // A range: values in [1.2, 3.2] (λ), at most 100. let ranged = lanczos(&op, shift, Wanted { count: 100, min: 1.2, max: 3.2 }, 6, n, 1e-9).unwrap(); assert_eq!(ranged.values.len(), 4, "{:?}", ranged.values); assert!(ranged.values.iter().zip([1.5, 2.0, 2.5, 3.0]).all(|(g, w)| (g - w).abs() < 1e-8), "{:?}", ranged.values); // Caps: a basis too small to converge is an error, not a hang. assert!(lanczos(&op, shift, Wanted { count: 50, min: f64::NEG_INFINITY, max: f64::INFINITY }, 6, 24, 1e-9).is_err()); // Tiny problems: the whole space. let small = Diagonal { k: vec![2.0, 1.0, 3.0], m: vec![1.0; 3], shift: 0.0 }; let all = lanczos(&small, 0.0, Wanted { count: 5, min: f64::NEG_INFINITY, max: f64::INFINITY }, 6, 100, 1e-9).unwrap(); assert_eq!(all.values.len(), 3); assert!((all.values[0] - 1.0).abs() < 1e-12 && (all.values[2] - 3.0).abs() < 1e-12, "{:?}", all.values); } }