alice_physics/creep_longterm.rs
1//! Long-Term Creep Prediction (Findley + Time-Temperature Superposition)
2//!
3//! Extends the Norton
4//! power-law creep in `plastic.rs` with the **Findley** three-parameter
5//! model that captures both the initial elastic strain and the transient
6//! logarithmic-like creep phase common to polymers over months / years.
7//!
8//! # Findley model
9//!
10//! `ε(t) = ε_0 + m · tⁿ`
11//!
12//! - `ε_0` = instantaneous elastic strain (dimensionless)
13// LIMITATION(COV-STRUCT-064): `n` is an **integer** here
14//! - `m`, `n` = fitting constants; `n` is an **integer** here (`n_int`, 1 to 4,
15//! evaluated by repeated multiplication), unlike the fractional 0.15-0.30
16//! exponents of the literature
17//!
18//! The PLA preset is `ε_0 = 0.003`, `m = 8.3e-14 /h³`, `n = 3`: after 6 months
19//! (4380 h) it gives `0.003 + 8.3e-14 · 4380³ ≈ 1.0 %` total strain, matching
20//! the ALICE-Bamboo docs "PLA 棚 半年で反る" incident. The fit matches at 6 months
21//! only (see [`FindleyParameters::pla_25c_moderate`]).
22//!
23//! # Time-Temperature Superposition (WLF)
24//!
25//! To predict lifetime at temperature `T` from data at reference `T_ref`,
26//! shift the time axis by the Williams-Landel-Ferry factor `a_T`:
27//!
28//! `log a_T = −C_1·(T − T_ref) / (C_2 + T − T_ref)`
29//!
30//! `t_effective(T) = t / a_T`
31//!
32//! Universal WLF: `C_1 = 17.44`, `C_2 = 51.6` at `T_ref = T_g`.
33//!
34//! # References
35//!
36//! - Findley, Lai, Onaran, *Creep and Relaxation of Nonlinear Viscoelastic
37//! Materials*, Dover 1989. Ch 4-5.
38//! - Williams, Landel, Ferry, "The temperature dependence of relaxation
39//! mechanisms in amorphous polymers", J. Am. Chem. Soc. 77(14), 1955.
40//! - Bellehumeur (2004) — polymer creep parameters for FDM prints.
41//!
42//! # Integration status
43//!
44//! `FindleyParameters`, `FindleyParameters::pla_25c_moderate`, and
45//! `predict_strain` are wired into `structural_solver.rs`. The WLF
46//! subsystem (`WlfConstants`, `wlf_shift_factor`, `effective_time_at_temp`,
47//! `CREEP_FROZEN_AT`) and the standalone `strain_at` are crate-internal and
48//! reached through `predict_strain`; `CREEP_FROZEN_AT` is the saturation
49//! sentinel `effective_time_at_temp` turns into "no effective time" (see its
50//! doc for the threshold). `petg_25c_moderate` has no consumer (the solver
51//! installs the PLA preset for PLA only and leaves creep unmodelled for
52//! other materials; this PETG fit is uncalibrated) and carries an `ALLOW-UNWIRED` debt marker;
53//! its oracle is a unit test in this module.
54
55use crate::filament_db::MaterialProperties;
56use crate::math::Fix128;
57use crate::math_util::{exp_fix, EXP_OVERFLOW_SENTINEL};
58
59/// `base^n` via repeated [`Fix128::checked_mul`], or `None` the moment any
60/// intermediate product would overflow Fix128.
61fn pow_checked(base: Fix128, n: u32) -> Option<Fix128> {
62 let mut acc = Fix128::ONE;
63 for _ in 0..n {
64 acc = acc.checked_mul(base)?;
65 }
66 Some(acc)
67}
68
69// ============================================================================
70// Findley model
71// ============================================================================
72
73/// Findley three-parameter creep model parameters.
74///
75/// `ε(t) = ε_0 + m · tⁿ` with `t` in hours.
76#[derive(Clone, Copy, Debug, PartialEq, Eq)]
77pub struct FindleyParameters {
78 /// Instantaneous elastic strain (dimensionless).
79 pub epsilon_0: Fix128,
80 /// Transient coefficient `m` (dimensionless, calibrated per hour^n).
81 pub m: Fix128,
82 /// Time exponent `n` (integer for Fix128 pow: 1, 2, 3, or 4).
83 /// n=1 → linear creep, n=2 → parabolic, n=3 → cubic. An integer stand-in for the literature's fractional 0.15-0.30; the presets use 3.
84 pub n_int: u32,
85}
86
87impl FindleyParameters {
88 /// PLA at 25 °C under moderate stress (Bellehumeur 2004 fit):
89 /// `ε₀ = 0.3 %`, and `m` chosen so the creep term reaches 0.7 %
90 /// (total ≈ 1 %) at 6 months (4380 h): `m = 0.007 / 4380³ ≈ 8.3e-14`.
91 ///
92 /// **Warning**: integer `n = 3` is a much steeper time law than the
93 /// literature's 0.25 — the fit matches at 6 months only, under-predicts
94 /// before and over-predicts after (≈ 6 % total at 1 year). Before 1.2.0
95 /// `m = 1e-12` gave 8.7 % at 6 months while the docstring claimed 1 %.
96 #[must_use]
97 pub fn pla_25c_moderate() -> Self {
98 Self {
99 epsilon_0: Fix128::from_ratio(3, 1000),
100 m: Fix128::from_ratio(83, 1_000_000_000_000_000_i64),
101 n_int: 3,
102 }
103 }
104
105 /// PETG at 25 °C: `ε₀ = 0.2 %`, `m = 1e-13 /h³`, `n = 3`
106 /// (`ε(4380 h) = 0.2 % + 1e-13 · 4380³ ≈ 1.04 %`).
107 ///
108 /// The elastic term is lower than PLA's (higher `T_g`), but `m` was not
109 /// recalibrated when the PLA preset dropped to `8.3e-14` in 1.2.0, so
110 /// this preset creeps *more* than `pla_25c_moderate` after
111 /// `t = (0.001 / 1.7e-14)^(1/3) ≈ 3887 h`. Treat it as a placeholder
112 /// fit until a PETG datasheet calibration replaces `m`.
113 // ALLOW-DEAD: pub(crate) with no crate caller, same debt as the ALLOW-UNWIRED marker below
114 // ALLOW-UNWIRED: wiring debt structural-pub-crate-residue (structural_solver::new installs the PLA preset for PLA only; this PETG fit is uncalibrated), oracle src/creep_longterm.rs tests::petg_preset_findley_matches_closed_form_at_six_months
115 #[allow(dead_code)]
116 #[must_use]
117 pub(crate) fn petg_25c_moderate() -> Self {
118 Self {
119 epsilon_0: Fix128::from_ratio(2, 1000),
120 m: Fix128::from_ratio(1, 10_000_000_000_000_i64),
121 n_int: 3,
122 }
123 }
124
125 /// Predicted total strain at time `t_hours` (dimensionless, crate-internal helper called by `predict_strain`).
126 #[must_use]
127 pub(crate) fn strain_at(&self, t_hours: Fix128) -> Fix128 {
128 if t_hours <= Fix128::ZERO {
129 return self.epsilon_0;
130 }
131 // 1) t^n by repeated multiplication, then times `m` — exact and
132 // bit-identical to the pre-1.x behavior whenever it does not
133 // overflow (the oracle tolerance is as tight as 5e-8 in that
134 // range, so this path must stay untouched).
135 if let Some(t_n) = pow_checked(t_hours, self.n_int) {
136 if let Some(creep) = self.m.checked_mul(t_n) {
137 return self.epsilon_0 + creep;
138 }
139 }
140 // 2) `t_hours^n` overflows Fix128 well before `m * t_hours^n` (the
141 // actual creep contribution) does, because the calibrated `m` is
142 // many orders of magnitude below 1 (~8.3e-14 for the n=3 PLA
143 // preset) — inside the documented WLF domain T_g..T_g+100 the
144 // effective time can already be large enough to trip this.
145 // Scaling `t_hours` by `m^(1/n)` first folds that smallness in
146 // *before* the exponentiation: `(t_hours * m^(1/n))^n == m *
147 // t_hours^n` exactly in real arithmetic, so the intermediate does
148 // not leave the representable range as readily as the bare power
149 // does.
150 if self.n_int > 0 && self.m > Fix128::ZERO {
151 let root = self
152 .m
153 .powf_pos(Fix128::ONE / Fix128::from_int(i64::from(self.n_int)));
154 if let Some(scaled_t) = t_hours.checked_mul(root) {
155 if let Some(creep) = pow_checked(scaled_t, self.n_int) {
156 return self.epsilon_0 + creep;
157 }
158 }
159 }
160 // 3) The effective time is astronomically large even after
161 // rescaling (T far enough above T_g that the WLF shift factor is
162 // minuscule) — the true creep strain genuinely exceeds Fix128's
163 // representable range. Saturate instead of wrapping: the creep
164 // term is always non-negative for t_hours > 0, so clamping at the
165 // largest representable magnitude keeps the result monotone
166 // instead of flipping sign. Returning the bare maximum (rather
167 // than adding `epsilon_0` on top of it) avoids wrapping a second
168 // time at the very ceiling it is meant to saturate at, and is
169 // still >= every value branch 1/2 can produce (those are only
170 // reached when `epsilon_0 + creep` already fits under that
171 // ceiling).
172 Fix128::from_raw(i64::MAX, u64::MAX)
173 }
174}
175
176// ============================================================================
177// Time-Temperature Superposition
178// ============================================================================
179
180/// WLF constants (crate-internal).
181#[derive(Clone, Copy, Debug)]
182pub(crate) struct WlfConstants {
183 /// C_1 (dimensionless).
184 pub(crate) c1: Fix128,
185 /// C_2 (°C).
186 pub(crate) c2: Fix128,
187}
188
189impl WlfConstants {
190 /// Universal WLF (`C_1 = 17.44`, `C_2 = 51.6`), applied at `T_ref = T_g` (crate-internal).
191 #[must_use]
192 pub(crate) fn universal() -> Self {
193 Self {
194 c1: Fix128::from_ratio(1744, 100),
195 c2: Fix128::from_ratio(516, 10),
196 }
197 }
198}
199
200/// Sentinel re-export of [`crate::math_util::EXP_OVERFLOW_SENTINEL`], used to
201/// signal "creep frozen" (temperature well below reference, crate-internal).
202pub(crate) const CREEP_FROZEN_AT: Fix128 = EXP_OVERFLOW_SENTINEL;
203
204/// WLF shift factor `a_T` (dimensionless multiplier). For `T > T_ref` returns
205/// a value < 1 (creep accelerates); for `T < T_ref` returns a value > 1.
206///
207/// Uses the shared [`crate::math_util::exp_fix`] deterministic exponential.
208/// Very cold conditions saturate at `CREEP_FROZEN_AT` (crate-internal helper).
209#[must_use]
210pub(crate) fn wlf_shift_factor(temp_c: Fix128, t_ref_c: Fix128, wlf: &WlfConstants) -> Fix128 {
211 let dt = temp_c - t_ref_c;
212 let denom = wlf.c2 + dt;
213 if denom.is_zero() {
214 return Fix128::ONE;
215 }
216 // log10 a_T = -C_1 · dT / (C_2 + dT)
217 let log_at = Fix128::ZERO - wlf.c1 * dt / denom;
218 let ln10 = Fix128::from_ratio(230_258_509, 100_000_000);
219 let y = log_at * ln10;
220 exp_fix(y)
221}
222
223/// Effective time (hours) at temperature `t` relative to reference `t_ref`,
224/// using the WLF shift factor.
225///
226/// When the shift factor saturates at [`CREEP_FROZEN_AT`] (the `exp_fix`
227/// overflow sentinel, reached once
228/// `ln10 · C₁ · (T_ref − T) / (C₂ − (T_ref − T)) ≥ 20`, i.e.
229/// `T ≤ T_ref − 20·C₂ / (20 + C₁·ln10) ≈ T_ref − 17.16 °C` for the universal
230/// constants) creep is frozen and the effective time is exactly zero rather
231/// than `t / sentinel`.
232#[must_use]
233pub(crate) fn effective_time_at_temp(
234 t_hours: Fix128,
235 temp_c: Fix128,
236 t_ref_c: Fix128,
237 wlf: &WlfConstants,
238) -> Fix128 {
239 let dt = temp_c - t_ref_c;
240 if dt > Fix128::ZERO {
241 // above the reference a_T < 1 can fall below the 2^-64 resolution
242 // (a_T = 4e-18 at T_ref + 13200 for the universal constants): divide by
243 // it as t * exp(-ln a_T) instead, -ln a_T = ln10 C1 dT / (C2 + dT) being
244 // below ln10 C1 = 40.2; `exp_fix` covers arguments below 20, so a
245 // larger one is taken as the cube of exp(x / 3)
246 let ln10 = Fix128::from_ratio(230_258_509, 100_000_000);
247 let neg_ln_at = ln10 * wlf.c1 * dt / (wlf.c2 + dt);
248 let speedup = if neg_ln_at < Fix128::from_int(20) {
249 exp_fix(neg_ln_at)
250 } else {
251 let third = exp_fix(neg_ln_at / Fix128::from_int(3));
252 third * third * third
253 };
254 return t_hours
255 .checked_mul(speedup)
256 .unwrap_or(Fix128::from_raw(i64::MAX, u64::MAX));
257 }
258 let a_t = wlf_shift_factor(temp_c, t_ref_c, wlf);
259 if a_t.is_zero() {
260 return Fix128::ZERO;
261 }
262 if a_t >= CREEP_FROZEN_AT {
263 return Fix128::ZERO;
264 }
265 t_hours / a_t
266}
267
268// ============================================================================
269// Aggregate helper
270// ============================================================================
271
272/// Predict long-term strain under constant stress and temperature.
273///
274/// Combines `FindleyParameters` with a WLF time–temperature shift **above
275/// `T_g`** (the domain the universal constants are fitted for, Williams,
276/// Landel & Ferry 1955: `T_g ≤ T ≤ T_g + 100 °C`), referenced to `T_g`. At
277/// or below `T_g` the parameters are used as calibrated: the crate presets
278/// (`pla_25c_moderate`, …) are room-temperature fits, and the glassy-regime
279/// temperature dependence follows Arrhenius with an activation energy the
280/// parameter set does not carry (a 2.0 field). Before 1.2.0 the WLF shift
281/// was applied *below* `T_g` too, where the universal constants give
282/// `a_T ≈ 10^37` at `T_g − 35 °C` — the effective time collapsed to zero and
283/// room-temperature PLA reported no creep at all
284/// (`tests/engineering_oracles_solid.rs`).
285#[must_use]
286pub fn predict_strain(
287 parameters: &FindleyParameters,
288 material: &MaterialProperties,
289 t_hours: Fix128,
290 operating_temp_c: Fix128,
291) -> Fix128 {
292 let t_g = material.glass_transition_c;
293 if t_g.is_zero() || operating_temp_c <= t_g {
294 return parameters.strain_at(t_hours);
295 }
296 let wlf = WlfConstants::universal();
297 let t_eff = effective_time_at_temp(t_hours, operating_temp_c, t_g, &wlf);
298 parameters.strain_at(t_eff)
299}
300
301// ============================================================================
302// Tests
303// ============================================================================
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn approx_eq(a: Fix128, b: Fix128, tol: Fix128) -> bool {
310 let d = if a > b { a - b } else { b - a };
311 d <= tol
312 }
313
314 #[test]
315 fn strain_at_zero_time_is_epsilon_0() {
316 let p = FindleyParameters::pla_25c_moderate();
317 assert_eq!(p.strain_at(Fix128::ZERO), p.epsilon_0);
318 }
319
320 #[test]
321 fn strain_monotonic_increasing() {
322 let p = FindleyParameters::pla_25c_moderate();
323 let e1 = p.strain_at(Fix128::from_int(100));
324 let e2 = p.strain_at(Fix128::from_int(1000));
325 assert!(e2 > e1);
326 }
327
328 #[test]
329 fn petg_lower_creep_than_pla() {
330 let pla = FindleyParameters::pla_25c_moderate();
331 let petg = FindleyParameters::petg_25c_moderate();
332 let t = Fix128::from_int(1000);
333 assert!(petg.strain_at(t) < pla.strain_at(t));
334 }
335
336 #[test]
337 fn wlf_shift_at_ref_temp_is_unity() {
338 let wlf = WlfConstants::universal();
339 let a = wlf_shift_factor(Fix128::from_int(60), Fix128::from_int(60), &wlf);
340 assert!(approx_eq(a, Fix128::ONE, Fix128::from_ratio(1, 1000)));
341 }
342
343 #[test]
344 fn wlf_shift_above_ref_less_than_unity() {
345 let wlf = WlfConstants::universal();
346 // T > Tref → creep accelerates → a_T < 1 → effective time > actual
347 let a = wlf_shift_factor(Fix128::from_int(70), Fix128::from_int(60), &wlf);
348 assert!(a < Fix128::ONE);
349 }
350
351 #[test]
352 fn wlf_shift_below_ref_greater_than_unity() {
353 let wlf = WlfConstants::universal();
354 // T < Tref → creep slows → a_T > 1
355 let a = wlf_shift_factor(Fix128::from_int(50), Fix128::from_int(60), &wlf);
356 assert!(a > Fix128::ONE);
357 }
358
359 #[test]
360 fn effective_time_zero_a_t_returns_zero() {
361 // Manually construct pathological WLF whose numerator forces log→−∞
362 // Skip this edge case here — normal usage never triggers.
363 let wlf = WlfConstants::universal();
364 let t_eff = effective_time_at_temp(
365 Fix128::from_int(100),
366 Fix128::from_int(60),
367 Fix128::from_int(60),
368 &wlf,
369 );
370 assert!(approx_eq(
371 t_eff,
372 Fix128::from_int(100),
373 Fix128::from_ratio(1, 100)
374 ));
375 }
376
377 #[test]
378 fn predict_strain_scales_with_time_near_tg() {
379 // At 55°C (close to PLA Tg=60), the WLF shift factor is close to
380 // unity and creep is significant. At 25°C (35°C below Tg) creep is
381 // essentially frozen — the model correctly returns near-zero
382 // additional strain.
383 let params = FindleyParameters::pla_25c_moderate();
384 let material = MaterialProperties::pla();
385 let e_short = predict_strain(
386 ¶ms,
387 &material,
388 Fix128::from_int(1000),
389 Fix128::from_int(55),
390 );
391 let e_long = predict_strain(
392 ¶ms,
393 &material,
394 Fix128::from_int(10_000),
395 Fix128::from_int(55),
396 );
397 assert!(e_long > e_short);
398 }
399
400 #[test]
401 fn predict_strain_above_tg_much_higher() {
402 let params = FindleyParameters::pla_25c_moderate();
403 let material = MaterialProperties::pla();
404 let e_room = predict_strain(
405 ¶ms,
406 &material,
407 Fix128::from_int(1000),
408 Fix128::from_int(25),
409 );
410 let e_hot = predict_strain(
411 ¶ms,
412 &material,
413 Fix128::from_int(1000),
414 Fix128::from_int(65),
415 );
416 // At 65°C (above Tg 60), creep should be worse
417 assert!(e_hot > e_room);
418 }
419
420 /// Oracle: Findley `ε(t) = ε₀ + m·tⁿ` with the PETG preset's constants
421 /// (`ε₀ = 0.002`, `m = 1e-13`, `n = 3`) at 6 months, evaluated by hand:
422 /// `4380³ = 84 027 672 000`, `m·t³ = 0.0084027672`, total
423 /// `0.0104027672`. Routed through `predict_strain` at 25 °C, below the
424 /// PETG `T_g = 80 °C`, so no WLF shift applies. Tolerance: `m = 1e-13`
425 /// is `1 844 674.4` Fix128 ulp (`2⁻⁶⁴`), stored truncated, so the creep
426 /// term carries a relative error of `0.4 / 1.8e6 = 2.2e-7`
427 /// (`1.9e-9` absolute); `1e-8` leaves no room for a wrong constant.
428 #[test]
429 fn petg_preset_findley_matches_closed_form_at_six_months() {
430 let got = predict_strain(
431 &FindleyParameters::petg_25c_moderate(),
432 &MaterialProperties::petg(),
433 Fix128::from_int(4380),
434 Fix128::from_int(25),
435 )
436 .to_f64();
437 let want = 0.002 + 1e-13 * 84_027_672_000.0;
438 assert!((got - want).abs() < 1e-8, "got {got}, want {want}");
439 }
440
441 /// Degenerate time for the PETG preset: `strain_at` documents
442 /// `t ≤ 0 → ε₀`, so zero and negative hours return exactly `0.002`.
443 #[test]
444 fn petg_preset_degenerate_time_returns_epsilon_0() {
445 let p = FindleyParameters::petg_25c_moderate();
446 let m = MaterialProperties::petg();
447 let t25 = Fix128::from_int(25);
448 assert_eq!(
449 predict_strain(&p, &m, Fix128::ZERO, t25),
450 Fix128::from_ratio(2, 1000)
451 );
452 assert_eq!(
453 predict_strain(&p, &m, Fix128::from_int(-1), t25),
454 Fix128::from_ratio(2, 1000)
455 );
456 }
457
458 /// Oracle: the frozen threshold derived by hand from the WLF form and the
459 /// `exp_fix` saturation point. `a_T = exp(ln10 · C₁ · |ΔT| / (C₂ − |ΔT|))`
460 /// saturates once the exponent reaches 20, i.e.
461 /// `|ΔT| ≥ 20·C₂ / (20 + C₁·ln10) = 1032 / 60.158 = 17.155 °C`.
462 /// At `ΔT = −17.5 °C` the exponent is `2.302585·17.44·17.5/34.1 = 20.61`
463 /// (frozen, effective time exactly 0); at `ΔT = −17 °C` it is
464 /// `2.302585·17.44·17/34.6 = 19.729` (not frozen,
465 /// `t_eff = t · exp(−19.729)`).
466 #[test]
467 fn effective_time_is_exactly_zero_once_the_wlf_shift_saturates() {
468 let wlf = WlfConstants::universal();
469 let t = Fix128::from_int(4380);
470 let t_ref = Fix128::from_int(60);
471 let frozen = effective_time_at_temp(t, t_ref - Fix128::from_ratio(35, 2), t_ref, &wlf);
472 assert_eq!(frozen, Fix128::ZERO, "ΔT = −17.5 °C must be frozen");
473 let not_frozen = effective_time_at_temp(t, t_ref - Fix128::from_int(17), t_ref, &wlf);
474 let exponent = core::f64::consts::LN_10 * 17.44 * 17.0 / (51.6 - 17.0);
475 let want = 4380.0 * crate::det_math::exp64(-exponent);
476 let got = not_frozen.to_f64();
477 assert!(
478 got > 0.0 && ((got - want) / want).abs() < 1e-6,
479 "ΔT = −17 °C: got {got:e}, want {want:e}"
480 );
481 }
482
483 #[test]
484 fn universal_wlf_constants() {
485 let w = WlfConstants::universal();
486 assert!(approx_eq(
487 w.c1,
488 Fix128::from_ratio(1744, 100),
489 Fix128::from_ratio(1, 100)
490 ));
491 assert!(approx_eq(
492 w.c2,
493 Fix128::from_ratio(516, 10),
494 Fix128::from_ratio(1, 100)
495 ));
496 }
497}