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}