Skip to main content

alice_physics/
plastic.rs

1//! Elastoplastic Deformation with von Mises Yield & Creep Prediction
2//!
3//! Extends the elastic
4//! stress models (`beam_stress`, `anisotropic`) with **permanent (plastic)
5//! deformation** and **long-term creep** — the two mechanisms responsible for
6//! FDM part failures that "elastic-only" analysis misses:
7//!
8//! - PLA shelves that sag over 6 months (creep under gravity).
9//! - Metal brackets that yield locally at bolt holes (plastic slip).
10//! - SKADIS pegs that permanently splay after repeated loading.
11//!
12//! # Models provided
13//!
14//! - **von Mises equivalent stress** for a full 3D stress tensor.
15//! - **Isotropic / kinematic / combined hardening** (`HardeningType`).
16//! - **Radial return** integration algorithm for one time step under prescribed
17//!   trial stress (classical J2 plasticity, small-strain).
18//! - **Norton power-law creep** ε̇_c = A · σⁿ with time integration.
19//! - Preset `PlasticModel::from_fdm_material()` derives reasonable defaults
20//!   from the isotropic `MaterialProperties`.
21//!
22//! # Unit convention
23//!
24//! Same as `beam_stress` / `anisotropic`: MPa, mm, N, s.
25//!
26//! # References
27//!
28//! - Simo & Hughes, *Computational Inelasticity*, Springer 1998. Ch 2 (radial
29//!   return), Ch 3 (kinematic hardening).
30//! - Chaboche, "A review of some plasticity and viscoplasticity constitutive
31//!   theories", Int. J. Plasticity 24 (2008).
32//! - Norton, "The creep of steel at high temperatures", McGraw-Hill 1929
33//!   (original power-law).
34//! - Bellehumeur et al., "Modeling of Bond Formation Between Polymer Filaments
35//!   in the FDM Process", J. Manuf. Processes 2004 (PLA creep parameters).
36//!
37//! # Integration status
38//!
39//! `PlasticModel`, `PlasticState`, `NortonCreep`, and `radial_return_1d`
40//! are wired into `structural_solver.rs`. `PlasticStep`,
41//! `current_yield_mpa`, and `strain_rate_per_s` are crate-internal and
42//! reached through `radial_return_1d` / `integrate`.
43//! `PlasticModel::with_hardening` is the public way to pick the hardening law
44//! of a model installed on `StructuralSolver::plastic_model` (the solver's
45//! default is the isotropic `from_fdm_material`; see
46//! `examples/plastic_hardening_bauschinger.rs`). `NortonCreep::petg_room_temp`
47//! has no consumer (the solver installs the PLA creep preset for PLA only) and
48//! carries an `ALLOW-UNWIRED` debt marker with a closed-form oracle in this
49//! module's unit tests. Stress tensors
50//! (von Mises, hydrostatic part) live in the public
51//! `linear_elastic_fem::StressTensor`.
52
53use crate::filament_db::MaterialProperties;
54use crate::math::Fix128;
55
56// ============================================================================
57// Hardening
58// ============================================================================
59
60/// Plastic hardening law selection.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
62pub enum HardeningType {
63    /// Yield surface expands uniformly. Simple, but no Bauschinger effect.
64    #[default]
65    Isotropic,
66    /// Yield surface translates (back-stress evolves). Reproduces Bauschinger
67    /// under load reversals — required for cyclic / fatigue analysis.
68    Kinematic,
69    // LIMITATION(COV-MAT-068): Combined isotropic + kinematic (equal split).
70    /// Combined isotropic + kinematic (equal split).
71    Combined,
72}
73
74// ============================================================================
75// PlasticModel
76// ============================================================================
77
78/// Elastoplastic constitutive model parameters.
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub struct PlasticModel {
81    /// Initial yield stress σ_y0 (MPa).
82    pub yield_strength_mpa: Fix128,
83    /// Linear hardening modulus H (MPa). Slope of the σ vs ε_p curve.
84    ///
85    /// Typical polymer values: PLA H ≈ 200 MPa, ABS H ≈ 150 MPa.
86    /// Metals: mild steel H ≈ 1500 MPa, aluminium H ≈ 500 MPa.
87    pub hardening_modulus_mpa: Fix128,
88    /// Hardening law.
89    pub hardening_type: HardeningType,
90    /// Young's modulus (MPa) for elastic strain accumulation.
91    pub youngs_modulus_mpa: Fix128,
92}
93
94impl PlasticModel {
95    // LIMITATION(COV-MAT-073): Defaults to isotropic hardening with `H = 0.05 · E` (typical elastic-plastic ratio for polymers).
96    /// Construct from an isotropic material. Defaults to isotropic hardening
97    /// with `H = 0.05 · E` (typical elastic-plastic ratio for polymers).
98    #[must_use]
99    pub fn from_fdm_material(m: &MaterialProperties) -> Self {
100        let e_mpa = m.youngs_modulus_gpa * Fix128::from_int(1000);
101        let h_mpa = e_mpa * Fix128::from_ratio(5, 100);
102        Self {
103            yield_strength_mpa: m.yield_strength_mpa,
104            hardening_modulus_mpa: h_mpa,
105            hardening_type: HardeningType::Isotropic,
106            youngs_modulus_mpa: e_mpa,
107        }
108    }
109
110    /// Override the hardening law, leaving every other parameter unchanged.
111    ///
112    /// Install the result on [`crate::structural_solver::StructuralSolver::plastic_model`]
113    /// to run a load history under kinematic or combined hardening (the
114    /// solver's own default is the isotropic [`Self::from_fdm_material`]).
115    #[must_use]
116    pub const fn with_hardening(mut self, ht: HardeningType) -> Self {
117        self.hardening_type = ht;
118        self
119    }
120}
121
122// ============================================================================
123// PlasticState
124// ============================================================================
125
126/// Internal state variables that evolve during plastic deformation.
127///
128/// One instance is carried per integration point (per mesh element or per
129/// beam cross-section). Must be persisted across time steps.
130#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
131pub struct PlasticState {
132    /// Accumulated equivalent plastic strain (dimensionless).
133    pub equivalent_plastic_strain: Fix128,
134    /// Back-stress (MPa) — center of the yield surface (kinematic hardening).
135    pub back_stress_mpa: Fix128,
136    /// Accumulated creep strain (dimensionless). Independent of plasticity.
137    pub creep_strain: Fix128,
138}
139
140// ============================================================================
141// Yield check & radial return
142// ============================================================================
143
144/// Current yield stress accounting for hardening.
145///
146/// - Isotropic: `σ_y0 + H · ε_p`
147/// - Kinematic: `σ_y0` (radius fixed, back-stress captures translation)
148/// - Combined: `σ_y0 + ½ · H · ε_p` (half growth, half translation)
149#[must_use]
150pub(crate) fn current_yield_mpa(model: &PlasticModel, state: &PlasticState) -> Fix128 {
151    match model.hardening_type {
152        HardeningType::Isotropic => {
153            model.yield_strength_mpa + model.hardening_modulus_mpa * state.equivalent_plastic_strain
154        }
155        HardeningType::Kinematic => model.yield_strength_mpa,
156        HardeningType::Combined => {
157            model.yield_strength_mpa
158                + model.hardening_modulus_mpa
159                    * state.equivalent_plastic_strain
160                    * Fix128::from_ratio(1, 2)
161        }
162    }
163}
164
165/// Result of an incremental plasticity update (crate-internal).
166#[derive(Clone, Copy, Debug, PartialEq, Eq)]
167pub(crate) struct PlasticStep {
168    /// Actual stress magnitude after return-mapping (MPa).
169    pub(crate) stress_mpa: Fix128,
170    /// Plastic strain increment this step (dimensionless).
171    pub(crate) plastic_strain_increment: Fix128,
172    /// True iff yielding occurred (plastic strain > 0).
173    pub(crate) yielded: bool,
174}
175
176/// One-dimensional radial-return update for uniaxial loading.
177///
178/// Given the elastic-trial stress `trial_stress_mpa` computed as
179/// `σ_trial = σ_prev + E · Δε` (predictor step), this function checks the
180/// yield condition against the current hardened yield surface. If yielding,
181/// it computes the plastic multiplier that returns the trial stress to the
182/// yield surface (radial return, Simo & Hughes eq. 2.13) and updates
183/// `state.equivalent_plastic_strain` (isotropic) or `state.back_stress_mpa`
184/// (kinematic).
185///
186/// This is the standard J2 return-mapping specialised to 1-D — sufficient
187/// for beam-like analyses. Full 3-D versions use the same structure with
188/// a deviatoric stress tensor.
189pub(crate) fn radial_return_1d(
190    trial_stress_mpa: Fix128,
191    model: &PlasticModel,
192    state: &mut PlasticState,
193) -> PlasticStep {
194    let yield_now = current_yield_mpa(model, state);
195
196    // Effective stress relative to back-stress (kinematic shift)
197    let effective = trial_stress_mpa - state.back_stress_mpa;
198    let abs_eff = effective.abs();
199
200    if abs_eff <= yield_now {
201        // Elastic: trial stress is within yield surface
202        return PlasticStep {
203            stress_mpa: trial_stress_mpa,
204            plastic_strain_increment: Fix128::ZERO,
205            yielded: false,
206        };
207    }
208
209    // Plastic loading: solve for plastic multiplier Δλ
210    // f = |σ − α| − (σ_y + H·ε_p) = 0
211    // With E as the elastic modulus, the return gives
212    // Δλ = (|σ_trial − α| − σ_y_current) / (E + H)
213    let e_plus_h = model.youngs_modulus_mpa + model.hardening_modulus_mpa;
214    if e_plus_h.is_zero() {
215        return PlasticStep {
216            stress_mpa: trial_stress_mpa,
217            plastic_strain_increment: Fix128::ZERO,
218            yielded: false,
219        };
220    }
221    let dlambda = (abs_eff - yield_now) / e_plus_h;
222
223    // Direction: sign of the effective stress
224    let sign = if effective.is_negative() {
225        Fix128::NEG_ONE
226    } else {
227        Fix128::ONE
228    };
229
230    // Update state
231    state.equivalent_plastic_strain = state.equivalent_plastic_strain + dlambda;
232    match model.hardening_type {
233        HardeningType::Kinematic => {
234            // Back-stress evolves in the flow direction
235            state.back_stress_mpa =
236                state.back_stress_mpa + sign * model.hardening_modulus_mpa * dlambda;
237        }
238        HardeningType::Combined => {
239            // Half of H goes to back-stress
240            state.back_stress_mpa = state.back_stress_mpa
241                + sign * model.hardening_modulus_mpa * dlambda * Fix128::from_ratio(1, 2);
242        }
243        HardeningType::Isotropic => {}
244    }
245
246    // Corrected stress = trial minus elastic overshoot
247    let stress = trial_stress_mpa - sign * model.youngs_modulus_mpa * dlambda;
248
249    PlasticStep {
250        stress_mpa: stress,
251        plastic_strain_increment: dlambda,
252        yielded: true,
253    }
254}
255
256// ============================================================================
257// Norton creep
258// ============================================================================
259
260/// Norton power-law creep model: ε̇_c = A · σⁿ
261///
262/// Coefficients are strongly temperature-dependent. Presets in
263/// `NortonCreep::pla_room_temp()` etc. reproduce the typical creep response
264/// documented in Bellehumeur (2004) and Prusa's technical papers.
265#[derive(Clone, Copy, Debug, PartialEq, Eq)]
266pub struct NortonCreep {
267    /// Material constant A (creep coefficient) — units chosen such that with
268    /// σ in MPa and integer exponent n, ε̇ is per second.
269    pub a: Fix128,
270    /// Stress exponent n (dimensionless, typically 3-8 for polymers, 1-3
271    // LIMITATION(COV-STRUCT-063): Stored as integer for numerical stability (Fix128 lacks `.powf`)
272    /// for metals near room temperature). Stored as integer for numerical
273    /// stability (Fix128 lacks `.powf`).
274    pub n: u32,
275}
276
277impl NortonCreep {
278    /// PLA at 25°C — significant creep even at moderate stress.
279    /// Calibrated to Bellehumeur (2004): 1 % strain after 6 months at 10 MPa,
280    /// i.e. `A = 0.01 / (10³ MPa³ · 1.5768e7 s) ≈ 6.34e-13 /(MPa³·s)`, `n = 3`.
281    /// Before 1.2.0 `A = 3e-10` was 470× the documented calibration (466 %
282    /// strain in 6 months; `tests/engineering_oracles_solid.rs`).
283    #[must_use]
284    pub fn pla_room_temp() -> Self {
285        Self {
286            a: Fix128::from_ratio(634, 1_000_000_000_000_000_i64),
287            n: 3,
288        }
289    }
290
291    /// PETG at 25°C — lower creep than PLA (higher Tg):
292    /// `A = 1e-13 /(MPa³·s)`, `n = 3`, about 6.3× below the PLA calibration.
293    // ALLOW-DEAD: pub(crate) with no crate caller, same debt as the ALLOW-UNWIRED marker below
294    // ALLOW-UNWIRED: wiring debt structural-pub-crate-residue (structural_solver::new installs the PLA creep preset for PLA only; this PETG fit is uncalibrated), oracle src/plastic.rs tests::petg_norton_preset_integrates_a_sigma_cubed_dt
295    #[allow(dead_code)]
296    #[must_use]
297    pub(crate) fn petg_room_temp() -> Self {
298        // ≈ 6× lower than the PLA calibration (1.2.0: 1e-11 was 16× *above*
299        // the corrected PLA value)
300        Self {
301            a: Fix128::from_ratio(1, 10_000_000_000_000_i64),
302            n: 3,
303        }
304    }
305
306    /// Instantaneous creep strain rate (per second) at stress `sigma_mpa` (crate-internal).
307    #[must_use]
308    pub(crate) fn strain_rate_per_s(&self, sigma_mpa: Fix128) -> Fix128 {
309        let mut sn = Fix128::ONE;
310        for _ in 0..self.n {
311            sn = sn * sigma_mpa;
312        }
313        self.a * sn
314    }
315
316    /// Advance creep state by `dt` seconds under constant stress.
317    ///
318    /// Uses forward-Euler integration: `ε_c_new = ε_c + ε̇·dt`. Adequate for
319    /// engineering time-scales (hours to years) with a suitably small dt.
320    pub fn integrate(&self, sigma_mpa: Fix128, dt_seconds: Fix128, state: &mut PlasticState) {
321        let rate = self.strain_rate_per_s(sigma_mpa);
322        state.creep_strain = state.creep_strain + rate * dt_seconds;
323    }
324}
325
326// ============================================================================
327// Tests
328// ============================================================================
329
330#[cfg(test)]
331mod tests {
332    use super::*;
333
334    fn approx_eq(a: Fix128, b: Fix128, tol: Fix128) -> bool {
335        let d = if a > b { a - b } else { b - a };
336        d <= tol
337    }
338
339    #[test]
340    fn elastic_stress_below_yield() {
341        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla());
342        let mut state = PlasticState::default();
343        // PLA σ_y = 50 MPa, apply 30 → elastic
344        let step = radial_return_1d(Fix128::from_int(30), &model, &mut state);
345        assert!(!step.yielded);
346        assert_eq!(step.stress_mpa, Fix128::from_int(30));
347        assert_eq!(state.equivalent_plastic_strain, Fix128::ZERO);
348    }
349
350    #[test]
351    fn plastic_loading_isotropic() {
352        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla());
353        let mut state = PlasticState::default();
354        // Trial stress 60 MPa > yield 50 MPa
355        let step = radial_return_1d(Fix128::from_int(60), &model, &mut state);
356        assert!(step.yielded);
357        assert!(step.plastic_strain_increment > Fix128::ZERO);
358        // Stress should be pulled back near yield surface
359        assert!(step.stress_mpa < Fix128::from_int(60));
360        assert!(step.stress_mpa > Fix128::from_int(40));
361    }
362
363    #[test]
364    fn isotropic_hardening_raises_next_yield() {
365        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla());
366        let mut state = PlasticState::default();
367        // First yield
368        let _ = radial_return_1d(Fix128::from_int(60), &model, &mut state);
369        let y1 = current_yield_mpa(&model, &state);
370        // Second overload
371        let _ = radial_return_1d(Fix128::from_int(70), &model, &mut state);
372        let y2 = current_yield_mpa(&model, &state);
373        assert!(y2 > y1, "isotropic hardening should grow σ_y");
374    }
375
376    #[test]
377    fn kinematic_hardening_moves_back_stress() {
378        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla())
379            .with_hardening(HardeningType::Kinematic);
380        let mut state = PlasticState::default();
381        // Tensile loading beyond yield
382        let _ = radial_return_1d(Fix128::from_int(60), &model, &mut state);
383        assert!(state.back_stress_mpa > Fix128::ZERO);
384    }
385
386    #[test]
387    fn kinematic_hardening_bauschinger_reversal() {
388        // After heavy tension the back-stress is positive. A subsequent
389        // negative loading past yield must push the back-stress in the
390        // negative direction (Bauschinger effect).
391        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla())
392            .with_hardening(HardeningType::Kinematic);
393        let mut state = PlasticState::default();
394        // Heavy tension: apply trial stress way above yield to force plastic flow
395        let _ = radial_return_1d(Fix128::from_int(500), &model, &mut state);
396        let alpha_after_tension = state.back_stress_mpa;
397        assert!(alpha_after_tension > Fix128::ZERO);
398        // Heavy compression
399        let _ = radial_return_1d(Fix128::from_int(-500), &model, &mut state);
400        // Back-stress should have decreased (moved toward zero or negative)
401        assert!(state.back_stress_mpa < alpha_after_tension);
402    }
403
404    #[test]
405    fn combined_hardening_between_iso_and_kin() {
406        let mat = MaterialProperties::pla();
407        let model_iso = PlasticModel::from_fdm_material(&mat);
408        let model_comb =
409            PlasticModel::from_fdm_material(&mat).with_hardening(HardeningType::Combined);
410
411        let mut s_iso = PlasticState::default();
412        let mut s_comb = PlasticState::default();
413        let _ = radial_return_1d(Fix128::from_int(60), &model_iso, &mut s_iso);
414        let _ = radial_return_1d(Fix128::from_int(60), &model_comb, &mut s_comb);
415
416        let y_iso = current_yield_mpa(&model_iso, &s_iso);
417        let y_comb = current_yield_mpa(&model_comb, &s_comb);
418        // Combined has half isotropic growth so yield should be intermediate
419        assert!(y_comb > mat.yield_strength_mpa);
420        assert!(y_comb < y_iso);
421    }
422
423    #[test]
424    fn plastic_strain_accumulates_across_steps() {
425        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla());
426        let mut state = PlasticState::default();
427        for _ in 0..5 {
428            let _ = radial_return_1d(Fix128::from_int(100), &model, &mut state);
429        }
430        // With H > 0, subsequent yields decrease Δλ but strain still grows
431        assert!(state.equivalent_plastic_strain > Fix128::ZERO);
432    }
433
434    #[test]
435    fn norton_creep_rate_scales_with_stress_cubed() {
436        let creep = NortonCreep::pla_room_temp();
437        let r1 = creep.strain_rate_per_s(Fix128::from_int(10));
438        let r2 = creep.strain_rate_per_s(Fix128::from_int(20));
439        // n=3, so 2× stress → 8× rate
440        let ratio = r2 / r1;
441        assert!(approx_eq(
442            ratio,
443            Fix128::from_int(8),
444            Fix128::from_ratio(1, 100)
445        ));
446    }
447
448    #[test]
449    fn norton_creep_integration_accumulates() {
450        let creep = NortonCreep::pla_room_temp();
451        let mut state = PlasticState::default();
452        // 10 MPa constant for 100 seconds
453        creep.integrate(Fix128::from_int(10), Fix128::from_int(100), &mut state);
454        assert!(state.creep_strain > Fix128::ZERO);
455    }
456
457    #[test]
458    fn petg_creeps_slower_than_pla() {
459        let pla = NortonCreep::pla_room_temp();
460        let petg = NortonCreep::petg_room_temp();
461        let sigma = Fix128::from_int(20);
462        let r_pla = pla.strain_rate_per_s(sigma);
463        let r_petg = petg.strain_rate_per_s(sigma);
464        assert!(r_petg < r_pla);
465    }
466
467    #[test]
468    fn creep_strain_independent_of_plastic_strain() {
469        let creep = NortonCreep::pla_room_temp();
470        let model = PlasticModel::from_fdm_material(&MaterialProperties::pla());
471        let mut state = PlasticState::default();
472        // Plastic loading first
473        let _ = radial_return_1d(Fix128::from_int(70), &model, &mut state);
474        let ep_before = state.equivalent_plastic_strain;
475        // Creep on top
476        creep.integrate(Fix128::from_int(30), Fix128::from_int(1000), &mut state);
477        // Plastic strain unchanged, creep strain grew
478        assert_eq!(state.equivalent_plastic_strain, ep_before);
479        assert!(state.creep_strain > Fix128::ZERO);
480    }
481
482    /// Oracle: 1-D radial return by hand (Simo & Hughes eq. 2.13) for the PLA
483    /// model (`σ_y = 50`, `E = 3500`, `H = 0.05·E = 175`) and a trial of
484    /// `60 MPa`: `Δλ = (60 − 50) / (E + H) = 10/3675 = 2.7210884e-3`,
485    /// corrected stress `60 − E·Δλ = 50.476190`. The hardening law chosen
486    /// through `with_hardening` decides where `H·Δλ = 0.476190` goes:
487    /// kinematic → all into the back-stress, combined → half
488    /// (`0.238095`), isotropic → none. A second kinematic step with the same
489    /// trial sees `|60 − α₁| = 59.523810` against the unchanged radius 50:
490    /// `Δλ₂ = 9.523810/3675`, `α₂ = α₁ + 175·Δλ₂ = 0.929705`.
491    #[test]
492    fn with_hardening_back_stress_matches_radial_return_closed_form() {
493        let base = PlasticModel::from_fdm_material(&MaterialProperties::pla());
494        let (e, h) = (3500.0_f64, 175.0_f64);
495        let dl1 = 10.0 / (e + h);
496        let cases = [
497            (HardeningType::Kinematic, h * dl1),
498            (HardeningType::Combined, 0.5 * h * dl1),
499            (HardeningType::Isotropic, 0.0),
500        ];
501        for (law, want_alpha) in cases {
502            let model = base.with_hardening(law);
503            assert_eq!(model.hardening_type, law);
504            assert_eq!(model.yield_strength_mpa, base.yield_strength_mpa);
505            assert_eq!(model.hardening_modulus_mpa, base.hardening_modulus_mpa);
506            assert_eq!(model.youngs_modulus_mpa, base.youngs_modulus_mpa);
507            let mut state = PlasticState::default();
508            let step = radial_return_1d(Fix128::from_int(60), &model, &mut state);
509            assert!(step.yielded);
510            let got_alpha = state.back_stress_mpa.to_f64();
511            assert!(
512                (got_alpha - want_alpha).abs() < 1e-9,
513                "{law:?}: α = {got_alpha}, want {want_alpha}"
514            );
515            let got_ep = state.equivalent_plastic_strain.to_f64();
516            assert!((got_ep - dl1).abs() < 1e-9, "{law:?}: ε_p = {got_ep}");
517            let got_s = step.stress_mpa.to_f64();
518            assert!(
519                (got_s - (60.0 - e * dl1)).abs() < 1e-9,
520                "{law:?}: σ = {got_s}"
521            );
522        }
523        // second kinematic step: Bauschinger shift of the surface centre
524        let model = base.with_hardening(HardeningType::Kinematic);
525        let mut state = PlasticState::default();
526        let _ = radial_return_1d(Fix128::from_int(60), &model, &mut state);
527        let _ = radial_return_1d(Fix128::from_int(60), &model, &mut state);
528        let alpha1 = h * dl1;
529        let dl2 = (60.0 - alpha1 - 50.0) / (e + h);
530        let want_alpha2 = alpha1 + h * dl2;
531        let got_alpha2 = state.back_stress_mpa.to_f64();
532        assert!(
533            (got_alpha2 - want_alpha2).abs() < 1e-9,
534            "α₂ = {got_alpha2}, want {want_alpha2}"
535        );
536    }
537
538    /// Degenerate use of the builder: re-applying the model's own law is the
539    /// identity, applying a law twice equals applying it once, and
540    /// overriding back restores the original (only `hardening_type` moves).
541    #[test]
542    fn with_hardening_is_idempotent_and_reversible() {
543        let base = PlasticModel::from_fdm_material(&MaterialProperties::pla());
544        assert_eq!(base.with_hardening(HardeningType::Isotropic), base);
545        assert_eq!(
546            base.with_hardening(HardeningType::Kinematic)
547                .with_hardening(HardeningType::Kinematic),
548            base.with_hardening(HardeningType::Kinematic)
549        );
550        assert_eq!(
551            base.with_hardening(HardeningType::Combined)
552                .with_hardening(HardeningType::Isotropic),
553            base
554        );
555    }
556
557    /// Oracle: Norton `ε_c = A·σⁿ·Δt` by hand with the PETG preset
558    /// (`A = 1e-13`, `n = 3`): `σ = 20 MPa`, `Δt = 10⁶ s` →
559    /// `1e-13 · 8000 · 10⁶ = 8e-4`. The exponent is odd, so a compressive
560    /// `−20 MPa` gives `−8e-4` (signed creep). Tolerance: `A = 1e-13` is
561    /// `1 844 674.4` Fix128 ulp stored truncated (relative `2.2e-7`, i.e.
562    /// `1.8e-10` absolute here); `1e-9` leaves no room for a wrong constant.
563    #[test]
564    fn petg_norton_preset_integrates_a_sigma_cubed_dt() {
565        let creep = NortonCreep::petg_room_temp();
566        let dt = Fix128::from_int(1_000_000);
567        let mut state = PlasticState::default();
568        creep.integrate(Fix128::from_int(20), dt, &mut state);
569        let got = state.creep_strain.to_f64();
570        assert!((got - 8e-4).abs() < 1e-9, "got {got}");
571        let mut neg = PlasticState::default();
572        creep.integrate(Fix128::from_int(-20), dt, &mut neg);
573        let got_neg = neg.creep_strain.to_f64();
574        assert!((got_neg + 8e-4).abs() < 1e-9, "got {got_neg}");
575    }
576
577    /// Degenerate inputs for the PETG preset: zero stress and zero time step
578    /// leave the creep strain exactly unchanged (`ε̇ = 0` / `Δt = 0`).
579    #[test]
580    fn petg_norton_preset_degenerate_stress_or_dt_leaves_state() {
581        let creep = NortonCreep::petg_room_temp();
582        let mut state = PlasticState {
583            creep_strain: Fix128::from_ratio(1, 1000),
584            ..Default::default()
585        };
586        creep.integrate(Fix128::ZERO, Fix128::from_int(1_000_000), &mut state);
587        assert_eq!(state.creep_strain, Fix128::from_ratio(1, 1000));
588        creep.integrate(Fix128::from_int(20), Fix128::ZERO, &mut state);
589        assert_eq!(state.creep_strain, Fix128::from_ratio(1, 1000));
590    }
591}