Skip to main content

carapace_core/model/materials/
composite.rs

1use super::Material;
2
3impl Material {
4    /// Equal-factor (`1.0` each) parallel composite. For custom factors,
5    /// build the `Material::Parallel(Vec<(Material, f64)>)` variant
6    /// directly.
7    pub fn parallel(materials: Vec<Material>) -> Self {
8        Material::Parallel(materials.into_iter().map(|m| (m, 1.0)).collect())
9    }
10
11    /// Series composite with OpenSees' default tolerance (`1e-10`) and a
12    /// generous internal iteration cap (`30`) — since, unlike OpenSees'
13    /// `SeriesMaterial`, `evaluate` can't lean on many external Newton
14    /// calls to slowly converge a warm-started trial state (see the type's
15    /// doc), the internal loop needs to reach convergence by itself.
16    pub fn series(children: Vec<Material>) -> Self {
17        let n = children.len();
18        Material::Series {
19            children,
20            child_strains: vec![0.0; n],
21            stress: 0.0,
22            max_iter: 30,
23            tol: 1e-10,
24        }
25    }
26
27    pub fn min_max(inner: Material, min_strain: f64, max_strain: f64) -> Self {
28        Material::MinMax {
29            inner: Box::new(inner),
30            min_strain,
31            max_strain,
32            failed: false,
33        }
34    }
35}
36
37pub(super) fn evaluate_parallel(children: &[(Material, f64)], strain: f64) -> (f64, f64, Material) {
38    let mut stress = 0.0;
39    let mut tangent = 0.0;
40    let mut next_children = Vec::with_capacity(children.len());
41    for (child, factor) in children {
42        let (s, t, next) = child.evaluate(strain);
43        stress += factor * s;
44        tangent += factor * t;
45        next_children.push((next, *factor));
46    }
47    (stress, tangent, Material::Parallel(next_children))
48}
49
50pub(super) fn evaluate_min_max(
51    inner: &Material,
52    min_strain: f64,
53    max_strain: f64,
54    failed: bool,
55    strain: f64,
56) -> (f64, f64, Material) {
57    if failed || strain <= min_strain || strain >= max_strain {
58        let tangent = 1.0e-8 * inner.initial_tangent();
59        (
60            0.0,
61            tangent,
62            Material::MinMax {
63                inner: Box::new(inner.clone()),
64                min_strain,
65                max_strain,
66                failed: true,
67            },
68        )
69    } else {
70        let (s, t, next_inner) = inner.evaluate(strain);
71        (
72            s,
73            t,
74            Material::MinMax {
75                inner: Box::new(next_inner),
76                min_strain,
77                max_strain,
78                failed: false,
79            },
80        )
81    }
82}
83
84/// Ported from `SeriesMaterial::setTrialStrain` (`SeriesMaterial.cpp`)
85/// — flexibility-based iteration, but self-contained: applies the
86/// stress correction `dq` every inner iteration (a proper Newton
87/// update) rather than only once after the loop, since — unlike
88/// OpenSees, which carries `Tstress`/`Ttangent` across many external
89/// `setTrialStrain` calls to slowly converge — this must converge
90/// within one `evaluate` call. See the type's doc comment.
91pub(super) fn evaluate_series(m: &Material, total_strain: f64) -> (f64, f64, Material) {
92    let Material::Series {
93        children,
94        child_strains,
95        stress: cstress,
96        max_iter,
97        tol,
98    } = m
99    else {
100        unreachable!()
101    };
102
103    let n = children.len();
104    let mut e = child_strains.clone();
105    let mut child_stress = vec![0.0; n];
106    let mut flex = vec![0.0; n];
107    let mut tstress = *cstress;
108    let mut series_tangent = 0.0;
109
110    let safe_inv = |t: f64| {
111        if t.abs() > 1.0e-12 {
112            1.0 / t
113        } else if t < 0.0 {
114            -1.0e12
115        } else {
116            1.0e12
117        }
118    };
119
120    for _ in 0..*max_iter {
121        let mut f = 0.0;
122        let mut vr = 0.0;
123        for i in 0..n {
124            let ds = tstress - child_stress[i];
125            e[i] += flex[i] * ds;
126            let (s, t) = children[i].trial_stress_tangent(e[i]);
127            child_stress[i] = s;
128            flex[i] = safe_inv(t);
129            let ds = tstress - child_stress[i];
130            let de = flex[i] * ds;
131            f += flex[i];
132            vr += e[i] + de;
133        }
134        series_tangent = safe_inv(f);
135        let dv = total_strain - vr;
136        let dq = series_tangent * dv;
137        tstress += dq;
138        if (dq * dv).abs() < *tol {
139            break;
140        }
141    }
142
143    let next_children = children
144        .iter()
145        .zip(e.iter())
146        .map(|(child, &strain)| child.evaluate(strain).2)
147        .collect();
148
149    (
150        tstress,
151        series_tangent,
152        Material::Series {
153            children: next_children,
154            child_strains: e,
155            stress: tstress,
156            max_iter: *max_iter,
157            tol: *tol,
158        },
159    )
160}
161
162#[cfg(test)]
163mod tests {
164    use super::*;
165
166    #[test]
167    fn parallel_of_two_elastics_equals_one_elastic_with_summed_stiffness() {
168        let combined = Material::parallel(vec![
169            Material::Elastic { e: 100.0 },
170            Material::Elastic { e: 50.0 },
171        ]);
172        let single = Material::Elastic { e: 150.0 };
173        assert_eq!(
174            combined.trial_stress_tangent(0.01),
175            single.trial_stress_tangent(0.01)
176        );
177    }
178
179    #[test]
180    fn parallel_history_is_independent_per_child() {
181        // Two ElasticPP with different yield points in parallel: combined
182        // response is the sum of each independently-clamped branch.
183        let combined = Material::parallel(vec![
184            Material::elastic_pp(100.0, 0.01),
185            Material::elastic_pp(50.0, 0.02),
186        ]);
187        let (stress, _tangent) = combined.trial_stress_tangent(0.05);
188        // Both branches are well past yield: 100*0.01 + 50*0.02 = 2.0.
189        assert!((stress - 2.0).abs() < 1e-9, "got {stress}");
190    }
191
192    #[test]
193    fn series_of_two_equal_elastics_halves_the_stiffness() {
194        let series = Material::series(vec![
195            Material::Elastic { e: 100.0 },
196            Material::Elastic { e: 100.0 },
197        ]);
198        let (stress, tangent) = series.trial_stress_tangent(0.02);
199        // Two equal springs in series: combined stiffness = E/2, so for a
200        // given total strain the stress equals a single spring of
201        // stiffness 50 at that same total strain.
202        assert!(
203            (tangent - 50.0).abs() < 1e-6,
204            "expected tangent 50, got {tangent}"
205        );
206        assert!((stress - 50.0 * 0.02).abs() < 1e-6, "got {stress}");
207    }
208
209    #[test]
210    fn series_splits_strain_so_each_child_sees_equal_stress() {
211        let series = Material::series(vec![
212            Material::Elastic { e: 100.0 },
213            Material::Elastic { e: 300.0 },
214        ]);
215        let Material::Series { child_strains, .. } = series.commit(0.04) else {
216            panic!()
217        };
218        // Equilibrium requires equal stress: 100*e0 = 300*e1, and
219        // compatibility requires e0+e1=0.04 => e0=0.03, e1=0.01.
220        assert!(
221            (child_strains[0] - 0.03).abs() < 1e-6,
222            "got {child_strains:?}"
223        );
224        assert!(
225            (child_strains[1] - 0.01).abs() < 1e-6,
226            "got {child_strains:?}"
227        );
228    }
229
230    #[test]
231    fn min_max_fails_permanently_once_strain_exits_bounds() {
232        let m = Material::min_max(Material::Elastic { e: 100.0 }, -0.01, 0.01);
233        let m = m.commit(0.02); // exceeds max_strain
234        let Material::MinMax { failed, .. } = m else {
235            panic!()
236        };
237        assert!(failed);
238
239        let (stress, tangent) = m.trial_stress_tangent(0.0); // back within bounds
240        assert_eq!(
241            stress, 0.0,
242            "failed material must read zero stress even if strain re-enters bounds"
243        );
244        assert!((tangent - 1.0e-8 * 100.0).abs() < 1e-12, "got {tangent}");
245    }
246
247    #[test]
248    fn min_max_passes_through_inner_material_before_failure() {
249        let m = Material::min_max(Material::Elastic { e: 100.0 }, -0.01, 0.01);
250        assert_eq!(m.trial_stress_tangent(0.005), (0.5, 100.0));
251    }
252}