Skip to main content

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            &params,
387            &material,
388            Fix128::from_int(1000),
389            Fix128::from_int(55),
390        );
391        let e_long = predict_strain(
392            &params,
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            &params,
406            &material,
407            Fix128::from_int(1000),
408            Fix128::from_int(25),
409        );
410        let e_hot = predict_strain(
411            &params,
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}