carapace_core/model/elements/force_beam_column.rs
1use nalgebra::{Matrix3, SMatrix, SVector, Vector3};
2
3use super::super::transform::Corotational2d;
4use super::super::{
5 BeamIntegration, ElementLoad, ElementLoad3, Fiber, Fiber3, FiberSection, FiberSection3,
6 GeomTransf, GeomTransf3, Node, Node3, Node3Id, NodeId,
7};
8use super::truss::{SpatialElementMatrix, SpatialElementVector};
9
10/// A 2-node, force-based (flexibility-method) 2D beam-column:
11/// unlike every other element in the catalog, its section forces are
12/// determined directly from the element's basic (reduced, rigid-body-mode-
13/// free) end forces via statics-exact force-interpolation functions —
14/// always in equilibrium, regardless of material state — while *section*
15/// deformations (and hence the element's tangent) come from inverting each
16/// section's nonlinear force-deformation response. That inversion is why
17/// this element needs its own internal Newton iteration
18/// (`state_determination`) every time `form_tangent_and_resistance` is
19/// called, instead of a single direct evaluation like `DispBeamColumn`.
20///
21/// **Basic system.** 3 dof, rigid-body-mode-free: `v = [v1, v2, v3]` where
22/// `v1` is axial elongation and `v2`/`v3` are the end rotations *relative to
23/// the chord* (`θ_i - ψ`, `θ_j - ψ`, `ψ = (v2_local - v1_local)/L` the
24/// chord's rigid-rotation) — the same objective quantity `GeomTransf`
25/// implicitly builds into `ElasticBeamColumn`'s closed-form stiffness, made
26/// explicit here because the flexibility method needs an isolated,
27/// statically-determinate reduced system to define force-interpolation
28/// functions on. Basic forces `q = [q1, q2, q3]` are q1 = axial force, and
29/// `q2`/`q3` are *not* simply the local nodal moments — the force
30/// interpolation `b(x)` below (and the `a_matrix`/`a_matrix_transpose` map
31/// between basic and local systems) were derived to be consistent with
32/// `DispBeamColumn`'s already-verified curvature-displacement convention
33/// (`FiberSection`'s `M = -sum(stress*area*y)`, `ElasticBeamColumn`'s
34/// closed-form `4EI/L`/`2EI/L` pattern) and checked against
35/// `ElasticBeamColumn`'s exact elastic stiffness in
36/// `core/tests/elements/force_beam.rs` — that test is the actual source of
37/// truth for these signs, not a textbook convention taken on faith.
38///
39/// **State determination algorithm** — ported directly from
40/// `xara/SRC/element/Frame/Other/Force/ForceBeamColumn2d.cpp`'s
41/// `update()` (the Neuenhofer-Filippou/Spacone-Ciampi-Filippou algorithm
42/// that file's header cites), not re-derived by hand; see
43/// `state_determination` and
44/// `try_state_determination`'s
45/// own doc comments for the mechanics (a two-stage per-section correction
46/// each outer iteration, an energy-based convergence check, and a
47/// bisection fallback when a single jump from `v_commit` to the target
48/// `v` doesn't converge directly) — done fresh on every call from
49/// `q_commit`/`e_commit`/`v_commit` (see those fields' doc comments for
50/// why there's no mutable trial cache). At convergence, the basic tangent
51/// stiffness is `F^-1` (`F` the element flexibility `integral(b^T * f_s *
52/// b) dx`) — the standard result for force-based elements, from
53/// differentiating the equilibrium `r(q, v) = 0` implicit relation.
54///
55/// Small-displacement by default, with an opt-in `GeomTransf::Corotational`
56/// transformation that supplies objective chord-relative basic
57/// deformations and the consistent geometric tangent.
58/// `BeamIntegration::Lobatto` is the usual choice (endpoints included —
59/// plastic hinges concentrate at member ends).
60///
61/// **Element loads.** A uniform local-axis load enters the section forces
62/// exactly, which is the point of a force-based element: `s(x) = b(x)·q +
63/// s_p(x)`, where `s_p` is the load's own section force on the statically
64/// determinate basic system (axial restrained at node i, simply supported in
65/// bending: `N_p = wx·(L−x)`, `M_p = wy·x(x−L)/2`). The load therefore
66/// reaches `state_determination`, and the element's nodal resistance is
67/// `Aᵀq` — the load's statically equivalent nodal forces (`wx·L` axial at
68/// node i, `w·L/2` shear at each end; `form_load_vector`) are the *applied*
69/// side. This mirrors OpenSees's `ForceBeamColumn2d` (`sp` and `p0`).
70#[derive(Debug, Clone)]
71pub struct ForceBeamColumn {
72 pub node_i: NodeId,
73 pub node_j: NodeId,
74 integration: BeamIntegration,
75 sections: Vec<FiberSection>,
76 transform: GeomTransf,
77 /// Mass per unit volume, applied uniformly over the section's total
78 /// fiber area, same convention as `DispBeamColumn`. Zero (the default)
79 /// means massless.
80 pub density: f64,
81 /// Last-committed basic force `[q1, q2, q3]` — the state-determination
82 /// Newton iteration's starting guess for `q`, refreshed by `commit`.
83 q_commit: SVector<f64, 3>,
84 /// Last-committed per-section deformation `(eps0, kappa)` — the
85 /// starting guess for each section's `e_s`, refreshed by `commit`.
86 /// Needed because `FiberSection` has no "given force, find deformation"
87 /// operation of its own (only the reverse); the element itself must
88 /// track the deformation iterate that state determination corrects.
89 e_commit: Vec<(f64, f64)>,
90 /// Last-committed basic deformation — the endpoint `q_commit`/
91 /// `e_commit` are consistent with, needed so `state_determination` can
92 /// subdivide a too-large jump to `v` into smaller intermediate targets
93 /// (see its doc comment) rather than only its own start and end point.
94 v_commit: SVector<f64, NBD>,
95 /// Effective element load `q_commit`/`e_commit` are consistent with —
96 /// the starting point for interpolating the load across subdivided
97 /// state-determination sub-steps.
98 load_commit: ElementLoad,
99 max_iters: usize,
100 /// Energy tolerance (`dv·dq`) of the state-determination loop. Tight on
101 /// purpose (OpenSees's own default): a loose value leaves a freshly
102 /// committed state re-evaluating slightly off equilibrium at the same
103 /// displacement, which `DisplacementControl`'s first-iteration exactness
104 /// relies on never happening.
105 tolerance: f64,
106 /// Last-committed local nodal force (`a.transpose() * q_commit`, or its
107 /// corotational-frame equivalent) — cached at `commit` time rather than
108 /// recomputed on demand like every other element's `local_force`,
109 /// because recomputing it here would mean re-running `state_
110 /// determination`'s Newton iteration, not a cheap closed-form
111 /// evaluation. `commit` already produces `q`/`a` as part of finding
112 /// `q_commit` itself, so this is free to capture there.
113 local_force: SVector<f64, 6>,
114}
115
116/// `2` — number of section force/deformation components (`N`, `M`; 2D, no
117/// shear/torsion).
118const NSD: usize = 2;
119/// `3` — number of basic (reduced) element dof.
120const NBD: usize = 3;
121
122/// `(basic force, per-section (eps0, kappa), basic tangent stiffness)` —
123/// [`ForceBeamColumn::state_determination`] and
124/// [`ForceBeamColumn::try_state_determination`]'s common result shape.
125type StateDeterminationResult = (SVector<f64, NBD>, Vec<(f64, f64)>, SMatrix<f64, NBD, NBD>);
126
127impl ForceBeamColumn {
128 pub fn new(
129 node_i: NodeId,
130 node_j: NodeId,
131 fibers: Vec<Fiber>,
132 integration: BeamIntegration,
133 ) -> Self {
134 let n_points = integration.points().len();
135 let sections = (0..n_points)
136 .map(|_| FiberSection::new(fibers.clone()))
137 .collect();
138 ForceBeamColumn {
139 node_i,
140 node_j,
141 integration,
142 sections,
143 transform: GeomTransf::Linear,
144 density: 0.0,
145 q_commit: SVector::<f64, NBD>::zeros(),
146 e_commit: vec![(0.0, 0.0); n_points],
147 v_commit: SVector::<f64, NBD>::zeros(),
148 load_commit: ElementLoad::default(),
149 max_iters: 50,
150 tolerance: 1e-12,
151 local_force: SVector::<f64, 6>::zeros(),
152 }
153 }
154
155 pub fn with_density(mut self, density: f64) -> Self {
156 self.density = density;
157 self
158 }
159
160 /// Use finite-rotation, chord-following kinematics for this member.
161 pub fn with_corotational(mut self) -> Self {
162 self.transform = GeomTransf::Corotational;
163 self
164 }
165
166 fn geometry(&self, node_i: &Node, node_j: &Node) -> (f64, f64, f64) {
167 let dx = node_j.coords[0] - node_i.coords[0];
168 let dy = node_j.coords[1] - node_i.coords[1];
169 let length = (dx * dx + dy * dy).sqrt();
170 (length, dx / length, dy / length)
171 }
172
173 fn transformation(&self, cx: f64, cy: f64) -> SMatrix<f64, 6, 6> {
174 #[rustfmt::skip]
175 let block = SMatrix::<f64, 3, 3>::new(
176 cx, cy, 0.0,
177 -cy, cx, 0.0,
178 0.0, 0.0, 1.0,
179 );
180 let mut t = SMatrix::<f64, 6, 6>::zeros();
181 t.fixed_view_mut::<3, 3>(0, 0).copy_from(&block);
182 t.fixed_view_mut::<3, 3>(3, 3).copy_from(&block);
183 t
184 }
185
186 fn local_displacement(
187 &self,
188 node_i: &Node,
189 node_j: &Node,
190 ) -> (f64, SMatrix<f64, 6, 6>, SVector<f64, 6>) {
191 let (length, cx, cy) = self.geometry(node_i, node_j);
192 let t = self.transformation(cx, cy);
193 let d_global = SVector::<f64, 6>::from_row_slice(&[
194 node_i.displacement[0],
195 node_i.displacement[1],
196 node_i.displacement[2],
197 node_j.displacement[0],
198 node_j.displacement[1],
199 node_j.displacement[2],
200 ]);
201 (length, t, t * d_global)
202 }
203
204 /// Basic (rigid-body-mode-free) deformation `v = A * d_local` — see the
205 /// type doc comment for `A`'s definition (chord-relative end rotations).
206 fn basic_deformation_matrix(length: f64) -> SMatrix<f64, NBD, 6> {
207 let l_inv = 1.0 / length;
208 #[rustfmt::skip]
209 let a = SMatrix::<f64, NBD, 6>::from_row_slice(&[
210 -1.0, 0.0, 0.0, 1.0, 0.0, 0.0,
211 0.0, l_inv, 1.0, 0.0, -l_inv, 0.0,
212 0.0, l_inv, 0.0, 0.0, -l_inv, 1.0,
213 ]);
214 a
215 }
216
217 /// Force interpolation `s(x) = b(xi) * q`, `s = [N, M]`, `xi` in
218 /// `[0,1]` along the length — see the type doc comment for the sign
219 /// convention (`M(0) = -q2`, `M(1) = q3`, matched to `DispBeamColumn`'s
220 /// curvature convention and verified by
221 /// `core/tests/elements/force_beam.rs`).
222 fn b_matrix(xi: f64) -> SMatrix<f64, NSD, NBD> {
223 #[rustfmt::skip]
224 let b = SMatrix::<f64, NSD, NBD>::from_row_slice(&[
225 1.0, 0.0, 0.0,
226 0.0, xi - 1.0, xi,
227 ]);
228 b
229 }
230
231 /// The load's own section force `s_p = [N_p, M_p]` at `xi` on the basic
232 /// system (see the type doc comment) — sagging-positive `M`, matching
233 /// `b_matrix`.
234 fn section_load(xi: f64, length: f64, load: &ElementLoad) -> SVector<f64, NSD> {
235 let [wx, wy] = load.beam_uniform;
236 SVector::<f64, NSD>::new(
237 wx * length * (1.0 - xi),
238 wy * length * length * xi * (xi - 1.0) / 2.0,
239 )
240 }
241
242 fn invert_2x2(m: [[f64; 2]; 2]) -> SMatrix<f64, NSD, NSD> {
243 let det = m[0][0] * m[1][1] - m[0][1] * m[1][0];
244 SMatrix::<f64, NSD, NSD>::new(m[1][1] / det, -m[0][1] / det, -m[1][0] / det, m[0][0] / det)
245 }
246
247 /// Outer driver: try [`try_state_determination`]'s single-shot Newton
248 /// solve straight from `v_commit` to `v`; if it doesn't converge,
249 /// subdivide the gap into progressively more, smaller intermediate
250 /// targets (each sub-step starting from the previous one's *converged*
251 /// state) — the source's own `numSubdivide` fallback (`update()`'s
252 /// outer `while` loop), simplified to bisection alone (the source also
253 /// tries an initial-tangent iteration variant before subdividing; not
254 /// needed here since bisection alone is enough to keep every
255 /// sub-step's jump small). A too-large jump in one shot can walk a
256 /// section briefly through an unreachable, exactly-singular
257 /// fully-plastic state on the way to a perfectly reasonable final
258 /// answer — subdividing keeps each step's target close enough to its
259 /// start that this doesn't happen.
260 fn state_determination(
261 &self,
262 v: SVector<f64, NBD>,
263 load: &ElementLoad,
264 length: f64,
265 ) -> StateDeterminationResult {
266 let mut divisions = 1;
267 loop {
268 let mut q = self.q_commit;
269 let mut e = self.e_commit.clone();
270 let mut k_basic = SMatrix::<f64, NBD, NBD>::identity();
271 let mut all_converged = true;
272
273 for step in 1..=divisions {
274 let fraction = step as f64 / divisions as f64;
275 let v_step = self.v_commit + (v - self.v_commit) * fraction;
276 // The load moves from its committed to its current level in
277 // step with `v`, so each sub-step's target stays close to its
278 // start in both.
279 let load_step = self.load_commit * (1.0 - fraction) + *load * fraction;
280 let ((q2, e2, k2), converged) =
281 self.try_state_determination(v_step, &load_step, length, q, e);
282 q = q2;
283 e = e2;
284 k_basic = k2;
285 if !converged {
286 all_converged = false;
287 break;
288 }
289 }
290
291 if all_converged || divisions >= 256 {
292 return (q, e, k_basic);
293 }
294 divisions *= 4;
295 }
296 }
297
298 /// Single-shot Newton solve from `(q0, e0)` to the target `v` —
299 /// ported directly from
300 /// `xara/SRC/element/Frame/Other/Force/ForceBeamColumn2d.cpp`'s
301 /// `update()` (the Neuenhofer-Filippou/Spacone-Ciampi-Filippou
302 /// algorithm that file's header cites), not re-derived by hand.
303 /// Simplified from the source: no initial-tangent iteration variant (the source's
304 /// `Newton`/`InitialFirst`/`Initial` algorithm ladder — an additional
305 /// robustness measure on top of subdivision, not needed for
306 /// correctness, addable later if a model actually demands it).
307 ///
308 /// Each outer iteration corrects every section's deformation `e_s`
309 /// *twice* against the target section force `b(xi_s) * q`, both using
310 /// `FiberSection::trial` (never mutating): once to advance `e_s`
311 /// itself (`vsSubdivide[i] += dvs` in the source), and again — after
312 /// re-evaluating the section at that advanced `e_s` — to get the
313 /// "leftover" unbalance folded into the compatibility integral (`vr`)
314 /// and flexibility (`f`) without being written back into `e_s`. This
315 /// distinction is the actual fix for a bug an earlier, hand-derived
316 /// version of this function had: correcting `e_s` only *once* per
317 /// outer iteration and checking convergence via the bare compatibility
318 /// residual `v - integral(b^T * e)` is a trap — that residual is
319 /// driven to *exactly* zero after a single outer iteration by the
320 /// linear algebra of the update alone, regardless of whether any
321 /// section is actually in equilibrium with `q`, so the loop
322 /// "converges" immediately every time and silently freezes `q`/`e` at
323 /// a materially wrong, too-stiff answer once any section is nonlinear
324 /// (caught by `core/tests/elements/force_beam.rs`'s past-yield test).
325 /// The source's two-stage correction plus its energy-based convergence
326 /// check (`dv . dSe`, not the raw residual norm) avoid that trap;
327 /// match its structure, not a simplification of it.
328 ///
329 /// Returns whether it actually converged within `self.max_iters`;
330 /// [`state_determination`] is what decides what to do if not.
331 fn try_state_determination(
332 &self,
333 v: SVector<f64, NBD>,
334 load: &ElementLoad,
335 length: f64,
336 q0: SVector<f64, NBD>,
337 e0: Vec<(f64, f64)>,
338 ) -> (StateDeterminationResult, bool) {
339 let points = self.integration.points();
340 let mut q = q0;
341 let mut e = e0;
342 let mut k_basic = SMatrix::<f64, NBD, NBD>::identity();
343 let mut converged = false;
344
345 for _ in 0..self.max_iters {
346 let mut flexibility = SMatrix::<f64, NBD, NBD>::zeros();
347 let mut vr = SVector::<f64, NBD>::zeros();
348
349 for (i, (xi, w)) in points.iter().enumerate() {
350 let b = Self::b_matrix(*xi);
351 let target = b * q + Self::section_load(*xi, length, load);
352 let scale = w * length;
353
354 // First correction: advance this section's own stored
355 // deformation estimate toward the target, using its
356 // tangent from *before* this correction (matches the
357 // source's first `dvs.addMatrixVector(..fsSubdivide[i]..)`
358 // before `setTrialSectionDeformation`).
359 let (n0, m0, k_sec0) = self.sections[i].trial(e[i].0, e[i].1);
360 let f_sec0 = Self::invert_2x2(k_sec0);
361 let d0 = f_sec0 * (target - SVector::<f64, NSD>::new(n0, m0));
362 e[i].0 += d0[0];
363 e[i].1 += d0[1];
364
365 // Re-evaluate at the advanced deformation (the source's
366 // `setTrialSectionDeformation` + `getStressResultant`/
367 // `getSectionFlexibility`) — this is the section's
368 // genuine nonlinear response, generally still not
369 // exactly matching `target`.
370 let (n1, m1, k_sec1) = self.sections[i].trial(e[i].0, e[i].1);
371 let f_sec1 = Self::invert_2x2(k_sec1);
372
373 // Second correction, using the *fresh* tangent against
374 // the *fresh* response — folded into `vr`/`flexibility`
375 // only, never written back into `e[i]` (matches the
376 // source computing this into a fresh local `dvs`, added
377 // to `vsSubdivide[i]` only for the `vr` integration).
378 let d1 = f_sec1 * (target - SVector::<f64, NSD>::new(n1, m1));
379 let e_star = SVector::<f64, NSD>::new(e[i].0 + d1[0], e[i].1 + d1[1]);
380
381 flexibility += scale * (b.transpose() * f_sec1 * b);
382 vr += scale * (b.transpose() * e_star);
383 }
384
385 k_basic = match flexibility.try_inverse() {
386 Some(k) if k.iter().all(|x| x.is_finite()) => k,
387 _ => break,
388 };
389 let dv = v - vr;
390 let dq = k_basic * dv;
391 if !dq.iter().all(|x| x.is_finite()) {
392 break;
393 }
394 q += dq;
395
396 if dv.dot(&dq).abs() < self.tolerance {
397 converged = true;
398 break;
399 }
400 }
401
402 ((q, e, k_basic), converged)
403 }
404
405 pub(super) fn form_tangent_and_resistance(
406 &self,
407 node_i: &Node,
408 node_j: &Node,
409 load: Option<&ElementLoad>,
410 ) -> (SMatrix<f64, 6, 6>, SVector<f64, 6>) {
411 let load = load.copied().unwrap_or(ElementLoad::default());
412 let corotational = (self.transform == GeomTransf::Corotational)
413 .then(|| Corotational2d::new(node_i, node_j));
414 let (length, t, d_local) = if let Some(state) = &corotational {
415 (
416 state.initial_length(),
417 SMatrix::<f64, 6, 6>::identity(),
418 SVector::<f64, 6>::zeros(),
419 )
420 } else {
421 self.local_displacement(node_i, node_j)
422 };
423 let a = Self::basic_deformation_matrix(length);
424 let v = corotational
425 .as_ref()
426 .map_or_else(|| a * d_local, Corotational2d::basic_deformation);
427
428 let (q, _e, k_basic) = self.state_determination(v, &load, length);
429
430 if let Some(state) = &corotational {
431 return (
432 state.global_tangent(&k_basic, &q),
433 state.global_resistance(&q),
434 );
435 }
436
437 let k_local = a.transpose() * k_basic * a;
438 let r_local = a.transpose() * q;
439
440 (t.transpose() * k_local * t, t.transpose() * r_local)
441 }
442
443 pub(super) fn commit(&mut self, node_i: &Node, node_j: &Node, load: Option<&ElementLoad>) {
444 let load = load.copied().unwrap_or(ElementLoad::default());
445 let (length, d_local, corotational) = if self.transform == GeomTransf::Corotational {
446 let state = Corotational2d::new(node_i, node_j);
447 (
448 state.initial_length(),
449 SVector::<f64, 6>::zeros(),
450 Some(state),
451 )
452 } else {
453 let (length, _t, d_local) = self.local_displacement(node_i, node_j);
454 (length, d_local, None)
455 };
456 let a = Self::basic_deformation_matrix(length);
457 let v = corotational
458 .as_ref()
459 .map_or_else(|| a * d_local, Corotational2d::basic_deformation);
460
461 let (q, e, _k_basic) = self.state_determination(v, &load, length);
462
463 for (i, (eps0, kappa)) in e.iter().enumerate() {
464 self.sections[i].commit(*eps0, *kappa);
465 }
466 self.local_force = match &corotational {
467 Some(state) => state.local_resistance(&q),
468 None => a.transpose() * q,
469 };
470 self.q_commit = q;
471 self.e_commit = e;
472 self.v_commit = v;
473 self.load_commit = load;
474 }
475
476 /// Equivalent nodal load (global coordinates) from a uniform local-axis
477 /// load — the *statically* equivalent forces on the basic system
478 /// (`wx·L` axial at node i, `wy·L/2` shear at each end, no moments), not
479 /// the consistent Hermite vector the displacement-based and elastic
480 /// beams use: this element's section forces carry the rest of the load's
481 /// effect (see the type doc comment). A corotational member treats it as
482 /// a follower load over the undeformed length.
483 pub(super) fn form_load_vector(
484 &self,
485 node_i: &Node,
486 node_j: &Node,
487 wx: f64,
488 wy: f64,
489 ) -> SVector<f64, 6> {
490 if wx == 0.0 && wy == 0.0 {
491 return SVector::<f64, 6>::zeros();
492 }
493 if self.transform == GeomTransf::Corotational {
494 let state = Corotational2d::new(node_i, node_j);
495 return state.global_to_local().transpose()
496 * Self::static_load_local(state.initial_length(), wx, wy);
497 }
498 let (length, cx, cy) = self.geometry(node_i, node_j);
499 self.transformation(cx, cy).transpose() * Self::static_load_local(length, wx, wy)
500 }
501
502 /// `form_load_vector` in the element-local frame `local_force` reports in.
503 pub(super) fn form_local_load_vector(
504 &self,
505 node_i: &Node,
506 node_j: &Node,
507 wx: f64,
508 wy: f64,
509 ) -> SVector<f64, 6> {
510 let length = if self.transform == GeomTransf::Corotational {
511 Corotational2d::new(node_i, node_j).initial_length()
512 } else {
513 self.geometry(node_i, node_j).0
514 };
515 Self::static_load_local(length, wx, wy)
516 }
517
518 fn static_load_local(l: f64, wx: f64, wy: f64) -> SVector<f64, 6> {
519 SVector::<f64, 6>::from_row_slice(&[wx * l, wy * l / 2.0, 0.0, 0.0, wy * l / 2.0, 0.0])
520 }
521
522 /// Reads the local nodal force cached by `commit` — see this element's
523 /// `local_force` field doc comment for why it's cached, not recomputed.
524 pub(super) fn local_force(&self) -> SVector<f64, 6> {
525 self.local_force
526 }
527
528 /// Every integration point's per-fiber `(strain, stress)`, read
529 /// directly from `e_commit` — unlike `DispBeamColumn::fiber_responses`,
530 /// no recomputation from nodal displacement is needed: `e_commit`
531 /// already *is* each section's last-committed `(eps0, kappa)` (this
532 /// element's own state-determination target, refreshed by `commit` —
533 /// see `e_commit`'s field doc comment), the same reason `local_force`
534 /// above is a plain cached read rather than a recomputation. Outer
535 /// `Vec` is one entry per integration point, inner `Vec` one entry per
536 /// fiber (the order originally passed to `new`).
537 pub fn fiber_responses(&self) -> Vec<Vec<(f64, f64)>> {
538 self.e_commit
539 .iter()
540 .zip(&self.sections)
541 .map(|(&(eps0, kappa), section)| section.fiber_responses(eps0, kappa))
542 .collect()
543 }
544
545 pub(super) fn form_mass(&self, node_i: &Node, node_j: &Node) -> SVector<f64, 6> {
546 let (length, _cx, _cy) = self.geometry(node_i, node_j);
547 let total_area = self.sections[0].total_area();
548 let half = self.density * total_area * length / 2.0;
549 SVector::<f64, 6>::from_column_slice(&[half, half, 0.0, half, half, 0.0])
550 }
551}
552
553/// Sign of the `wz`-driven `My` section force relative to `Mz`'s `wy`-driven
554/// form (the `ry = -dw/dx` convention flips it).
555const NO_LOAD3: ElementLoad3 = ElementLoad3::uniform(0.0, 0.0, 0.0);
556
557/// `3` — number of section force/deformation components for
558/// `ForceBeamColumn3` (`N`, `Mz`, `My` — biaxial bending, no torsion; see
559/// `FiberSection3`'s doc comment for why).
560const NSD3: usize = 3;
561/// `5` — number of basic (reduced) dof for `ForceBeamColumn3`: axial
562/// elongation plus two chord-relative end rotations in *each* bending
563/// plane. Torsion is excluded from the basic system entirely (unlike
564/// `NSD3`'s `N`/`Mz`/`My`, there's no `q6`/`v6` pair) — it's a decoupled
565/// elastic term folded onto the local tangent/resistance directly, the
566/// same treatment `DispBeamColumn3` uses.
567const NBD3: usize = 5;
568
569/// `(basic force, per-section (eps0, kappa_z, kappa_y), basic tangent
570/// stiffness)` — `ForceBeamColumn3`'s counterpart to `StateDeterminationResult`.
571type StateDeterminationResult3 = (
572 SVector<f64, NBD3>,
573 Vec<(f64, f64, f64)>,
574 SMatrix<f64, NBD3, NBD3>,
575);
576
577/// `ForceBeamColumn`'s spatial (biaxial) counterpart: a 2-node, force-based
578/// 3D beam-column. Basic system, force interpolation, and state-
579/// determination algorithm are the direct biaxial generalization of
580/// `ForceBeamColumn`'s (see that type's doc comment for the algorithm
581/// itself, ported unchanged in structure — only `NSD`/`NBD` and the
582/// matrices below grow); torsion is excluded from the basic system and
583/// handled as a decoupled elastic `G*J/L` term, same as `DispBeamColumn3`.
584///
585/// **Basic system**, `v = [v1..v5]`: `v1` axial elongation (same as
586/// `ForceBeamColumn`'s `v1`); `v2`/`v3` the z-bending chord-relative end
587/// rotations, numerically identical in form to `ForceBeamColumn`'s
588/// `v2`/`v3` since local z-bending uses the same `[v, rz]` convention;
589/// `v4`/`v5` the y-bending chord-relative end rotations, built by
590/// substituting `theta_equiv = -ry` into the same construction (matching
591/// `DispBeamColumn3::strain_displacement`'s `b_kappa_y` substitution) —
592/// this flips the sign of `v4`/`v5`'s *rotation* coefficient relative to
593/// `v2`/`v3`'s (their translation coefficients are unchanged, since the
594/// chord slope `psi` is purely geometric and doesn't depend on the
595/// rotation sign convention). Basic forces `q = [q1..q5]` are then
596/// automatically work-conjugate to `v` by construction (`A`/`A^T` share the
597/// same matrix — the standard force-method contragredience), with `q1=N`,
598/// `(q2,q3)` conjugate to `(Mz`'s end values`)`, `(q4,q5)` conjugate to
599/// `My`'s. This whole construction — not just guessed by analogy — is
600/// verified against `ElasticBeamColumn3`'s exact biaxial-bending elastic
601/// stiffness in `core/tests/elements/force_beam.rs`, the same role
602/// `core/tests/elements/force_beam.rs` plays for the planar element.
603#[derive(Debug, Clone)]
604pub struct ForceBeamColumn3 {
605 pub node_i: Node3Id,
606 pub node_j: Node3Id,
607 pub g: f64,
608 pub j: f64,
609 vec_xz: [f64; 3],
610 integration: BeamIntegration,
611 sections: Vec<FiberSection3>,
612 /// Mass per unit volume, same convention as `ForceBeamColumn::density`.
613 pub density: f64,
614 q_commit: SVector<f64, NBD3>,
615 e_commit: Vec<(f64, f64, f64)>,
616 v_commit: SVector<f64, NBD3>,
617 /// See `ForceBeamColumn::load_commit`.
618 load_commit: ElementLoad3,
619 max_iters: usize,
620 tolerance: f64,
621 /// See `ForceBeamColumn::local_force`'s doc comment — same reasoning,
622 /// cached at `commit` time. Includes the decoupled torsion term, same
623 /// as `form_tangent_and_resistance`'s `r_local`.
624 local_force: SpatialElementVector,
625}
626
627impl ForceBeamColumn3 {
628 #[allow(clippy::too_many_arguments)]
629 pub fn new(
630 node_i: Node3Id,
631 node_j: Node3Id,
632 g: f64,
633 j: f64,
634 vec_xz: [f64; 3],
635 fibers: Vec<Fiber3>,
636 integration: BeamIntegration,
637 ) -> Self {
638 let n_points = integration.points().len();
639 let sections = (0..n_points)
640 .map(|_| FiberSection3::new(fibers.clone()))
641 .collect();
642 ForceBeamColumn3 {
643 node_i,
644 node_j,
645 g,
646 j,
647 vec_xz,
648 integration,
649 sections,
650 density: 0.0,
651 q_commit: SVector::<f64, NBD3>::zeros(),
652 e_commit: vec![(0.0, 0.0, 0.0); n_points],
653 v_commit: SVector::<f64, NBD3>::zeros(),
654 load_commit: ElementLoad3::uniform(0.0, 0.0, 0.0),
655 max_iters: 50,
656 tolerance: 1e-12,
657 local_force: SpatialElementVector::zeros(),
658 }
659 }
660
661 pub fn with_density(mut self, density: f64) -> Self {
662 self.density = density;
663 self
664 }
665
666 fn local_displacement(
667 &self,
668 node_i: &Node3,
669 node_j: &Node3,
670 ) -> (f64, SpatialElementMatrix, SpatialElementVector) {
671 let transform = GeomTransf3::linear(self.vec_xz);
672 let (length, r) = transform.local_axes(node_i, node_j);
673 let t = GeomTransf3::rotation_matrix(&r);
674 let d_global = SpatialElementVector::from_iterator(
675 node_i
676 .displacement
677 .iter()
678 .chain(node_j.displacement.iter())
679 .copied(),
680 );
681 (length, t, t * d_global)
682 }
683
684 fn torsion_stiffness(&self, length: f64) -> SpatialElementMatrix {
685 let gj_l = self.g * self.j / length;
686 let mut k = SpatialElementMatrix::zeros();
687 k[(3, 3)] = gj_l;
688 k[(9, 9)] = gj_l;
689 k[(3, 9)] = -gj_l;
690 k[(9, 3)] = -gj_l;
691 k
692 }
693
694 /// Basic deformation `v = A * d_local` — see the type doc comment for
695 /// `A`'s rows. `d_local` is `[u,v,w,rx,ry,rz]` per node; rows here skip
696 /// the torsion columns (`3`, `9`) entirely, since torsion isn't part of
697 /// the basic system.
698 fn basic_deformation_matrix(length: f64) -> SMatrix<f64, NBD3, 12> {
699 let l_inv = 1.0 / length;
700 let mut a = SMatrix::<f64, NBD3, 12>::zeros();
701 // v1: axial.
702 a[(0, 0)] = -1.0;
703 a[(0, 6)] = 1.0;
704 // v2, v3: z-bending ([v, rz] at indices 1,5,7,11).
705 a[(1, 1)] = l_inv;
706 a[(1, 5)] = 1.0;
707 a[(1, 7)] = -l_inv;
708 a[(2, 1)] = l_inv;
709 a[(2, 7)] = -l_inv;
710 a[(2, 11)] = 1.0;
711 // v4, v5: y-bending ([w, ry] at indices 2,4,8,10), rotation
712 // coefficient sign-flipped relative to z-bending (see doc comment).
713 a[(3, 2)] = l_inv;
714 a[(3, 4)] = -1.0;
715 a[(3, 8)] = -l_inv;
716 a[(4, 2)] = l_inv;
717 a[(4, 8)] = -l_inv;
718 a[(4, 10)] = -1.0;
719 a
720 }
721
722 /// Force interpolation `s(x) = b(xi) * q`, `s = [N, Mz, My]` — `Mz`'s
723 /// row is `ForceBeamColumn::b_matrix`'s unchanged (same `[v, rz]`
724 /// convention); `My`'s row has the identical `[xi-1, xi]` form against
725 /// `q4`/`q5`, with no further sign change needed — the rotation-sign
726 /// flip is already fully absorbed into `v4`/`v5`'s definition, and
727 /// work-conjugacy (`q` conjugate to `v`, `My` conjugate to `kappa_y`,
728 /// both by the same construction) carries it through consistently.
729 fn b_matrix(xi: f64) -> SMatrix<f64, NSD3, NBD3> {
730 let mut b = SMatrix::<f64, NSD3, NBD3>::zeros();
731 b[(0, 0)] = 1.0;
732 b[(1, 1)] = xi - 1.0;
733 b[(1, 2)] = xi;
734 b[(2, 3)] = xi - 1.0;
735 b[(2, 4)] = xi;
736 b
737 }
738
739 /// The load's own section force `s_p = [N_p, Mz_p, My_p]` at `xi` on the
740 /// basic system — see `ForceBeamColumn::section_load`. `My_p` is `Mz_p`'s
741 /// form driven by `wz`, with no sign change: the `ry = -dw/dx` flip is
742 /// already absorbed into `b_matrix`'s conjugate `q4`/`q5` (verified
743 /// against `ElasticBeamColumn3` in `core/tests/elements/force_beam_loads.rs`).
744 fn section_load(xi: f64, length: f64, load: &ElementLoad3) -> SVector<f64, NSD3> {
745 let [wx, wy, wz] = load.uniform;
746 let bending = length * length * xi * (xi - 1.0) / 2.0;
747 SVector::<f64, NSD3>::new(wx * length * (1.0 - xi), wy * bending, wz * bending)
748 }
749
750 /// See `ForceBeamColumn::state_determination`'s doc comment — identical
751 /// bisection-subdivision structure, `NSD3`/`NBD3` in place of `NSD`/`NBD`.
752 fn state_determination(
753 &self,
754 v: SVector<f64, NBD3>,
755 load: &ElementLoad3,
756 length: f64,
757 ) -> StateDeterminationResult3 {
758 let mut divisions = 1;
759 loop {
760 let mut q = self.q_commit;
761 let mut e = self.e_commit.clone();
762 let mut k_basic = SMatrix::<f64, NBD3, NBD3>::identity();
763 let mut all_converged = true;
764
765 for step in 1..=divisions {
766 let fraction = step as f64 / divisions as f64;
767 let v_step = self.v_commit + (v - self.v_commit) * fraction;
768 let load_step = self.load_commit * (1.0 - fraction) + *load * fraction;
769 let ((q2, e2, k2), converged) =
770 self.try_state_determination(v_step, &load_step, length, q, e);
771 q = q2;
772 e = e2;
773 k_basic = k2;
774 if !converged {
775 all_converged = false;
776 break;
777 }
778 }
779
780 if all_converged || divisions >= 256 {
781 return (q, e, k_basic);
782 }
783 divisions *= 4;
784 }
785 }
786
787 /// See `ForceBeamColumn::try_state_determination`'s doc comment —
788 /// identical two-stage-per-section-correction, energy-based-convergence
789 /// algorithm, `NSD3`/`NBD3` in place of `NSD`/`NBD` and a 3x3 section
790 /// flexibility (`nalgebra`'s `try_inverse`) in place of the hand-rolled
791 /// `invert_2x2`.
792 fn try_state_determination(
793 &self,
794 v: SVector<f64, NBD3>,
795 load: &ElementLoad3,
796 length: f64,
797 q0: SVector<f64, NBD3>,
798 e0: Vec<(f64, f64, f64)>,
799 ) -> (StateDeterminationResult3, bool) {
800 let points = self.integration.points();
801 let mut q = q0;
802 let mut e = e0;
803 let mut k_basic = SMatrix::<f64, NBD3, NBD3>::identity();
804 let mut converged = false;
805
806 for _ in 0..self.max_iters {
807 let mut flexibility = SMatrix::<f64, NBD3, NBD3>::zeros();
808 let mut vr = SVector::<f64, NBD3>::zeros();
809
810 for (i, (xi, w)) in points.iter().enumerate() {
811 let b = Self::b_matrix(*xi);
812 let target = b * q + Self::section_load(*xi, length, load);
813 let scale = w * length;
814
815 let (n0, mz0, my0, k_sec0) = self.sections[i].trial(e[i].0, e[i].1, e[i].2);
816 let f_sec0 = Matrix3::from_row_slice(&[
817 k_sec0[0][0],
818 k_sec0[0][1],
819 k_sec0[0][2],
820 k_sec0[1][0],
821 k_sec0[1][1],
822 k_sec0[1][2],
823 k_sec0[2][0],
824 k_sec0[2][1],
825 k_sec0[2][2],
826 ])
827 .try_inverse()
828 .expect("fiber section tangent should be nonsingular for a real cross-section");
829 let d0 = f_sec0 * (target - Vector3::new(n0, mz0, my0));
830 e[i].0 += d0[0];
831 e[i].1 += d0[1];
832 e[i].2 += d0[2];
833
834 let (n1, mz1, my1, k_sec1) = self.sections[i].trial(e[i].0, e[i].1, e[i].2);
835 let f_sec1 = Matrix3::from_row_slice(&[
836 k_sec1[0][0],
837 k_sec1[0][1],
838 k_sec1[0][2],
839 k_sec1[1][0],
840 k_sec1[1][1],
841 k_sec1[1][2],
842 k_sec1[2][0],
843 k_sec1[2][1],
844 k_sec1[2][2],
845 ])
846 .try_inverse()
847 .expect("fiber section tangent should be nonsingular for a real cross-section");
848
849 let d1 = f_sec1 * (target - Vector3::new(n1, mz1, my1));
850 let e_star = Vector3::new(e[i].0 + d1[0], e[i].1 + d1[1], e[i].2 + d1[2]);
851
852 flexibility += scale * (b.transpose() * f_sec1 * b);
853 vr += scale * (b.transpose() * e_star);
854 }
855
856 k_basic = match flexibility.try_inverse() {
857 Some(k) if k.iter().all(|x| x.is_finite()) => k,
858 _ => break,
859 };
860 let dv = v - vr;
861 let dq = k_basic * dv;
862 if !dq.iter().all(|x| x.is_finite()) {
863 break;
864 }
865 q += dq;
866
867 if dv.dot(&dq).abs() < self.tolerance {
868 converged = true;
869 break;
870 }
871 }
872
873 ((q, e, k_basic), converged)
874 }
875
876 pub(super) fn form_tangent_and_resistance(
877 &self,
878 node_i: &Node3,
879 node_j: &Node3,
880 load: Option<&ElementLoad3>,
881 ) -> (SpatialElementMatrix, SpatialElementVector) {
882 let load = load.copied().unwrap_or(NO_LOAD3);
883 let (length, t, d_local) = self.local_displacement(node_i, node_j);
884 let a = Self::basic_deformation_matrix(length);
885 let v = a * d_local;
886
887 let (q, _e, k_basic) = self.state_determination(v, &load, length);
888
889 let k_torsion = self.torsion_stiffness(length);
890 let k_local = a.transpose() * k_basic * a + k_torsion;
891 let r_local = a.transpose() * q + k_torsion * d_local;
892
893 (t.transpose() * k_local * t, t.transpose() * r_local)
894 }
895
896 pub(super) fn commit(&mut self, node_i: &Node3, node_j: &Node3, load: Option<&ElementLoad3>) {
897 let load = load.copied().unwrap_or(NO_LOAD3);
898 let (length, _t, d_local) = self.local_displacement(node_i, node_j);
899 let a = Self::basic_deformation_matrix(length);
900 let v = a * d_local;
901
902 let (q, e, _k_basic) = self.state_determination(v, &load, length);
903
904 for (i, (eps0, kappa_z, kappa_y)) in e.iter().enumerate() {
905 self.sections[i].commit(*eps0, *kappa_z, *kappa_y);
906 }
907 self.local_force = a.transpose() * q + self.torsion_stiffness(length) * d_local;
908 self.q_commit = q;
909 self.e_commit = e;
910 self.v_commit = v;
911 self.load_commit = load;
912 }
913
914 /// Spatial counterpart of `ForceBeamColumn::form_load_vector`: the
915 /// load's statically equivalent nodal forces (`wx·L` axial at node i,
916 /// `wy·L/2`/`wz·L/2` shear at each end), global coordinates.
917 pub(super) fn form_load_vector(
918 &self,
919 node_i: &Node3,
920 node_j: &Node3,
921 wx: f64,
922 wy: f64,
923 wz: f64,
924 ) -> SpatialElementVector {
925 if wx == 0.0 && wy == 0.0 && wz == 0.0 {
926 return SpatialElementVector::zeros();
927 }
928 let (length, r) = GeomTransf3::linear(self.vec_xz).local_axes(node_i, node_j);
929 let t = GeomTransf3::rotation_matrix(&r);
930 t.transpose() * Self::static_load_local(length, wx, wy, wz)
931 }
932
933 /// `form_load_vector` in the element-local frame `local_force` reports in.
934 pub(super) fn form_local_load_vector(
935 &self,
936 node_i: &Node3,
937 node_j: &Node3,
938 wx: f64,
939 wy: f64,
940 wz: f64,
941 ) -> SpatialElementVector {
942 let (length, _r) = GeomTransf3::linear(self.vec_xz).local_axes(node_i, node_j);
943 Self::static_load_local(length, wx, wy, wz)
944 }
945
946 fn static_load_local(l: f64, wx: f64, wy: f64, wz: f64) -> SpatialElementVector {
947 let mut local = SpatialElementVector::zeros();
948 local[0] = wx * l;
949 local[1] = wy * l / 2.0;
950 local[7] = wy * l / 2.0;
951 local[2] = wz * l / 2.0;
952 local[8] = wz * l / 2.0;
953 local
954 }
955
956 /// See `ForceBeamColumn::local_force`'s doc comment.
957 pub(super) fn local_force(&self) -> SpatialElementVector {
958 self.local_force
959 }
960
961 /// See `ForceBeamColumn::fiber_responses`'s doc comment — same
962 /// contract, biaxial `e_commit`.
963 pub fn fiber_responses(&self) -> Vec<Vec<(f64, f64)>> {
964 self.e_commit
965 .iter()
966 .zip(&self.sections)
967 .map(|(&(eps0, kappa_z, kappa_y), section)| {
968 section.fiber_responses(eps0, kappa_z, kappa_y)
969 })
970 .collect()
971 }
972
973 pub(super) fn form_mass(&self, node_i: &Node3, node_j: &Node3) -> SpatialElementVector {
974 let transform = GeomTransf3::linear(self.vec_xz);
975 let (length, _r) = transform.local_axes(node_i, node_j);
976 let total_area = self.sections[0].total_area();
977 let half = self.density * total_area * length / 2.0;
978 let mut mass = SpatialElementVector::zeros();
979 for dof in [0, 1, 2, 6, 7, 8] {
980 mass[dof] = half;
981 }
982 mass
983 }
984}