Skip to main content

carapace_core/model/materials/
mod.rs

1/// Uniaxial material catalog. Closed enum, not a trait object — the set is fixed at compile time.
2///
3/// # Trial vs. commit
4///
5/// A `Material` value holds only *committed* state (e.g. `ElasticPP`'s
6/// plastic strain `ep`) — there is no OpenSees-style parallel `T`/`C`
7/// field pair. Instead there are two pure (`&self`, non-mutating) views
8/// over that committed state:
9///
10/// - [`trial_stress_tangent`](Material::trial_stress_tangent) — called
11///   every Newton iteration, as many times as needed, always relative to
12///   the same fixed committed state. Since it can't mutate anything,
13///   there's no way for one iteration's (possibly discarded) trial to leak
14///   into the next — a real correctness requirement for path-dependent
15///   materials, not just a convenience: `setTrialStrain` in OpenSees is
16///   `&mut self`, but its `T*` fields are always recomputed from the `C*`
17///   fields, i.e. it's *this* pure-function property, not the mutation
18///   itself, that OpenSees's parallel field pair exists to guarantee.
19/// - [`commit`](Material::commit) — called once, only after a step's
20///   Newton iteration actually converges (or, for `Algorithm::Linear`,
21///   after its one unconditional solve), producing the new committed
22///   `Material`. `Domain::commit` is what calls this and writes the
23///   result back into the model; nothing else ever mutates a `Material`.
24///
25/// This gets rollback "for free": since nothing is mutated until
26/// `commit`, a step that fails partway through a Newton loop has left no
27/// material in an inconsistent state to revert — only nodal displacement
28/// (mutated eagerly every iteration, which is correct Newton behavior)
29/// needs `Analysis`/`TransientAnalysis`'s snapshot-and-restore-on-failure.
30///
31/// # Porting a new leaf material from OpenSees/Xara
32///
33/// OpenSees implements each `UniaxialMaterial` as `setTrialStrain`/
34/// `determineTrialState` mutating parallel `T*`/`C*` field pairs. None of
35/// that duplication is needed here — every port becomes a pure
36/// `fn evaluate(&self, strain: f64) -> (stress, tangent, next_self)` where
37/// `self` is always the committed state:
38///
39/// 1. Every place the C++ reads a `C*` field, read `self`'s corresponding
40///    field. Every place it reads/writes a `T*` field, use a local variable
41///    (never write to `self` — `evaluate` takes `&self`).
42/// 2. Watch for places the algorithm reads `Cstrain`/`Cstress` directly, not
43///    just the "obvious" history variables (e.g. `Cloading`-style fields) —
44///    `Steel01`/`Concrete01` both need last-committed strain and stress as
45///    explicit fields, and it's easy to miss if you only skim the header's
46///    "History Variables" section (OpenSees files `Cstrain`/`Cstress`
47///    separately under "State Variables", but they're just as load-bearing).
48/// 3. The function's return is `(Tstress, Ttangent, Self { ..committed
49///    fields updated.. })`; `trial_stress_tangent` discards the third
50///    element, `commit` discards the first two.
51/// 4. Port the *math* faithfully (published, referenced research — see each
52///    source `.cpp`'s `// References` comment), but the *state-machine
53///    mechanics* (trial/commit, no field duplication) always follow the
54///    recipe above, not the C++ structure.
55///
56/// Once a variant's field count gets large (`HystereticFields`,
57/// `Pinching4Fields`), box the payload as its own `Copy` struct instead of
58/// inlining it into `Material` — otherwise every `Material` value pays the
59/// largest variant's stack size, not just values that are actually that
60/// variant (`clippy::large_enum_variant` is the trip-wire for this).
61///
62/// `Material` is `Clone`, not `Copy`: `Parallel`/`Series`/`MinMax` hold a
63/// `Vec`/`Box` of child materials, so every call site that used to rely on
64/// an implicit copy (array-repeat initializers, `match *self`, ...) needs
65/// an explicit `.clone()` instead.
66mod composite;
67mod concrete01;
68mod concrete02;
69mod elastic_like;
70mod hysteretic;
71mod pinching4;
72mod plane;
73mod shell_section;
74mod steel01;
75mod steel02;
76
77pub use hysteretic::HystereticFields;
78pub use pinching4::{Pinching4DmgCyc, Pinching4Fields, Pinching4State};
79pub use plane::{PlaneMaterial, PlaneMaterialError, PlaneMatrix, PlaneVector};
80pub use shell_section::{ShellMatrix, ShellSection, ShellSectionError, ShellVector};
81pub use steel01::Steel01Loading;
82pub use steel02::Steel02Kon;
83
84#[derive(Debug, Clone)]
85pub enum Material {
86    Elastic {
87        e: f64,
88    },
89    /// Elastic-perfectly-plastic, with real permanent-set tracking via the
90    /// committed plastic strain `ep` — standard 1D return-mapping
91    /// plasticity. Construct via [`Material::elastic_pp`], not the struct
92    /// literal: `ep` is internal history, not a material parameter, and
93    /// must start at zero.
94    ElasticPP {
95        e: f64,
96        eyp: f64,
97        ep: f64,
98    },
99    /// One-sided gap: zero stiffness until the strain closes the gap
100    /// (`strain + gap < 0`, i.e. compressive strain past the opening), then
101    /// linear elastic. Stateless — a genuine function of current strain
102    /// only (no permanent set to track), unlike `ElasticPP`.
103    Gap {
104        e: f64,
105        gap: f64,
106    },
107    /// "No tension": zero stiffness in tension (`strain >= 0`), linear
108    /// elastic in compression. Equivalent to `Gap { e, gap: 0.0 }`, kept as
109    /// its own variant since OpenSees exposes it separately and it's the
110    /// simplest possible nonlinear material (useful as the base case for
111    /// element wiring tests). Stateless, same reasoning as `Gap`.
112    Ent {
113        e: f64,
114    },
115    /// Bilinear kinematic hardening with isotropic hardening on load
116    /// reversal (Filippou 1983), ported from OpenSees' `Steel01`. Construct
117    /// via [`Material::steel01`].
118    ///
119    /// `min_strain`/`max_strain` track the largest excursion ever committed
120    /// in each direction (used only to update `shift_p`/`shift_n` on a
121    /// reversal, so they lag one reversal behind, exactly like OpenSees'
122    /// `CminStrain`/`CmaxStrain`); `shift_p`/`shift_n` are the isotropic
123    /// hardening shifts applied to the bounding lines; `strain`/`stress`
124    /// are the last committed point (needed because the bounding formula
125    /// `c = Cstress + E0*dStrain` references it directly, not just the
126    /// "obvious" history fields).
127    Steel01 {
128        fy: f64,
129        e0: f64,
130        b: f64,
131        a1: f64,
132        a2: f64,
133        a3: f64,
134        a4: f64,
135        min_strain: f64,
136        max_strain: f64,
137        shift_p: f64,
138        shift_n: f64,
139        loading: Steel01Loading,
140        strain: f64,
141        stress: f64,
142        /// Committed tangent (OpenSees' `Ctangent`). A trial at the
143        /// committed strain returns it unchanged, so a displacement-control
144        /// predictor sees the hardening tangent of a yielded fiber, not `e0`.
145        tangent: f64,
146    },
147    /// Kent-Scott-Park compression envelope with Karsan-Jirsa degrading
148    /// unload/reload and zero tensile strength, ported from OpenSees'
149    /// `Concrete01`. Construct via [`Material::concrete01`] — `fpc`,
150    /// `epsc0`, `fpcu`, `epscu` are normalized negative (compression) at
151    /// construction, matching the OpenSees convention.
152    ///
153    /// `min_strain` is the most negative strain ever committed (drives the
154    /// envelope); `end_strain` is where the current unload/reload line
155    /// would cross zero stress (needed by `reload`'s branch to tell
156    /// whether a trial strain is still on that line or must re-enter the
157    /// envelope); `unload_slope` is that line's slope; `strain`/`stress`
158    /// are the last committed point (same reason as `Steel01` above).
159    Concrete01 {
160        fpc: f64,
161        epsc0: f64,
162        fpcu: f64,
163        epscu: f64,
164        min_strain: f64,
165        end_strain: f64,
166        unload_slope: f64,
167        strain: f64,
168        stress: f64,
169        /// Committed tangent (OpenSees' `Ctangent`), returned unchanged for
170        /// a trial at the committed strain.
171        tangent: f64,
172    },
173    /// Menegotto-Pinto transition curve with Filippou isotropic hardening,
174    /// ported from OpenSees' `Steel02`. Unlike `Steel01`'s piecewise-linear
175    /// bilinear rule, the loading/unloading branches are smooth curved
176    /// asymptotes to the elastic and strain-hardening lines (parameters
177    /// `r0`/`cr1`/`cr2` control the transition sharpness). Construct via
178    /// [`Material::steel02`] — `sigini` (OpenSees' optional initial-stress
179    /// offset) is not supported, matching `Material::steel01`'s scope.
180    ///
181    /// `min_strain`/`max_strain` are the peak strain reached on each side so
182    /// far (`Steel02`'s `epsmaxP`/`epsminP` — note the reversed initial
183    /// values vs. `Steel01`: `Steel02` seeds them to `±fy/e0`, not `0.0`, so
184    /// the very first reversal has a nonzero isotropic-hardening baseline);
185    /// `pl_strain`/`asymptote_strain`/`asymptote_stress` are the strain at
186    /// the last reversal's plastic-excursion bound and the strain/stress
187    /// where the current curve's two asymptotes (elastic and
188    /// strain-hardening) intersect (`Steel02`'s `epsplP`/`epss0P`/`sigs0P`);
189    /// `reversal_strain`/`reversal_stress` are the point of the last load
190    /// reversal (`epssrP`/`sigsrP`) — the curve's other anchor. `kon`
191    /// mirrors `Steel02`'s `konP`, minus its `sigini`-only pinned state.
192    Steel02 {
193        fy: f64,
194        e0: f64,
195        b: f64,
196        r0: f64,
197        cr1: f64,
198        cr2: f64,
199        a1: f64,
200        a2: f64,
201        a3: f64,
202        a4: f64,
203        min_strain: f64,
204        max_strain: f64,
205        pl_strain: f64,
206        asymptote_strain: f64,
207        asymptote_stress: f64,
208        reversal_strain: f64,
209        reversal_stress: f64,
210        kon: Steel02Kon,
211        strain: f64,
212        stress: f64,
213    },
214    /// `Concrete01`'s Kent-Scott-Park compression envelope plus linear
215    /// tension softening and a stiffer reloading rule, ported from
216    /// OpenSees' `Concrete02`. Construct via [`Material::concrete02`] —
217    /// `fc`/`epsc0`/`fcu`/`epscu` are normalized negative, same as
218    /// `Concrete01`.
219    ///
220    /// `min_strain` is the most negative strain ever committed (`Concrete02`'s
221    /// `ecminP`, same role as `Concrete01`'s `min_strain`); `tension_strain`
222    /// is the peak tensile-excursion strain beyond the unload line's
223    /// zero-stress crossing (`Concrete02`'s `deptP` — the tension-side
224    /// analogue of `min_strain`, with no `Concrete01` equivalent since
225    /// `Concrete01` carries no tension branch at all).
226    Concrete02 {
227        fc: f64,
228        epsc0: f64,
229        fcu: f64,
230        epscu: f64,
231        rat: f64,
232        ft: f64,
233        ets: f64,
234        min_strain: f64,
235        tension_strain: f64,
236        strain: f64,
237        stress: f64,
238        /// Committed tangent (OpenSees' `eP`), returned unchanged for a
239        /// trial at the committed strain.
240        tangent: f64,
241    },
242    /// Multi-linear (up to 3 points per side) backbone with pinching,
243    /// deformation/energy-based damage, and ductility-degraded unloading
244    /// stiffness, ported from OpenSees' `HystereticMaterial`. Construct via
245    /// [`Material::hysteretic`].
246    ///
247    /// Boxed (`HystereticFields` bundles ~30 fields — see its own doc
248    /// comment) rather than inlined into the enum like every other leaf
249    /// variant: `Material`'s size is its largest variant's size, and
250    /// without boxing this one, *every* `Material` — including the huge
251    /// majority that are a plain `Elastic { e: f64 }` — would pay for the
252    /// most field-heavy variant's stack space, everywhere a `Material`
253    /// is stored (`Fiber`, `ZeroLength`'s per-DOF array, every composite's
254    /// `Vec<Material>`). `Pinching4` (stage 4's other material) boxes the
255    /// same way, for the same reason.
256    Hysteretic(Box<HystereticFields>),
257    /// Four-point-per-side backbone with a pinched trilinear unload/reload
258    /// path and three independent cyclic-damage rules (stiffness, strength,
259    /// and deformation-demand degradation), ported from OpenSees'
260    /// `Pinching4Material`. Construct via [`Material::pinching4`].
261    ///
262    /// The most involved material in this catalog: a genuine 5-state
263    /// machine (see [`Pinching4State`]) rather than the
264    /// positive/negative/interior split every other path-dependent variant
265    /// here uses, driven by an explicit strain bracket per state rather
266    /// than re-derived thresholds.
267    ///
268    /// Boxed for the same reason `Hysteretic` is (see above) — this one is
269    /// larger still (~60 fields, including four 6-element envelope arrays).
270    Pinching4(Box<Pinching4Fields>),
271    /// Composite: every child sees the same strain, stress/tangent are the
272    /// factor-weighted sum. Ported from OpenSees' `ParallelMaterial` — the
273    /// factor defaults to `1.0` (see [`Material::parallel`]) but is kept
274    /// explicit per OpenSees' API.
275    Parallel(Vec<(Material, f64)>),
276    /// Composite: every child sees the same *stress*, total strain is the
277    /// sum of the children's individual strains. Not directly invertible
278    /// (unlike `Parallel`), so `evaluate` runs its own small internal
279    /// flexibility-based Newton loop, entirely self-contained within one
280    /// call — see the type's doc for why this
281    /// doesn't violate the trial/commit purity rule. `child_strains`
282    /// is the last converged per-child strain split, kept explicitly
283    /// (mirroring OpenSees' own `SeriesMaterial::strain[]` array) since not
284    /// every leaf material tracks its own committed strain. Construct via
285    /// [`Material::series`].
286    Series {
287        children: Vec<Material>,
288        child_strains: Vec<f64>,
289        stress: f64,
290        max_iter: usize,
291        tol: f64,
292    },
293    /// Wraps one material with strain bounds: once a committed strain
294    /// exits `[min_strain, max_strain]`, `failed` becomes (and permanently
295    /// stays) `true` — stress reads as `0.0`, tangent as `1e-8 *
296    /// inner.initial_tangent()` (not exactly zero, to avoid handing back a
297    /// structurally singular row/column — see OpenSees'
298    /// `MinMaxMaterial::getTangent`). Once failed, the inner material's
299    /// state is frozen (no further trial/commit reaches it), matching
300    /// OpenSees' `Cfailed` short-circuit. Construct via
301    /// [`Material::min_max`].
302    MinMax {
303        inner: Box<Material>,
304        min_strain: f64,
305        max_strain: f64,
306        failed: bool,
307    },
308}
309
310impl Material {
311    /// `(stress, tangent)` for a trial evaluation at `strain` — read-only,
312    /// safe to call any number of times from any Newton iteration. See the
313    /// type-level doc comment for why this must never mutate `self`.
314    pub fn trial_stress_tangent(&self, strain: f64) -> (f64, f64) {
315        let (stress, tangent, _next) = self.evaluate(strain);
316        (stress, tangent)
317    }
318
319    /// The new committed `Material` if `strain` were accepted as the
320    /// converged state. Called once per converged step, by `Domain::commit`
321    /// — see the type-level doc comment.
322    pub fn commit(&self, strain: f64) -> Material {
323        let (_stress, _tangent, next) = self.evaluate(strain);
324        next
325    }
326
327    /// The tangent modulus at zero history — OpenSees' `getInitialTangent`.
328    /// Only needed by `MinMax` (for its post-failure tangent) and the
329    /// composites (to propagate it to a wrapping `MinMax`).
330    pub fn initial_tangent(&self) -> f64 {
331        match self {
332            Material::Elastic { e } => *e,
333            Material::ElasticPP { e, .. } => *e,
334            Material::Gap { e, .. } => *e,
335            Material::Ent { e } => *e,
336            Material::Steel01 { e0, .. } => *e0,
337            Material::Concrete01 { fpc, epsc0, .. } => 2.0 * fpc / epsc0,
338            Material::Steel02 { e0, .. } => *e0,
339            Material::Concrete02 { fc, epsc0, .. } => 2.0 * fc / epsc0,
340            Material::Hysteretic(h) => h.e1p,
341            // `Pinching4Material::getInitialTangent` — the slope of the
342            // synthesized near-origin envelope point, i.e. the state-0
343            // slope, not `stress1p/strain1p` directly (they're equal only
344            // when the positive side is the stiffer of the two).
345            Material::Pinching4(p) => p.envlp_pos_stress[0] / p.envlp_pos_strain[0],
346            Material::Parallel(children) => {
347                children.iter().map(|(m, f)| f * m.initial_tangent()).sum()
348            }
349            Material::Series { children, .. } => {
350                let total_flex: f64 = children.iter().map(|m| 1.0 / m.initial_tangent()).sum();
351                if total_flex.abs() > 1e-12 {
352                    1.0 / total_flex
353                } else {
354                    0.0
355                }
356            }
357            Material::MinMax { inner, .. } => inner.initial_tangent(),
358        }
359    }
360
361    /// Shared core: `(stress, tangent, next_committed_state)`. Leaf
362    /// materials without history (`Elastic`, `Gap`, `Ent`) return `self`'s
363    /// own fields unchanged as `next_committed_state` — committing them is
364    /// a no-op, correctly, since they have nothing to remember.
365    pub(crate) fn evaluate(&self, strain: f64) -> (f64, f64, Material) {
366        match self {
367            Material::Elastic { e } => elastic_like::evaluate_elastic(*e, strain),
368            Material::ElasticPP { e, eyp, ep } => {
369                elastic_like::evaluate_elastic_pp(*e, *eyp, *ep, strain)
370            }
371            Material::Gap { e, gap } => elastic_like::evaluate_gap(*e, *gap, strain),
372            Material::Ent { e } => elastic_like::evaluate_ent(*e, strain),
373            Material::Steel01 { .. } => steel01::evaluate(self, strain),
374            Material::Concrete01 { .. } => concrete01::evaluate(self, strain),
375            Material::Steel02 { .. } => steel02::evaluate(self, strain),
376            Material::Concrete02 { .. } => concrete02::evaluate(self, strain),
377            Material::Hysteretic(_) => hysteretic::evaluate(self, strain),
378            Material::Pinching4(_) => pinching4::evaluate(self, strain),
379            Material::Parallel(children) => composite::evaluate_parallel(children, strain),
380            Material::Series { .. } => composite::evaluate_series(self, strain),
381            Material::MinMax {
382                inner,
383                min_strain,
384                max_strain,
385                failed,
386            } => composite::evaluate_min_max(inner, *min_strain, *max_strain, *failed, strain),
387        }
388    }
389}