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}