Skip to main content

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}