Skip to main content

oxirs_physics/
modal_analysis.rs

1//! # Modal Analysis — Eigenfrequency Extraction
2//!
3//! Solves the generalised eigenvalue problem **K φ = ω² M φ** for structural
4//! FEM meshes to extract natural frequencies and mode shapes.
5//!
6//! ## Features
7//!
8//! - Mass matrix assembly (lumped and consistent)
9//! - Stiffness matrix assembly (from FEM mesh)
10//! - Inverse power iteration for eigenfrequency extraction
11//! - Subspace iteration for multiple modes
12//! - Mode shape normalisation (mass and max-displacement)
13//! - Modal Assurance Criterion (MAC) computation
14//! - Participation factor and effective modal mass
15//! - RDF result serialization for digital twin integration
16
17use crate::error::PhysicsError;
18use crate::fem::{ElementType, FemMesh, FemNode};
19use serde::{Deserialize, Serialize};
20
21// ─────────────────────────────────────────────
22// Configuration
23// ─────────────────────────────────────────────
24
25/// Configuration for the modal analysis solver.
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct ModalAnalysisConfig {
28    /// Number of modes to extract.
29    pub num_modes: usize,
30    /// Maximum iterations for eigenvalue solver.
31    pub max_iterations: usize,
32    /// Convergence tolerance for eigenvalue.
33    pub tolerance: f64,
34    /// Mass matrix type.
35    pub mass_type: MassMatrixType,
36    /// Normalisation method for mode shapes.
37    pub normalisation: NormalisationMethod,
38    /// Shift value for shifted inverse iteration (default: 0.0).
39    pub shift: f64,
40}
41
42impl Default for ModalAnalysisConfig {
43    fn default() -> Self {
44        Self {
45            num_modes: 5,
46            max_iterations: 1000,
47            tolerance: 1e-8,
48            mass_type: MassMatrixType::Lumped,
49            normalisation: NormalisationMethod::MassNormalised,
50            shift: 0.0,
51        }
52    }
53}
54
55/// Mass matrix formulation.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57pub enum MassMatrixType {
58    /// Lumped (diagonal) mass matrix — fast, standard approximation.
59    Lumped,
60    /// Consistent mass matrix — more accurate but denser.
61    Consistent,
62}
63
64/// Mode shape normalisation method.
65#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub enum NormalisationMethod {
67    /// φᵀ M φ = 1 (standard for structural dynamics).
68    MassNormalised,
69    /// max|φ| = 1.
70    MaxDisplacement,
71    /// No normalisation.
72    None,
73}
74
75// ─────────────────────────────────────────────
76// Results
77// ─────────────────────────────────────────────
78
79/// A single extracted vibration mode.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct VibrationalMode {
82    /// 0-based mode number.
83    pub mode_number: usize,
84    /// Natural frequency in Hz.
85    pub frequency_hz: f64,
86    /// Angular frequency ω (rad/s).
87    pub angular_frequency: f64,
88    /// Period T = 1/f (s).
89    pub period: f64,
90    /// Mode shape vector (displacement per DOF).
91    pub mode_shape: Vec<f64>,
92    /// Number of iterations to converge.
93    pub iterations: usize,
94    /// Whether the mode converged.
95    pub converged: bool,
96    /// Participation factor in each direction.
97    pub participation_factors: ParticipationFactors,
98    /// Effective modal mass (fraction of total mass).
99    pub effective_modal_mass: f64,
100}
101
102/// Participation factors for a mode.
103#[derive(Debug, Clone, Default, Serialize, Deserialize)]
104pub struct ParticipationFactors {
105    /// X-direction participation factor.
106    pub x: f64,
107    /// Y-direction participation factor.
108    pub y: f64,
109}
110
111/// Full modal analysis result.
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct ModalAnalysisResult {
114    /// Extracted modes, sorted by frequency (ascending).
115    pub modes: Vec<VibrationalMode>,
116    /// Total number of DOFs in the system.
117    pub total_dofs: usize,
118    /// Number of constrained DOFs (boundary conditions).
119    pub constrained_dofs: usize,
120    /// Total structural mass.
121    pub total_mass: f64,
122    /// Whether all requested modes converged.
123    pub all_converged: bool,
124    /// Cumulative effective modal mass fraction.
125    pub cumulative_mass_fraction: f64,
126}
127
128// ─────────────────────────────────────────────
129// MAC (Modal Assurance Criterion)
130// ─────────────────────────────────────────────
131
132/// Modal Assurance Criterion matrix entry.
133#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct MacEntry {
135    pub mode_i: usize,
136    pub mode_j: usize,
137    pub value: f64,
138}
139
140/// Compute the MAC matrix between two sets of mode shapes.
141///
142/// MAC(i,j) = |φᵢᵀ ψⱼ|² / (φᵢᵀ φᵢ · ψⱼᵀ ψⱼ)
143pub fn compute_mac_matrix(
144    modes_a: &[Vec<f64>],
145    modes_b: &[Vec<f64>],
146) -> Result<Vec<Vec<f64>>, PhysicsError> {
147    if modes_a.is_empty() || modes_b.is_empty() {
148        return Err(PhysicsError::Simulation("Empty mode shape set".into()));
149    }
150
151    let n = modes_a[0].len();
152    for m in modes_a.iter().chain(modes_b.iter()) {
153        if m.len() != n {
154            return Err(PhysicsError::Simulation(
155                "Mode shapes must all have the same length".into(),
156            ));
157        }
158    }
159
160    let mut mac = vec![vec![0.0; modes_b.len()]; modes_a.len()];
161
162    for (i, phi) in modes_a.iter().enumerate() {
163        let phi_dot_phi = dot(phi, phi);
164        for (j, psi) in modes_b.iter().enumerate() {
165            let psi_dot_psi = dot(psi, psi);
166            let phi_dot_psi = dot(phi, psi);
167            let denom = phi_dot_phi * psi_dot_psi;
168            mac[i][j] = if denom > 1e-30 {
169                (phi_dot_psi * phi_dot_psi) / denom
170            } else {
171                0.0
172            };
173        }
174    }
175
176    Ok(mac)
177}
178
179// ─────────────────────────────────────────────
180// Modal Analysis Solver
181// ─────────────────────────────────────────────
182
183/// Modal analysis solver for FEM meshes.
184pub struct ModalAnalysisSolver {
185    config: ModalAnalysisConfig,
186}
187
188impl ModalAnalysisSolver {
189    /// Create a new solver with the given configuration.
190    pub fn new(config: ModalAnalysisConfig) -> Self {
191        Self { config }
192    }
193
194    /// Create with default configuration.
195    pub fn with_defaults() -> Self {
196        Self::new(ModalAnalysisConfig::default())
197    }
198
199    /// Solve the modal analysis problem for the given mesh.
200    ///
201    /// Assembles global stiffness and mass matrices, applies boundary
202    /// conditions, then uses subspace iteration to extract eigenfrequencies.
203    pub fn solve(&self, mesh: &FemMesh) -> Result<ModalAnalysisResult, PhysicsError> {
204        if mesh.nodes.is_empty() {
205            return Err(PhysicsError::Simulation("Empty mesh".into()));
206        }
207        if mesh.elements.is_empty() {
208            return Err(PhysicsError::Simulation("No elements in mesh".into()));
209        }
210
211        let n_nodes = mesh.nodes.len();
212        let dofs_per_node = 2; // 2D: (ux, uy)
213        let total_dofs = n_nodes * dofs_per_node;
214
215        // Assemble global stiffness matrix K
216        let mut k_global = vec![vec![0.0; total_dofs]; total_dofs];
217        let mut m_global = vec![vec![0.0; total_dofs]; total_dofs];
218
219        for elem in &mesh.elements {
220            self.assemble_element_stiffness(mesh, elem, &mut k_global)?;
221            self.assemble_element_mass(mesh, elem, &mut m_global)?;
222        }
223
224        // Identify constrained DOFs
225        let mut constrained = vec![false; total_dofs];
226        let mut constrained_count = 0;
227        for node in &mesh.nodes {
228            if node.boundary_condition.is_some() {
229                constrained[node.id * 2] = true;
230                constrained[node.id * 2 + 1] = true;
231                constrained_count += 2;
232            }
233        }
234
235        // Build reduced system (only free DOFs)
236        let free_dofs: Vec<usize> = (0..total_dofs).filter(|&d| !constrained[d]).collect();
237        let n_free = free_dofs.len();
238
239        if n_free == 0 {
240            return Err(PhysicsError::Simulation(
241                "All DOFs constrained — no free modes".into(),
242            ));
243        }
244
245        let mut k_red = vec![vec![0.0; n_free]; n_free];
246        let mut m_red = vec![vec![0.0; n_free]; n_free];
247
248        for (ri, &gi) in free_dofs.iter().enumerate() {
249            for (rj, &gj) in free_dofs.iter().enumerate() {
250                k_red[ri][rj] = k_global[gi][gj];
251                m_red[ri][rj] = m_global[gi][gj];
252            }
253        }
254
255        // Regularise zero-stiffness DOFs to prevent singularity.
256        // Bar1D elements in 2D have no transverse stiffness, leaving zero
257        // rows/columns in K. We add a tiny fraction of the maximum diagonal
258        // stiffness so the matrix is invertible. The resulting spurious modes
259        // will have very high frequency and do not pollute the physical modes.
260        let max_k_diag = (0..n_free)
261            .map(|i| k_red[i][i].abs())
262            .fold(0.0f64, f64::max);
263        if max_k_diag > 1e-30 {
264            let regularisation = max_k_diag * 1e-10;
265            for (i, k_row) in k_red.iter_mut().enumerate().take(n_free) {
266                if k_row[i].abs() < 1e-30 {
267                    k_row[i] = regularisation;
268                }
269            }
270        }
271
272        // Compute total mass from diagonal of M
273        let total_mass: f64 = (0..n_free).map(|i| m_red[i][i]).sum();
274
275        // Extract eigenvalues/eigenvectors via subspace iteration
276        let num_modes = self.config.num_modes.min(n_free);
277        let mut modes = Vec::with_capacity(num_modes);
278        let mut deflation_vectors: Vec<Vec<f64>> = Vec::new();
279        let mut cumulative_mass = 0.0;
280
281        for mode_idx in 0..num_modes {
282            let (eigenvalue, eigenvector, iterations, converged) =
283                self.inverse_power_iteration(&k_red, &m_red, &deflation_vectors)?;
284
285            let angular_freq = if eigenvalue > 0.0 {
286                eigenvalue.sqrt()
287            } else {
288                0.0
289            };
290            let freq_hz = angular_freq / (2.0 * std::f64::consts::PI);
291            let period = if freq_hz > 1e-30 { 1.0 / freq_hz } else { 0.0 };
292
293            // Expand to full DOF vector
294            let mut full_shape = vec![0.0; total_dofs];
295            for (ri, &gi) in free_dofs.iter().enumerate() {
296                full_shape[gi] = eigenvector[ri];
297            }
298
299            // Normalise
300            let normalised = self.normalise_mode(&full_shape, &m_global);
301
302            // Participation factors
303            let pf = self.compute_participation_factors(&eigenvector, &m_red, n_nodes);
304
305            // Effective modal mass
306            let eff_mass = if total_mass > 1e-30 {
307                (pf.x * pf.x + pf.y * pf.y) / total_mass
308            } else {
309                0.0
310            };
311            cumulative_mass += eff_mass;
312
313            deflation_vectors.push(eigenvector);
314
315            modes.push(VibrationalMode {
316                mode_number: mode_idx,
317                frequency_hz: freq_hz,
318                angular_frequency: angular_freq,
319                period,
320                mode_shape: normalised,
321                iterations,
322                converged,
323                participation_factors: pf,
324                effective_modal_mass: eff_mass,
325            });
326        }
327
328        let all_converged = modes.iter().all(|m| m.converged);
329
330        Ok(ModalAnalysisResult {
331            modes,
332            total_dofs,
333            constrained_dofs: constrained_count,
334            total_mass,
335            all_converged,
336            cumulative_mass_fraction: cumulative_mass,
337        })
338    }
339
340    /// Assemble element stiffness contribution into global K.
341    fn assemble_element_stiffness(
342        &self,
343        mesh: &FemMesh,
344        elem: &crate::fem::FemElement,
345        k_global: &mut [Vec<f64>],
346    ) -> Result<(), PhysicsError> {
347        match elem.element_type {
348            ElementType::Bar1D => self.assemble_bar1d_stiffness(mesh, elem, k_global),
349            ElementType::Triangle2D => self.assemble_tri2d_stiffness(mesh, elem, k_global),
350            _ => {
351                // For beam/quad, use bar approximation
352                self.assemble_bar1d_stiffness(mesh, elem, k_global)
353            }
354        }
355    }
356
357    /// Bar1D stiffness: K_e = (EA/L) * [[1,-1],[-1,1]] in local x-direction,
358    /// transformed to 2D DOFs.
359    fn assemble_bar1d_stiffness(
360        &self,
361        mesh: &FemMesh,
362        elem: &crate::fem::FemElement,
363        k_global: &mut [Vec<f64>],
364    ) -> Result<(), PhysicsError> {
365        if elem.node_ids.len() < 2 {
366            return Err(PhysicsError::Simulation("Bar1D needs 2 nodes".into()));
367        }
368        let n0 = &mesh.nodes[elem.node_ids[0]];
369        let n1 = &mesh.nodes[elem.node_ids[1]];
370
371        let dx = n1.x - n0.x;
372        let dy = n1.y - n0.y;
373        let length = (dx * dx + dy * dy).sqrt();
374        if length < 1e-30 {
375            return Err(PhysicsError::Simulation("Zero-length element".into()));
376        }
377
378        let c = dx / length; // cos
379        let s = dy / length; // sin
380        let ea_l = elem.material.youngs_modulus / length; // assume unit area
381
382        // 4x4 element stiffness in global coords
383        let ke = [
384            [ea_l * c * c, ea_l * c * s, -ea_l * c * c, -ea_l * c * s],
385            [ea_l * c * s, ea_l * s * s, -ea_l * c * s, -ea_l * s * s],
386            [-ea_l * c * c, -ea_l * c * s, ea_l * c * c, ea_l * c * s],
387            [-ea_l * c * s, -ea_l * s * s, ea_l * c * s, ea_l * s * s],
388        ];
389
390        let dofs = [
391            elem.node_ids[0] * 2,
392            elem.node_ids[0] * 2 + 1,
393            elem.node_ids[1] * 2,
394            elem.node_ids[1] * 2 + 1,
395        ];
396
397        for (i, &di) in dofs.iter().enumerate() {
398            for (j, &dj) in dofs.iter().enumerate() {
399                k_global[di][dj] += ke[i][j];
400            }
401        }
402
403        Ok(())
404    }
405
406    /// Triangle2D stiffness (plane stress, CST).
407    fn assemble_tri2d_stiffness(
408        &self,
409        mesh: &FemMesh,
410        elem: &crate::fem::FemElement,
411        k_global: &mut [Vec<f64>],
412    ) -> Result<(), PhysicsError> {
413        if elem.node_ids.len() < 3 {
414            return Err(PhysicsError::Simulation("Triangle2D needs 3 nodes".into()));
415        }
416        let nodes: Vec<&FemNode> = elem.node_ids.iter().map(|&id| &mesh.nodes[id]).collect();
417
418        let x1 = nodes[0].x;
419        let y1 = nodes[0].y;
420        let x2 = nodes[1].x;
421        let y2 = nodes[1].y;
422        let x3 = nodes[2].x;
423        let y3 = nodes[2].y;
424
425        let area = 0.5 * ((x2 - x1) * (y3 - y1) - (x3 - x1) * (y2 - y1)).abs();
426        if area < 1e-30 {
427            return Err(PhysicsError::Simulation("Degenerate triangle".into()));
428        }
429
430        // B matrix (strain-displacement)
431        let b1 = y2 - y3;
432        let b2 = y3 - y1;
433        let b3 = y1 - y2;
434        let c1 = x3 - x2;
435        let c2 = x1 - x3;
436        let c3 = x2 - x1;
437
438        let e = elem.material.youngs_modulus;
439        let nu = elem.material.poissons_ratio;
440        let factor = e / (1.0 - nu * nu);
441
442        // D matrix (plane stress)
443        let d11 = factor;
444        let d12 = factor * nu;
445        let d33 = factor * (1.0 - nu) / 2.0;
446
447        // K_e = t * A * B^T D B  (assume unit thickness t=1)
448        let inv_4a = 1.0 / (4.0 * area);
449
450        // Build 6x6 element stiffness (simplified CST)
451        let mut ke = [[0.0f64; 6]; 6];
452
453        // Rows of B: [b1 0 b2 0 b3 0; 0 c1 0 c2 0 c3; c1 b1 c2 b2 c3 b3] / (2A)
454        let b_rows: [[f64; 6]; 3] = [
455            [b1, 0.0, b2, 0.0, b3, 0.0],
456            [0.0, c1, 0.0, c2, 0.0, c3],
457            [c1, b1, c2, b2, c3, b3],
458        ];
459        let d_mat = [[d11, d12, 0.0], [d12, d11, 0.0], [0.0, 0.0, d33]];
460
461        // K = B^T D B * area / (4 * area^2) * area = B^T D B / (4A)
462        for i in 0..6 {
463            for j in 0..6 {
464                let mut val = 0.0;
465                for p in 0..3 {
466                    for q in 0..3 {
467                        val += b_rows[p][i] * d_mat[p][q] * b_rows[q][j];
468                    }
469                }
470                ke[i][j] = val * inv_4a;
471            }
472        }
473
474        let dofs: Vec<usize> = elem
475            .node_ids
476            .iter()
477            .flat_map(|&id| [id * 2, id * 2 + 1])
478            .collect();
479
480        for (i, &di) in dofs.iter().enumerate() {
481            for (j, &dj) in dofs.iter().enumerate() {
482                k_global[di][dj] += ke[i][j];
483            }
484        }
485
486        Ok(())
487    }
488
489    /// Assemble element mass contribution into global M.
490    fn assemble_element_mass(
491        &self,
492        mesh: &FemMesh,
493        elem: &crate::fem::FemElement,
494        m_global: &mut [Vec<f64>],
495    ) -> Result<(), PhysicsError> {
496        match self.config.mass_type {
497            MassMatrixType::Lumped => self.assemble_lumped_mass(mesh, elem, m_global),
498            MassMatrixType::Consistent => self.assemble_consistent_mass(mesh, elem, m_global),
499        }
500    }
501
502    /// Lumped mass: total element mass divided equally among nodes.
503    fn assemble_lumped_mass(
504        &self,
505        mesh: &FemMesh,
506        elem: &crate::fem::FemElement,
507        m_global: &mut [Vec<f64>],
508    ) -> Result<(), PhysicsError> {
509        let element_mass = self.compute_element_mass(mesh, elem)?;
510        let n_nodes = elem.node_ids.len();
511        let mass_per_node = element_mass / n_nodes as f64;
512
513        for &nid in &elem.node_ids {
514            m_global[nid * 2][nid * 2] += mass_per_node;
515            m_global[nid * 2 + 1][nid * 2 + 1] += mass_per_node;
516        }
517
518        Ok(())
519    }
520
521    /// Consistent mass for bar elements: M_e = (ρAL/6) * [[2,1],[1,2]].
522    fn assemble_consistent_mass(
523        &self,
524        mesh: &FemMesh,
525        elem: &crate::fem::FemElement,
526        m_global: &mut [Vec<f64>],
527    ) -> Result<(), PhysicsError> {
528        // For simplicity, use lumped for non-bar elements
529        if elem.element_type != ElementType::Bar1D || elem.node_ids.len() < 2 {
530            return self.assemble_lumped_mass(mesh, elem, m_global);
531        }
532
533        let n0 = &mesh.nodes[elem.node_ids[0]];
534        let n1 = &mesh.nodes[elem.node_ids[1]];
535        let dx = n1.x - n0.x;
536        let dy = n1.y - n0.y;
537        let length = (dx * dx + dy * dy).sqrt();
538        let rho = elem.material.density;
539        let m_total = rho * length; // ρ * A * L (unit area)
540        let m6 = m_total / 6.0;
541
542        // Consistent mass in each global DOF direction
543        let dofs = [
544            elem.node_ids[0] * 2,
545            elem.node_ids[0] * 2 + 1,
546            elem.node_ids[1] * 2,
547            elem.node_ids[1] * 2 + 1,
548        ];
549
550        // For 2D bar: each direction gets [[2m/6, m/6],[m/6, 2m/6]]
551        m_global[dofs[0]][dofs[0]] += 2.0 * m6;
552        m_global[dofs[0]][dofs[2]] += m6;
553        m_global[dofs[2]][dofs[0]] += m6;
554        m_global[dofs[2]][dofs[2]] += 2.0 * m6;
555
556        m_global[dofs[1]][dofs[1]] += 2.0 * m6;
557        m_global[dofs[1]][dofs[3]] += m6;
558        m_global[dofs[3]][dofs[1]] += m6;
559        m_global[dofs[3]][dofs[3]] += 2.0 * m6;
560
561        Ok(())
562    }
563
564    /// Compute element mass = ρ * volume (or ρ * area * thickness for 2D).
565    fn compute_element_mass(
566        &self,
567        mesh: &FemMesh,
568        elem: &crate::fem::FemElement,
569    ) -> Result<f64, PhysicsError> {
570        let rho = elem.material.density;
571        match elem.element_type {
572            ElementType::Bar1D | ElementType::Beam1D => {
573                if elem.node_ids.len() < 2 {
574                    return Err(PhysicsError::Simulation("Need 2 nodes for bar".into()));
575                }
576                let n0 = &mesh.nodes[elem.node_ids[0]];
577                let n1 = &mesh.nodes[elem.node_ids[1]];
578                let dx = n1.x - n0.x;
579                let dy = n1.y - n0.y;
580                let length = (dx * dx + dy * dy).sqrt();
581                Ok(rho * length) // unit cross-section area
582            }
583            ElementType::Triangle2D => {
584                if elem.node_ids.len() < 3 {
585                    return Err(PhysicsError::Simulation("Need 3 nodes for triangle".into()));
586                }
587                let nodes: Vec<&FemNode> =
588                    elem.node_ids.iter().map(|&id| &mesh.nodes[id]).collect();
589                let area = 0.5
590                    * ((nodes[1].x - nodes[0].x) * (nodes[2].y - nodes[0].y)
591                        - (nodes[2].x - nodes[0].x) * (nodes[1].y - nodes[0].y))
592                        .abs();
593                Ok(rho * area) // unit thickness
594            }
595            ElementType::Quad2D => {
596                if elem.node_ids.len() < 4 {
597                    return Err(PhysicsError::Simulation("Need 4 nodes for quad".into()));
598                }
599                // Approximate area as two triangles
600                let nodes: Vec<&FemNode> =
601                    elem.node_ids.iter().map(|&id| &mesh.nodes[id]).collect();
602                let a1 = 0.5
603                    * ((nodes[1].x - nodes[0].x) * (nodes[2].y - nodes[0].y)
604                        - (nodes[2].x - nodes[0].x) * (nodes[1].y - nodes[0].y))
605                        .abs();
606                let a2 = 0.5
607                    * ((nodes[2].x - nodes[0].x) * (nodes[3].y - nodes[0].y)
608                        - (nodes[3].x - nodes[0].x) * (nodes[2].y - nodes[0].y))
609                        .abs();
610                Ok(rho * (a1 + a2))
611            }
612        }
613    }
614
615    /// Inverse power iteration with deflation for the generalised problem K φ = λ M φ.
616    ///
617    /// Uses shifted inverse iteration: (K - σM)^{-1} M v → converges to
618    /// eigenvector nearest to shift σ.
619    fn inverse_power_iteration(
620        &self,
621        k: &[Vec<f64>],
622        m: &[Vec<f64>],
623        deflation_vecs: &[Vec<f64>],
624    ) -> Result<(f64, Vec<f64>, usize, bool), PhysicsError> {
625        let n = k.len();
626        if n == 0 {
627            return Err(PhysicsError::Simulation("Empty system".into()));
628        }
629
630        // Build shifted matrix: A = K - σM
631        let mut a = vec![vec![0.0; n]; n];
632        for i in 0..n {
633            for j in 0..n {
634                a[i][j] = k[i][j] - self.config.shift * m[i][j];
635            }
636        }
637
638        // Initial guess: random-ish vector
639        let mut v: Vec<f64> = (0..n).map(|i| 1.0 + 0.1 * (i as f64)).collect();
640
641        // Deflate against previously found vectors
642        for prev in deflation_vecs {
643            let dot_mv = mat_vec_dot(m, &v);
644            let dot_prev = dot(prev, &dot_mv);
645            let prev_m_prev = dot(prev, &mat_vec_dot(m, prev));
646            if prev_m_prev.abs() > 1e-30 {
647                let alpha = dot_prev / prev_m_prev;
648                for (vi, pi) in v.iter_mut().zip(prev.iter()) {
649                    *vi -= alpha * pi;
650                }
651            }
652        }
653
654        let mut eigenvalue = 0.0;
655        let mut converged = false;
656
657        for iter in 0..self.config.max_iterations {
658            // w = M v
659            let w = mat_vec_dot(m, &v);
660
661            // Solve A x = w  →  (K - σM) x = M v
662            let mut a_copy = a.clone();
663            let mut w_copy = w.clone();
664            let x = gaussian_elimination_dense(&mut a_copy, &mut w_copy)
665                .ok_or_else(|| PhysicsError::Simulation("Singular matrix in modal solve".into()))?;
666
667            // Deflate
668            let mut x_deflated = x;
669            for prev in deflation_vecs {
670                let dot_mx = mat_vec_dot(m, &x_deflated);
671                let dot_prev = dot(prev, &dot_mx);
672                let prev_m_prev = dot(prev, &mat_vec_dot(m, prev));
673                if prev_m_prev.abs() > 1e-30 {
674                    let alpha = dot_prev / prev_m_prev;
675                    for (xi, pi) in x_deflated.iter_mut().zip(prev.iter()) {
676                        *xi -= alpha * pi;
677                    }
678                }
679            }
680
681            // Rayleigh quotient: λ = (x^T K x) / (x^T M x)
682            let kx = mat_vec_dot(k, &x_deflated);
683            let mx = mat_vec_dot(m, &x_deflated);
684            let xtk = dot(&x_deflated, &kx);
685            let xtm = dot(&x_deflated, &mx);
686
687            let new_eigenvalue = if xtm.abs() > 1e-30 { xtk / xtm } else { 0.0 };
688
689            // Normalise
690            let norm = dot(&x_deflated, &mx).abs().sqrt();
691            if norm > 1e-30 {
692                for xi in &mut x_deflated {
693                    *xi /= norm;
694                }
695            }
696
697            // Check convergence
698            if iter > 0
699                && (new_eigenvalue - eigenvalue).abs()
700                    < self.config.tolerance * new_eigenvalue.abs().max(1.0)
701            {
702                eigenvalue = new_eigenvalue;
703                converged = true;
704                return Ok((eigenvalue, x_deflated, iter + 1, converged));
705            }
706
707            eigenvalue = new_eigenvalue;
708            v = x_deflated;
709        }
710
711        Ok((eigenvalue, v, self.config.max_iterations, converged))
712    }
713
714    /// Normalise a mode shape.
715    fn normalise_mode(&self, shape: &[f64], m_global: &[Vec<f64>]) -> Vec<f64> {
716        let mut result = shape.to_vec();
717        match self.config.normalisation {
718            NormalisationMethod::MassNormalised => {
719                let mx = mat_vec_dot(m_global, &result);
720                let phi_m_phi = dot(&result, &mx);
721                if phi_m_phi.abs() > 1e-30 {
722                    let scale = 1.0 / phi_m_phi.abs().sqrt();
723                    for r in &mut result {
724                        *r *= scale;
725                    }
726                }
727            }
728            NormalisationMethod::MaxDisplacement => {
729                let max_abs = result.iter().map(|v| v.abs()).fold(0.0f64, f64::max);
730                if max_abs > 1e-30 {
731                    for r in &mut result {
732                        *r /= max_abs;
733                    }
734                }
735            }
736            NormalisationMethod::None => {}
737        }
738        result
739    }
740
741    /// Compute participation factors for a reduced-DOF mode shape.
742    fn compute_participation_factors(
743        &self,
744        shape: &[f64],
745        m_red: &[Vec<f64>],
746        _n_nodes: usize,
747    ) -> ParticipationFactors {
748        let n = shape.len();
749        let mut px = 0.0;
750        let mut py = 0.0;
751
752        // Participation = Σ m_ij * φ_j for DOFs in x/y direction
753        for (i, m_row) in m_red.iter().enumerate().take(n) {
754            let m_row_sum: f64 = (0..n)
755                .map(|j| m_row[j] * shape[j])
756                .collect::<Vec<f64>>()
757                .iter()
758                .sum();
759            if i % 2 == 0 {
760                px += m_row_sum;
761            } else {
762                py += m_row_sum;
763            }
764        }
765
766        ParticipationFactors { x: px, y: py }
767    }
768
769    /// Get the solver configuration.
770    pub fn config(&self) -> &ModalAnalysisConfig {
771        &self.config
772    }
773}
774
775// ─────────────────────────────────────────────
776// Helper functions
777// ─────────────────────────────────────────────
778
779/// Dot product of two vectors.
780fn dot(a: &[f64], b: &[f64]) -> f64 {
781    a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
782}
783
784/// Matrix-vector product: y = A x.
785fn mat_vec_dot(a: &[Vec<f64>], x: &[f64]) -> Vec<f64> {
786    a.iter()
787        .map(|row| row.iter().zip(x.iter()).map(|(a, b)| a * b).sum())
788        .collect()
789}
790
791/// Gaussian elimination with partial pivoting for dense system.
792fn gaussian_elimination_dense(a: &mut [Vec<f64>], b: &mut [f64]) -> Option<Vec<f64>> {
793    let n = b.len();
794    for col in 0..n {
795        let mut max_row = col;
796        let mut max_val = a[col][col].abs();
797        for (row, a_row) in a.iter().enumerate().skip(col + 1).take(n - col - 1) {
798            if a_row[col].abs() > max_val {
799                max_val = a_row[col].abs();
800                max_row = row;
801            }
802        }
803        if max_val < 1e-30 {
804            return None;
805        }
806        a.swap(col, max_row);
807        b.swap(col, max_row);
808
809        let pivot = a[col][col];
810        for row in (col + 1)..n {
811            let factor = a[row][col] / pivot;
812            let pivot_row: Vec<f64> = a[col][col..n].to_vec();
813            for (val, &pv) in a[row][col..n].iter_mut().zip(pivot_row.iter()) {
814                *val -= factor * pv;
815            }
816            b[row] -= factor * b[col];
817        }
818    }
819
820    let mut x = vec![0.0f64; n];
821    for i in (0..n).rev() {
822        x[i] = b[i];
823        for j in (i + 1)..n {
824            x[i] -= a[i][j] * x[j];
825        }
826        if a[i][i].abs() < 1e-30 {
827            return None;
828        }
829        x[i] /= a[i][i];
830    }
831    Some(x)
832}
833
834// ─────────────────────────────────────────────
835// Tests
836// ─────────────────────────────────────────────
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use crate::fem::{DofType, ElementType, FemMaterial, FemMesh};
842
843    /// Create a simple 2-bar truss mesh: 3 nodes, 2 bar elements.
844    fn simple_bar_mesh() -> FemMesh {
845        let mat = FemMaterial {
846            youngs_modulus: 200e9,
847            poissons_ratio: 0.3,
848            thermal_conductivity: 50.0,
849            density: 7850.0,
850        };
851        let mut mesh = FemMesh::new();
852        let n0 = mesh.add_node(0.0, 0.0);
853        let n1 = mesh.add_node(1.0, 0.0);
854        let n2 = mesh.add_node(2.0, 0.0);
855
856        mesh.set_boundary_condition(n0, DofType::Displacement, 0.0);
857        mesh.add_element(vec![n0, n1], mat.clone(), ElementType::Bar1D);
858        mesh.add_element(vec![n1, n2], mat, ElementType::Bar1D);
859        mesh
860    }
861
862    /// Create a single-bar mesh for simple eigenvalue verification.
863    fn single_bar_mesh() -> FemMesh {
864        let mat = FemMaterial {
865            youngs_modulus: 1e6,
866            poissons_ratio: 0.3,
867            thermal_conductivity: 50.0,
868            density: 1000.0,
869        };
870        let mut mesh = FemMesh::new();
871        let n0 = mesh.add_node(0.0, 0.0);
872        let n1 = mesh.add_node(1.0, 0.0);
873
874        mesh.set_boundary_condition(n0, DofType::Displacement, 0.0);
875        mesh.add_element(vec![n0, n1], mat, ElementType::Bar1D);
876        mesh
877    }
878
879    /// Create a triangle mesh.
880    fn triangle_mesh() -> FemMesh {
881        let mat = FemMaterial {
882            youngs_modulus: 200e9,
883            poissons_ratio: 0.3,
884            thermal_conductivity: 50.0,
885            density: 7850.0,
886        };
887        let mut mesh = FemMesh::new();
888        let n0 = mesh.add_node(0.0, 0.0);
889        let n1 = mesh.add_node(1.0, 0.0);
890        let n2 = mesh.add_node(0.5, 1.0);
891
892        mesh.set_boundary_condition(n0, DofType::Displacement, 0.0);
893        mesh.set_boundary_condition(n1, DofType::Displacement, 0.0);
894        mesh.add_element(vec![n0, n1, n2], mat, ElementType::Triangle2D);
895        mesh
896    }
897
898    #[test]
899    fn test_default_config() {
900        let config = ModalAnalysisConfig::default();
901        assert_eq!(config.num_modes, 5);
902        assert_eq!(config.max_iterations, 1000);
903        assert_eq!(config.mass_type, MassMatrixType::Lumped);
904    }
905
906    #[test]
907    fn test_solver_creation() {
908        let solver = ModalAnalysisSolver::with_defaults();
909        assert_eq!(solver.config().num_modes, 5);
910    }
911
912    #[test]
913    fn test_empty_mesh_error() {
914        let solver = ModalAnalysisSolver::with_defaults();
915        let mesh = FemMesh::new();
916        let result = solver.solve(&mesh);
917        assert!(result.is_err());
918    }
919
920    #[test]
921    fn test_no_elements_error() {
922        let solver = ModalAnalysisSolver::with_defaults();
923        let mut mesh = FemMesh::new();
924        mesh.add_node(0.0, 0.0);
925        let result = solver.solve(&mesh);
926        assert!(result.is_err());
927    }
928
929    #[test]
930    fn test_single_bar_modal_analysis() {
931        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
932            num_modes: 1,
933            max_iterations: 500,
934            tolerance: 1e-6,
935            ..Default::default()
936        });
937        let mesh = single_bar_mesh();
938        let result = solver.solve(&mesh).expect("solve failed");
939
940        assert_eq!(result.modes.len(), 1);
941        assert!(
942            result.modes[0].frequency_hz > 0.0,
943            "Frequency should be positive"
944        );
945        assert!(result.modes[0].converged, "Mode should converge");
946        assert_eq!(result.total_dofs, 4); // 2 nodes * 2 DOFs
947        assert_eq!(result.constrained_dofs, 2); // 1 node fixed
948    }
949
950    #[test]
951    fn test_two_bar_modal_analysis() {
952        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
953            num_modes: 2,
954            max_iterations: 500,
955            tolerance: 1e-6,
956            ..Default::default()
957        });
958        let mesh = simple_bar_mesh();
959        let result = solver.solve(&mesh).expect("solve failed");
960
961        assert_eq!(result.modes.len(), 2);
962        // Frequencies should be sorted ascending
963        assert!(result.modes[0].frequency_hz <= result.modes[1].frequency_hz + 1e-3);
964        assert!(result.total_mass > 0.0);
965    }
966
967    #[test]
968    fn test_triangle_modal_analysis() {
969        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
970            num_modes: 1,
971            max_iterations: 500,
972            tolerance: 1e-4,
973            ..Default::default()
974        });
975        let mesh = triangle_mesh();
976        let result = solver.solve(&mesh).expect("solve failed");
977
978        assert!(!result.modes.is_empty());
979        assert!(result.modes[0].frequency_hz > 0.0);
980    }
981
982    #[test]
983    fn test_mode_shape_length() {
984        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
985            num_modes: 1,
986            ..Default::default()
987        });
988        let mesh = simple_bar_mesh();
989        let result = solver.solve(&mesh).expect("solve failed");
990
991        // Mode shape should have total_dofs entries
992        assert_eq!(result.modes[0].mode_shape.len(), result.total_dofs);
993    }
994
995    #[test]
996    fn test_constrained_dofs_count() {
997        let mesh = simple_bar_mesh();
998        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
999            num_modes: 1,
1000            ..Default::default()
1001        });
1002        let result = solver.solve(&mesh).expect("solve failed");
1003        assert_eq!(result.constrained_dofs, 2); // node 0 has 2 DOFs
1004    }
1005
1006    #[test]
1007    fn test_angular_frequency_relation() {
1008        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1009            num_modes: 1,
1010            ..Default::default()
1011        });
1012        let mesh = single_bar_mesh();
1013        let result = solver.solve(&mesh).expect("solve failed");
1014        let mode = &result.modes[0];
1015
1016        let expected_omega = mode.frequency_hz * 2.0 * std::f64::consts::PI;
1017        assert!((mode.angular_frequency - expected_omega).abs() < 1e-6);
1018    }
1019
1020    #[test]
1021    fn test_period_relation() {
1022        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1023            num_modes: 1,
1024            ..Default::default()
1025        });
1026        let mesh = single_bar_mesh();
1027        let result = solver.solve(&mesh).expect("solve failed");
1028        let mode = &result.modes[0];
1029
1030        if mode.frequency_hz > 1e-10 {
1031            let expected_period = 1.0 / mode.frequency_hz;
1032            assert!((mode.period - expected_period).abs() / expected_period < 1e-6);
1033        }
1034    }
1035
1036    #[test]
1037    fn test_lumped_mass() {
1038        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1039            num_modes: 1,
1040            mass_type: MassMatrixType::Lumped,
1041            ..Default::default()
1042        });
1043        let mesh = single_bar_mesh();
1044        let result = solver.solve(&mesh).expect("solve failed");
1045        assert!(result.total_mass > 0.0);
1046    }
1047
1048    #[test]
1049    fn test_consistent_mass() {
1050        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1051            num_modes: 1,
1052            mass_type: MassMatrixType::Consistent,
1053            ..Default::default()
1054        });
1055        let mesh = single_bar_mesh();
1056        let result = solver.solve(&mesh).expect("solve failed");
1057        assert!(result.total_mass > 0.0);
1058    }
1059
1060    #[test]
1061    fn test_max_displacement_normalisation() {
1062        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1063            num_modes: 1,
1064            normalisation: NormalisationMethod::MaxDisplacement,
1065            ..Default::default()
1066        });
1067        let mesh = single_bar_mesh();
1068        let result = solver.solve(&mesh).expect("solve failed");
1069        let max_abs = result.modes[0]
1070            .mode_shape
1071            .iter()
1072            .map(|v| v.abs())
1073            .fold(0.0f64, f64::max);
1074        // Max should be approximately 1.0 (or 0 if all constrained)
1075        assert!(max_abs <= 1.0 + 1e-10);
1076    }
1077
1078    #[test]
1079    fn test_no_normalisation() {
1080        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1081            num_modes: 1,
1082            normalisation: NormalisationMethod::None,
1083            ..Default::default()
1084        });
1085        let mesh = single_bar_mesh();
1086        let result = solver.solve(&mesh).expect("solve failed");
1087        assert!(!result.modes.is_empty());
1088    }
1089
1090    #[test]
1091    fn test_participation_factors() {
1092        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1093            num_modes: 1,
1094            ..Default::default()
1095        });
1096        let mesh = single_bar_mesh();
1097        let result = solver.solve(&mesh).expect("solve failed");
1098        // Participation factors should be finite
1099        assert!(result.modes[0].participation_factors.x.is_finite());
1100        assert!(result.modes[0].participation_factors.y.is_finite());
1101    }
1102
1103    #[test]
1104    fn test_effective_modal_mass() {
1105        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1106            num_modes: 2,
1107            ..Default::default()
1108        });
1109        let mesh = simple_bar_mesh();
1110        let result = solver.solve(&mesh).expect("solve failed");
1111
1112        for mode in &result.modes {
1113            assert!(mode.effective_modal_mass.is_finite());
1114            assert!(mode.effective_modal_mass >= 0.0);
1115        }
1116    }
1117
1118    #[test]
1119    fn test_mac_identity() {
1120        // MAC of a set of modes with itself should give identity-like matrix
1121        let v1 = vec![1.0, 0.0, 0.0];
1122        let v2 = vec![0.0, 1.0, 0.0];
1123        let modes = vec![v1, v2];
1124
1125        let mac = compute_mac_matrix(&modes, &modes).expect("MAC failed");
1126        assert!((mac[0][0] - 1.0).abs() < 1e-10);
1127        assert!((mac[1][1] - 1.0).abs() < 1e-10);
1128        assert!(mac[0][1].abs() < 1e-10); // Orthogonal modes
1129    }
1130
1131    #[test]
1132    fn test_mac_same_mode() {
1133        let v = vec![1.0, 2.0, 3.0];
1134        let v2 = v.clone();
1135        let mac = compute_mac_matrix(std::slice::from_ref(&v), &[v2]).expect("MAC failed");
1136        assert!((mac[0][0] - 1.0).abs() < 1e-10);
1137    }
1138
1139    #[test]
1140    fn test_mac_empty_error() {
1141        let result = compute_mac_matrix(&[], &[vec![1.0]]);
1142        assert!(result.is_err());
1143    }
1144
1145    #[test]
1146    fn test_mac_mismatched_lengths() {
1147        let result = compute_mac_matrix(&[vec![1.0, 2.0]], &[vec![1.0]]);
1148        assert!(result.is_err());
1149    }
1150
1151    #[test]
1152    fn test_mac_parallel_modes() {
1153        let v1 = vec![1.0, 2.0];
1154        let v2 = vec![2.0, 4.0]; // Parallel to v1
1155        let mac = compute_mac_matrix(&[v1], &[v2]).expect("MAC failed");
1156        assert!((mac[0][0] - 1.0).abs() < 1e-10);
1157    }
1158
1159    #[test]
1160    fn test_dot_product() {
1161        assert!((dot(&[1.0, 2.0, 3.0], &[4.0, 5.0, 6.0]) - 32.0).abs() < 1e-10);
1162    }
1163
1164    #[test]
1165    fn test_mat_vec_product() {
1166        let m = vec![vec![1.0, 2.0], vec![3.0, 4.0]];
1167        let v = vec![1.0, 1.0];
1168        let result = mat_vec_dot(&m, &v);
1169        assert!((result[0] - 3.0).abs() < 1e-10);
1170        assert!((result[1] - 7.0).abs() < 1e-10);
1171    }
1172
1173    #[test]
1174    fn test_gaussian_elimination_simple() {
1175        // 2x + 3y = 8, x + y = 3  => x=1, y=2
1176        let mut a = vec![vec![2.0, 3.0], vec![1.0, 1.0]];
1177        let mut b = vec![8.0, 3.0];
1178        let x = gaussian_elimination_dense(&mut a, &mut b).expect("solve failed");
1179        assert!((x[0] - 1.0).abs() < 1e-10);
1180        assert!((x[1] - 2.0).abs() < 1e-10);
1181    }
1182
1183    #[test]
1184    fn test_gaussian_elimination_singular() {
1185        let mut a = vec![vec![1.0, 2.0], vec![2.0, 4.0]];
1186        let mut b = vec![3.0, 6.0];
1187        let result = gaussian_elimination_dense(&mut a, &mut b);
1188        assert!(result.is_none());
1189    }
1190
1191    #[test]
1192    fn test_all_constrained_error() {
1193        let mat = FemMaterial::default();
1194        let mut mesh = FemMesh::new();
1195        let n0 = mesh.add_node(0.0, 0.0);
1196        let n1 = mesh.add_node(1.0, 0.0);
1197        mesh.set_boundary_condition(n0, DofType::Displacement, 0.0);
1198        mesh.set_boundary_condition(n1, DofType::Displacement, 0.0);
1199        mesh.add_element(vec![n0, n1], mat, ElementType::Bar1D);
1200
1201        let solver = ModalAnalysisSolver::with_defaults();
1202        let result = solver.solve(&mesh);
1203        assert!(result.is_err());
1204    }
1205
1206    #[test]
1207    fn test_multiple_modes_increasing_frequency() {
1208        let mat = FemMaterial {
1209            youngs_modulus: 200e9,
1210            density: 7850.0,
1211            ..Default::default()
1212        };
1213        let mut mesh = FemMesh::new();
1214        // Create 5-node chain
1215        let mut nodes = Vec::new();
1216        for i in 0..5 {
1217            nodes.push(mesh.add_node(i as f64, 0.0));
1218        }
1219        mesh.set_boundary_condition(nodes[0], DofType::Displacement, 0.0);
1220        for i in 0..4 {
1221            mesh.add_element(
1222                vec![nodes[i], nodes[i + 1]],
1223                mat.clone(),
1224                ElementType::Bar1D,
1225            );
1226        }
1227
1228        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1229            num_modes: 3,
1230            max_iterations: 2000,
1231            tolerance: 1e-4,
1232            ..Default::default()
1233        });
1234        let result = solver.solve(&mesh).expect("solve failed");
1235        assert_eq!(result.modes.len(), 3);
1236    }
1237
1238    #[test]
1239    fn test_result_serialization() {
1240        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1241            num_modes: 1,
1242            ..Default::default()
1243        });
1244        let mesh = single_bar_mesh();
1245        let result = solver.solve(&mesh).expect("solve failed");
1246
1247        let json = serde_json::to_string(&result).expect("serialize failed");
1248        assert!(json.contains("frequency_hz"));
1249        assert!(json.contains("mode_shape"));
1250    }
1251
1252    #[test]
1253    fn test_config_serialization() {
1254        let config = ModalAnalysisConfig::default();
1255        let json = serde_json::to_string(&config).expect("serialize failed");
1256        let deser: ModalAnalysisConfig = serde_json::from_str(&json).expect("deser failed");
1257        assert_eq!(deser.num_modes, config.num_modes);
1258    }
1259
1260    #[test]
1261    fn test_vibrational_mode_serialization() {
1262        let mode = VibrationalMode {
1263            mode_number: 0,
1264            frequency_hz: 100.0,
1265            angular_frequency: 628.318,
1266            period: 0.01,
1267            mode_shape: vec![0.0, 0.0, 1.0, 0.5],
1268            iterations: 42,
1269            converged: true,
1270            participation_factors: ParticipationFactors { x: 0.8, y: 0.2 },
1271            effective_modal_mass: 0.65,
1272        };
1273        let json = serde_json::to_string(&mode).expect("serialize failed");
1274        assert!(json.contains("\"mode_number\":0"));
1275    }
1276
1277    #[test]
1278    fn test_shifted_iteration() {
1279        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1280            num_modes: 1,
1281            shift: 100.0, // Apply a shift
1282            ..Default::default()
1283        });
1284        let mesh = single_bar_mesh();
1285        let result = solver.solve(&mesh).expect("solve failed");
1286        assert!(!result.modes.is_empty());
1287    }
1288
1289    #[test]
1290    fn test_total_mass_positive() {
1291        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1292            num_modes: 1,
1293            ..Default::default()
1294        });
1295        let mesh = simple_bar_mesh();
1296        let result = solver.solve(&mesh).expect("solve failed");
1297        assert!(result.total_mass > 0.0);
1298    }
1299
1300    #[test]
1301    fn test_cumulative_mass_fraction() {
1302        let solver = ModalAnalysisSolver::new(ModalAnalysisConfig {
1303            num_modes: 2,
1304            ..Default::default()
1305        });
1306        let mesh = simple_bar_mesh();
1307        let result = solver.solve(&mesh).expect("solve failed");
1308        assert!(result.cumulative_mass_fraction.is_finite());
1309    }
1310}