carapace_core/analysis/modal.rs
1use nalgebra::{DMatrix, DVector, SymmetricEigen};
2use slotmap::Key;
3
4use crate::model::{Domain, ElementOps};
5
6use super::{AnalysisError, SparseSolver};
7
8/// One computed mode: natural circular frequency (rad/time) and the
9/// corresponding free-DOF mode shape, M-normalized (`shape^T * M * shape = 1`)
10/// — the normalization modal damping needs to apply a per-mode
11/// damping ratio consistently.
12#[derive(Debug, Clone)]
13pub struct Mode {
14 pub frequency: f64,
15 pub shape: DVector<f64>,
16}
17
18/// A shift-invert Lanczos eigensolver (shift = 0, so the operator is simply
19/// `K^-1 * M`), not a dense full-spectrum `nalgebra::SymmetricEigen` and
20/// not an ARPACK FFI binding either. Two things made the earlier dense
21/// choice wrong beyond just performance, once `SparseSolver` itself went
22/// sparse:
23///
24/// - **Memory.** A dense N×N matrix costs O(n²) regardless of how sparse
25/// the model actually is — on a model `SparseSolver` handles comfortably,
26/// the dense eigensolve path could exhaust a wasm worker's memory before
27/// it's even "slow". `Domain` no longer builds one for this at all.
28/// - **Shape of the problem.** A full-spectrum solve computes *all* n
29/// eigenpairs even when only the lowest `num_modes` are wanted — the
30/// usual case, and the one modal-superposition damping actually
31/// needs. Lanczos naturally converges the extreme eigenvalues of the
32/// shift-inverted operator first, so asking for just `num_modes` does
33/// the proportionate amount of work.
34///
35/// This isn't "roll our own ARPACK": the hard numerical pieces are both
36/// already library code — `SparseSolver`'s sparse LU, factored once and
37/// reused for each shift-invert solve `K*w = M*v`, and
38/// `nalgebra::SymmetricEigen` for the small
39/// `m*m` (`m` = Lanczos subspace size, not `n`) projected tridiagonal
40/// eigenproblem. What's ours is the well-understood outer Lanczos
41/// recurrence and (full, since `m` is always small) M-orthogonal
42/// re-orthogonalization loop.
43///
44/// Only `shift = 0` is supported (find the `num_modes` lowest natural
45/// frequencies) — by far the standard structural-dynamics case. A nonzero
46/// shift (targeting modes near a chosen frequency, or handling a
47/// near-singular `K` for buckling analysis) is a natural extension, not
48/// implemented until something actually needs it.
49///
50/// `M` (lumped, `Node::mass`) must be diagonal and non-negative, with at
51/// least `num_modes` strictly positive entries; massless DOFs are
52/// statically condensed.
53///
54/// Generic over the same kinematic profile as `Domain`/`Analysis` (see
55/// `Domain`'s doc comment) — the Lanczos recurrence and mass-normalization
56/// above only ever touch `domain` through its generic free-DOF assembly
57/// interface (`assemble_mass_diagonal`, `num_free_dofs`,
58/// `assemble_tangent_and_resistance`), never anything DOF-count-specific,
59/// so one implementation covers both `Domain`/`Domain3` — inferred from
60/// `domain`'s concrete type at each call site, same as
61/// `AnalysisBuilder::build`.
62pub fn modal_analysis<const NDIM: usize, const NDOF: usize, NId, E>(
63 domain: &mut Domain<NDIM, NDOF, NId, E>,
64 num_modes: usize,
65) -> Result<Vec<Mode>, AnalysisError>
66where
67 NId: Key,
68 E: ElementOps<NDIM, NDOF, NId>,
69{
70 domain.validate().map_err(AnalysisError::InvalidModel)?;
71 let mass = domain.assemble_mass_diagonal();
72 let n = domain.num_free_dofs();
73
74 if num_modes == 0 || num_modes > n {
75 return Err(AnalysisError::InvalidModeCount {
76 requested: num_modes,
77 free_dofs: n,
78 });
79 }
80 // Massless DOFs (typically rotations with no rotational inertia) are
81 // allowed: `K^-1 * M` is then rank-deficient, which only removes the
82 // infinite-frequency modes, and the recovered shapes statically condense
83 // those DOFs. Negative mass, or fewer massive DOFs than requested modes,
84 // cannot produce the modes asked for.
85 if mass.iter().any(|&m| m < 0.0) || mass.iter().filter(|&&m| m > 0.0).count() < num_modes {
86 return Err(AnalysisError::SingularSystem);
87 }
88
89 let (k, _resistance) = domain.assemble_tangent_and_resistance(domain.committed_time());
90 let solver = SparseSolver::new();
91 // K is unchanged throughout Lanczos; only the right-hand side varies.
92 let factorization = solver.factor(&k)?;
93
94 let m_dot =
95 |x: &DVector<f64>, y: &DVector<f64>| -> f64 { (0..n).map(|i| x[i] * mass[i] * y[i]).sum() };
96 let m_norm = |x: &DVector<f64>| -> f64 { m_dot(x, x).sqrt() };
97
98 // Lanczos subspace size: bigger than `num_modes` for accuracy (the
99 // extra dimensions are "scratch" that improve convergence of the
100 // modes we keep), capped at `n` (can't build a larger Krylov subspace
101 // than the space itself).
102 let subspace_size = (2 * num_modes + 8).min(n);
103
104 // `vectors[0]` is an unused zero placeholder so `vectors[j]`/`beta[j]`
105 // line up with the standard 1-indexed Lanczos recurrence; the real
106 // basis is `vectors[1..]`.
107 let mut vectors: Vec<DVector<f64>> = Vec::with_capacity(subspace_size + 1);
108 vectors.push(DVector::zeros(n));
109 let seed = DVector::from_element(n, 1.0);
110 vectors.push(&seed / m_norm(&seed));
111
112 let mut alpha = Vec::with_capacity(subspace_size);
113 let mut beta = vec![0.0];
114
115 let mut built = 0;
116 for j in 1..=subspace_size {
117 let mv = mass.component_mul(&vectors[j]);
118 let mut w = factorization.solve(&mv);
119 w -= beta[j - 1] * &vectors[j - 1];
120
121 let alpha_j = m_dot(&w, &vectors[j]);
122 w -= alpha_j * &vectors[j];
123
124 // Full M-orthogonal re-orthogonalization against every prior
125 // basis vector — cheap since `subspace_size` is always small,
126 // and necessary since three-term recurrence alone loses
127 // orthogonality to rounding error within a handful of steps.
128 for v in vectors.iter().skip(1) {
129 let proj = m_dot(&w, v);
130 w -= proj * v;
131 }
132
133 alpha.push(alpha_j);
134 built = j;
135
136 let beta_j = m_norm(&w);
137 if beta_j < 1e-10 || j == subspace_size {
138 break;
139 }
140 beta.push(beta_j);
141 vectors.push(&w / beta_j);
142 }
143
144 let mut t = DMatrix::<f64>::zeros(built, built);
145 for i in 0..built {
146 t[(i, i)] = alpha[i];
147 }
148 for i in 0..built.saturating_sub(1) {
149 t[(i, i + 1)] = beta[i + 1];
150 t[(i + 1, i)] = beta[i + 1];
151 }
152
153 let eigen = SymmetricEigen::new(t);
154
155 // Ritz values `theta` of `K^-1 * M` relate to the generalized problem's
156 // `lambda = omega^2` via `lambda = 1/theta` (shift = 0). The largest
157 // `theta` are the best-converged Lanczos results and correspond to the
158 // smallest `lambda` — the lowest frequencies, which is what we want.
159 let mut modes: Vec<Mode> = (0..built)
160 .filter(|&i| eigen.eigenvalues[i] > 1e-12)
161 .map(|i| {
162 let theta = eigen.eigenvalues[i];
163 let y = eigen.eigenvectors.column(i);
164 let mut shape = DVector::<f64>::zeros(n);
165 for l in 0..built {
166 shape += y[l] * &vectors[l + 1];
167 }
168 Mode {
169 frequency: (1.0 / theta).sqrt(),
170 shape,
171 }
172 })
173 .collect();
174 modes.sort_by(|a, b| a.frequency.partial_cmp(&b.frequency).unwrap());
175 modes.truncate(num_modes);
176
177 Ok(modes)
178}