Skip to main content

carapace_core/model/materials/
pinching4.rs

1use super::Material;
2
3/// `Pinching4Material`'s `Cstate`/`Tstate` — an `int` 0..4 in the OpenSees
4/// source, modelled here as a real enum.
5///
6/// Unlike every other material in this catalog, this is *not* a
7/// positive/negative/interior split:
8///
9/// - [`NearOrigin`](Pinching4State::NearOrigin) (`0`) — before the first
10///   real excursion in either direction, i.e. still inside the tiny
11///   synthesized near-origin bracket `[-u, +u]` that `SetEnvelope` builds.
12/// - [`Positive`](Pinching4State::Positive) (`1`) — riding the positive
13///   (damaged) envelope.
14/// - [`Negative`](Pinching4State::Negative) (`2`) — riding the negative
15///   (damaged) envelope.
16/// - [`Trilinear3`](Pinching4State::Trilinear3) (`3`) — the pinched
17///   trilinear path connecting a negative excursion back *toward* (not
18///   necessarily reaching) the positive side.
19/// - [`Trilinear4`](Pinching4State::Trilinear4) (`4`) — the mirror image:
20///   a positive excursion heading back toward the negative side.
21#[derive(Debug, Clone, Copy, PartialEq, Eq)]
22pub enum Pinching4State {
23    NearOrigin,
24    Positive,
25    Negative,
26    Trilinear3,
27    Trilinear4,
28}
29
30/// `Pinching4Material`'s `DmgCyc` flag (a raw `int` in OpenSees): whether
31/// the second damage term is driven by the dissipated-energy ratio
32/// (`DmgCyc == 0`) or by an accumulated half-cycle count (`DmgCyc == 1`).
33///
34/// Note the two are not symmetric in the source: the energy-based term is
35/// additionally gated on `Tenergy > elasticStrainEnergy`, the cycle-based
36/// one is not (see `update_dmg`).
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
38pub enum Pinching4DmgCyc {
39    EnergyBased,
40    CycleBased,
41}
42
43/// The full field set behind `Material::Pinching4` (see that variant's doc
44/// comment for why it's boxed). `Copy` for the same reason
45/// `HystereticFields` is — every field is an `f64`, a small `f64` array, or
46/// a fieldless enum, so one cheap copy out of the `Box` per `evaluate` call
47/// beats borrow-juggling a destructure.
48///
49/// # What is stored vs. what is derived
50///
51/// OpenSees carries several *derived* arrays/scalars as members, refreshed
52/// in `commitState`. None of them are stored here, because each is an exact
53/// function of what is:
54///
55/// - `envlpPosDamgdStress`/`envlpNegDamgdStress` — always
56///   `envlp*Stress[i] * (1 - gammaFUsed)` for whatever `gammaFUsed` was
57///   current when they were last written, so one scalar per side recovers
58///   them exactly. They *are* rebuilt as trial-local `[f64; 6]` arrays
59///   inside `evaluate` (see the note there about the per-side lag).
60/// - `kElasticPosDamgd`/`kElasticNegDamgd` — `kElastic* * (1 - gammaKUsed)`,
61///   same argument.
62/// - `uMaxDamgd`/`uMinDamgd` — `C*StrainDmnd * (1 + CgammaD)`; `commitState`
63///   writes them from exactly those two committed fields, and nothing
64///   mutates them mid-step, so they are recomputed at the top of `evaluate`.
65///
66/// The backbone is kept as the 6-point envelope arrays `SetEnvelope`
67/// produces rather than the 16 raw `stress1p..strain4n` inputs: points 1-4
68/// of each array *are* those inputs, and points 0/5 (the synthesized
69/// near-origin point and the far extrapolated point) are the only things
70/// the raw values are otherwise needed for.
71///
72/// History fields mirror the `C*` half of OpenSees' `T*`/`C*` pairs:
73/// `state`/`strain`/`stress`/`strain_rate`, the current state's strain
74/// bracket and the stresses at its ends
75/// (`low_state_*`/`hgh_state_*`), the peak strain demand on each side
76/// (`min_strain_dmnd`/`max_strain_dmnd`), cumulative dissipated `energy`,
77/// the three damage factors `gamma_k`/`gamma_d`/`gamma_f`, the two
78/// *last-transition* damage factors `gamma_k_used`/`gamma_f_used`, and the
79/// accumulated half-cycle count `n_cycle`.
80#[derive(Debug, Clone, Copy)]
81pub struct Pinching4Fields {
82    // --- envelope (fixed at construction) ---
83    pub envlp_pos_strain: [f64; 6],
84    pub envlp_pos_stress: [f64; 6],
85    pub envlp_neg_strain: [f64; 6],
86    pub envlp_neg_stress: [f64; 6],
87    pub k_elastic_pos: f64,
88    pub k_elastic_neg: f64,
89    pub energy_capacity: f64,
90    // --- pinching parameters (fixed) ---
91    pub r_disp_p: f64,
92    pub r_force_p: f64,
93    pub u_force_p: f64,
94    pub r_disp_n: f64,
95    pub r_force_n: f64,
96    pub u_force_n: f64,
97    // --- damage parameters (fixed) ---
98    pub gamma_k_params: [f64; 4],
99    pub gamma_k_limit: f64,
100    pub gamma_d_params: [f64; 4],
101    pub gamma_d_limit: f64,
102    pub gamma_f_params: [f64; 4],
103    pub gamma_f_limit: f64,
104    pub dmg_cyc: Pinching4DmgCyc,
105    // --- committed history ---
106    pub state: Pinching4State,
107    pub strain: f64,
108    pub stress: f64,
109    pub strain_rate: f64,
110    pub low_state_strain: f64,
111    pub low_state_stress: f64,
112    pub hgh_state_strain: f64,
113    pub hgh_state_stress: f64,
114    pub min_strain_dmnd: f64,
115    pub max_strain_dmnd: f64,
116    pub energy: f64,
117    pub gamma_k: f64,
118    pub gamma_d: f64,
119    pub gamma_f: f64,
120    pub gamma_k_used: f64,
121    pub gamma_f_used: f64,
122    pub n_cycle: f64,
123}
124
125/// Everything `Pinching4Material`'s `setTrialStrain` treats as a mutable
126/// `T*` member for the duration of one call. Bundled into a struct purely
127/// so `getstate`/`update_dmg` can take one `&mut` instead of a dozen.
128///
129/// `pos_damgd_stress`/`neg_damgd_stress` are trial-local copies of
130/// OpenSees' `envlpPosDamgdStress`/`envlpNegDamgdStress`, seeded from the
131/// committed `gamma_f_used` and refreshed *one side at a time* by
132/// `getstate` — faithfully reproducing a real asymmetry in the source: a
133/// transition out of state 1 rewrites only the negative side's damaged
134/// stresses (and only `kElasticPosDamgd`), leaving the other side one
135/// commit behind until `commitState` resynchronizes both. The same applies
136/// to `k_elastic_pos_damgd`/`k_elastic_neg_damgd`.
137struct Trial {
138    state: Pinching4State,
139    low_strain: f64,
140    low_stress: f64,
141    hgh_strain: f64,
142    hgh_stress: f64,
143    min_dmnd: f64,
144    max_dmnd: f64,
145    energy: f64,
146    gamma_k: f64,
147    gamma_d: f64,
148    gamma_f: f64,
149    gamma_k_used: f64,
150    gamma_f_used: f64,
151    k_elastic_pos_damgd: f64,
152    k_elastic_neg_damgd: f64,
153    pos_damgd_stress: [f64; 6],
154    neg_damgd_stress: [f64; 6],
155    n_cycle: f64,
156}
157
158impl Trial {
159    /// `Pinching4Material::posEnvlpStress` — a piecewise search over the
160    /// 6-point positive envelope, using the *damaged* stresses but the
161    /// undamaged strains (strength degradation scales force, not
162    /// deformation). Ported literally, `k == 0.0` sentinel and all.
163    fn pos_envlp_stress(&self, p: &Pinching4Fields, u: f64) -> f64 {
164        let mut k = 0.0;
165        let mut f = 0.0;
166        let mut i = 0;
167        while k == 0.0 && i <= 4 {
168            if u <= p.envlp_pos_strain[i + 1] {
169                k = (self.pos_damgd_stress[i + 1] - self.pos_damgd_stress[i])
170                    / (p.envlp_pos_strain[i + 1] - p.envlp_pos_strain[i]);
171                f = self.pos_damgd_stress[i] + (u - p.envlp_pos_strain[i]) * k;
172            }
173            i += 1;
174        }
175        if k == 0.0 {
176            k = (self.pos_damgd_stress[5] - self.pos_damgd_stress[4])
177                / (p.envlp_pos_strain[5] - p.envlp_pos_strain[4]);
178            f = self.pos_damgd_stress[5] + k * (u - p.envlp_pos_strain[5]);
179        }
180        f
181    }
182
183    /// `Pinching4Material::posEnvlpTangent`.
184    fn pos_envlp_tangent(&self, p: &Pinching4Fields, u: f64) -> f64 {
185        let mut k = 0.0;
186        let mut i = 0;
187        while k == 0.0 && i <= 4 {
188            if u <= p.envlp_pos_strain[i + 1] {
189                k = (self.pos_damgd_stress[i + 1] - self.pos_damgd_stress[i])
190                    / (p.envlp_pos_strain[i + 1] - p.envlp_pos_strain[i]);
191            }
192            i += 1;
193        }
194        if k == 0.0 {
195            k = (self.pos_damgd_stress[5] - self.pos_damgd_stress[4])
196                / (p.envlp_pos_strain[5] - p.envlp_pos_strain[4]);
197        }
198        k
199    }
200
201    /// `Pinching4Material::negEnvlpStress`.
202    fn neg_envlp_stress(&self, p: &Pinching4Fields, u: f64) -> f64 {
203        let mut k = 0.0;
204        let mut f = 0.0;
205        let mut i = 0;
206        while k == 0.0 && i <= 4 {
207            if u >= p.envlp_neg_strain[i + 1] {
208                k = (self.neg_damgd_stress[i] - self.neg_damgd_stress[i + 1])
209                    / (p.envlp_neg_strain[i] - p.envlp_neg_strain[i + 1]);
210                f = self.neg_damgd_stress[i + 1] + (u - p.envlp_neg_strain[i + 1]) * k;
211            }
212            i += 1;
213        }
214        if k == 0.0 {
215            k = (self.neg_damgd_stress[4] - self.neg_damgd_stress[5])
216                / (p.envlp_neg_strain[4] - p.envlp_neg_strain[5]);
217            f = self.neg_damgd_stress[5] + k * (u - p.envlp_neg_strain[5]);
218        }
219        f
220    }
221
222    /// `Pinching4Material::negEnvlpTangent`.
223    fn neg_envlp_tangent(&self, p: &Pinching4Fields, u: f64) -> f64 {
224        let mut k = 0.0;
225        let mut i = 0;
226        while k == 0.0 && i <= 4 {
227            if u >= p.envlp_neg_strain[i + 1] {
228                k = (self.neg_damgd_stress[i] - self.neg_damgd_stress[i + 1])
229                    / (p.envlp_neg_strain[i] - p.envlp_neg_strain[i + 1]);
230            }
231            i += 1;
232        }
233        if k == 0.0 {
234            k = (self.neg_damgd_stress[4] - self.neg_damgd_stress[5])
235                / (p.envlp_neg_strain[4] - p.envlp_neg_strain[5]);
236        }
237        k
238    }
239}
240
241impl Material {
242    /// Ported from `Pinching4Material`'s full (asymmetric-backbone)
243    /// constructor. OpenSees' second overload — the one that mirrors the
244    /// positive backbone onto the negative side and copies
245    /// `rDispP`/`rForceP`/`uForceP` across — is not ported, for the same
246    /// reason `Material::hysteretic` skipped its 2-point overload: a caller
247    /// can pass the negated values directly.
248    ///
249    /// The four-term damage parameter groups are taken as `[f64; 4]` arrays
250    /// (`gammaK1..gammaK4` etc.) rather than 12 separate scalars — a purely
251    /// mechanical regrouping of OpenSees' flat signature that keeps this
252    /// under 30 arguments instead of 39.
253    ///
254    /// Debug builds assert the backbone is one-to-one (all positive strains
255    /// `> 0`, all negative strains `< 0`); OpenSees only prints a warning
256    /// and carries on with a nonsensical envelope.
257    #[allow(clippy::too_many_arguments)]
258    pub fn pinching4(
259        stress1p: f64,
260        strain1p: f64,
261        stress2p: f64,
262        strain2p: f64,
263        stress3p: f64,
264        strain3p: f64,
265        stress4p: f64,
266        strain4p: f64,
267        stress1n: f64,
268        strain1n: f64,
269        stress2n: f64,
270        strain2n: f64,
271        stress3n: f64,
272        strain3n: f64,
273        stress4n: f64,
274        strain4n: f64,
275        r_disp_p: f64,
276        r_force_p: f64,
277        u_force_p: f64,
278        r_disp_n: f64,
279        r_force_n: f64,
280        u_force_n: f64,
281        gamma_k_params: [f64; 4],
282        gamma_k_limit: f64,
283        gamma_d_params: [f64; 4],
284        gamma_d_limit: f64,
285        gamma_f_params: [f64; 4],
286        gamma_f_limit: f64,
287        gamma_e: f64,
288        dmg_cyc: Pinching4DmgCyc,
289    ) -> Self {
290        debug_assert!(
291            strain1p > 0.0 && strain2p > 0.0 && strain3p > 0.0 && strain4p > 0.0,
292            "positive backbone strains must all be positive"
293        );
294        debug_assert!(
295            strain1n < 0.0 && strain2n < 0.0 && strain3n < 0.0 && strain4n < 0.0,
296            "negative backbone strains must all be negative"
297        );
298
299        // `Pinching4Material::SetEnvelope`.
300        let k_pos = stress1p / strain1p;
301        let k_neg = stress1n / strain1n;
302        let k = if k_pos > k_neg { k_pos } else { k_neg };
303        let u = if strain1p > -strain1n {
304            1e-4 * strain1p
305        } else {
306            -1e-4 * strain1n
307        };
308
309        let mut envlp_pos_strain = [u, strain1p, strain2p, strain3p, strain4p, 0.0];
310        let mut envlp_pos_stress = [u * k, stress1p, stress2p, stress3p, stress4p, 0.0];
311        let mut envlp_neg_strain = [-u, strain1n, strain2n, strain3n, strain4n, 0.0];
312        let mut envlp_neg_stress = [-u * k, stress1n, stress2n, stress3n, stress4n, 0.0];
313
314        let k1 = (stress4p - stress3p) / (strain4p - strain3p);
315        let k2 = (stress4n - stress3n) / (strain4n - strain3n);
316
317        envlp_pos_strain[5] = 1e6 * strain4p;
318        envlp_pos_stress[5] = if k1 > 0.0 {
319            stress4p + k1 * (envlp_pos_strain[5] - strain4p)
320        } else {
321            stress4p * 1.1
322        };
323        envlp_neg_strain[5] = 1e6 * strain4n;
324        envlp_neg_stress[5] = if k2 > 0.0 {
325            stress4n + k2 * (envlp_neg_strain[5] - strain4n)
326        } else {
327            stress4n * 1.1
328        };
329
330        let k_elastic_pos = envlp_pos_stress[1] / envlp_pos_strain[1];
331        let k_elastic_neg = envlp_neg_stress[1] / envlp_neg_strain[1];
332
333        let mut energy_pos = 0.5 * envlp_pos_strain[0] * envlp_pos_stress[0];
334        for jt in 0..4 {
335            energy_pos += 0.5
336                * (envlp_pos_stress[jt] + envlp_pos_stress[jt + 1])
337                * (envlp_pos_strain[jt + 1] - envlp_pos_strain[jt]);
338        }
339        let mut energy_neg = 0.5 * envlp_neg_strain[0] * envlp_neg_stress[0];
340        for jy in 0..4 {
341            energy_neg += 0.5
342                * (envlp_neg_stress[jy] + envlp_neg_stress[jy + 1])
343                * (envlp_neg_strain[jy + 1] - envlp_neg_strain[jy]);
344        }
345        let max_energy = if energy_pos > energy_neg {
346            energy_pos
347        } else {
348            energy_neg
349        };
350        let energy_capacity = gamma_e * max_energy;
351
352        // `Pinching4Material::revertToStart`.
353        Material::Pinching4(Box::new(Pinching4Fields {
354            envlp_pos_strain,
355            envlp_pos_stress,
356            envlp_neg_strain,
357            envlp_neg_stress,
358            k_elastic_pos,
359            k_elastic_neg,
360            energy_capacity,
361            r_disp_p,
362            r_force_p,
363            u_force_p,
364            r_disp_n,
365            r_force_n,
366            u_force_n,
367            gamma_k_params,
368            gamma_k_limit,
369            gamma_d_params,
370            gamma_d_limit,
371            gamma_f_params,
372            gamma_f_limit,
373            dmg_cyc,
374            state: Pinching4State::NearOrigin,
375            strain: 0.0,
376            stress: 0.0,
377            strain_rate: 0.0,
378            low_state_strain: envlp_neg_strain[0],
379            low_state_stress: envlp_neg_stress[0],
380            hgh_state_strain: envlp_pos_strain[0],
381            hgh_state_stress: envlp_pos_stress[0],
382            min_strain_dmnd: envlp_neg_strain[1],
383            max_strain_dmnd: envlp_pos_strain[1],
384            energy: 0.0,
385            gamma_k: 0.0,
386            gamma_d: 0.0,
387            gamma_f: 0.0,
388            gamma_k_used: 0.0,
389            gamma_f_used: 0.0,
390            n_cycle: 0.0,
391        }))
392    }
393}
394
395/// Ported from `Pinching4Material::setTrialStrain` (plus the parts of
396/// `commitState` that only recompute derived quantities).
397///
398/// Structure follows the C++ exactly: seed the trial state from the
399/// committed state, compute `dstrain` (with OpenSees' `1e-12` deadband),
400/// run the transition function `getstate`, evaluate stress/tangent for
401/// whichever state we ended up in, accumulate energy, then run `update_dmg`
402/// unconditionally.
403pub(super) fn evaluate(m: &Material, strain: f64) -> (f64, f64, Material) {
404    let Material::Pinching4(fields) = m else {
405        unreachable!()
406    };
407    let p = **fields;
408
409    let mut dstrain = strain - p.strain;
410    if dstrain < 1e-12 && dstrain > -1e-12 {
411        dstrain = 0.0;
412    }
413
414    // `commitState` derives these three from committed fields only, and
415    // nothing mutates them during a trial step — so they're recomputed here
416    // rather than stored (see `Pinching4Fields`' doc comment).
417    let u_max_damgd = p.max_strain_dmnd * (1.0 + p.gamma_d);
418    let u_min_damgd = p.min_strain_dmnd * (1.0 + p.gamma_d);
419
420    let mut t = Trial {
421        state: p.state,
422        low_strain: p.low_state_strain,
423        low_stress: p.low_state_stress,
424        hgh_strain: p.hgh_state_strain,
425        hgh_stress: p.hgh_state_stress,
426        min_dmnd: p.min_strain_dmnd,
427        max_dmnd: p.max_strain_dmnd,
428        energy: p.energy,
429        gamma_k: p.gamma_k,
430        gamma_d: p.gamma_d,
431        gamma_f: p.gamma_f,
432        gamma_k_used: p.gamma_k_used,
433        gamma_f_used: p.gamma_f_used,
434        k_elastic_pos_damgd: p.k_elastic_pos * (1.0 - p.gamma_k_used),
435        k_elastic_neg_damgd: p.k_elastic_neg * (1.0 - p.gamma_k_used),
436        pos_damgd_stress: scaled(&p.envlp_pos_stress, 1.0 - p.gamma_f_used),
437        neg_damgd_stress: scaled(&p.envlp_neg_stress, 1.0 - p.gamma_f_used),
438        n_cycle: p.n_cycle,
439    };
440
441    getstate(&mut t, &p, strain, dstrain, u_max_damgd, u_min_damgd);
442
443    let (stress, tangent) = match t.state {
444        Pinching4State::NearOrigin => {
445            let tangent = p.envlp_pos_stress[0] / p.envlp_pos_strain[0];
446            (tangent * strain, tangent)
447        }
448        Pinching4State::Positive => (
449            t.pos_envlp_stress(&p, strain),
450            t.pos_envlp_tangent(&p, strain),
451        ),
452        Pinching4State::Negative => (
453            t.neg_envlp_stress(&p, strain),
454            t.neg_envlp_tangent(&p, strain),
455        ),
456        Pinching4State::Trilinear3 => {
457            let kunload = if t.hgh_strain < 0.0 {
458                t.k_elastic_neg_damgd
459            } else {
460                t.k_elastic_pos_damgd
461            };
462            let mut s3_strain = [t.low_strain, 0.0, 0.0, t.hgh_strain];
463            let mut s3_stress = [t.low_stress, 0.0, 0.0, t.hgh_stress];
464            get_state3(&mut s3_strain, &mut s3_stress, kunload, &t, &p);
465            (
466                envlp34_stress(&s3_strain, &s3_stress, strain),
467                envlp34_tangent(&s3_strain, &s3_stress, strain),
468            )
469        }
470        Pinching4State::Trilinear4 => {
471            let kunload = if t.low_strain < 0.0 {
472                t.k_elastic_neg_damgd
473            } else {
474                t.k_elastic_pos_damgd
475            };
476            let mut s4_strain = [t.low_strain, 0.0, 0.0, t.hgh_strain];
477            let mut s4_stress = [t.low_stress, 0.0, 0.0, t.hgh_stress];
478            get_state4(&mut s4_strain, &mut s4_stress, kunload, &t, &p);
479            (
480                envlp34_stress(&s4_strain, &s4_stress, strain),
481                envlp34_tangent(&s4_strain, &s4_stress, strain),
482            )
483        }
484    };
485
486    let denergy = 0.5 * (stress + p.stress) * dstrain;
487    let elastic_strain_energy = if strain > 0.0 {
488        0.5 * stress / t.k_elastic_pos_damgd * stress
489    } else {
490        0.5 * stress / t.k_elastic_neg_damgd * stress
491    };
492    t.energy = p.energy + denergy;
493
494    update_dmg(
495        &mut t,
496        &p,
497        strain,
498        dstrain,
499        elastic_strain_energy,
500        p.n_cycle,
501    );
502
503    // `commitState`: `CstrainRate` keeps its previous value across a
504    // zero-increment step rather than latching zero.
505    let strain_rate = if dstrain > 1e-12 || dstrain < -1e-12 {
506        dstrain
507    } else {
508        p.strain_rate
509    };
510
511    (
512        stress,
513        tangent,
514        Material::Pinching4(Box::new(Pinching4Fields {
515            state: t.state,
516            strain,
517            stress,
518            strain_rate,
519            low_state_strain: t.low_strain,
520            low_state_stress: t.low_stress,
521            hgh_state_strain: t.hgh_strain,
522            hgh_state_stress: t.hgh_stress,
523            min_strain_dmnd: t.min_dmnd,
524            max_strain_dmnd: t.max_dmnd,
525            energy: t.energy,
526            gamma_k: t.gamma_k,
527            gamma_d: t.gamma_d,
528            gamma_f: t.gamma_f,
529            gamma_k_used: t.gamma_k_used,
530            gamma_f_used: t.gamma_f_used,
531            n_cycle: t.n_cycle,
532            ..p
533        })),
534    )
535}
536
537fn scaled(v: &[f64; 6], factor: f64) -> [f64; 6] {
538    let mut out = [0.0; 6];
539    for i in 0..6 {
540        out[i] = v[i] * factor;
541    }
542    out
543}
544
545/// Ported from `Pinching4Material::getstate` — the transition function.
546///
547/// A transition is only *considered* when the trial strain leaves the
548/// current state's `[low, hgh]` bracket or the strain rate changes sign
549/// (`cid`); which transition is taken then depends on the current state and
550/// the sign of `du`. Note that `du == 0.0` always sets `cid`, which is
551/// harmless: every inner branch that could fire requires a strictly signed
552/// `du`, except state 0's, which tests the bracket itself.
553fn getstate(
554    t: &mut Trial,
555    p: &Pinching4Fields,
556    u: f64,
557    du: f64,
558    u_max_damgd: f64,
559    u_min_damgd: f64,
560) {
561    // `CstrainRate`/`CgammaF`/`CgammaK`/`Cstrain`/`Cstress` are read from
562    // the committed state; `t.*` are the trial values being built.
563    let c_strain = p.strain;
564    let c_stress = p.stress;
565
566    let cid = du * p.strain_rate <= 0.0;
567    let mut cis = false;
568    let mut new_state = Pinching4State::NearOrigin;
569
570    if u < t.low_strain || u > t.hgh_strain || cid {
571        match t.state {
572            Pinching4State::NearOrigin => {
573                if u > t.hgh_strain {
574                    cis = true;
575                    new_state = Pinching4State::Positive;
576                    t.low_strain = p.envlp_pos_strain[0];
577                    t.low_stress = p.envlp_pos_stress[0];
578                    t.hgh_strain = p.envlp_pos_strain[5];
579                    t.hgh_stress = p.envlp_pos_stress[5];
580                } else if u < t.low_strain {
581                    cis = true;
582                    new_state = Pinching4State::Negative;
583                    t.low_strain = p.envlp_neg_strain[5];
584                    t.low_stress = p.envlp_neg_stress[5];
585                    t.hgh_strain = p.envlp_neg_strain[0];
586                    t.hgh_stress = p.envlp_neg_stress[0];
587                }
588            }
589            Pinching4State::Positive if du < 0.0 => {
590                cis = true;
591                if c_strain > t.max_dmnd {
592                    t.max_dmnd = u - du;
593                }
594                if t.max_dmnd < u_max_damgd {
595                    t.max_dmnd = u_max_damgd;
596                }
597                if u < u_min_damgd {
598                    new_state = Pinching4State::Negative;
599                    t.gamma_f_used = p.gamma_f;
600                    t.neg_damgd_stress = scaled(&p.envlp_neg_stress, 1.0 - t.gamma_f_used);
601                    t.low_strain = p.envlp_neg_strain[5];
602                    t.low_stress = p.envlp_neg_stress[5];
603                    t.hgh_strain = p.envlp_neg_strain[0];
604                    t.hgh_stress = p.envlp_neg_stress[0];
605                } else {
606                    new_state = Pinching4State::Trilinear3;
607                    t.low_strain = u_min_damgd;
608                    t.gamma_f_used = p.gamma_f;
609                    t.neg_damgd_stress = scaled(&p.envlp_neg_stress, 1.0 - t.gamma_f_used);
610                    t.low_stress = t.neg_envlp_stress(p, u_min_damgd);
611                    t.hgh_strain = c_strain;
612                    t.hgh_stress = c_stress;
613                }
614                t.gamma_k_used = p.gamma_k;
615                t.k_elastic_pos_damgd = p.k_elastic_pos * (1.0 - t.gamma_k_used);
616            }
617            Pinching4State::Negative if du > 0.0 => {
618                cis = true;
619                if c_strain < t.min_dmnd {
620                    t.min_dmnd = c_strain;
621                }
622                if t.min_dmnd > u_min_damgd {
623                    t.min_dmnd = u_min_damgd;
624                }
625                if u > u_max_damgd {
626                    new_state = Pinching4State::Positive;
627                    t.gamma_f_used = p.gamma_f;
628                    t.pos_damgd_stress = scaled(&p.envlp_pos_stress, 1.0 - t.gamma_f_used);
629                    t.low_strain = p.envlp_pos_strain[0];
630                    t.low_stress = p.envlp_pos_stress[0];
631                    t.hgh_strain = p.envlp_pos_strain[5];
632                    t.hgh_stress = p.envlp_pos_stress[5];
633                } else {
634                    new_state = Pinching4State::Trilinear4;
635                    t.low_strain = c_strain;
636                    t.low_stress = c_stress;
637                    t.hgh_strain = u_max_damgd;
638                    t.gamma_f_used = p.gamma_f;
639                    t.pos_damgd_stress = scaled(&p.envlp_pos_stress, 1.0 - t.gamma_f_used);
640                    t.hgh_stress = t.pos_envlp_stress(p, u_max_damgd);
641                }
642                t.gamma_k_used = p.gamma_k;
643                t.k_elastic_neg_damgd = p.k_elastic_neg * (1.0 - t.gamma_k_used);
644            }
645            Pinching4State::Trilinear3 => {
646                if u < t.low_strain {
647                    cis = true;
648                    new_state = Pinching4State::Negative;
649                    t.low_strain = p.envlp_neg_strain[5];
650                    t.hgh_strain = p.envlp_neg_strain[0];
651                    t.low_stress = t.neg_damgd_stress[5];
652                    t.hgh_stress = t.neg_damgd_stress[0];
653                } else if u > u_max_damgd && du > 0.0 {
654                    cis = true;
655                    new_state = Pinching4State::Positive;
656                    t.low_strain = p.envlp_pos_strain[0];
657                    t.low_stress = p.envlp_pos_stress[0];
658                    t.hgh_strain = p.envlp_pos_strain[5];
659                    t.hgh_stress = p.envlp_pos_stress[5];
660                } else if du > 0.0 {
661                    cis = true;
662                    new_state = Pinching4State::Trilinear4;
663                    t.low_strain = c_strain;
664                    t.low_stress = c_stress;
665                    t.hgh_strain = u_max_damgd;
666                    t.gamma_f_used = p.gamma_f;
667                    t.pos_damgd_stress = scaled(&p.envlp_pos_stress, 1.0 - t.gamma_f_used);
668                    t.hgh_stress = t.pos_envlp_stress(p, u_max_damgd);
669                    t.gamma_k_used = p.gamma_k;
670                    t.k_elastic_neg_damgd = p.k_elastic_neg * (1.0 - t.gamma_k_used);
671                }
672            }
673            Pinching4State::Trilinear4 => {
674                if u > t.hgh_strain {
675                    cis = true;
676                    new_state = Pinching4State::Positive;
677                    t.low_strain = p.envlp_pos_strain[0];
678                    t.low_stress = t.pos_damgd_stress[0];
679                    t.hgh_strain = p.envlp_pos_strain[5];
680                    t.hgh_stress = t.pos_damgd_stress[5];
681                } else if u < u_min_damgd && du < 0.0 {
682                    cis = true;
683                    new_state = Pinching4State::Negative;
684                    t.low_strain = p.envlp_neg_strain[5];
685                    t.low_stress = t.neg_damgd_stress[5];
686                    t.hgh_strain = p.envlp_neg_strain[0];
687                    t.hgh_stress = t.neg_damgd_stress[0];
688                } else if du < 0.0 {
689                    cis = true;
690                    new_state = Pinching4State::Trilinear3;
691                    t.low_strain = u_min_damgd;
692                    t.gamma_f_used = p.gamma_f;
693                    t.neg_damgd_stress = scaled(&p.envlp_neg_stress, 1.0 - t.gamma_f_used);
694                    t.low_stress = t.neg_envlp_stress(p, u_min_damgd);
695                    t.hgh_strain = c_strain;
696                    t.hgh_stress = c_stress;
697                    t.gamma_k_used = p.gamma_k;
698                    t.k_elastic_pos_damgd = p.k_elastic_pos * (1.0 - t.gamma_k_used);
699                }
700            }
701            _ => {}
702        }
703    }
704
705    if cis {
706        t.state = new_state;
707    }
708}
709
710/// Ported from `Pinching4Material::getState3` — builds the 4-point
711/// trilinear path for state 3. Points 0 and 3 are the state's bracket
712/// (already filled in by the caller); points 1 and 2 are constructed from
713/// the pinching parameters and then run through a cascade of
714/// degenerate-geometry repairs.
715///
716/// Translated as a literal line-by-line trace of the C++, including the
717/// several places where the "same" check is spelled differently between
718/// `getState3` and `getState4` — those asymmetries are in the source and
719/// are deliberately preserved rather than reconciled.
720fn get_state3(
721    strain: &mut [f64; 4],
722    stress: &mut [f64; 4],
723    kunload: f64,
724    t: &Trial,
725    p: &Pinching4Fields,
726) {
727    let kmax = if kunload > t.k_elastic_neg_damgd {
728        kunload
729    } else {
730        t.k_elastic_neg_damgd
731    };
732
733    if strain[0] * strain[3] < 0.0 {
734        // Trilinear unload/reload path expected; first define the point
735        // for reloading.
736        strain[1] = t.low_strain * p.r_disp_n;
737        if p.r_force_n - p.u_force_n > 1e-8 {
738            stress[1] = t.low_stress * p.r_force_n;
739        } else if t.min_dmnd < p.envlp_neg_strain[3] {
740            let st1 = t.low_stress * p.u_force_n * (1.0 + 1e-6);
741            let st2 = t.neg_damgd_stress[4] * (1.0 + 1e-6);
742            stress[1] = if st1 < st2 { st1 } else { st2 };
743        } else {
744            let st1 = t.neg_damgd_stress[3] * p.u_force_n * (1.0 + 1e-6);
745            let st2 = t.neg_damgd_stress[4] * (1.0 + 1e-6);
746            stress[1] = if st1 < st2 { st1 } else { st2 };
747        }
748
749        // If the reload stiffness exceeds the unload stiffness, reduce it
750        // to match.
751        if (stress[1] - stress[0]) / (strain[1] - strain[0]) > t.k_elastic_neg_damgd {
752            strain[1] = t.low_strain + (stress[1] - stress[0]) / t.k_elastic_neg_damgd;
753        }
754
755        // Check that the reloading point is not behind point 4.
756        if strain[1] > strain[3] {
757            // Path taken to be a straight line between points 1 and 4.
758            let du = strain[3] - strain[0];
759            let df = stress[3] - stress[0];
760            strain[1] = strain[0] + 0.33 * du;
761            strain[2] = strain[0] + 0.67 * du;
762            stress[1] = stress[0] + 0.33 * df;
763            stress[2] = stress[0] + 0.67 * df;
764        } else {
765            if t.min_dmnd < p.envlp_neg_strain[3] {
766                stress[2] = p.u_force_n * t.neg_damgd_stress[4];
767            } else {
768                stress[2] = p.u_force_n * t.neg_damgd_stress[3];
769            }
770            strain[2] = t.hgh_strain - (t.hgh_stress - stress[2]) / kunload;
771
772            if strain[2] > strain[3] {
773                // Point 3 should be along a line between 2 and 4.
774                let du = strain[3] - strain[1];
775                let df = stress[3] - stress[1];
776                strain[2] = strain[1] + 0.5 * du;
777                stress[2] = stress[1] + 0.5 * df;
778            } else if (stress[2] - stress[1]) / (strain[2] - strain[1]) > kmax {
779                // Linear unload-reload path expected.
780                let du = strain[3] - strain[0];
781                let df = stress[3] - stress[0];
782                strain[1] = strain[0] + 0.33 * du;
783                strain[2] = strain[0] + 0.67 * du;
784                stress[1] = stress[0] + 0.33 * df;
785                stress[2] = stress[0] + 0.67 * df;
786            } else if (strain[2] < strain[1])
787                || ((stress[2] - stress[1]) / (strain[2] - strain[1]) < 0.0)
788            {
789                if strain[2] < 0.0 {
790                    // Point 3 should be along a line between 2 and 4.
791                    let du = strain[3] - strain[1];
792                    let df = stress[3] - stress[1];
793                    strain[2] = strain[1] + 0.5 * du;
794                    stress[2] = stress[1] + 0.5 * df;
795                } else if strain[1] > 0.0 {
796                    // Point 2 should be along a line between 1 and 3.
797                    let du = strain[2] - strain[0];
798                    let df = stress[2] - stress[0];
799                    strain[1] = strain[0] + 0.5 * du;
800                    stress[1] = stress[0] + 0.5 * df;
801                } else {
802                    let avgforce = 0.5 * (stress[2] + stress[1]);
803                    let dfr = if avgforce < 0.0 {
804                        -avgforce / 100.0
805                    } else {
806                        avgforce / 100.0
807                    };
808                    let slope12 = (stress[1] - stress[0]) / (strain[1] - strain[0]);
809                    let slope34 = (stress[3] - stress[2]) / (strain[3] - strain[2]);
810                    stress[1] = avgforce - dfr;
811                    stress[2] = avgforce + dfr;
812                    strain[1] = strain[0] + (stress[1] - stress[0]) / slope12;
813                    strain[2] = strain[3] - (stress[3] - stress[2]) / slope34;
814                }
815            }
816        }
817    } else {
818        // Linear unload-reload path is expected.
819        let du = strain[3] - strain[0];
820        let df = stress[3] - stress[0];
821        strain[1] = strain[0] + 0.33 * du;
822        strain[2] = strain[0] + 0.67 * du;
823        stress[1] = stress[0] + 0.33 * df;
824        stress[2] = stress[0] + 0.67 * df;
825    }
826
827    final_check(strain, stress);
828}
829
830/// Ported from `Pinching4Material::getState4` — the mirror image of
831/// [`get_state3`]; see its doc comment. Note the extra `uForceP == 0.0`
832/// special case at the top, which `getState3` has no counterpart for: that
833/// asymmetry is in the OpenSees source.
834///
835/// `clippy::if_same_then_else` fires on that very special case (`u_force_p
836/// == 0.0` and `r_force_p - u_force_p > 1e-8` do the same thing), and is
837/// allowed rather than collapsed for the same reason `hysteretic.rs`
838/// allows it: the two conditions are genuinely different in the source,
839/// and merging them would obscure the line-by-line correspondence this
840/// whole cascade depends on for reviewability.
841#[allow(clippy::if_same_then_else)]
842fn get_state4(
843    strain: &mut [f64; 4],
844    stress: &mut [f64; 4],
845    kunload: f64,
846    t: &Trial,
847    p: &Pinching4Fields,
848) {
849    let kmax = if kunload > t.k_elastic_pos_damgd {
850        kunload
851    } else {
852        t.k_elastic_pos_damgd
853    };
854
855    if strain[0] * strain[3] < 0.0 {
856        // Trilinear unload/reload path expected.
857        strain[2] = t.hgh_strain * p.r_disp_p;
858        if p.u_force_p == 0.0 {
859            stress[2] = t.hgh_stress * p.r_force_p;
860        } else if p.r_force_p - p.u_force_p > 1e-8 {
861            stress[2] = t.hgh_stress * p.r_force_p;
862        } else if t.max_dmnd > p.envlp_pos_strain[3] {
863            let st1 = t.hgh_stress * p.u_force_p * (1.0 + 1e-6);
864            let st2 = t.pos_damgd_stress[4] * (1.0 + 1e-6);
865            stress[2] = if st1 > st2 { st1 } else { st2 };
866        } else {
867            let st1 = t.pos_damgd_stress[3] * p.u_force_p * (1.0 + 1e-6);
868            let st2 = t.pos_damgd_stress[4] * (1.0 + 1e-6);
869            stress[2] = if st1 > st2 { st1 } else { st2 };
870        }
871
872        // If the reload stiffness exceeds the unload stiffness, reduce it
873        // to match.
874        if (stress[3] - stress[2]) / (strain[3] - strain[2]) > t.k_elastic_pos_damgd {
875            strain[2] = t.hgh_strain - (stress[3] - stress[2]) / t.k_elastic_pos_damgd;
876        }
877
878        // Check that the reloading point is not behind point 1.
879        if strain[2] < strain[0] {
880            // Path taken to be a straight line between points 1 and 4.
881            let du = strain[3] - strain[0];
882            let df = stress[3] - stress[0];
883            strain[1] = strain[0] + 0.33 * du;
884            strain[2] = strain[0] + 0.67 * du;
885            stress[1] = stress[0] + 0.33 * df;
886            stress[2] = stress[0] + 0.67 * df;
887        } else {
888            if t.max_dmnd > p.envlp_pos_strain[3] {
889                stress[1] = p.u_force_p * t.pos_damgd_stress[4];
890            } else {
891                stress[1] = p.u_force_p * t.pos_damgd_stress[3];
892            }
893            strain[1] = t.low_strain + (-t.low_stress + stress[1]) / kunload;
894
895            if strain[1] < strain[0] {
896                // Point 2 should be along a line between 1 and 3.
897                let du = strain[2] - strain[0];
898                let df = stress[2] - stress[0];
899                strain[1] = strain[0] + 0.5 * du;
900                stress[1] = stress[0] + 0.5 * df;
901            } else if (stress[2] - stress[1]) / (strain[2] - strain[1]) > kmax {
902                // Linear unload-reload path expected.
903                let du = strain[3] - strain[0];
904                let df = stress[3] - stress[0];
905                strain[1] = strain[0] + 0.33 * du;
906                strain[2] = strain[0] + 0.67 * du;
907                stress[1] = stress[0] + 0.33 * df;
908                stress[2] = stress[0] + 0.67 * df;
909            } else if (strain[2] < strain[1])
910                || ((stress[2] - stress[1]) / (strain[2] - strain[1]) < 0.0)
911            {
912                if strain[1] > 0.0 {
913                    // Point 2 should be along a line between 1 and 3.
914                    let du = strain[2] - strain[0];
915                    let df = stress[2] - stress[0];
916                    strain[1] = strain[0] + 0.5 * du;
917                    stress[1] = stress[0] + 0.5 * df;
918                } else if strain[2] < 0.0 {
919                    // Point 2 should be along a line between 2 and 4.
920                    let du = strain[3] - strain[1];
921                    let df = stress[3] - stress[1];
922                    strain[2] = strain[1] + 0.5 * du;
923                    stress[2] = stress[1] + 0.5 * df;
924                } else {
925                    let avgforce = 0.5 * (stress[2] + stress[1]);
926                    let dfr = if avgforce < 0.0 {
927                        -avgforce / 100.0
928                    } else {
929                        avgforce / 100.0
930                    };
931                    let slope12 = (stress[1] - stress[0]) / (strain[1] - strain[0]);
932                    let slope34 = (stress[3] - stress[2]) / (strain[3] - strain[2]);
933                    stress[1] = avgforce - dfr;
934                    stress[2] = avgforce + dfr;
935                    strain[1] = strain[0] + (stress[1] - stress[0]) / slope12;
936                    strain[2] = strain[3] - (stress[3] - stress[2]) / slope34;
937                }
938            }
939        }
940    } else {
941        // Linear unload-reload path is expected.
942        let du = strain[3] - strain[0];
943        let df = stress[3] - stress[0];
944        strain[1] = strain[0] + 0.33 * du;
945        strain[2] = strain[0] + 0.67 * du;
946        stress[1] = stress[0] + 0.33 * df;
947        stress[2] = stress[0] + 0.67 * df;
948    }
949
950    final_check(strain, stress);
951}
952
953/// The identical "final check" tail shared verbatim by `getState3` and
954/// `getState4`: if any segment of the path runs backwards in strain or
955/// stress, collapse the whole path to the straight 33%/67% split; and —
956/// only reachable once that collapse has happened, since `slope` is
957/// otherwise left at its initial `0.0` — if the collapsed slope is
958/// positive but flatter than the secant to point 1, pin point 2 to the
959/// origin and put point 3 at the midpoint.
960///
961/// The `slope == 0.0` gating is an artifact of the C++ control flow rather
962/// than an obviously intended design, so it's preserved exactly: both the
963/// loop-local shadowing of `du`/`df` and the fact that the second `if` is
964/// evaluated on every iteration are load-bearing for which branch fires.
965fn final_check(strain: &mut [f64; 4], stress: &mut [f64; 4]) {
966    let check_slope = stress[0] / strain[0];
967    let mut slope = 0.0;
968
969    let mut i = 0;
970    while i < 3 {
971        let du = strain[i + 1] - strain[i];
972        let df = stress[i + 1] - stress[i];
973        if du < 0.0 || df < 0.0 {
974            let du = strain[3] - strain[0];
975            let df = stress[3] - stress[0];
976            strain[1] = strain[0] + 0.33 * du;
977            strain[2] = strain[0] + 0.67 * du;
978            stress[1] = stress[0] + 0.33 * df;
979            stress[2] = stress[0] + 0.67 * df;
980            slope = df / du;
981            i = 3;
982        }
983        if slope > 1e-8 && slope < check_slope {
984            strain[1] = 0.0;
985            stress[1] = 0.0;
986            strain[2] = strain[3] / 2.0;
987            stress[2] = stress[3] / 2.0;
988        }
989        i += 1;
990    }
991}
992
993/// `Pinching4Material::Envlp3Stress`/`Envlp4Stress` — identical bodies in
994/// the source, so shared here. Piecewise-interpolates the 4-point state-3/4
995/// path, extrapolating off the first or last segment when the strain falls
996/// outside it.
997fn envlp34_stress(strain: &[f64; 4], stress: &[f64; 4], u: f64) -> f64 {
998    let mut k = 0.0;
999    let mut f = 0.0;
1000    for i in 0..3 {
1001        if u >= strain[i] {
1002            k = (stress[i + 1] - stress[i]) / (strain[i + 1] - strain[i]);
1003            f = stress[i] + (u - strain[i]) * k;
1004        }
1005    }
1006    if k == 0.0 {
1007        let i = if u < strain[0] { 0 } else { 2 };
1008        k = (stress[i + 1] - stress[i]) / (strain[i + 1] - strain[i]);
1009        f = stress[i] + (u - strain[i]) * k;
1010    }
1011    f
1012}
1013
1014/// `Pinching4Material::Envlp3Tangent`/`Envlp4Tangent`; see
1015/// [`envlp34_stress`].
1016fn envlp34_tangent(strain: &[f64; 4], stress: &[f64; 4], u: f64) -> f64 {
1017    let mut k = 0.0;
1018    for i in 0..3 {
1019        if u >= strain[i] {
1020            k = (stress[i + 1] - stress[i]) / (strain[i + 1] - strain[i]);
1021        }
1022    }
1023    if k == 0.0 {
1024        let i = if u < strain[0] { 0 } else { 2 };
1025        k = (stress[i + 1] - stress[i]) / (strain[i + 1] - strain[i]);
1026    }
1027    k
1028}
1029
1030/// Ported from `Pinching4Material::updateDmg`. Runs unconditionally at the
1031/// end of every trial evaluation — *not* gated behind a state transition,
1032/// unlike `gamma_k_used`/`gamma_f_used`.
1033///
1034/// This produces the first of three damage "clocks": the values written
1035/// here become the *committed* `gamma_k`/`gamma_d`/`gamma_f` of the next
1036/// step, which in turn become `gamma_k_used`/`gamma_f_used` only if some
1037/// later step happens to transition state. Each damage factor combines a
1038/// ductility term (`umax_abs/uult_abs`) with either a dissipated-energy
1039/// ratio or a half-cycle count, then is clamped by its own limit and — for
1040/// the stiffness factor — by `gamma_k_lim_env`, an envelope-derived bound
1041/// that prevents damage from ever *increasing* the tangent.
1042fn update_dmg(
1043    t: &mut Trial,
1044    p: &Pinching4Fields,
1045    strain: f64,
1046    dstrain: f64,
1047    elastic_strain_energy: f64,
1048    c_n_cycle: f64,
1049) {
1050    let umax_abs = if t.max_dmnd > -t.min_dmnd {
1051        t.max_dmnd
1052    } else {
1053        -t.min_dmnd
1054    };
1055    let uult_abs = if p.envlp_pos_strain[4] > -p.envlp_neg_strain[4] {
1056        p.envlp_pos_strain[4]
1057    } else {
1058        -p.envlp_neg_strain[4]
1059    };
1060    t.n_cycle = c_n_cycle + dstrain.abs() / (4.0 * umax_abs);
1061
1062    let within = strain < uult_abs && strain > -uult_abs;
1063
1064    if within && t.energy < p.energy_capacity {
1065        t.gamma_k = p.gamma_k_params[0] * (umax_abs / uult_abs).powf(p.gamma_k_params[2]);
1066        t.gamma_d = p.gamma_d_params[0] * (umax_abs / uult_abs).powf(p.gamma_d_params[2]);
1067        t.gamma_f = p.gamma_f_params[0] * (umax_abs / uult_abs).powf(p.gamma_f_params[2]);
1068
1069        if t.energy > elastic_strain_energy && p.dmg_cyc == Pinching4DmgCyc::EnergyBased {
1070            let tes = (t.energy - elastic_strain_energy) / p.energy_capacity;
1071            t.gamma_k += p.gamma_k_params[1] * tes.powf(p.gamma_k_params[3]);
1072            t.gamma_d += p.gamma_d_params[1] * tes.powf(p.gamma_d_params[3]);
1073            t.gamma_f += p.gamma_f_params[1] * tes.powf(p.gamma_f_params[3]);
1074        } else if p.dmg_cyc == Pinching4DmgCyc::CycleBased {
1075            t.gamma_k += p.gamma_k_params[1] * t.n_cycle.powf(p.gamma_k_params[3]);
1076            t.gamma_d += p.gamma_d_params[1] * t.n_cycle.powf(p.gamma_d_params[3]);
1077            t.gamma_f += p.gamma_f_params[1] * t.n_cycle.powf(p.gamma_f_params[3]);
1078        }
1079
1080        let gamma_k_lim_env = gamma_k_lim_env(t, p);
1081        let k1 = if t.gamma_k < p.gamma_k_limit {
1082            t.gamma_k
1083        } else {
1084            p.gamma_k_limit
1085        };
1086        t.gamma_k = if k1 < gamma_k_lim_env {
1087            k1
1088        } else {
1089            gamma_k_lim_env
1090        };
1091        if t.gamma_d >= p.gamma_d_limit {
1092            t.gamma_d = p.gamma_d_limit;
1093        }
1094        if t.gamma_f >= p.gamma_f_limit {
1095            t.gamma_f = p.gamma_f_limit;
1096        }
1097    } else if within {
1098        let gamma_k_lim_env = gamma_k_lim_env(t, p);
1099        t.gamma_k = if p.gamma_k_limit < gamma_k_lim_env {
1100            p.gamma_k_limit
1101        } else {
1102            gamma_k_lim_env
1103        };
1104        t.gamma_d = p.gamma_d_limit;
1105        t.gamma_f = p.gamma_f_limit;
1106    }
1107}
1108
1109/// `updateDmg`'s `gammaKLimEnv`: how much the current peak-ductility-demand
1110/// point has already softened relative to the undamaged elastic stiffness,
1111/// which bounds how much *further* stiffness damage may be applied.
1112///
1113/// The two copies in the C++ differ only in `>` vs. `>=` when picking the
1114/// governing side — irrelevant (they pick numerically equal values on a
1115/// tie), so they're shared here.
1116fn gamma_k_lim_env(t: &Trial, p: &Pinching4Fields) -> f64 {
1117    let kmin_p = t.pos_envlp_stress(p, t.max_dmnd) / t.max_dmnd;
1118    let kmin_n = t.neg_envlp_stress(p, t.min_dmnd) / t.min_dmnd;
1119    let kmin = if (kmin_p / p.k_elastic_pos) > (kmin_n / p.k_elastic_neg) {
1120        kmin_p / p.k_elastic_pos
1121    } else {
1122        kmin_n / p.k_elastic_neg
1123    };
1124    if 0.0 > (1.0 - kmin) {
1125        0.0
1126    } else {
1127        1.0 - kmin
1128    }
1129}
1130
1131#[cfg(test)]
1132mod tests {
1133    use super::*;
1134
1135    /// A symmetric 4-point-per-side backbone shared by the tests below:
1136    /// positive points `(0.01, 10)`, `(0.02, 15)`, `(0.03, 17)`,
1137    /// `(0.04, 10)` — an elastic slope of 1000, then 500, then 200, then a
1138    /// softening -700 — with the negative side its exact mirror.
1139    ///
1140    /// All twelve damage parameters and all three limits are zero, so
1141    /// `gamma_k`/`gamma_d`/`gamma_f` stay identically zero for the whole
1142    /// test: the damaged envelope equals the undamaged one and
1143    /// `u_max_damgd`/`u_min_damgd` equal the raw strain demands. That's the
1144    /// same tactic `Hysteretic`'s `beta = 0` test material used — it keeps
1145    /// the state machine and the geometric cascade (the actual subjects of
1146    /// these tests) hand-computable.
1147    fn pinching4_test_material(r_disp: f64, r_force: f64, u_force: f64) -> Material {
1148        Material::pinching4(
1149            10.0,
1150            0.01,
1151            15.0,
1152            0.02,
1153            17.0,
1154            0.03,
1155            10.0,
1156            0.04,
1157            -10.0,
1158            -0.01,
1159            -15.0,
1160            -0.02,
1161            -17.0,
1162            -0.03,
1163            -10.0,
1164            -0.04,
1165            r_disp,
1166            r_force,
1167            u_force,
1168            r_disp,
1169            r_force,
1170            u_force,
1171            [0.0; 4],
1172            0.0,
1173            [0.0; 4],
1174            0.0,
1175            [0.0; 4],
1176            0.0,
1177            10.0,
1178            Pinching4DmgCyc::EnergyBased,
1179        )
1180    }
1181
1182    /// Test 1: state 0 (`NearOrigin`) gives the synthesized near-origin
1183    /// point's slope exactly. `SetEnvelope` puts that point at
1184    /// `u = 1e-4*strain1p` with stress `u*k`, `k = max(stress1p/strain1p,
1185    /// stress1n/strain1n) = 1000`, so the state-0 tangent is exactly 1000 —
1186    /// the same slope as the first real backbone segment, but arrived at
1187    /// through an entirely different code path.
1188    ///
1189    /// "Exactly" up to one ulp: the tangent is recovered as
1190    /// `(u*k)/u` rather than as `k` itself, which for `u = 1e-6` lands on
1191    /// `1000.0000000000001`. Reconstructing `k` directly would be a
1192    /// deviation from the source, so the test carries the tolerance
1193    /// instead.
1194    #[test]
1195    fn pinching4_gives_exact_initial_tangent_at_zero_strain() {
1196        let m = pinching4_test_material(0.2, 0.4, 0.05);
1197        let (stress, tangent) = m.trial_stress_tangent(0.0);
1198        assert_eq!(stress, 0.0);
1199        assert!((tangent - 1000.0).abs() < 1e-9, "got {tangent}");
1200        assert_eq!(m.initial_tangent(), tangent);
1201    }
1202
1203    /// Test 2: state 1 rides the 6-point positive envelope. Checks every
1204    /// real breakpoint (1-4), a midpoint of every real segment, and the
1205    /// extrapolated point-5 segment beyond `strain4p`.
1206    ///
1207    /// Note the point-0/point-1 segment: it runs from `(1e-6, 1e-3)` to
1208    /// `(0.01, 10)`, i.e. slope exactly 1000 and therefore
1209    /// indistinguishable from a straight elastic line through the origin —
1210    /// which is the whole point of the synthesized point.
1211    #[test]
1212    fn pinching4_matches_the_six_point_envelope_on_first_loading() {
1213        // One commit gets us out of state 0 and onto the positive
1214        // envelope; every trial below then stays in state 1 (all are
1215        // inside the state's `[1e-6, 4e4]` bracket, and all are increasing
1216        // so the strain rate never flips sign).
1217        let m = pinching4_test_material(0.2, 0.4, 0.05).commit(0.005);
1218
1219        let close = |got: (f64, f64), want: (f64, f64)| {
1220            assert!(
1221                (got.0 - want.0).abs() < 1e-9,
1222                "stress: got {}, want {}",
1223                got.0,
1224                want.0
1225            );
1226            assert!(
1227                (got.1 - want.1).abs() < 1e-9,
1228                "tangent: got {}, want {}",
1229                got.1,
1230                want.1
1231            );
1232        };
1233
1234        close(m.trial_stress_tangent(0.005), (5.0, 1000.0));
1235        close(m.trial_stress_tangent(0.01), (10.0, 1000.0)); // breakpoint 1
1236        close(m.trial_stress_tangent(0.015), (12.5, 500.0));
1237        close(m.trial_stress_tangent(0.02), (15.0, 500.0)); // breakpoint 2
1238        close(m.trial_stress_tangent(0.025), (16.0, 200.0));
1239        close(m.trial_stress_tangent(0.03), (17.0, 200.0)); // breakpoint 3
1240        close(m.trial_stress_tangent(0.035), (13.5, -700.0)); // softening
1241        close(m.trial_stress_tangent(0.04), (10.0, -700.0)); // breakpoint 4
1242
1243        // Past breakpoint 4, the extrapolated point 5 takes over: since the
1244        // 3-4 segment is softening (k1 < 0), `SetEnvelope` puts point 5 at
1245        // `(1e6*strain4p, 1.1*stress4p) = (4e4, 11)` — a nearly flat
1246        // segment, not a continuation of the -700 softening slope.
1247        let k5 = (11.0 - 10.0) / (40000.0 - 0.04);
1248        close(m.trial_stress_tangent(0.05), (10.0 + 0.01 * k5, k5));
1249    }
1250
1251    /// Test 3: a full reversal off the positive envelope into state 3,
1252    /// checked against a hand-walked `get_state3` trace that takes the
1253    /// *clean* trilinear path — no degenerate-geometry fallback fires.
1254    ///
1255    /// `r_disp <= r_force` (0.2 vs. 0.4) is what keeps the reload-stiffness
1256    /// cap from engaging: the secant from point 1 to point 2 comes out at
1257    /// `1000*(1-r_force)/(1-r_disp) = 750`, under the elastic 1000.
1258    ///
1259    /// Hand trace, after committing 0.005 then 0.02 (so `Cstrain = 0.02`,
1260    /// `Cstress = 15`, `max_strain_dmnd` still at its initial `0.01`):
1261    /// reversing to -0.005 sets `max_dmnd = 0.02`, and since -0.005 is not
1262    /// past `u_min_damgd = -0.01`, the new state is 3 with bracket
1263    /// `low = (-0.01, negEnvlpStress(-0.01) = -10)` and
1264    /// `hgh = (0.02, 15)`. Then:
1265    ///   point 1 = `(-0.01*0.2, -10*0.4) = (-0.002, -4)`;
1266    ///   point 2 = stress `u_force*envlp_neg[3] = 0.05*-17 = -0.85`,
1267    ///     strain `0.02 - (15+0.85)/1000 = 0.00415`.
1268    /// Both survive every check, so the path is
1269    /// `(-0.01,-10) -> (-0.002,-4) -> (0.00415,-0.85) -> (0.02,15)`.
1270    #[test]
1271    fn pinching4_reloads_along_a_clean_trilinear_path_after_a_reversal() {
1272        let m = pinching4_test_material(0.2, 0.4, 0.05)
1273            .commit(0.005)
1274            .commit(0.02);
1275
1276        // On the first segment of the path: slope (-4+10)/(-0.002+0.01) =
1277        // 750, so stress = -10 + 0.005*750 = -6.25.
1278        let (stress, tangent) = m.trial_stress_tangent(-0.005);
1279        assert!((stress - -6.25).abs() < 1e-9, "got {stress}");
1280        assert!((tangent - 750.0).abs() < 1e-9, "got {tangent}");
1281
1282        // On the second (pinched) segment, at zero strain: slope
1283        // (-0.85+4)/(0.00415+0.002) = 3.15/0.00615, so stress =
1284        // -4 + 0.002*that.
1285        let k = 3.15 / 0.00615;
1286        let (stress, tangent) = m.trial_stress_tangent(0.0);
1287        assert!((stress - (-4.0 + 0.002 * k)).abs() < 1e-9, "got {stress}");
1288        assert!((tangent - k).abs() < 1e-9, "got {tangent}");
1289        assert!(
1290            tangent < 750.0,
1291            "the pinched segment should be softer than the reload segment, got {tangent}"
1292        );
1293    }
1294
1295    /// Test 4: the same reversal, but with pinching parameters chosen to
1296    /// drive one of `get_state3`'s degenerate-geometry fallbacks — the
1297    /// "linear unload-reload path expected" branch, taken when the
1298    /// constructed segment from point 1 to point 2 is stiffer than `kmax`
1299    /// (the greater of the unload stiffness and the damaged negative
1300    /// elastic stiffness, both 1000 here).
1301    ///
1302    /// `r_disp = 0.05` pulls point 1 in close to the origin while
1303    /// `r_force = 0.9` keeps its stress near the full envelope value, so
1304    /// the 1-to-2 secant comes out at `(−0.85+9)/(0.00415+0.0005) ≈ 1753`,
1305    /// over `kmax`. The whole path is then discarded and redrawn as the
1306    /// straight 33%/67% split between points 0 and 3:
1307    /// `(-0.01,-10) -> (-0.0001,-1.75) -> (0.0101, 6.75) -> (0.02, 15)`.
1308    #[test]
1309    fn pinching4_falls_back_to_a_linear_path_when_the_reload_segment_is_too_stiff() {
1310        let m = pinching4_test_material(0.05, 0.9, 0.05)
1311            .commit(0.005)
1312            .commit(0.02);
1313
1314        // First segment of the redrawn path: from (-0.01,-10) to
1315        // (-0.0001,-1.75), slope 8.25/0.0099 = 2500/3.
1316        let k = 8.25 / 0.0099;
1317        let (stress, tangent) = m.trial_stress_tangent(-0.005);
1318        assert!((stress - (-10.0 + 0.005 * k)).abs() < 1e-9, "got {stress}");
1319        assert!((tangent - k).abs() < 1e-9, "got {tangent}");
1320        assert!((stress - -5.833333333333333).abs() < 1e-9, "got {stress}");
1321
1322        // The fallback is genuinely a different path, not a relabelling of
1323        // the trilinear one: the un-fallen-back construction would have put
1324        // point 1 at (-0.0005, -9), a first-segment slope of 1/0.0095 ≈ 105
1325        // rather than ~833.
1326        assert!(
1327            (tangent - 1.0 / 0.0095).abs() > 1.0,
1328            "expected the fallback path, not the raw trilinear construction"
1329        );
1330    }
1331
1332    /// The `Trilinear3 -> Trilinear4` transition, i.e. reversing *again*
1333    /// mid-reload. Exercises the one `getstate` branch that both refreshes
1334    /// the positive damaged envelope and rebuilds the bracket from the
1335    /// committed point, and confirms the resulting state-4 path is anchored
1336    /// at that reversal point.
1337    #[test]
1338    fn pinching4_reverses_from_state_three_into_state_four() {
1339        let m = pinching4_test_material(0.2, 0.4, 0.05)
1340            .commit(0.005)
1341            .commit(0.02)
1342            .commit(-0.005)
1343            .commit(0.0);
1344        let Material::Pinching4(f) = &m else { panic!() };
1345        assert_eq!(f.state, Pinching4State::Trilinear4);
1346        // The state-4 bracket runs from the reversal point (the previous
1347        // committed state, -0.005) up to `u_max_damgd` (0.02, the peak
1348        // strain demand, undamaged since gamma_d is zero).
1349        assert!((f.low_state_strain - -0.005).abs() < 1e-12);
1350        assert!((f.hgh_state_strain - 0.02).abs() < 1e-12);
1351        // ...anchored at the stress the state-3 path had reached there
1352        // (-6.25, the value test 3 hand-derived).
1353        assert!(
1354            (f.low_state_stress - -6.25).abs() < 1e-9,
1355            "got {}",
1356            f.low_state_stress
1357        );
1358        // ...and at the *damaged* positive envelope stress at that peak
1359        // demand, 15 (undamaged here). Evaluating at the top of the
1360        // bracket walks the whole state-4 path and lands exactly there.
1361        assert!(
1362            (f.hgh_state_stress - 15.0).abs() < 1e-9,
1363            "got {}",
1364            f.hgh_state_stress
1365        );
1366        let (stress, _) = m.trial_stress_tangent(0.02);
1367        assert!((stress - 15.0).abs() < 1e-9, "got {stress}");
1368    }
1369}