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}