Skip to main content

alice_physics/
structural_solver.rs

1//! Structural (Solid Mechanics) Time-Stepping Solver
2//!
3//! Composes the strength / plasticity / creep / fatigue / buckling
4//! models into a single time-stepping loop that follows a component through
5//! its service life:
6//!
7//! 1. Apply the current load case → nominal stress via `beam_stress`.
8//! 2. Yield check via `plastic::radial_return_1d` (permanent strain
9//!    accumulates each step).
10//! 3. Creep integration via `plastic::NortonCreep` and the long-term
11//!    Findley model from `creep_longterm` (temperature-shifted).
12//! 4. Fatigue damage accumulation via Miner's rule (`fatigue`).
13//! 5. Buckling check via `buckling::analyze_column` — instability trip.
14//!
15//! Output: `StructuralReport` per step and a final `StructuralHistory`
16//! with lifetime totals.
17//!
18//! Creep is modelled only where calibrated parameters exist. `new` installs
19//! the PLA presets (`FindleyParameters::pla_25c_moderate`,
20//! `NortonCreep::pla_room_temp`) only for the crate's PLA preset material
21//! (`MaterialProperties::pla()`, identified by its `Fdm` category and its
22//! `"PLA"` name). Any other material starts with creep not modelled: both
23//! creep models carry zero coefficients, `creep_modelled()` is `false` and
24//! every report has `creep_strain == 0`. `with_creep` supplies parameters
25//! for any material. Before this change every material received the PLA
26//! creep presets.
27//!
28//! Fatigue: `new` selects the S-N curve with `SnCurve::for_material`, i.e.
29//! the SUS304 / A5052 metal presets for the crate's `MaterialProperties::sus304()`
30//! / `a5052()` (sheet-metal category and preset name) and
31//! `SnCurve::from_fdm_material` for every other material. `with_sn_curve`
32//! overrides it. The per-step Miner damage, [`StructuralSolver::fatigue_strength_mpa`]
33//! (Basquin inverse) and [`StructuralSolver::fatigue_spectrum_report`] all use
34//! the selected curve. Before this change the two metal presets also got the
35//! polymer rule of thumb (`S_e = 0.3 UTS`, `N_e = 10^6`, `m = 5`).
36//!
37//! Buckling: the column check in `step` uses `end_condition` (`K`) and the
38//! material's Young's modulus. Local plate buckling and snap-through are
39//! queries on the solver ([`StructuralSolver::plate_buckling_mpa`],
40//! [`StructuralSolver::snap_through_load_n`]) evaluated with the solver's
41// LIMITATION(COV-STRUCT-132): they do not trip a failure in `step`
42//! material; they do not trip a failure in `step`.
43
44use crate::beam_stress::{BeamAnalysis, ColumnEndCondition, CrossSection, LoadCase};
45use crate::buckling::{
46    analyze_column, plate_buckling_mpa, snap_through_load_n, BucklingRegime, ColumnBucklingReport,
47    SnapThroughError,
48};
49use crate::creep_longterm::{predict_strain, FindleyParameters};
50use crate::fatigue::{
51    analyze_spectrum, miner_damage, stress_at_cycles, FatigueRangeError, FatigueReport, SnCurve,
52    SpectrumEntry,
53};
54use crate::filament_db::{MaterialCategory, MaterialProperties};
55use crate::math::Fix128;
56use crate::plastic::{radial_return_1d, NortonCreep, PlasticModel, PlasticState};
57
58/// Per-step diagnostic snapshot.
59#[derive(Clone, Copy, Debug, PartialEq, Eq)]
60pub struct StructuralReport {
61    /// Elapsed simulation time (hours).
62    pub elapsed_hours: Fix128,
63    /// Applied bending stress (MPa).
64    pub bending_stress_mpa: Fix128,
65    /// Column buckling factor of safety (P_cr / applied axial).
66    pub buckling_fos: Fix128,
67    /// Current plastic strain (dimensionless).
68    pub plastic_strain: Fix128,
69    /// Cumulative creep strain (dimensionless): `max(Norton, Findley)`.
70    /// Exactly zero when creep is not modelled for the material
71    /// (see [`StructuralSolver::creep_modelled`]).
72    pub creep_strain: Fix128,
73    /// Cumulative fatigue damage (0 to > 1, where 1 = failure).
74    pub fatigue_damage: Fix128,
75    /// True iff current step tripped a yield, buckling, or fatigue failure.
76    pub failed_this_step: bool,
77    /// Aggregate `is_safe` (no failures throughout the run so far).
78    pub is_safe: bool,
79}
80
81/// Lifetime accumulation.
82#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
83pub struct StructuralHistory {
84    /// Total elapsed time (hours).
85    pub elapsed_hours: Fix128,
86    /// First step index at which any failure was detected, if any.
87    pub failure_step: Option<u64>,
88    /// Final plastic state.
89    pub plastic_state: PlasticState,
90    /// Final Miner damage `D`.
91    pub fatigue_damage: Fix128,
92    /// Number of steps advanced.
93    pub steps: u64,
94}
95
96/// Structural solver configuration bundle.
97#[derive(Clone, Copy, Debug)]
98pub struct StructuralSolver {
99    /// Beam geometry (used for bending stress and axial buckling).
100    pub section: CrossSection,
101    /// Bending load applied every step.
102    pub load: LoadCase,
103    /// Axial column load (N) applied every step (0 = pure bending).
104    pub axial_load_n: Fix128,
105    /// Column length for buckling check (mm).
106    pub column_length_mm: Fix128,
107    /// End condition for buckling check.
108    pub end_condition: ColumnEndCondition,
109    /// Filament material.
110    pub material: MaterialProperties,
111    /// Plastic constitutive model.
112    pub plastic_model: PlasticModel,
113    /// Fatigue S-N curve.
114    pub sn_curve: SnCurve,
115    /// Long-term creep parameters.
116    pub creep_params: FindleyParameters,
117    /// Norton creep (short-term); used for the plastic-state creep field.
118    pub norton_creep: NortonCreep,
119    /// Operating temperature (°C) used for time-temperature shift.
120    pub operating_temp_c: Fix128,
121    /// Time-step size (seconds).
122    pub dt_s: Fix128,
123    /// State carried across steps.
124    pub state: PlasticState,
125    /// Total elapsed simulation time (hours).
126    pub elapsed_hours: Fix128,
127    /// Accumulated fatigue damage (Miner).
128    pub fatigue_damage: Fix128,
129    /// Failure step marker (once set, remains set).
130    pub failure_step: Option<u64>,
131    /// Step counter.
132    pub step_count: u64,
133}
134
135/// Name carried by the crate's PLA preset (`MaterialProperties::pla().name`).
136/// The creep presets are calibrated for that material only.
137const PLA_PRESET_NAME: &str = "PLA";
138
139/// Findley parameters of a material whose creep is not modelled: all
140/// coefficients zero, so `epsilon_0 + m * t^n == 0` for every `t`.
141const FINDLEY_NOT_MODELLED: FindleyParameters = FindleyParameters {
142    epsilon_0: Fix128::ZERO,
143    m: Fix128::ZERO,
144    n_int: 1,
145};
146
147/// Norton parameters of a material whose creep is not modelled (`A = 0`).
148const NORTON_NOT_MODELLED: NortonCreep = NortonCreep {
149    a: Fix128::ZERO,
150    n: 1,
151};
152
153/// Whether the crate has calibrated creep presets for `material`: the PLA
154/// preset (FDM category, name `"PLA"`). Materials derived from
155/// `MaterialProperties::pla()` with other fields edited keep the name and
156/// therefore the PLA creep, as before.
157fn has_pla_creep_calibration(material: &MaterialProperties) -> bool {
158    material.category == MaterialCategory::Fdm && material.name == PLA_PRESET_NAME
159}
160
161impl StructuralSolver {
162    /// Construct with `from_fdm_material` defaults for the given material and
163    /// bending load case. Axial load defaults to zero (pure bending).
164    ///
165    /// Creep: the PLA presets are installed only when `material` is the PLA
166    /// preset (FDM category, name `"PLA"`). For every other material creep is
167    /// not modelled (zero coefficients, [`Self::creep_modelled`] is `false`,
168    /// reported creep strain is exactly zero) until [`Self::with_creep`]
169    /// supplies parameters.
170    ///
171    /// Fatigue: the S-N curve is [`SnCurve::for_material`] (SUS304 / A5052
172    /// metal presets for those preset materials, the FDM rule of thumb for
173    /// every other material) until [`Self::with_sn_curve`] replaces it.
174    #[must_use]
175    pub fn new(section: CrossSection, load: LoadCase, material: MaterialProperties) -> Self {
176        let (creep_params, norton_creep) = if has_pla_creep_calibration(&material) {
177            (
178                FindleyParameters::pla_25c_moderate(),
179                NortonCreep::pla_room_temp(),
180            )
181        } else {
182            (FINDLEY_NOT_MODELLED, NORTON_NOT_MODELLED)
183        };
184        Self {
185            section,
186            load,
187            axial_load_n: Fix128::ZERO,
188            column_length_mm: load.length_mm(),
189            end_condition: ColumnEndCondition::PinPin,
190            material,
191            plastic_model: PlasticModel::from_fdm_material(&material),
192            sn_curve: SnCurve::for_material(&material),
193            creep_params,
194            norton_creep,
195            operating_temp_c: Fix128::from_int(25),
196            dt_s: Fix128::from_ratio(3600, 1), // default 1 hour steps
197            state: PlasticState::default(),
198            elapsed_hours: Fix128::ZERO,
199            fatigue_damage: Fix128::ZERO,
200            failure_step: None,
201            step_count: 0,
202        }
203    }
204
205    /// Replace both creep models, for any material. After this call creep is
206    /// modelled with exactly these parameters (unless all of `findley.epsilon_0`,
207    /// `findley.m` and `norton.a` are zero, which is the not-modelled state).
208    #[must_use]
209    pub fn with_creep(mut self, findley: FindleyParameters, norton: NortonCreep) -> Self {
210        self.creep_params = findley;
211        self.norton_creep = norton;
212        self
213    }
214
215    /// Replace the S-N curve used by the per-step Miner damage,
216    /// [`Self::fatigue_strength_mpa`] and [`Self::fatigue_spectrum_report`].
217    #[must_use]
218    pub fn with_sn_curve(mut self, curve: SnCurve) -> Self {
219        self.sn_curve = curve;
220        self
221    }
222
223    /// Alternating stress (MPa) at which the solver's S-N curve predicts
224    /// failure after exactly `cycles`: the Basquin inverse
225    /// `S = S_e · (N_e / N)^(1/m)`, and `S_e` for `cycles ≥ N_e`.
226    ///
227    /// # Errors
228    ///
229    /// [`FatigueRangeError::ZeroCycles`] for `cycles == 0`,
230    /// [`FatigueRangeError::BelowLowCycleBound`] under 1 000 cycles (outside
231    /// the Basquin range), [`FatigueRangeError::NonPositiveExponent`] for a
232    /// curve with `fatigue_exponent_m == 0`.
233    pub fn fatigue_strength_mpa(&self, cycles: u64) -> Result<Fix128, FatigueRangeError> {
234        stress_at_cycles(&self.sn_curve, cycles)
235    }
236
237    /// Miner's rule `D = Σ nᵢ / N(Sᵢ)` of a variable-amplitude spectrum on
238    /// the solver's S-N curve, with `is_safe = D < 1` and the safety factor
239    /// `1 / D`. Independent of the solver's accumulated per-step damage.
240    #[must_use]
241    pub fn fatigue_spectrum_report(&self, spectrum: &[SpectrumEntry]) -> FatigueReport {
242        analyze_spectrum(spectrum, &self.sn_curve)
243    }
244
245    /// Local plate buckling stress (MPa) of a wall of `thickness_mm` and
246    /// width `width_mm` made of the solver's material (Timoshenko):
247    /// `σ_cr = k · π² E / (12 (1 − ν²)) · (t / b)²`, `E` = the material's
248    /// Young's modulus. `edge_factor_k` is 4 for a wall simply supported on
249    /// all edges, 0.425 with one free edge; `poisson` is supplied by the
250    /// caller because `MaterialProperties` carries no Poisson ratio. Returns 0
251    /// for `width_mm == 0` or `poisson == ±1`.
252    #[must_use]
253    pub fn plate_buckling_mpa(
254        &self,
255        thickness_mm: Fix128,
256        width_mm: Fix128,
257        poisson: Fix128,
258        edge_factor_k: Fix128,
259    ) -> Fix128 {
260        plate_buckling_mpa(
261            self.youngs_modulus_mpa(),
262            poisson,
263            thickness_mm,
264            width_mm,
265            edge_factor_k,
266        )
267    }
268
269    /// Snap-through (limit) load (N) of a shallow two-bar truss of rise
270    /// `rise_mm` and span `span_mm` whose bars have the solver's
271    /// cross-section and material: `P = (16 / (3√3)) · E A (h / L)³`.
272    ///
273    /// # Errors
274    ///
275    /// [`SnapThroughError::NonPositiveSpan`] for `span_mm ≤ 0`,
276    /// [`SnapThroughError::NonPositiveStiffness`] for a non-positive modulus
277    /// or section area, [`SnapThroughError::NegativeRise`] for `rise_mm < 0`.
278    pub fn snap_through_load_n(
279        &self,
280        rise_mm: Fix128,
281        span_mm: Fix128,
282    ) -> Result<Fix128, SnapThroughError> {
283        snap_through_load_n(
284            self.youngs_modulus_mpa(),
285            self.section.area_mm2(),
286            rise_mm,
287            span_mm,
288        )
289    }
290
291    /// Young's modulus of the material in MPa, the unit `analyze_column`
292    /// uses.
293    fn youngs_modulus_mpa(&self) -> Fix128 {
294        self.material.youngs_modulus_gpa * Fix128::from_int(1000)
295    }
296
297    /// Whether creep is modelled for this solver. `false` means both creep
298    /// models carry zero coefficients (`creep_params.epsilon_0`,
299    /// `creep_params.m` and `norton_creep.a` all zero) and every
300    /// [`StructuralReport::creep_strain`] is exactly zero: `new` leaves a
301    /// non-PLA material in this state.
302    #[must_use]
303    pub fn creep_modelled(&self) -> bool {
304        !(self.creep_params.epsilon_0.is_zero()
305            && self.creep_params.m.is_zero()
306            && self.norton_creep.a.is_zero())
307    }
308
309    /// Advance one step and return the current diagnostics.
310    pub fn step(&mut self) -> StructuralReport {
311        // 1. Bending stress from current beam / load
312        let beam = BeamAnalysis::new(self.section, self.load, self.material)
313            .with_end_condition(self.end_condition);
314        let beam_report = beam.analyze();
315        let sigma = beam_report.max_bending_stress_mpa;
316
317        // 2. Plasticity update (radial return; simplified as if this stress
318        //    is applied as a trial each step). Steady-state cyclic loading
319        //    can be modelled by a stress spectrum via `fatigue`.
320        let step = radial_return_1d(sigma, &self.plastic_model, &mut self.state);
321        let yielded_this_step = step.yielded;
322
323        // 3. Creep — accumulate short-term Norton over one dt window. Skipped
324        //    when creep is not modelled, so the report carries an exact zero
325        //    (`strain_at` could otherwise saturate for a huge `t^n`).
326        let reported_creep = if self.creep_modelled() {
327            self.creep_increment(sigma)
328        } else {
329            Fix128::ZERO
330        };
331
332        // 4. Fatigue: apply the current stress as one cycle.
333        let spectrum: [SpectrumEntry; 1] = [(sigma, 1)];
334        let dd = miner_damage(&spectrum, &self.sn_curve);
335        self.fatigue_damage = self.fatigue_damage + dd;
336
337        // 5. Buckling: compute critical load and FoS.
338        let bp: ColumnBucklingReport = analyze_column(
339            &self.section,
340            self.column_length_mm,
341            self.end_condition,
342            &self.material,
343        );
344        let buckling_fos = if self.axial_load_n.is_zero() {
345            Fix128::from_int(i64::MAX >> 8)
346        } else {
347            bp.critical_load_n / self.axial_load_n
348        };
349        let buckled = buckling_fos < Fix128::ONE && bp.regime != BucklingRegime::Yielding;
350
351        // Failure conditions
352        let fatigue_failed = self.fatigue_damage >= Fix128::ONE;
353        let overloaded = !beam_report.is_safe;
354        let failed_now = yielded_this_step || fatigue_failed || overloaded || buckled;
355        if failed_now && self.failure_step.is_none() {
356            self.failure_step = Some(self.step_count);
357        }
358
359        // Advance clock (dt_s to hours)
360        self.elapsed_hours = self.elapsed_hours + self.dt_s / Fix128::from_int(3600);
361        self.step_count += 1;
362
363        StructuralReport {
364            elapsed_hours: self.elapsed_hours,
365            bending_stress_mpa: sigma,
366            buckling_fos,
367            plastic_strain: self.state.equivalent_plastic_strain,
368            creep_strain: reported_creep,
369            fatigue_damage: self.fatigue_damage,
370            failed_this_step: failed_now,
371            is_safe: self.failure_step.is_none(),
372        }
373    }
374
375    /// Norton accumulation over one `dt` plus the Findley projection; returns
376    /// the reported creep strain `max(Norton, Findley)`.
377    fn creep_increment(&mut self, sigma: Fix128) -> Fix128 {
378        self.norton_creep
379            .integrate(sigma, self.dt_s, &mut self.state);
380        // Also compute the long-term Findley projection at the operating
381        // temperature for reporting (does not feed back into radial return).
382        // `state.creep_strain` stays the pure Norton accumulation; the report
383        // carries max(Norton, Findley). Before 1.2.0 the max was written back
384        // into the state, so the next Norton increment landed on top of the
385        // Findley value and the reported creep was neither model.
386        let long_term_creep_strain = predict_strain(
387            &self.creep_params,
388            &self.material,
389            self.elapsed_hours,
390            self.operating_temp_c,
391        );
392        if long_term_creep_strain > self.state.creep_strain {
393            long_term_creep_strain
394        } else {
395            self.state.creep_strain
396        }
397    }
398
399    /// Run for `n` steps and return a snapshot of the final state.
400    pub fn run(&mut self, n_steps: u64) -> StructuralHistory {
401        for _ in 0..n_steps {
402            let _ = self.step();
403        }
404        StructuralHistory {
405            elapsed_hours: self.elapsed_hours,
406            failure_step: self.failure_step,
407            plastic_state: self.state,
408            fatigue_damage: self.fatigue_damage,
409            steps: self.step_count,
410        }
411    }
412}
413
414// ============================================================================
415// Tests
416// ============================================================================
417
418#[cfg(test)]
419mod tests {
420    use super::*;
421
422    #[test]
423    fn solver_new_defaults() {
424        let section = CrossSection::Rectangular {
425            width_mm: Fix128::from_int(10),
426            height_mm: Fix128::from_int(20),
427        };
428        let load = LoadCase::CantileverEndPoint {
429            load_n: Fix128::from_int(5),
430            length_mm: Fix128::from_int(200),
431        };
432        let solver = StructuralSolver::new(section, load, MaterialProperties::pla());
433        assert_eq!(solver.step_count, 0);
434        assert_eq!(solver.elapsed_hours, Fix128::ZERO);
435        assert!(solver.failure_step.is_none());
436    }
437
438    #[test]
439    fn step_advances_time() {
440        let section = CrossSection::Rectangular {
441            width_mm: Fix128::from_int(10),
442            height_mm: Fix128::from_int(20),
443        };
444        let load = LoadCase::CantileverEndPoint {
445            load_n: Fix128::from_int(5),
446            length_mm: Fix128::from_int(200),
447        };
448        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
449        let r = solver.step();
450        assert!(r.elapsed_hours > Fix128::ZERO);
451        assert_eq!(solver.step_count, 1);
452    }
453
454    #[test]
455    fn safe_light_load_stays_safe() {
456        let section = CrossSection::Rectangular {
457            width_mm: Fix128::from_int(10),
458            height_mm: Fix128::from_int(20),
459        };
460        let load = LoadCase::CantileverEndPoint {
461            load_n: Fix128::from_int(2),
462            length_mm: Fix128::from_int(200),
463        };
464        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
465        let history = solver.run(10);
466        assert!(history.failure_step.is_none());
467        assert!(history.fatigue_damage < Fix128::ONE);
468    }
469
470    #[test]
471    fn overloaded_beam_flags_failure() {
472        // Section that yields at 5 N load already
473        let section = CrossSection::Rectangular {
474            width_mm: Fix128::from_int(5),
475            height_mm: Fix128::from_int(5),
476        };
477        let load = LoadCase::CantileverEndPoint {
478            load_n: Fix128::from_int(100),
479            length_mm: Fix128::from_int(300),
480        };
481        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
482        let r = solver.step();
483        assert!(r.failed_this_step);
484        assert_eq!(solver.failure_step, Some(0));
485    }
486
487    #[test]
488    fn buckling_flags_when_axial_load_exceeds_cr() {
489        let section = CrossSection::Rectangular {
490            width_mm: Fix128::from_int(5),
491            height_mm: Fix128::from_int(5),
492        };
493        let load = LoadCase::CantileverEndPoint {
494            load_n: Fix128::from_int(1),
495            length_mm: Fix128::from_int(500),
496        };
497        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
498        solver.axial_load_n = Fix128::from_int(10_000); // huge axial
499        let r = solver.step();
500        assert!(r.buckling_fos < Fix128::ONE);
501    }
502
503    #[test]
504    fn run_produces_history_snapshot() {
505        let section = CrossSection::Rectangular {
506            width_mm: Fix128::from_int(10),
507            height_mm: Fix128::from_int(20),
508        };
509        let load = LoadCase::CantileverEndPoint {
510            load_n: Fix128::from_int(5),
511            length_mm: Fix128::from_int(200),
512        };
513        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
514        let history = solver.run(5);
515        assert_eq!(history.steps, 5);
516        assert!(history.elapsed_hours > Fix128::ZERO);
517    }
518
519    #[test]
520    fn creep_strain_grows_over_time() {
521        let section = CrossSection::Rectangular {
522            width_mm: Fix128::from_int(10),
523            height_mm: Fix128::from_int(20),
524        };
525        let load = LoadCase::CantileverEndPoint {
526            load_n: Fix128::from_int(3),
527            length_mm: Fix128::from_int(200),
528        };
529        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
530        solver.operating_temp_c = Fix128::from_int(55); // close to PLA Tg
531        let r1 = solver.step();
532        let r_later = solver.run(10);
533        // the Norton accumulation in the state grows on its own (1.2.0: it is
534        // no longer overwritten by the Findley projection), and the reported
535        // creep of a later step is monotone
536        let r11 = solver.step();
537        assert!(r_later.plastic_state.creep_strain > Fix128::ZERO);
538        assert!(r11.creep_strain >= r1.creep_strain);
539    }
540
541    #[test]
542    fn fatigue_damage_accumulates_each_step() {
543        let section = CrossSection::Rectangular {
544            width_mm: Fix128::from_int(5),
545            height_mm: Fix128::from_int(5),
546        };
547        let load = LoadCase::CantileverEndPoint {
548            load_n: Fix128::from_int(20),
549            length_mm: Fix128::from_int(200),
550        };
551        let mut solver = StructuralSolver::new(section, load, MaterialProperties::pla());
552        let r1 = solver.step();
553        let r2 = solver.step();
554        assert!(r2.fatigue_damage >= r1.fatigue_damage);
555    }
556}