Skip to main content

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}