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}