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}