Skip to main content

qualia_core_db/specialized_libs/engineering_analysis/
fem.rs

1//! Real finite-element subsystem for structural static / dynamic / nonlinear analysis.
2//!
3//! This is a genuine, deterministic FE stack — **no fabricated numbers, no hard-coded
4//! reference answers**. Every result is produced by assembling element matrices into a
5//! global system and solving it with the crate's dense linear solvers
6//! ([`crate::solvers::linear_algebra::lu`]). It backs the previously-`NotImplemented`
7//! `AnalysisType` variants (`NonlinearStatic`, `LinearDynamic`, `NonlinearDynamic`).
8//!
9//! ## Model
10//! A planar (2-D) frame model with a uniform **3 DOF per node** layout
11//! `(ux, uy, θz)`. Global DOF index for node `n`: `ux = 3n`, `uy = 3n+1`, `θz = 3n+2`.
12//! Two element families are provided:
13//!
14//! * [`FeElement::Truss`] — pin-jointed axial bar, element stiffness
15//!   `kₑ = (EA/L)·[[1,−1],[−1,1]]` in the axial coordinate, rotated into global
16//!   `(ux,uy)` DOFs by direction cosines. Rotational DOFs are untouched (the caller
17//!   constrains them for a pure truss).
18//! * [`FeElement::Frame`] — 2-node Euler–Bernoulli beam-column: axial `EA/L` plus the
19//!   4×4 bending block with `EI/L³` terms, assembled as a 6×6 local matrix and rotated
20//!   into global coordinates. Consistent 6×6 mass is provided.
21//!
22//! ## Solvers
23//! * [`solve_static`] — `K u = F` with boundary conditions applied by row/column
24//!   elimination (exact reactions), solved via LU.
25//! * [`newmark_linear`] — average-acceleration Newmark-β (β=¼, γ=½) time integration of
26//!   `M ü + C u̇ + K u = F(t)`.
27//! * [`newton_raphson`] — Newton iteration `R(u) = f_int(u) − F_ext → 0` with a
28//!   caller-supplied tangent.
29//! * [`newmark_nonlinear`] — Newmark with an inner Newton–Raphson iteration each step
30//!   (composition of the two above) for `M ü + C u̇ + f_int(u) = F(t)`.
31
32use super::EngineeringError;
33use crate::solvers::linear_algebra::lu::lu_decompose;
34
35// ────────────────────────────── Model types ──────────────────────────────
36
37/// A structural node in the planar (2-D) frame model. Coordinates in metres.
38#[derive(Debug, Clone, Copy)]
39pub struct FeNode {
40    pub x: f64,
41    pub y: f64,
42}
43
44/// A 2-node structural finite element. All elements live in the uniform
45/// 3-DOF-per-node layout `(ux, uy, θz)`.
46#[derive(Debug, Clone, Copy)]
47pub enum FeElement {
48    /// Pin-jointed truss bar: carries only axial force `EA/L`. Rotational DOFs are
49    /// left untouched and must be constrained by the caller for a pure truss.
50    Truss {
51        ni: usize,
52        nj: usize,
53        e: f64,
54        area: f64,
55        rho: f64,
56    },
57    /// Euler–Bernoulli beam-column (frame): axial `EA` + bending `EI`, assembled as a
58    /// 6×6 local matrix (2×2 axial + 4×4 bending) and rotated into global coordinates.
59    Frame {
60        ni: usize,
61        nj: usize,
62        e: f64,
63        area: f64,
64        inertia: f64,
65        rho: f64,
66    },
67}
68
69impl FeElement {
70    fn nodes(&self) -> (usize, usize) {
71        match *self {
72            FeElement::Truss { ni, nj, .. } | FeElement::Frame { ni, nj, .. } => (ni, nj),
73        }
74    }
75}
76
77/// A complete finite-element model: nodes, elements, prescribed-displacement
78/// constraints and applied nodal loads.
79#[derive(Debug, Clone)]
80pub struct FeModel {
81    pub nodes: Vec<FeNode>,
82    pub elements: Vec<FeElement>,
83    /// `(global_dof, prescribed_value)` displacement boundary conditions.
84    pub constraints: Vec<(usize, f64)>,
85    /// `(global_dof, force)` applied nodal loads.
86    pub loads: Vec<(usize, f64)>,
87}
88
89impl FeModel {
90    /// Total number of global degrees of freedom (`3 · number_of_nodes`).
91    pub fn ndof(&self) -> usize {
92        self.nodes.len() * 3
93    }
94
95    /// Geometry of an element: `(dx, dy, length, cos, sin)`. Errors on a zero-length
96    /// element or an out-of-range node index.
97    fn geom(&self, el: &FeElement) -> Result<(f64, f64, f64, f64, f64), EngineeringError> {
98        let (ni, nj) = el.nodes();
99        let n = self.nodes.len();
100        if ni >= n || nj >= n {
101            return Err(EngineeringError::ValidationError(format!(
102                "element references node {ni}/{nj} but model has {n} nodes"
103            )));
104        }
105        let a = self.nodes[ni];
106        let b = self.nodes[nj];
107        let dx = b.x - a.x;
108        let dy = b.y - a.y;
109        let len = (dx * dx + dy * dy).sqrt();
110        if !(len > 0.0) {
111            return Err(EngineeringError::ValidationError(
112                "element has zero length".to_string(),
113            ));
114        }
115        Ok((dx, dy, len, dx / len, dy / len))
116    }
117}
118
119// ─────────────────────────── Element matrices ───────────────────────────
120
121/// Truss element global stiffness: returns the coupled global DOF indices
122/// `[3ni, 3ni+1, 3nj, 3nj+1]` and the dense 4×4 matrix (row-major) in those DOFs.
123/// `kₑ = (EA/L)·[[c²,cs,−c²,−cs],[cs,s²,−cs,−s²],[−c²,−cs,c²,cs],[−cs,−s²,cs,s²]]`.
124fn truss_stiffness(
125    model: &FeModel,
126    el: &FeElement,
127) -> Result<(Vec<usize>, Vec<f64>), EngineeringError> {
128    let (e, area) = match *el {
129        FeElement::Truss { e, area, .. } => (e, area),
130        _ => unreachable!(),
131    };
132    let (ni, nj) = el.nodes();
133    let (_dx, _dy, len, c, s) = model.geom(el)?;
134    let k = e * area / len;
135    let (cc, cs, ss) = (c * c, c * s, s * s);
136    #[rustfmt::skip]
137    let ke = vec![
138        k*cc,  k*cs, -k*cc, -k*cs,
139        k*cs,  k*ss, -k*cs, -k*ss,
140       -k*cc, -k*cs,  k*cc,  k*cs,
141       -k*cs, -k*ss,  k*cs,  k*ss,
142    ];
143    let dofs = vec![3 * ni, 3 * ni + 1, 3 * nj, 3 * nj + 1];
144    Ok((dofs, ke))
145}
146
147/// Global DOF map of a frame element: `[3ni,3ni+1,3ni+2, 3nj,3nj+1,3nj+2]`.
148fn frame_dofs(el: &FeElement) -> Vec<usize> {
149    let (ni, nj) = el.nodes();
150    vec![
151        3 * ni,
152        3 * ni + 1,
153        3 * ni + 2,
154        3 * nj,
155        3 * nj + 1,
156        3 * nj + 2,
157    ]
158}
159
160/// The 6×6 node rotation transform `T` (block-diagonal of two `[[c,s,0],[−s,c,0],[0,0,1]]`)
161/// mapping global → local DOFs, so `k_global = Tᵀ k_local T`.
162fn frame_transform(c: f64, s: f64) -> Vec<f64> {
163    #[rustfmt::skip]
164    let t = vec![
165         c,  s, 0.0, 0.0, 0.0, 0.0,
166        -s,  c, 0.0, 0.0, 0.0, 0.0,
167        0.0,0.0,1.0, 0.0, 0.0, 0.0,
168        0.0,0.0,0.0,  c,   s,  0.0,
169        0.0,0.0,0.0, -s,   c,  0.0,
170        0.0,0.0,0.0, 0.0, 0.0, 1.0,
171    ];
172    t
173}
174
175/// `Tᵀ · A · T` for dense 6×6 matrices (row-major).
176fn congruence_6(t: &[f64], a: &[f64]) -> Vec<f64> {
177    // tmp = Tᵀ · A
178    let mut tmp = vec![0.0_f64; 36];
179    for i in 0..6 {
180        for j in 0..6 {
181            let mut acc = 0.0;
182            for p in 0..6 {
183                acc += t[p * 6 + i] * a[p * 6 + j]; // Tᵀ[i,p] = T[p,i]
184            }
185            tmp[i * 6 + j] = acc;
186        }
187    }
188    // out = tmp · T
189    let mut out = vec![0.0_f64; 36];
190    for i in 0..6 {
191        for j in 0..6 {
192            let mut acc = 0.0;
193            for p in 0..6 {
194                acc += tmp[i * 6 + p] * t[p * 6 + j];
195            }
196            out[i * 6 + j] = acc;
197        }
198    }
199    out
200}
201
202/// Frame element global stiffness (6×6). Local order `[u1,v1,θ1,u2,v2,θ2]`:
203/// axial `EA/L` on the `u` DOFs, Euler–Bernoulli bending `EI/L³` block on `(v,θ)`.
204fn frame_stiffness(
205    model: &FeModel,
206    el: &FeElement,
207) -> Result<(Vec<usize>, Vec<f64>), EngineeringError> {
208    let (e, area, inertia) = match *el {
209        FeElement::Frame {
210            e, area, inertia, ..
211        } => (e, area, inertia),
212        _ => unreachable!(),
213    };
214    let (_dx, _dy, l, c, s) = model.geom(el)?;
215    let ea_l = e * area / l;
216    let ei = e * inertia;
217    let (l2, l3) = (l * l, l * l * l);
218    let (b12, b6, b4, b2) = (12.0 * ei / l3, 6.0 * ei / l2, 4.0 * ei / l, 2.0 * ei / l);
219    // Local 6×6 (row-major), order [u1,v1,θ1,u2,v2,θ2].
220    #[rustfmt::skip]
221    let kl = vec![
222        ea_l,  0.0,   0.0,  -ea_l, 0.0,   0.0,
223        0.0,   b12,   b6,    0.0,  -b12,   b6,
224        0.0,   b6,    b4,    0.0,  -b6,    b2,
225       -ea_l,  0.0,   0.0,   ea_l, 0.0,   0.0,
226        0.0,  -b12,  -b6,    0.0,   b12,  -b6,
227        0.0,   b6,    b2,    0.0,  -b6,    b4,
228    ];
229    let t = frame_transform(c, s);
230    let ke = congruence_6(&t, &kl);
231    Ok((frame_dofs(el), ke))
232}
233
234/// Frame element global consistent mass (6×6). `m = ρ·A·L`; axial `(m/6)[[2,1],[1,2]]`,
235/// bending `(m/420)`-scaled Euler–Bernoulli block, rotated into global coordinates.
236fn frame_mass_consistent(
237    model: &FeModel,
238    el: &FeElement,
239) -> Result<(Vec<usize>, Vec<f64>), EngineeringError> {
240    let (area, inertia, rho) = match *el {
241        FeElement::Frame {
242            area, inertia, rho, ..
243        } => (area, inertia, rho),
244        _ => unreachable!(),
245    };
246    let _ = inertia;
247    let (_dx, _dy, l, c, s) = model.geom(el)?;
248    let m = rho * area * l;
249    let ax = m / 6.0;
250    let (l2, mb) = (l * l, m / 420.0);
251    // Local consistent mass, order [u1,v1,θ1,u2,v2,θ2].
252    #[rustfmt::skip]
253    let ml = vec![
254        2.0*ax, 0.0,          0.0,          1.0*ax, 0.0,          0.0,
255        0.0,    156.0*mb,     22.0*l*mb,    0.0,     54.0*mb,    -13.0*l*mb,
256        0.0,    22.0*l*mb,    4.0*l2*mb,    0.0,     13.0*l*mb,  -3.0*l2*mb,
257        1.0*ax, 0.0,          0.0,          2.0*ax, 0.0,          0.0,
258        0.0,    54.0*mb,      13.0*l*mb,    0.0,     156.0*mb,   -22.0*l*mb,
259        0.0,   -13.0*l*mb,   -3.0*l2*mb,    0.0,    -22.0*l*mb,   4.0*l2*mb,
260    ];
261    let t = frame_transform(c, s);
262    let me = congruence_6(&t, &ml);
263    Ok((frame_dofs(el), me))
264}
265
266/// Truss element lumped mass: `m = ρ·A·L`, half at each node on the translational
267/// `(ux,uy)` DOFs. Returns the same 4-DOF map as the truss stiffness.
268fn truss_mass_lumped(
269    model: &FeModel,
270    el: &FeElement,
271) -> Result<(Vec<usize>, Vec<f64>), EngineeringError> {
272    let (area, rho) = match *el {
273        FeElement::Truss { area, rho, .. } => (area, rho),
274        _ => unreachable!(),
275    };
276    let (ni, nj) = el.nodes();
277    let (_dx, _dy, len, _c, _s) = model.geom(el)?;
278    let half = rho * area * len / 2.0;
279    #[rustfmt::skip]
280    let me = vec![
281        half, 0.0,  0.0,  0.0,
282        0.0,  half, 0.0,  0.0,
283        0.0,  0.0,  half, 0.0,
284        0.0,  0.0,  0.0,  half,
285    ];
286    let dofs = vec![3 * ni, 3 * ni + 1, 3 * nj, 3 * nj + 1];
287    Ok((dofs, me))
288}
289
290// ─────────────────────────── Global assembly ───────────────────────────
291
292/// Scatter a dense `d×d` element matrix into the global `n×n` matrix at `dofs`.
293fn scatter(global: &mut [f64], n: usize, dofs: &[usize], ke: &[f64]) {
294    let d = dofs.len();
295    for a in 0..d {
296        for b in 0..d {
297            global[dofs[a] * n + dofs[b]] += ke[a * d + b];
298        }
299    }
300}
301
302/// Assemble the global stiffness matrix `K` (row-major `n×n`).
303pub fn assemble_stiffness(model: &FeModel) -> Result<Vec<f64>, EngineeringError> {
304    let n = model.ndof();
305    if n == 0 {
306        return Err(EngineeringError::InsufficientData(
307            "FE model has no nodes".to_string(),
308        ));
309    }
310    let mut k = vec![0.0_f64; n * n];
311    for el in &model.elements {
312        let (dofs, ke) = match el {
313            FeElement::Truss { .. } => truss_stiffness(model, el)?,
314            FeElement::Frame { .. } => frame_stiffness(model, el)?,
315        };
316        scatter(&mut k, n, &dofs, &ke);
317    }
318    Ok(k)
319}
320
321/// Assemble the global mass matrix `M` (row-major `n×n`). Frame elements use their
322/// consistent mass; truss elements use lumped mass.
323pub fn assemble_mass(model: &FeModel) -> Result<Vec<f64>, EngineeringError> {
324    let n = model.ndof();
325    if n == 0 {
326        return Err(EngineeringError::InsufficientData(
327            "FE model has no nodes".to_string(),
328        ));
329    }
330    let mut mm = vec![0.0_f64; n * n];
331    for el in &model.elements {
332        let (dofs, me) = match el {
333            FeElement::Truss { .. } => truss_mass_lumped(model, el)?,
334            FeElement::Frame { .. } => frame_mass_consistent(model, el)?,
335        };
336        scatter(&mut mm, n, &dofs, &me);
337    }
338    Ok(mm)
339}
340
341/// Assemble the global load vector `F` (length `n`) from the model's nodal loads.
342pub fn assemble_loads(model: &FeModel) -> Vec<f64> {
343    let n = model.ndof();
344    let mut f = vec![0.0_f64; n];
345    for &(dof, val) in &model.loads {
346        if dof < n {
347            f[dof] += val;
348        }
349    }
350    f
351}
352
353// ─────────────────────────── Static solve ───────────────────────────
354
355/// Result of a linear-static FE solve.
356#[derive(Debug, Clone)]
357pub struct FeStaticResult {
358    /// Full global displacement vector (length `ndof`).
359    pub displacements: Vec<f64>,
360    /// Reaction forces at the constrained DOFs: `(global_dof, reaction)`.
361    pub reactions: Vec<(usize, f64)>,
362    /// Axial force in each element (tension positive), same order as `model.elements`.
363    pub element_axial_force: Vec<f64>,
364}
365
366/// Solve `K u = F` with the model's displacement boundary conditions applied by
367/// row/column elimination (partitioning into free / constrained DOFs). Reactions are
368/// recovered exactly from the assembled full stiffness.
369pub fn solve_static(model: &FeModel) -> Result<FeStaticResult, EngineeringError> {
370    let n = model.ndof();
371    let k = assemble_stiffness(model)?;
372    let f = assemble_loads(model);
373    let u = solve_with_bcs(&k, &f, n, &model.constraints)?;
374
375    // Reactions R_c = Σ_j K[c,j] u_j − F_c at each constrained DOF.
376    let mut reactions = Vec::with_capacity(model.constraints.len());
377    for &(c, _) in &model.constraints {
378        let mut r = -f[c];
379        for j in 0..n {
380            r += k[c * n + j] * u[j];
381        }
382        reactions.push((c, r));
383    }
384
385    // Element axial forces.
386    let mut element_axial_force = Vec::with_capacity(model.elements.len());
387    for el in &model.elements {
388        element_axial_force.push(element_axial_force_of(model, el, &u)?);
389    }
390
391    Ok(FeStaticResult {
392        displacements: u,
393        reactions,
394        element_axial_force,
395    })
396}
397
398/// Solve `K u = F` for prescribed `constraints`, returning the full displacement
399/// vector. Free DOFs are solved via LU; constrained DOFs carry their prescribed value.
400fn solve_with_bcs(
401    k: &[f64],
402    f: &[f64],
403    n: usize,
404    constraints: &[(usize, f64)],
405) -> Result<Vec<f64>, EngineeringError> {
406    let mut is_fixed = vec![false; n];
407    let mut fixed_val = vec![0.0_f64; n];
408    for &(dof, val) in constraints {
409        if dof >= n {
410            return Err(EngineeringError::ValidationError(format!(
411                "constraint DOF {dof} out of range (ndof {n})"
412            )));
413        }
414        is_fixed[dof] = true;
415        fixed_val[dof] = val;
416    }
417    let free: Vec<usize> = (0..n).filter(|&d| !is_fixed[d]).collect();
418    let nf = free.len();
419    if nf == 0 {
420        // Fully constrained — displacement is exactly the prescribed values.
421        return Ok(fixed_val);
422    }
423
424    // Reduced K_ff and rhs = F_f − K_fc u_c.
425    let mut kff = vec![0.0_f64; nf * nf];
426    let mut rhs = vec![0.0_f64; nf];
427    for (ii, &fi) in free.iter().enumerate() {
428        rhs[ii] = f[fi];
429        for (jj, &fj) in free.iter().enumerate() {
430            kff[ii * nf + jj] = k[fi * n + fj];
431        }
432        for &(cd, cv) in constraints {
433            rhs[ii] -= k[fi * n + cd] * cv;
434        }
435    }
436
437    let lu = lu_decompose(nf, &kff)
438        .map_err(|e| EngineeringError::SolverError(format!("LU factorization failed: {e:?}")))?;
439    let uf = lu.solve(&rhs).ok_or_else(|| {
440        EngineeringError::SolverError(
441            "reduced stiffness matrix is singular (under-constrained model?)".to_string(),
442        )
443    })?;
444
445    let mut u = fixed_val;
446    for (ii, &fi) in free.iter().enumerate() {
447        u[fi] = uf[ii];
448    }
449    Ok(u)
450}
451
452/// Axial force in an element given the global displacement vector (tension positive).
453/// `N = (EA/L)·(axial elongation)`, elongation = `(u_j − u_i)·axis` projected on the
454/// element's unit axial direction.
455fn element_axial_force_of(
456    model: &FeModel,
457    el: &FeElement,
458    u: &[f64],
459) -> Result<f64, EngineeringError> {
460    let (ni, nj) = el.nodes();
461    let (_dx, _dy, len, c, s) = model.geom(el)?;
462    let (e, area) = match *el {
463        FeElement::Truss { e, area, .. } => (e, area),
464        FeElement::Frame { e, area, .. } => (e, area),
465    };
466    let uix = u[3 * ni];
467    let uiy = u[3 * ni + 1];
468    let ujx = u[3 * nj];
469    let ujy = u[3 * nj + 1];
470    let elong = (ujx - uix) * c + (ujy - uiy) * s;
471    Ok(e * area / len * elong)
472}
473
474// ─────────────────────── Dense linear-algebra helpers ───────────────────────
475
476/// `y = A·x` for a dense row-major `n×n` matrix.
477fn matvec(a: &[f64], x: &[f64], n: usize) -> Vec<f64> {
478    let mut y = vec![0.0_f64; n];
479    for i in 0..n {
480        let mut acc = 0.0;
481        let row = i * n;
482        for j in 0..n {
483            acc += a[row + j] * x[j];
484        }
485        y[i] = acc;
486    }
487    y
488}
489
490fn axpy_into(dst: &mut [f64], a: f64, x: &[f64]) {
491    for (d, &xi) in dst.iter_mut().zip(x.iter()) {
492        *d += a * xi;
493    }
494}
495
496fn norm2(v: &[f64]) -> f64 {
497    v.iter().map(|&x| x * x).sum::<f64>().sqrt()
498}
499
500// ─────────────────────────── Newmark-β (linear) ───────────────────────────
501
502/// Time-history response from a Newmark integration.
503#[derive(Debug, Clone)]
504pub struct NewmarkResult {
505    /// Time grid, length `nsteps + 1` (includes `t = 0`).
506    pub time: Vec<f64>,
507    /// Displacement vector at each time step.
508    pub disp: Vec<Vec<f64>>,
509    /// Velocity vector at each time step.
510    pub vel: Vec<Vec<f64>>,
511    /// Acceleration vector at each time step.
512    pub acc: Vec<Vec<f64>>,
513}
514
515impl NewmarkResult {
516    /// Peak absolute value of DOF `d` across the whole history.
517    pub fn peak_abs(&self, d: usize) -> f64 {
518        self.disp
519            .iter()
520            .map(|u| u.get(d).copied().unwrap_or(0.0).abs())
521            .fold(0.0_f64, f64::max)
522    }
523}
524
525/// Average-acceleration Newmark-β (β=¼, γ=½ by default) integration of
526/// `M ü + C u̇ + K u = F(t)` on an already-reduced `n`-DOF system (no constraints).
527/// `force(t)` returns the length-`n` load vector. Unconditionally stable; the
528/// effective stiffness is factored once and reused every step.
529#[allow(clippy::too_many_arguments)]
530pub fn newmark_linear(
531    m: &[f64],
532    c: &[f64],
533    k: &[f64],
534    n: usize,
535    force: impl Fn(f64) -> Vec<f64>,
536    u0: &[f64],
537    v0: &[f64],
538    dt: f64,
539    nsteps: usize,
540    beta: f64,
541    gamma: f64,
542) -> Result<NewmarkResult, EngineeringError> {
543    if n == 0 {
544        return Err(EngineeringError::InsufficientData("zero-DOF system".into()));
545    }
546    if !(dt > 0.0) || nsteps == 0 {
547        return Err(EngineeringError::ValidationError(
548            "dt and nsteps must be positive".into(),
549        ));
550    }
551    if !(beta > 0.0) {
552        return Err(EngineeringError::ValidationError(
553            "Newmark β must be > 0".into(),
554        ));
555    }
556    if m.len() != n * n || c.len() != n * n || k.len() != n * n {
557        return Err(EngineeringError::ValidationError(
558            "M, C, K must each be n×n".into(),
559        ));
560    }
561
562    // Integration constants.
563    let a0 = 1.0 / (beta * dt * dt);
564    let a1 = gamma / (beta * dt);
565    let a2 = 1.0 / (beta * dt);
566    let a3 = 1.0 / (2.0 * beta) - 1.0;
567    let a4 = gamma / beta - 1.0;
568    let a5 = dt / 2.0 * (gamma / beta - 2.0);
569    let a6 = dt * (1.0 - gamma);
570    let a7 = gamma * dt;
571
572    // Effective stiffness  K_eff = K + a0·M + a1·C  (constant → factor once).
573    let mut keff = k.to_vec();
574    for i in 0..n * n {
575        keff[i] += a0 * m[i] + a1 * c[i];
576    }
577    let keff_lu = lu_decompose(n, &keff).map_err(|e| {
578        EngineeringError::SolverError(format!("Newmark effective-stiffness LU failed: {e:?}"))
579    })?;
580
581    let mut u = u0.to_vec();
582    let mut v = v0.to_vec();
583    // Initial acceleration: M a = F(0) − C v0 − K u0.
584    let f0 = force(0.0);
585    let cv = matvec(c, &v, n);
586    let ku = matvec(k, &u, n);
587    let mut rhs0 = vec![0.0_f64; n];
588    for i in 0..n {
589        rhs0[i] = f0[i] - cv[i] - ku[i];
590    }
591    let m_lu = lu_decompose(n, m).map_err(|e| {
592        EngineeringError::SolverError(format!("Newmark mass-matrix LU failed: {e:?}"))
593    })?;
594    let mut a = m_lu
595        .solve(&rhs0)
596        .ok_or_else(|| EngineeringError::SolverError("mass matrix is singular".to_string()))?;
597
598    let mut out = NewmarkResult {
599        time: Vec::with_capacity(nsteps + 1),
600        disp: Vec::with_capacity(nsteps + 1),
601        vel: Vec::with_capacity(nsteps + 1),
602        acc: Vec::with_capacity(nsteps + 1),
603    };
604    out.time.push(0.0);
605    out.disp.push(u.clone());
606    out.vel.push(v.clone());
607    out.acc.push(a.clone());
608
609    for step in 0..nsteps {
610        let t_next = (step + 1) as f64 * dt;
611        // Effective load  F_eff = F(t+dt) + M(a0 u + a2 v + a3 a) + C(a1 u + a4 v + a5 a).
612        let mut mvec = vec![0.0_f64; n];
613        let mut cvec = vec![0.0_f64; n];
614        for i in 0..n {
615            mvec[i] = a0 * u[i] + a2 * v[i] + a3 * a[i];
616            cvec[i] = a1 * u[i] + a4 * v[i] + a5 * a[i];
617        }
618        let mm = matvec(m, &mvec, n);
619        let cc = matvec(c, &cvec, n);
620        let fext = force(t_next);
621        let mut feff = vec![0.0_f64; n];
622        for i in 0..n {
623            feff[i] = fext[i] + mm[i] + cc[i];
624        }
625        let u_new = keff_lu.solve(&feff).ok_or_else(|| {
626            EngineeringError::SolverError("Newmark step solve failed (singular)".to_string())
627        })?;
628        // Update accel and velocity.
629        let mut a_new = vec![0.0_f64; n];
630        for i in 0..n {
631            a_new[i] = a0 * (u_new[i] - u[i]) - a2 * v[i] - a3 * a[i];
632        }
633        let mut v_new = vec![0.0_f64; n];
634        for i in 0..n {
635            v_new[i] = v[i] + a6 * a[i] + a7 * a_new[i];
636        }
637        u = u_new;
638        v = v_new;
639        a = a_new;
640        out.time.push(t_next);
641        out.disp.push(u.clone());
642        out.vel.push(v.clone());
643        out.acc.push(a.clone());
644    }
645    Ok(out)
646}
647
648// ─────────────────────── Newton–Raphson (nonlinear static) ───────────────────────
649
650/// Newton–Raphson solve of `R(u) = f_int(u) − F_ext = 0`.
651///
652/// `residual(u)` returns `R` (length `n`); `tangent(u)` returns the `n×n` tangent
653/// stiffness `Kₜ = ∂f_int/∂u` (row-major). Iterates `u ← u − Kₜ⁻¹ R` until
654/// `‖R‖ ≤ tol` (or `‖Δu‖ ≤ tol`). Returns `(u, iterations)` or a `ConvergenceError`.
655pub fn newton_raphson(
656    n: usize,
657    residual: impl Fn(&[f64]) -> Vec<f64>,
658    tangent: impl Fn(&[f64]) -> Vec<f64>,
659    u0: &[f64],
660    tol: f64,
661    max_iter: usize,
662) -> Result<(Vec<f64>, usize), EngineeringError> {
663    let mut u = u0.to_vec();
664    for it in 0..max_iter {
665        let r = residual(&u);
666        if norm2(&r) <= tol {
667            return Ok((u, it));
668        }
669        let kt = tangent(&u);
670        let lu = lu_decompose(n, &kt).map_err(|e| {
671            EngineeringError::SolverError(format!("Newton tangent LU failed: {e:?}"))
672        })?;
673        // Solve Kt · du = −R.
674        let neg_r: Vec<f64> = r.iter().map(|&x| -x).collect();
675        let du = lu.solve(&neg_r).ok_or_else(|| {
676            EngineeringError::SolverError("Newton tangent is singular".to_string())
677        })?;
678        axpy_into(&mut u, 1.0, &du);
679        if norm2(&du) <= tol {
680            // Confirm the residual is also small before declaring convergence.
681            if norm2(&residual(&u)) <= tol.max(1e-8) {
682                return Ok((u, it + 1));
683            }
684        }
685    }
686    Err(EngineeringError::ConvergenceError(format!(
687        "Newton–Raphson did not converge in {max_iter} iterations"
688    )))
689}
690
691// ─────────────────────── Newmark + Newton (nonlinear dynamic) ───────────────────────
692
693/// Newmark-β integration with an inner Newton–Raphson iteration each step for
694/// `M ü + C u̇ + f_int(u) = F(t)`. `internal(u)` = `f_int`, `tangent(u)` = `∂f_int/∂u`.
695/// The step residual is `G(u) = M a(u) + C v(u) + f_int(u) − F(t+dt)` where `a,v`
696/// follow the Newmark relations; the effective tangent is `Kₜ + a0·M + a1·C`.
697#[allow(clippy::too_many_arguments)]
698pub fn newmark_nonlinear(
699    m: &[f64],
700    c: &[f64],
701    n: usize,
702    internal: impl Fn(&[f64]) -> Vec<f64>,
703    tangent: impl Fn(&[f64]) -> Vec<f64>,
704    force: impl Fn(f64) -> Vec<f64>,
705    u0: &[f64],
706    v0: &[f64],
707    dt: f64,
708    nsteps: usize,
709    beta: f64,
710    gamma: f64,
711    tol: f64,
712    max_iter: usize,
713) -> Result<NewmarkResult, EngineeringError> {
714    if n == 0 {
715        return Err(EngineeringError::InsufficientData("zero-DOF system".into()));
716    }
717    if !(dt > 0.0) || nsteps == 0 || !(beta > 0.0) {
718        return Err(EngineeringError::ValidationError(
719            "dt, nsteps, β must be positive".into(),
720        ));
721    }
722
723    let a0 = 1.0 / (beta * dt * dt);
724    let a1 = gamma / (beta * dt);
725    let a2 = 1.0 / (beta * dt);
726    let a3 = 1.0 / (2.0 * beta) - 1.0;
727    let a6 = dt * (1.0 - gamma);
728    let a7 = gamma * dt;
729
730    let mut u = u0.to_vec();
731    let mut v = v0.to_vec();
732    // Initial acceleration: M a = F(0) − C v0 − f_int(u0).
733    let f0 = force(0.0);
734    let cv = matvec(c, &v, n);
735    let fi = internal(&u);
736    let rhs0: Vec<f64> = (0..n).map(|i| f0[i] - cv[i] - fi[i]).collect();
737    let m_lu = lu_decompose(n, m)
738        .map_err(|e| EngineeringError::SolverError(format!("mass LU failed: {e:?}")))?;
739    let mut a = m_lu
740        .solve(&rhs0)
741        .ok_or_else(|| EngineeringError::SolverError("mass matrix singular".into()))?;
742
743    let mut out = NewmarkResult {
744        time: vec![0.0],
745        disp: vec![u.clone()],
746        vel: vec![v.clone()],
747        acc: vec![a.clone()],
748    };
749
750    for step in 0..nsteps {
751        let t_next = (step + 1) as f64 * dt;
752        let fext = force(t_next);
753        // Predictor: trial u = u_n (start Newton from previous displacement).
754        let u_n = u.clone();
755        let v_n = v.clone();
756        let a_n = a.clone();
757        let mut u_trial = u_n.clone();
758
759        let mut converged = false;
760        for _ in 0..max_iter {
761            // a(u) = a0(u − u_n) − a2 v_n − a3 a_n
762            // v(u) = v_n + a6 a_n + a7 a(u)
763            let a_u: Vec<f64> = (0..n)
764                .map(|i| a0 * (u_trial[i] - u_n[i]) - a2 * v_n[i] - a3 * a_n[i])
765                .collect();
766            let v_u: Vec<f64> = (0..n).map(|i| v_n[i] + a6 * a_n[i] + a7 * a_u[i]).collect();
767            let ma = matvec(m, &a_u, n);
768            let cvv = matvec(c, &v_u, n);
769            let fi_u = internal(&u_trial);
770            let g: Vec<f64> = (0..n).map(|i| ma[i] + cvv[i] + fi_u[i] - fext[i]).collect();
771            if norm2(&g) <= tol {
772                converged = true;
773                break;
774            }
775            // Effective tangent: Kt + a0 M + a1 C.
776            let kt = tangent(&u_trial);
777            let mut keff = kt;
778            for i in 0..n * n {
779                keff[i] += a0 * m[i] + a1 * c[i];
780            }
781            let lu = lu_decompose(n, &keff).map_err(|e| {
782                EngineeringError::SolverError(format!("nonlinear-dynamic tangent LU failed: {e:?}"))
783            })?;
784            let neg_g: Vec<f64> = g.iter().map(|&x| -x).collect();
785            let du = lu
786                .solve(&neg_g)
787                .ok_or_else(|| EngineeringError::SolverError("tangent singular".into()))?;
788            axpy_into(&mut u_trial, 1.0, &du);
789            if norm2(&du) <= tol {
790                converged = true;
791                break;
792            }
793        }
794        if !converged {
795            return Err(EngineeringError::ConvergenceError(format!(
796                "nonlinear-dynamic Newton did not converge at t = {t_next}"
797            )));
798        }
799        // Commit the step.
800        let a_new: Vec<f64> = (0..n)
801            .map(|i| a0 * (u_trial[i] - u_n[i]) - a2 * v_n[i] - a3 * a_n[i])
802            .collect();
803        let v_new: Vec<f64> = (0..n)
804            .map(|i| v_n[i] + a6 * a_n[i] + a7 * a_new[i])
805            .collect();
806        u = u_trial;
807        v = v_new;
808        a = a_new;
809        out.time.push(t_next);
810        out.disp.push(u.clone());
811        out.vel.push(v.clone());
812        out.acc.push(a.clone());
813    }
814    Ok(out)
815}
816
817// ─────────────────── Geometrically-nonlinear axial bar (facade) ───────────────────
818
819/// A single-DOF geometrically-nonlinear axial bar (fixed–free), Green–Lagrange strain.
820///
821/// For a straight prismatic bar of length `L`, section `EA`, with the fixed end at
822/// `x = 0` and axial tip displacement `u`, the Green–Lagrange axial strain is
823/// `ε = u/L + ½(u/L)²`. Strain energy `U = ½·EA·L·ε²`, so the internal tip force and
824/// its tangent are
825///
826/// * `f_int(u) = EA·ε·(1 + u/L)`
827/// * `Kₜ(u) = (EA/L)·[(1 + u/L)² + ε]`
828///
829/// These reduce to the linear bar (`f = EA·u/L`) for small `u` and stiffen
830/// geometrically for large `u`. This is the concrete nonlinear model wired to the
831/// facade `NonlinearStatic` / `NonlinearDynamic` variants.
832#[derive(Debug, Clone, Copy)]
833pub struct GeoNonlinearBar {
834    pub ea: f64,
835    pub length: f64,
836}
837
838impl GeoNonlinearBar {
839    /// Green–Lagrange axial strain at tip displacement `u`.
840    pub fn strain(&self, u: f64) -> f64 {
841        let r = u / self.length;
842        r + 0.5 * r * r
843    }
844    /// Internal tip force `f_int(u)`.
845    pub fn internal_force(&self, u: f64) -> f64 {
846        self.ea * self.strain(u) * (1.0 + u / self.length)
847    }
848    /// Tangent stiffness `Kₜ(u)`.
849    pub fn tangent(&self, u: f64) -> f64 {
850        let r = u / self.length;
851        self.ea / self.length * ((1.0 + r) * (1.0 + r) + self.strain(u))
852    }
853    /// Solve `f_int(u) = f_ext` for the tip displacement by Newton–Raphson.
854    pub fn solve_static(
855        &self,
856        f_ext: f64,
857        tol: f64,
858        max_iter: usize,
859    ) -> Result<f64, EngineeringError> {
860        let ea = self.ea;
861        let l = self.length;
862        let bar = *self;
863        let (u, _it) = newton_raphson(
864            1,
865            |u| vec![bar.internal_force(u[0]) - f_ext],
866            |u| vec![bar.tangent(u[0])],
867            &[f_ext * l / ea], // linear guess
868            tol,
869            max_iter,
870        )?;
871        Ok(u[0])
872    }
873}
874
875#[cfg(test)]
876mod tests {
877    use super::*;
878
879    const E_STEEL: f64 = 200.0e9; // Pa
880
881    #[test]
882    fn axial_bar_single_element_displacement() {
883        // Single 2-D truss bar along x: L = 2 m, A = 0.01 m², E = 200 GPa.
884        // Node 0 fixed (ux,uy,rz), node 1 rollers on uy,rz, axial load F = 50 kN.
885        // Closed form: δ = F·L / (A·E).
886        let f = 50.0e3;
887        let (l, area) = (2.0, 0.01);
888        let model = FeModel {
889            nodes: vec![FeNode { x: 0.0, y: 0.0 }, FeNode { x: l, y: 0.0 }],
890            elements: vec![FeElement::Truss {
891                ni: 0,
892                nj: 1,
893                e: E_STEEL,
894                area,
895                rho: 7850.0,
896            }],
897            // Fix node0 fully; constrain node1 transverse+rotation so only ux is free.
898            constraints: vec![(0, 0.0), (1, 0.0), (2, 0.0), (4, 0.0), (5, 0.0)],
899            loads: vec![(3, f)], // ux at node1
900        };
901        let res = solve_static(&model).unwrap();
902        let expected = f * l / (area * E_STEEL);
903        let got = res.displacements[3];
904        assert!(
905            (got - expected).abs() / expected < 1e-9,
906            "axial δ = {got} (expected {expected})"
907        );
908        // Reaction at the fixed ux DOF balances the applied load.
909        let rux = res.reactions.iter().find(|(d, _)| *d == 0).unwrap().1;
910        assert!(
911            (rux + f).abs() / f < 1e-9,
912            "reaction ux = {rux} (expected {})",
913            -f
914        );
915        // Element axial force equals the applied tension.
916        assert!(
917            (res.element_axial_force[0] - f).abs() / f < 1e-9,
918            "N = {}",
919            res.element_axial_force[0]
920        );
921    }
922
923    #[test]
924    fn two_bar_truss_hand_computed_joint_displacement() {
925        // Two collinear bars in series along x, each L = 1 m, A = 0.01, E = 200 GPa.
926        // n0 fixed — n1 (free) — n2 (free), axial load F at n2.
927        // Series stiffness: each k = EA/L. n2 disp = F/k + F/k = 2F/k (both carry F);
928        // n1 disp = F/k. Hand: k = EA/L = 200e9·0.01/1 = 2e9 N/m; F = 20 kN.
929        // u1 = F/k = 1e-5 m; u2 = 2F/k = 2e-5 m.
930        let f = 20.0e3;
931        let (l, area) = (1.0, 0.01);
932        let k = E_STEEL * area / l;
933        let model = FeModel {
934            nodes: vec![
935                FeNode { x: 0.0, y: 0.0 },
936                FeNode { x: l, y: 0.0 },
937                FeNode { x: 2.0 * l, y: 0.0 },
938            ],
939            elements: vec![
940                FeElement::Truss {
941                    ni: 0,
942                    nj: 1,
943                    e: E_STEEL,
944                    area,
945                    rho: 7850.0,
946                },
947                FeElement::Truss {
948                    ni: 1,
949                    nj: 2,
950                    e: E_STEEL,
951                    area,
952                    rho: 7850.0,
953                },
954            ],
955            // Fix n0 fully; constrain transverse + rotation of n1,n2 (pure axial chain).
956            constraints: vec![
957                (0, 0.0),
958                (1, 0.0),
959                (2, 0.0),
960                (4, 0.0),
961                (5, 0.0),
962                (7, 0.0),
963                (8, 0.0),
964            ],
965            loads: vec![(6, f)], // ux at n2
966        };
967        let res = solve_static(&model).unwrap();
968        let u1 = res.displacements[3];
969        let u2 = res.displacements[6];
970        assert!(
971            (u1 - f / k).abs() / (f / k) < 1e-9,
972            "u1 = {u1} (expected {})",
973            f / k
974        );
975        assert!((u2 - 2.0 * f / k).abs() / (2.0 * f / k) < 1e-9, "u2 = {u2}");
976    }
977
978    #[test]
979    fn cantilever_tip_deflection_matches_euler_bernoulli() {
980        // Cantilever, L = 3 m, E = 200 GPa, I = 8e-6 m⁴, tip point load P = 5 kN.
981        // Euler–Bernoulli closed form: δ_tip = P·L³ / (3·E·I).
982        // A single 2-node beam element gives the EXACT cubic tip solution for a tip load.
983        let (l, inertia, area) = (3.0, 8.0e-6, 0.01);
984        let p = 5.0e3;
985        let model = FeModel {
986            nodes: vec![FeNode { x: 0.0, y: 0.0 }, FeNode { x: l, y: 0.0 }],
987            elements: vec![FeElement::Frame {
988                ni: 0,
989                nj: 1,
990                e: E_STEEL,
991                area,
992                inertia,
993                rho: 7850.0,
994            }],
995            // Fully fix the root node (ux,uy,rz).
996            constraints: vec![(0, 0.0), (1, 0.0), (2, 0.0)],
997            loads: vec![(4, -p)], // downward transverse load (uy) at the tip
998        };
999        let res = solve_static(&model).unwrap();
1000        let tip = res.displacements[4]; // uy at node1
1001        let expected = -p * l * l * l / (3.0 * E_STEEL * inertia);
1002        assert!(
1003            (tip - expected).abs() / expected.abs() < 1e-9,
1004            "tip δ = {tip} (expected {expected})"
1005        );
1006        // Root vertical reaction balances the applied load.
1007        let ruy = res.reactions.iter().find(|(d, _)| *d == 1).unwrap().1;
1008        assert!(
1009            (ruy - p).abs() / p < 1e-9,
1010            "root reaction = {ruy} (expected {p})"
1011        );
1012        // Root moment reaction magnitude = P·L (cantilever). Sign follows the standard
1013        // Euler–Bernoulli DOF convention: hand-deriving the reduced 2-DOF system gives
1014        // R_θ1 = −6EI/L²·v2 + 2EI/L·θ2 = +P·L for a downward tip load.
1015        let rrz = res.reactions.iter().find(|(d, _)| *d == 2).unwrap().1;
1016        assert!(
1017            (rrz - p * l).abs() / (p * l) < 1e-9,
1018            "root moment = {rrz} (expected {})",
1019            p * l
1020        );
1021    }
1022
1023    #[test]
1024    fn cantilever_two_elements_also_exact() {
1025        // Refining to two beam elements must not change the (exact) tip deflection.
1026        let (l, inertia, area) = (3.0, 8.0e-6, 0.01);
1027        let p = 5.0e3;
1028        let model = FeModel {
1029            nodes: vec![
1030                FeNode { x: 0.0, y: 0.0 },
1031                FeNode { x: l / 2.0, y: 0.0 },
1032                FeNode { x: l, y: 0.0 },
1033            ],
1034            elements: vec![
1035                FeElement::Frame {
1036                    ni: 0,
1037                    nj: 1,
1038                    e: E_STEEL,
1039                    area,
1040                    inertia,
1041                    rho: 7850.0,
1042                },
1043                FeElement::Frame {
1044                    ni: 1,
1045                    nj: 2,
1046                    e: E_STEEL,
1047                    area,
1048                    inertia,
1049                    rho: 7850.0,
1050                },
1051            ],
1052            constraints: vec![(0, 0.0), (1, 0.0), (2, 0.0)],
1053            loads: vec![(7, -p)], // uy at node2 (tip)
1054        };
1055        let res = solve_static(&model).unwrap();
1056        let tip = res.displacements[7];
1057        let expected = -p * l * l * l / (3.0 * E_STEEL * inertia);
1058        assert!(
1059            (tip - expected).abs() / expected.abs() < 1e-8,
1060            "two-element tip δ = {tip} (expected {expected})"
1061        );
1062    }
1063
1064    #[test]
1065    fn newmark_sdof_undamped_tracks_cosine() {
1066        // M ü + K u = 0, u(0)=u0, u̇(0)=0 ⇒ u(t) = u0·cos(ωt), ω = √(K/M).
1067        let (mass, stiff, u0): (f64, f64, f64) = (2.0, 200.0, 0.05);
1068        let omega = (stiff / mass).sqrt();
1069        let period = 2.0 * std::f64::consts::PI / omega;
1070        let dt = period / 400.0;
1071        let nsteps = 400; // exactly one period
1072        let res = newmark_linear(
1073            &[mass],
1074            &[0.0],
1075            &[stiff],
1076            1,
1077            |_t| vec![0.0],
1078            &[u0],
1079            &[0.0],
1080            dt,
1081            nsteps,
1082            0.25,
1083            0.5,
1084        )
1085        .unwrap();
1086        // Track the analytic cosine at every step.
1087        let mut max_err = 0.0_f64;
1088        for (i, u) in res.disp.iter().enumerate() {
1089            let t = i as f64 * dt;
1090            let analytic = u0 * (omega * t).cos();
1091            max_err = max_err.max((u[0] - analytic).abs());
1092        }
1093        assert!(
1094            max_err < 1e-3 * u0,
1095            "max deviation from u0·cos(ωt) = {max_err} (u0 = {u0})"
1096        );
1097        // Undamped energy stays bounded: 0 ≤ E ≤ E0 (never grows).
1098        let e0 = 0.5 * stiff * u0 * u0;
1099        for (u, v) in res.disp.iter().zip(res.vel.iter()) {
1100            let e = 0.5 * mass * v[0] * v[0] + 0.5 * stiff * u[0] * u[0];
1101            assert!(e <= e0 * (1.0 + 1e-6), "energy grew: {e} > {e0}");
1102        }
1103        // After one full period, it returns to (u0, 0).
1104        assert!((res.disp[nsteps][0] - u0).abs() < 1e-3 * u0);
1105        assert!(res.vel[nsteps][0].abs() < 1e-2 * omega * u0);
1106    }
1107
1108    #[test]
1109    fn newton_raphson_cubic_spring_hand_solved_root() {
1110        // Nonlinear spring F = k·u + k3·u³. With k = 100, k3 = 100, F = 200,
1111        // the equilibrium is u = 1 exactly (100·1 + 100·1³ = 200).
1112        let (k, k3, f) = (100.0, 100.0, 200.0);
1113        let (u, iters) = newton_raphson(
1114            1,
1115            |u| vec![k * u[0] + k3 * u[0].powi(3) - f],
1116            |u| vec![k + 3.0 * k3 * u[0] * u[0]],
1117            &[0.0],
1118            1e-12,
1119            50,
1120        )
1121        .unwrap();
1122        assert!((u[0] - 1.0).abs() < 1e-10, "u = {} (expected 1.0)", u[0]);
1123        assert!(iters < 20, "took {iters} iterations");
1124
1125        // A second point: F = k·2 + k3·8 = 200 + 800 = 1000 ⇒ u = 2.
1126        let (u2, _) = newton_raphson(
1127            1,
1128            |u| vec![k * u[0] + k3 * u[0].powi(3) - 1000.0],
1129            |u| vec![k + 3.0 * k3 * u[0] * u[0]],
1130            &[0.0],
1131            1e-12,
1132            50,
1133        )
1134        .unwrap();
1135        assert!((u2[0] - 2.0).abs() < 1e-10, "u2 = {}", u2[0]);
1136    }
1137
1138    #[test]
1139    fn geometric_nonlinear_bar_stiffens_and_recovers_linear() {
1140        // Small load ⇒ ~linear (δ ≈ FL/EA); large load ⇒ geometric stiffening (δ < linear).
1141        let ea = 1.0e6;
1142        let bar = GeoNonlinearBar { ea, length: 1.0 };
1143        // Small load: 100 N ⇒ linear δ = 1e-4. Nonlinear correction is tiny.
1144        let small = bar.solve_static(100.0, 1e-12, 100).unwrap();
1145        let lin_small = 100.0 / ea;
1146        assert!(
1147            (small - lin_small).abs() / lin_small < 1e-3,
1148            "small u = {small}"
1149        );
1150        // Large load: nonlinear tip disp is strictly less than the linear estimate
1151        // (Green strain stiffens in tension) and satisfies f_int(u) = F exactly.
1152        let big = bar.solve_static(3.0e5, 1e-10, 100).unwrap();
1153        let lin_big = 3.0e5 / ea;
1154        assert!(
1155            big < lin_big,
1156            "expected stiffening: u = {big}, linear = {lin_big}"
1157        );
1158        assert!(
1159            (bar.internal_force(big) - 3.0e5).abs() < 1e-3,
1160            "residual not satisfied: f_int = {}",
1161            bar.internal_force(big)
1162        );
1163    }
1164
1165    #[test]
1166    fn newmark_nonlinear_duffing_energy_bounded() {
1167        // Undamped Duffing oscillator: M ü + k u + k3 u³ = 0, u(0)=u0, u̇(0)=0.
1168        // Total energy E = ½M v² + ½k u² + ¼k3 u⁴ is conserved. Newmark (avg-accel)
1169        // is not exactly energy-conserving but must stay bounded and close over time.
1170        let (mass, k, k3, u0): (f64, f64, f64, f64) = (1.0, 100.0, 500.0, 0.3);
1171        let e0 = 0.5 * k * u0 * u0 + 0.25 * k3 * u0.powi(4);
1172        // Small linear-regime period for step selection.
1173        let omega = (k / mass).sqrt();
1174        let dt = (2.0 * std::f64::consts::PI / omega) / 500.0;
1175        let res = newmark_nonlinear(
1176            &[mass],
1177            &[0.0],
1178            1,
1179            |u| vec![k * u[0] + k3 * u[0].powi(3)],
1180            |u| vec![k + 3.0 * k3 * u[0] * u[0]],
1181            |_t| vec![0.0],
1182            &[u0],
1183            &[0.0],
1184            dt,
1185            2000,
1186            0.25,
1187            0.5,
1188            1e-12,
1189            50,
1190        )
1191        .unwrap();
1192        let mut max_e = 0.0_f64;
1193        let mut min_e = f64::INFINITY;
1194        for (u, v) in res.disp.iter().zip(res.vel.iter()) {
1195            let e = 0.5 * mass * v[0] * v[0] + 0.5 * k * u[0] * u[0] + 0.25 * k3 * u[0].powi(4);
1196            max_e = max_e.max(e);
1197            min_e = min_e.min(e);
1198        }
1199        // Energy drift stays under 1% of E0 across 2000 steps (4 linear periods).
1200        assert!(
1201            (max_e - e0).abs() / e0 < 1e-2 && (e0 - min_e).abs() / e0 < 1e-2,
1202            "energy drift: E0 = {e0}, min = {min_e}, max = {max_e}"
1203        );
1204        // Amplitude never exceeds the initial (undamped, energy-bounded).
1205        let peak = res.peak_abs(0);
1206        assert!(peak <= u0 * (1.0 + 1e-3), "amplitude grew: {peak} > {u0}");
1207    }
1208}