Skip to main content

carapace_core/model/
fiber_section.rs

1use super::Material;
2
3/// One fiber: a small patch of cross-section area at distance `y` from the
4/// section centroid, carrying a `Material`. 2D only: `y` alone, no `z`,
5/// since bending is about the section's local z-axis only.
6#[derive(Debug, Clone)]
7pub struct Fiber {
8    pub y: f64,
9    pub area: f64,
10    pub material: Material,
11}
12
13impl Fiber {
14    pub fn new(y: f64, area: f64, material: Material) -> Self {
15        Fiber { y, area, material }
16    }
17}
18
19/// A fiber-discretized cross-section: given the section's axial strain
20/// `eps0` (at the centroid) and curvature `kappa`, each fiber's strain is
21/// `eps0 - y*kappa`. Work-conjugate to `[eps0, kappa]` is `[N, M]`
22/// (`N = sum(stress*area)`, `M = -sum(stress*area*y)`) — derived directly
23/// from virtual work, not assumed: `d(stress power) = integral(stress *
24/// d(strain)) dA = integral(stress*(d(eps0) - y*d(kappa))) dA =
25/// d(eps0)*N + d(kappa)*M` with `M` as defined above.
26///
27/// Each `DispBeamColumn` integration point owns its own `FiberSection`
28/// (independent material history — see `Element`'s commit/trial split),
29/// even when every point starts from an identical fiber layout (the
30/// prismatic-member assumption this element makes: same cross-section
31/// everywhere along the length — a tapered/non-prismatic member is a
32/// natural extension, not built until needed).
33#[derive(Debug, Clone)]
34pub struct FiberSection {
35    fibers: Vec<Fiber>,
36}
37
38/// `(N, M)` and the section tangent `[[dN/deps0, dN/dkappa], [dM/deps0, dM/dkappa]]`.
39pub type SectionResponse = (f64, f64, [[f64; 2]; 2]);
40
41impl FiberSection {
42    pub fn new(fibers: Vec<Fiber>) -> Self {
43        FiberSection { fibers }
44    }
45
46    /// Trial evaluation at `(eps0, kappa)` — read-only, safe every Newton
47    /// iteration; see `Material`'s doc comment for why this must not
48    /// mutate any fiber's material.
49    pub fn trial(&self, eps0: f64, kappa: f64) -> SectionResponse {
50        let (mut n, mut m) = (0.0, 0.0);
51        let (mut ea, mut eq, mut ei) = (0.0, 0.0, 0.0);
52        for fiber in &self.fibers {
53            let strain = eps0 - fiber.y * kappa;
54            let (stress, tangent) = fiber.material.trial_stress_tangent(strain);
55            n += stress * fiber.area;
56            m += -stress * fiber.area * fiber.y;
57            ea += tangent * fiber.area;
58            eq += tangent * fiber.area * fiber.y;
59            ei += tangent * fiber.area * fiber.y * fiber.y;
60        }
61        (n, m, [[ea, -eq], [-eq, ei]])
62    }
63
64    /// Commit every fiber's material at `(eps0, kappa)` — called once per
65    /// converged step, by `DispBeamColumn::commit`.
66    pub fn commit(&mut self, eps0: f64, kappa: f64) {
67        for fiber in &mut self.fibers {
68            let strain = eps0 - fiber.y * kappa;
69            fiber.material = fiber.material.commit(strain);
70        }
71    }
72
73    pub fn total_area(&self) -> f64 {
74        self.fibers.iter().map(|f| f.area).sum()
75    }
76
77    /// Every fiber's `(strain, stress)` at `(eps0, kappa)`, in the same
78    /// order fibers were originally passed to `new` — the same evaluation
79    /// `trial` sums into `(N, M)`, broken out per fiber instead of
80    /// aggregated. Read-only, safe every Newton iteration (`trial`'s own
81    /// doc comment); calling it at a section's current *committed*
82    /// `(eps0, kappa)` reproduces that fiber's committed stress exactly,
83    /// the same "recompute, don't cache" pattern `ZeroLength::local_force`
84    /// relies on.
85    pub fn fiber_responses(&self, eps0: f64, kappa: f64) -> Vec<(f64, f64)> {
86        self.fibers
87            .iter()
88            .map(|fiber| {
89                let strain = eps0 - fiber.y * kappa;
90                let (stress, _tangent) = fiber.material.trial_stress_tangent(strain);
91                (strain, stress)
92            })
93            .collect()
94    }
95}
96
97/// `FiberSection`'s spatial (biaxial) counterpart: each fiber carries both
98/// a `y` and a `z` coordinate, since bending can now occur about either
99/// local section axis. Torsion is deliberately *not* modeled here — a
100/// fiber's uniaxial material only sees axial strain, so torsional
101/// stiffness needs a separate (elastic, `G*J`) term, kept on the element
102/// (`DispBeamColumn3`/`ForceBeamColumn3`) rather than the section, the same
103/// decoupled treatment `ElasticBeamColumn3` already uses. This mirrors
104/// Xara's `FiberSection3d`, which likewise keeps torsion out of the fiber
105/// loop and folds it in afterward via a separate elastic term.
106#[derive(Debug, Clone)]
107pub struct Fiber3 {
108    pub y: f64,
109    pub z: f64,
110    pub area: f64,
111    pub material: Material,
112}
113
114impl Fiber3 {
115    pub fn new(y: f64, z: f64, area: f64, material: Material) -> Self {
116        Fiber3 {
117            y,
118            z,
119            area,
120            material,
121        }
122    }
123}
124
125/// Biaxial fiber-discretized cross-section: given axial strain `eps0`,
126/// curvature about local z `kappa_z` (bending that displaces local y, the
127/// plane `FiberSection`/`ElasticBeamColumn3`'s `Iz` governs), and curvature
128/// about local y `kappa_y` (displaces local z, `Iy`'s plane), each fiber's
129/// strain is `eps0 - y*kappa_z + z*kappa_y` — the natural biaxial extension
130/// of `FiberSection`'s `eps0 - y*kappa`, verified against Xara's
131/// `FiberSection3d::addFiber`/`getStressResultant` (`matData`/`sData`
132/// layout: `strain = e0 - y*e1 + z*e2`, `N = sum(stress*A)`,
133/// `Mz = sum(-y*stress*A)`, `My = sum(z*stress*A)`) rather than re-derived
134/// from scratch.
135///
136/// Work-conjugate to `[eps0, kappa_z, kappa_y]` is `[N, Mz, My]`, and the
137/// tangent's off-diagonal terms follow directly from differentiating those
138/// three sums (see [`FiberSection3::trial`]'s body) — independently
139/// reproducing Xara's `kData` layout (`kData[1]=-y*EA` summed → `-EQz`,
140/// `kData[2]=z*EA` summed → `EQy`, `kData[6]=-y*z*EA` summed → `-EIyz`)
141/// gives useful cross-confirmation that the sign convention here matches
142/// the reference exactly, not just plausibly.
143#[derive(Debug, Clone)]
144pub struct FiberSection3 {
145    fibers: Vec<Fiber3>,
146}
147
148/// `(N, Mz, My)` and the section tangent, ordered
149/// `[[dN/deps0, dN/dkz, dN/dky], [dMz/deps0, dMz/dkz, dMz/dky], [dMy/deps0, dMy/dkz, dMy/dky]]`.
150pub type SectionResponse3 = (f64, f64, f64, [[f64; 3]; 3]);
151
152impl FiberSection3 {
153    pub fn new(fibers: Vec<Fiber3>) -> Self {
154        FiberSection3 { fibers }
155    }
156
157    /// Trial evaluation at `(eps0, kappa_z, kappa_y)` — read-only, safe
158    /// every Newton iteration (see `FiberSection::trial`'s doc comment for
159    /// why).
160    pub fn trial(&self, eps0: f64, kappa_z: f64, kappa_y: f64) -> SectionResponse3 {
161        let (mut n, mut mz, mut my) = (0.0, 0.0, 0.0);
162        let (mut ea, mut eqz, mut eqy, mut eizz, mut eiyy, mut eiyz) =
163            (0.0, 0.0, 0.0, 0.0, 0.0, 0.0);
164        for fiber in &self.fibers {
165            let strain = eps0 - fiber.y * kappa_z + fiber.z * kappa_y;
166            let (stress, tangent) = fiber.material.trial_stress_tangent(strain);
167            let fs = stress * fiber.area;
168            n += fs;
169            mz += -fiber.y * fs;
170            my += fiber.z * fs;
171
172            let ta = tangent * fiber.area;
173            ea += ta;
174            eqz += ta * fiber.y;
175            eqy += ta * fiber.z;
176            eizz += ta * fiber.y * fiber.y;
177            eiyy += ta * fiber.z * fiber.z;
178            eiyz += ta * fiber.y * fiber.z;
179        }
180        (
181            n,
182            mz,
183            my,
184            [[ea, -eqz, eqy], [-eqz, eizz, -eiyz], [eqy, -eiyz, eiyy]],
185        )
186    }
187
188    /// Commit every fiber's material at `(eps0, kappa_z, kappa_y)` — called
189    /// once per converged step.
190    pub fn commit(&mut self, eps0: f64, kappa_z: f64, kappa_y: f64) {
191        for fiber in &mut self.fibers {
192            let strain = eps0 - fiber.y * kappa_z + fiber.z * kappa_y;
193            fiber.material = fiber.material.commit(strain);
194        }
195    }
196
197    pub fn total_area(&self) -> f64 {
198        self.fibers.iter().map(|f| f.area).sum()
199    }
200
201    /// See `FiberSection::fiber_responses`'s doc comment — same contract,
202    /// biaxial strain field.
203    pub fn fiber_responses(&self, eps0: f64, kappa_z: f64, kappa_y: f64) -> Vec<(f64, f64)> {
204        self.fibers
205            .iter()
206            .map(|fiber| {
207                let strain = eps0 - fiber.y * kappa_z + fiber.z * kappa_y;
208                let (stress, _tangent) = fiber.material.trial_stress_tangent(strain);
209                (strain, stress)
210            })
211            .collect()
212    }
213}
214
215#[cfg(test)]
216mod tests {
217    use super::*;
218
219    #[test]
220    fn two_symmetric_elastic_fibers_reproduce_ea_ei_exactly() {
221        let (e, area, iz): (f64, f64, f64) = (30000.0, 2.0, 1000.0);
222        let h = (iz / area).sqrt();
223        let section = FiberSection::new(vec![
224            Fiber::new(h, area / 2.0, Material::Elastic { e }),
225            Fiber::new(-h, area / 2.0, Material::Elastic { e }),
226        ]);
227
228        let (n, m, k) = section.trial(0.001, 0.0002);
229        assert!(
230            (k[0][0] - e * area).abs() < 1e-6,
231            "EA mismatch: {}",
232            k[0][0]
233        );
234        assert!((k[1][1] - e * iz).abs() < 1e-6, "EI mismatch: {}", k[1][1]);
235        assert!(
236            k[0][1].abs() < 1e-9,
237            "should be EQ=0 for a symmetric section"
238        );
239
240        // N = EA*eps0 (kappa doesn't couple in for a symmetric section),
241        // M = EI*kappa.
242        assert!((n - e * area * 0.001).abs() < 1e-6);
243        assert!((m - e * iz * 0.0002).abs() < 1e-6);
244    }
245
246    /// Nonlinear composite check: a real
247    /// reinforced-concrete-style section — one `Steel01` rebar layer plus
248    /// two `Concrete01` layers — pushed past first yield/cracking and then
249    /// partially unloaded. No closed form exists for a nonlinear composite
250    /// section (unlike the elastic case above), so each state is checked
251    /// against a hand computation: evaluate every fiber's `Material`
252    /// directly at that fiber's own strain and sum `N = sum(stress*area)`,
253    /// `M = -sum(stress*area*y)` — the same definition `FiberSection::trial`
254    /// uses, but computed independently, fiber by fiber, outside it.
255    #[test]
256    fn steel_and_concrete_fiber_section_matches_hand_computed_n_m_through_yield_and_partial_unload()
257    {
258        let y_rebar = 5.0;
259        let area_rebar = 0.4;
260        let y_c1 = -2.0;
261        let area_c1 = 3.0;
262        let y_c2 = -4.0;
263        let area_c2 = 3.0;
264
265        let rebar = || Material::steel01(60.0, 29000.0, 0.01, 0.9, 5.0, 0.9, 5.0);
266        let concrete = || Material::concrete01(4.0, 0.002, 3.0, 0.006);
267
268        let mut section = FiberSection::new(vec![
269            Fiber::new(y_rebar, area_rebar, rebar()),
270            Fiber::new(y_c1, area_c1, concrete()),
271            Fiber::new(y_c2, area_c2, concrete()),
272        ]);
273
274        // Independent "by hand" copies of each fiber's material, committed
275        // in lockstep with the section, to compute the expected (N, M) at
276        // each state from scratch.
277        let mut hand_rebar = rebar();
278        let mut hand_c1 = concrete();
279        let mut hand_c2 = concrete();
280
281        let check = |section: &mut FiberSection,
282                     hand_rebar: &mut Material,
283                     hand_c1: &mut Material,
284                     hand_c2: &mut Material,
285                     eps0: f64,
286                     kappa: f64,
287                     label: &str| {
288            let strain_rebar = eps0 - y_rebar * kappa;
289            let strain_c1 = eps0 - y_c1 * kappa;
290            let strain_c2 = eps0 - y_c2 * kappa;
291
292            let (stress_rebar, _) = hand_rebar.trial_stress_tangent(strain_rebar);
293            let (stress_c1, _) = hand_c1.trial_stress_tangent(strain_c1);
294            let (stress_c2, _) = hand_c2.trial_stress_tangent(strain_c2);
295
296            let expected_n = stress_rebar * area_rebar + stress_c1 * area_c1 + stress_c2 * area_c2;
297            let expected_m = -(stress_rebar * area_rebar * y_rebar
298                + stress_c1 * area_c1 * y_c1
299                + stress_c2 * area_c2 * y_c2);
300
301            let (n, m, _k) = section.trial(eps0, kappa);
302            assert!(
303                (n - expected_n).abs() < 1e-9,
304                "{label}: N mismatch, expected {expected_n}, got {n}"
305            );
306            assert!(
307                (m - expected_m).abs() < 1e-9,
308                "{label}: M mismatch, expected {expected_m}, got {m}"
309            );
310
311            section.commit(eps0, kappa);
312            *hand_rebar = hand_rebar.commit(strain_rebar);
313            *hand_c1 = hand_c1.commit(strain_c1);
314            *hand_c2 = hand_c2.commit(strain_c2);
315        };
316
317        // State 1: curvature alone (eps0=0) puts the rebar (y=5) into
318        // strain +0.005 — well past yield (epsy = 60/29000 ≈ 0.00207) —
319        // and the two concrete layers into -0.002 (right at the peak,
320        // epsc0) and -0.004 (past the peak, on the descending branch).
321        check(
322            &mut section,
323            &mut hand_rebar,
324            &mut hand_c1,
325            &mut hand_c2,
326            0.0,
327            -0.001,
328            "state 1: first yield/cracking",
329        );
330
331        // State 2: push further — deeper into the steel hardening range and
332        // the concrete's descending branch.
333        check(
334            &mut section,
335            &mut hand_rebar,
336            &mut hand_c1,
337            &mut hand_c2,
338            0.0,
339            -0.002,
340            "state 2: deeper into the nonlinear range",
341        );
342
343        // State 3: partial unload (smaller-magnitude curvature) — history
344        // (steel's isotropic-hardening shift, concrete's degraded
345        // unload-reload slope) must carry over correctly for the (N, M) to
346        // still match the independently-committed hand computation.
347        check(
348            &mut section,
349            &mut hand_rebar,
350            &mut hand_c1,
351            &mut hand_c2,
352            0.0,
353            -0.0008,
354            "state 3: partial unload",
355        );
356    }
357
358    /// `FiberSection3` biaxial analogue of `two_symmetric_elastic_fibers_
359    /// reproduce_ea_ei_exactly`: four corner fibers at `(±hy, ±hz)`
360    /// reproduce `EA`, `EIy`, `EIz` exactly and give zero product-of-inertia
361    /// coupling (`dMz/dky` / `dMy/dkz` = 0) by symmetry — verifying the
362    /// `eps0 - y*kappa_z + z*kappa_y` strain field and the `N`/`Mz`/`My`
363    /// sums independently of any element that will later drive this
364    /// section.
365    #[test]
366    fn four_corner_elastic_fibers_reproduce_ea_eiy_eiz_with_zero_cross_coupling() {
367        let (e, area, iy, iz): (f64, f64, f64, f64) = (30000.0, 4.0, 500.0, 2000.0);
368        let hz = (iy / area).sqrt();
369        let hy = (iz / area).sqrt();
370        let a4 = area / 4.0;
371        let section = FiberSection3::new(vec![
372            Fiber3::new(hy, hz, a4, Material::Elastic { e }),
373            Fiber3::new(hy, -hz, a4, Material::Elastic { e }),
374            Fiber3::new(-hy, hz, a4, Material::Elastic { e }),
375            Fiber3::new(-hy, -hz, a4, Material::Elastic { e }),
376        ]);
377
378        let (n, mz, my, k) = section.trial(0.001, 0.0002, 0.0003);
379        assert!(
380            (k[0][0] - e * area).abs() < 1e-6,
381            "EA mismatch: {}",
382            k[0][0]
383        );
384        assert!((k[1][1] - e * iz).abs() < 1e-6, "EIz mismatch: {}", k[1][1]);
385        assert!((k[2][2] - e * iy).abs() < 1e-6, "EIy mismatch: {}", k[2][2]);
386        assert!(
387            k[0][1].abs() < 1e-9,
388            "EQz should be 0 for a symmetric section"
389        );
390        assert!(
391            k[0][2].abs() < 1e-9,
392            "EQy should be 0 for a symmetric section"
393        );
394        assert!(
395            k[1][2].abs() < 1e-9,
396            "EIyz should be 0 for a symmetric section"
397        );
398
399        assert!((n - e * area * 0.001).abs() < 1e-6);
400        assert!((mz - e * iz * 0.0002).abs() < 1e-6);
401        assert!((my - e * iy * 0.0003).abs() < 1e-6);
402    }
403}