Skip to main content

carapace_core/analysis/
transient.rs

1use nalgebra::DVector;
2use slotmap::Key;
3
4use crate::model::{
5    Domain, Element, Element3, ElementOps, Node3Id, NodeId, NDF, PLANAR_NDIM, SPATIAL_NDF,
6    SPATIAL_NDIM,
7};
8
9use super::{
10    convergence::ForceTolerance, iterate_to_equilibrium, Algorithm, AnalysisError, ConvergenceTest,
11    GroundMotion, RayleighDamping, SparseFactorization, SparseSolver,
12};
13
14#[derive(Debug, Clone, Copy)]
15pub struct TransientStepResult {
16    pub step: usize,
17    pub time: f64,
18    /// `1` for `Algorithm::Linear` (never iterates — today's original,
19    /// only behavior before `docs/algorithms.md` §6.1's corrector existed).
20    pub iterations: usize,
21    /// How many times the effective operator was actually factored this
22    /// step — see `StepResult::factorizations`' doc comment for what this
23    /// proves about `TangentStrategy`.
24    pub factorizations: usize,
25}
26
27/// Time-history (transient) analysis via Newmark-beta integration — a
28/// distinct type from `Analysis`, not a third `Integrator`/`Algorithm`
29/// variant squeezed into it: static analysis is about a scalar load factor
30/// and (optionally) Newton-iterating displacement at a fixed time, dynamic
31/// analysis is about propagating a very different state (displacement,
32/// velocity, acceleration) forward through *actual* time. Trying to unify
33/// them into one `Analysis`/`AnalysisBuilder` would blur two genuinely
34/// different physical processes for no real benefit — same reasoning as
35/// `modal_analysis` being a free function rather than an `Analysis` mode.
36///
37/// Fixed at the "average acceleration" Newmark parameters
38/// (`beta = 1/4, gamma = 1/2`) — unconditionally stable, the standard
39/// default (matches OpenSees' own `Newmark` integrator default). Exact for
40/// genuinely linear elastic response (`Truss`/
41/// `ZeroLength`/`ElasticBeamColumn` with linear materials) since the
42/// tangent stiffness formed once per step doesn't change with displacement
43/// — one linear solve per step, no Newton iteration, mirroring `Algorithm::
44/// Linear`'s same caveat for the static case. A Newton-iterated corrector
45/// (needed once nonlinear materials are driven dynamically) is a natural
46/// extension, not built until something needs it.
47///
48/// Generic over the same kinematic profile as `Domain`/`Analysis` (see
49/// `Domain`'s doc comment) — Newmark stepping, the initial-acceleration
50/// solve, and ground-motion excitation only ever touch `domain` through its
51/// generic free-DOF interface (mass diagonal, gather/scatter, tangent
52/// assembly, `direction_incidence`), never anything DOF-count-specific, so
53/// one implementation covers both `Domain`/`Domain3`. Defaults to the
54/// planar profile, so bare `TransientAnalysis` keeps working unchanged;
55/// `TransientAnalysis3` (spatial) is a type alias below. A spatial
56/// `GroundMotion::direction` of `0..3` selects `ux`/`uy`/`uz`, unchanged
57/// from planar beyond the extra valid index.
58pub struct TransientAnalysis<
59    const NDIM: usize = PLANAR_NDIM,
60    const NDOF: usize = NDF,
61    NId = NodeId,
62    E = Element,
63> where
64    NId: Key,
65    E: ElementOps<NDIM, NDOF, NId>,
66{
67    domain: Domain<NDIM, NDOF, NId, E>,
68    mass: DVector<f64>,
69    damping: RayleighDamping,
70    dt: f64,
71    solver: SparseSolver,
72    time: f64,
73    step_count: usize,
74    /// Each active `GroundMotion` paired with its precomputed `mass ⊙
75    /// direction_incidence` (`Domain::direction_incidence`) — computed once
76    /// (here, and again whenever `with_ground_motion` adds one), not every
77    /// step, mirroring how `mass` itself is precomputed once.
78    ground_motions: Vec<(GroundMotion, DVector<f64>)>,
79    /// `Algorithm::Linear` (today's original, only behavior) unless
80    /// `with_algorithm` opts into a real Newton corrector — see
81    /// `docs/algorithms.md` §6.1 and `with_algorithm`'s doc comment.
82    algorithm: Algorithm,
83    test: ConvergenceTest,
84    /// Only ever populated/read when `algorithm` is `Newton`/`KrylovNewton`
85    /// — see `Analysis::cached_factorization`'s doc comment for what this
86    /// caches and why it persists across `step()` calls.
87    cached_factorization: Option<SparseFactorization>,
88}
89
90/// `TransientAnalysis`'s spatial instantiation — see `Domain3`'s doc
91/// comment for why this is a type alias rather than a hand-duplicated
92/// struct.
93pub type TransientAnalysis3 = TransientAnalysis<SPATIAL_NDIM, SPATIAL_NDF, Node3Id, Element3>;
94
95impl<const NDIM: usize, const NDOF: usize, NId, E> TransientAnalysis<NDIM, NDOF, NId, E>
96where
97    NId: Key,
98    E: ElementOps<NDIM, NDOF, NId> + Clone,
99    E::Load: Clone,
100{
101    /// Builds a transient analysis from a domain whose nodes may already
102    /// carry initial conditions (`Node::with_initial_displacement`/
103    /// `with_initial_velocity`). Computes the one thing Newmark needs that
104    /// isn't a direct initial condition: a consistent initial acceleration
105    /// from equilibrium at t=0, `M*a0 = F0 - K*u0 - C*v0 - M*ι*ag(0)` (the
106    /// last term only present once `with_ground_motion` adds one — see
107    /// `recompute_initial_acceleration`). `M` is diagonal, so this is a
108    /// single elementwise division, no solve needed.
109    pub fn new(
110        mut domain: Domain<NDIM, NDOF, NId, E>,
111        damping: RayleighDamping,
112        dt: f64,
113    ) -> Result<Self, AnalysisError> {
114        assert!(dt > 0.0, "Newmark dt must be positive");
115
116        domain.validate().map_err(AnalysisError::InvalidModel)?;
117        let mass = domain.assemble_mass_diagonal();
118        let n = domain.num_free_dofs();
119        for i in 0..n {
120            if !mass[i].is_finite() || mass[i] < 0.0 {
121                return Err(AnalysisError::SingularSystem);
122            }
123        }
124
125        let mut analysis = TransientAnalysis {
126            domain,
127            mass,
128            damping,
129            dt,
130            solver: SparseSolver::new(),
131            time: 0.0,
132            step_count: 0,
133            ground_motions: Vec::new(),
134            algorithm: Algorithm::Linear,
135            // Never read while `algorithm` is `Linear` — a placeholder
136            // that would be an obviously-wrong choice if `with_algorithm`
137            // were accidentally skipped, rather than a plausible-looking
138            // default silently doing the wrong thing.
139            test: ConvergenceTest::NormUnbalance {
140                tol: f64::NAN,
141                max_iter: 0,
142            },
143            cached_factorization: None,
144        };
145        analysis.recompute_initial_acceleration();
146        Ok(analysis)
147    }
148
149    /// Opts into a real Newton corrector (`docs/algorithms.md` §6.1)
150    /// instead of the default `Algorithm::Linear` (today's original
151    /// behavior: one effective-system solve per step, no iteration, exact
152    /// only for linear-material response). Mirrors `with_ground_motion`'s
153    /// consuming-builder style; unlike it, doesn't need to re-derive
154    /// anything (the initial acceleration doesn't depend on `algorithm`).
155    pub fn with_algorithm(mut self, algorithm: Algorithm, test: ConvergenceTest) -> Self {
156        self.algorithm = algorithm;
157        self.test = test;
158        self
159    }
160
161    /// Adds a `GroundMotion` and re-derives the initial acceleration to
162    /// include its contribution at `t=0` (usually zero — accelerograms
163    /// conventionally start at `ag(0) = 0` — but not assumed). Must be
164    /// called before any `step()`; re-scatters/re-commits the domain's
165    /// state exactly as `new` did, so it's equivalent to having passed this
166    /// motion to `new` directly.
167    pub fn with_ground_motion(mut self, motion: GroundMotion) -> Self {
168        let incidence = self.domain.direction_incidence(motion.direction);
169        let mass_incidence = self.mass.component_mul(&incidence);
170        self.ground_motions.push((motion, mass_incidence));
171        self.recompute_initial_acceleration();
172        self
173    }
174
175    fn recompute_initial_acceleration(&mut self) {
176        let n = self.domain.num_free_dofs();
177        let u0 = self.domain.gather_displacement();
178        let v0 = self.domain.gather_velocity();
179        let (_k, resistance0) = self.domain.assemble_tangent_and_resistance(self.time);
180        let load0 = self.domain.assemble_reference_load(self.time);
181        let k_v0 = self.domain.multiply_stiffness(&v0, self.time);
182        let ground_force0 = self.ground_force(self.time);
183
184        let mut a0 = DVector::<f64>::zeros(n);
185        for i in 0..n {
186            let c_v0_i =
187                self.damping.alpha_m * self.mass[i] * v0[i] + self.damping.beta_k * k_v0[i];
188            // Massless equilibrium rows (e.g. beam rotations) are solved through
189            // K + a1 M + a4 C on the first step; they carry no inertial force.
190            // Keep their initial acceleration at zero instead of dividing by zero.
191            if self.mass[i] > 0.0 {
192                a0[i] = (load0[i] - resistance0[i] - c_v0_i + ground_force0[i]) / self.mass[i];
193            }
194        }
195        self.domain.scatter_state(&u0, &v0, &a0);
196        // Align committed material state with the given initial condition
197        // (matters if `with_initial_displacement` was used) before any
198        // stepping begins — see `Material`'s doc comment.
199        self.domain.commit(self.time);
200    }
201
202    /// `-M·ι·ag(t)`, summed over every active `GroundMotion` — the
203    /// effective inertial force contribution to `f_eff` (`try_step`) and to
204    /// the initial-acceleration equilibrium (`recompute_initial_acceleration`).
205    fn ground_force(&self, time: f64) -> DVector<f64> {
206        let mut force = DVector::<f64>::zeros(self.domain.num_free_dofs());
207        for (motion, mass_incidence) in &self.ground_motions {
208            let ag = motion.acceleration(time);
209            if ag != 0.0 {
210                force -= mass_incidence * ag;
211            }
212        }
213        force
214    }
215
216    pub fn domain(&self) -> &Domain<NDIM, NDOF, NId, E> {
217        &self.domain
218    }
219
220    /// Support reaction including the uniform-excitation inertial load at that
221    /// restrained DOF. Like OpenSees' plain reaction recorder, excludes damping
222    /// and relative inertial forces (use relative acceleration separately).
223    pub fn reaction(&self, node: NId, dof: usize) -> f64 {
224        let acceleration: f64 = self
225            .ground_motions
226            .iter()
227            .filter(|(motion, _)| motion.direction == dof)
228            .map(|(motion, _)| motion.acceleration(self.time))
229            .sum();
230        self.domain.reaction(node, dof, self.time)
231            + self.domain.lumped_mass_at(node, dof) * acceleration
232    }
233
234    /// Ends this (dynamic) phase and hands back the `Domain` — symmetric
235    /// with `Analysis::into_domain`, so a dynamic phase can itself be
236    /// followed by another phase (static or dynamic).
237    pub fn into_domain(self) -> Domain<NDIM, NDOF, NId, E> {
238        self.domain
239    }
240
241    pub fn time(&self) -> f64 {
242        self.time
243    }
244
245    /// Advance one Newmark step of size `dt`. External load each step is
246    /// `Domain::assemble_reference_load(time)` (every `LoadPattern`'s
247    /// current/frozen contribution — see its doc comment) plus each active
248    /// `GroundMotion`'s effective inertial force at `time` (`ground_force`).
249    ///
250    /// On failure, the domain and `time`/`step_count` are restored to their
251    /// state before this call — same reasoning as `Analysis::step`.
252    pub fn step(&mut self) -> Result<TransientStepResult, AnalysisError> {
253        let snapshot = self.domain.clone();
254        let (snapshot_time, snapshot_step_count) = (self.time, self.step_count);
255
256        let result = self.try_step();
257
258        if result.is_err() {
259            self.domain = snapshot;
260            self.time = snapshot_time;
261            self.step_count = snapshot_step_count;
262        } else {
263            self.domain.commit(self.time);
264        }
265
266        result
267    }
268
269    fn try_step(&mut self) -> Result<TransientStepResult, AnalysisError> {
270        self.step_count += 1;
271        self.time += self.dt;
272
273        let (beta, gamma, dt) = (0.25, 0.5, self.dt);
274        let a1 = 1.0 / (beta * dt * dt);
275        let a2 = 1.0 / (beta * dt);
276        let a3 = 1.0 / (2.0 * beta) - 1.0;
277        let a4 = gamma / (beta * dt);
278        let a5 = gamma / beta - 1.0;
279        let a6 = dt * (gamma / (2.0 * beta) - 1.0);
280
281        let u_n = self.domain.gather_displacement();
282        let v_n = self.domain.gather_velocity();
283        let a_n = self.domain.gather_acceleration();
284
285        let mass_coeff = a1 + a4 * self.damping.alpha_m;
286        let stiffness_coeff = 1.0 + a4 * self.damping.beta_k;
287        let time = self.time;
288        let external = self.domain.assemble_reference_load(time) + self.ground_force(time);
289
290        let (iterations, factorizations) = match self.algorithm {
291            // One effective-system solve at `u_n`, unconditionally
292            // accepted — today's original behavior, kept byte-for-byte
293            // (see `docs/algorithms.md` §6.1's load-bearing correctness
294            // check: the `Newton`/`KrylovNewton` arm below reduces to
295            // exactly this for a linear-material model converged in one
296            // iteration — verified in `m21_transient_corrector.rs` — so
297            // this arm isn't strictly *necessary*, but keeping it removes
298            // any risk of this well-tested closed-form path regressing).
299            Algorithm::Linear => {
300                // Newmark predictor terms — see the module doc comment's
301                // citation of the standard (e.g. Chopra, "Dynamics of
302                // Structures") formula this whole arm derives from.
303                let mass_vec = &u_n * a1 + &v_n * a2 + &a_n * a3;
304                let damp_vec = &u_n * a4 + &v_n * a5 + &a_n * a6;
305
306                let (k_eff, _resistance) = self.domain.assemble_newmark_system(
307                    &self.mass,
308                    mass_coeff,
309                    stiffness_coeff,
310                    self.time,
311                );
312                let k_damp_vec = self.domain.multiply_stiffness(&damp_vec, self.time);
313
314                let n = self.domain.num_free_dofs();
315                let mut f_eff = external;
316                for i in 0..n {
317                    f_eff[i] += self.mass[i] * (mass_vec[i] + self.damping.alpha_m * damp_vec[i])
318                        + self.damping.beta_k * k_damp_vec[i];
319                }
320
321                let u_new = self.solver.solve(&k_eff, &f_eff)?;
322
323                let delta_u = &u_new - &u_n;
324                let a_new = &delta_u * a1 - &v_n * a2 - &a_n * a3;
325                let v_new = &delta_u * a4 - &v_n * a5 - &a_n * a6;
326                self.domain.scatter_state(&u_new, &v_new, &a_new);
327                (1, 1)
328            }
329            // Newton corrector (`docs/algorithms.md` §6.1): iterate the
330            // *effective* operator `Op = stiffness_coeff*K(u_trial) +
331            // mass_coeff*M` against the residual `external - F_int(u_trial)
332            // - C*v_trial - M*a_trial`, `v_trial`/`a_trial` recomputed each
333            // iteration from the *cumulative* displacement change since
334            // step start (`u_trial - u_n`), via the same Newmark relations
335            // the `Linear` arm above uses only once, at the end.
336            Algorithm::Newton { .. } | Algorithm::KrylovNewton { .. } => {
337                let algorithm = self.algorithm;
338                let solver = &self.solver;
339                let mass = &self.mass;
340                let (alpha_m, beta_k) = (self.damping.alpha_m, self.damping.beta_k);
341
342                // `v_trial`/`a_trial` at the domain's current cumulative
343                // displacement change since step start — shared by
344                // `form_system` (needs them for the residual) and
345                // `after_increment` (needs them to update the domain's
346                // velocity/acceleration state), so it's a closure of its
347                // own rather than duplicated inline in both places.
348                let newmark_state = |u_trial: &DVector<f64>| {
349                    let delta_u = u_trial - &u_n;
350                    let v_trial = &delta_u * a4 - &v_n * a5 - &a_n * a6;
351                    let a_trial = &delta_u * a1 - &v_n * a2 - &a_n * a3;
352                    (v_trial, a_trial)
353                };
354
355                // `Combined`'s per-equation reference: this step's external
356                // force and the committed internal force.
357                let force_tolerance = matches!(self.test, ConvergenceTest::Combined { .. })
358                    .then(|| {
359                        let (_k, resistance) =
360                            self.domain.assemble_tangent_and_resistance(self.time);
361                        ForceTolerance::for_test(
362                            &self.test,
363                            &self.domain.rotational_equations(),
364                            &[&external, &resistance],
365                        )
366                    })
367                    .flatten();
368
369                let (outcome, _) = iterate_to_equilibrium(
370                    self.step_count,
371                    &algorithm,
372                    &self.test,
373                    solver,
374                    &mut self.cached_factorization,
375                    &mut self.domain,
376                    0.0,
377                    |domain, _scalar| {
378                        let u_trial = domain.gather_displacement();
379                        let (v_trial, a_trial) = newmark_state(&u_trial);
380                        let (k_eff, resistance) =
381                            domain.assemble_newmark_system(mass, mass_coeff, stiffness_coeff, time);
382                        let k_v = domain.multiply_stiffness(&v_trial, time);
383
384                        let n = resistance.len();
385                        let mut residual = external.clone();
386                        for i in 0..n {
387                            residual[i] -= resistance[i]
388                                + alpha_m * mass[i] * v_trial[i]
389                                + beta_k * k_v[i]
390                                + mass[i] * a_trial[i];
391                        }
392                        (k_eff, residual)
393                    },
394                    // No `Integrator::DisplacementControl`-style corrector
395                    // here — `TransientAnalysis` has no solved-for pseudo-
396                    // time/load-factor scalar at all (`time` is fixed for
397                    // the whole step by `dt`), so this hook is unused.
398                    |_domain, _k, _current_factorization, du, _scalar, _iteration| Ok((du, 0.0)),
399                    |domain| {
400                        let u_trial = domain.gather_displacement();
401                        let (v_trial, a_trial) = newmark_state(&u_trial);
402                        domain.scatter_state(&u_trial, &v_trial, &a_trial);
403                    },
404                    force_tolerance.as_ref(),
405                )?;
406                (outcome.iterations, outcome.factorizations)
407            }
408        };
409
410        Ok(TransientStepResult {
411            step: self.step_count,
412            time: self.time,
413            iterations,
414            factorizations,
415        })
416    }
417}