carapace_core/analysis/state.rs
1use slotmap::Key;
2
3use crate::model::{
4 Domain, Element, Element3, ElementOps, Node3Id, NodeId, NDF, PLANAR_NDIM, SPATIAL_NDF,
5 SPATIAL_NDIM,
6};
7
8use super::arclength::{integrator_change_invalidates, ArcState};
9use super::bordered::BorderedSolver;
10use super::convergence::ForceTolerance;
11use super::{
12 iterate_to_equilibrium, Algorithm, AnalysisError, ArcStepInfo, ConstraintHandler,
13 ConvergenceTest, Integrator, SparseFactorization, SparseSolver,
14};
15
16/// A fully-wired analysis (only buildable via `AnalysisBuilder<Ready>::build`).
17///
18/// Generic over the same kinematic profile as `Domain` (see its doc
19/// comment) — `Analysis`'s step/iteration logic doesn't care about element
20/// physics either, only about `Domain`'s generic assembly interface, so one
21/// implementation covers both profiles. Defaults to the planar profile, so
22/// bare `Analysis` keeps working unchanged; `Analysis3` (spatial) is a type
23/// alias below.
24pub struct Analysis<
25 const NDIM: usize = PLANAR_NDIM,
26 const NDOF: usize = NDF,
27 NId = NodeId,
28 E = Element,
29> where
30 NId: Key,
31 E: ElementOps<NDIM, NDOF, NId>,
32{
33 pub(crate) domain: Domain<NDIM, NDOF, NId, E>,
34 /// Only actually consulted at `AnalysisBuilder<Ready>::build` time (to
35 /// reject `Plain` against a domain with multi-point constraints) —
36 /// kept here rather than dropped after `build` so `Analysis` still
37 /// records which strategy it was built with.
38 #[allow(dead_code)]
39 pub(crate) constraint_handler: ConstraintHandler,
40 pub(crate) integrator: Integrator<NId>,
41 pub(crate) algorithm: Algorithm,
42 pub(crate) test: ConvergenceTest,
43 pub(crate) solver: SparseSolver,
44 pub(crate) step_count: usize,
45 pub(crate) load_factor: f64,
46 /// Cached across `step()` calls (not reset each time) so
47 /// `TangentStrategy::Initial` (`docs/algorithms.md` §3) can actually
48 /// skip re-factoring for the whole analysis's lifetime, not just
49 /// within one step — `iterate_to_equilibrium` is the only thing that
50 /// reads/writes this.
51 pub(crate) cached_factorization: Option<SparseFactorization>,
52 /// Accepted arc-length continuation history (`Integrator::ArcLength`
53 /// only): the metric, last accepted direction, next radius, chord
54 /// length and stop state. `None` until the first arc step, and cleared
55 /// whenever the path definition may have changed (`set_integrator`,
56 /// `domain_mut`) so the next step re-validates from scratch.
57 pub(crate) arc: Option<Box<ArcState>>,
58 /// The bordered system's ordering cache — separate from `solver`'s
59 /// `n`-DOF cache so neither evicts the other.
60 pub(crate) bordered: BorderedSolver,
61}
62
63#[derive(Debug, Clone, Copy)]
64pub struct StepResult {
65 pub step: usize,
66 pub load_factor: f64,
67 /// `1` for `Algorithm::Linear` (never iterates). `docs/algorithms.md`
68 /// §8's verification hook for `TangentStrategy`/line search.
69 pub iterations: usize,
70 /// How many times the tangent was actually factored this step — `1`
71 /// for `Algorithm::Linear`; proves `TangentStrategy::ReuseAtStepStart`/
72 /// `Initial` actually save factorizations, not just "also converges"
73 /// (`docs/algorithms.md` §8).
74 pub factorizations: usize,
75 /// Continuation diagnostics, for `Integrator::ArcLength` steps only.
76 /// For those, `iterations` counts corrector solves in the accepted
77 /// attempt and `factorizations` counts bordered factorizations across
78 /// every attempt, predictor included.
79 pub arc: Option<ArcStepInfo>,
80}
81
82impl<const NDIM: usize, const NDOF: usize, NId, E> Analysis<NDIM, NDOF, NId, E>
83where
84 NId: Key,
85 E: ElementOps<NDIM, NDOF, NId> + Clone,
86 E::Load: Clone,
87{
88 pub fn domain(&self) -> &Domain<NDIM, NDOF, NId, E> {
89 &self.domain
90 }
91
92 /// Ends this phase and hands back the `Domain` (nodal state, committed
93 /// material history, load-pattern freeze state, all intact) so a
94 /// caller can compose the next phase — a fresh `AnalysisBuilder::build`
95 /// (a different `Integrator`/`Algorithm`/`ConvergenceTest` allowed) or
96 /// `TransientAnalysis::new` for a dynamic phase. Typically preceded by
97 /// `domain_mut().hold_pattern_constant(pattern, load_factor)` on
98 /// whichever pattern(s) should stop ramping (e.g. gravity) before the
99 /// next phase starts.
100 pub fn into_domain(self) -> Domain<NDIM, NDOF, NId, E> {
101 self.domain
102 }
103
104 /// Mutable access to the domain mid-analysis — needed to call
105 /// `Domain::hold_pattern_constant` right before `into_domain()` (the
106 /// pattern must be frozen while `self.load_factor`, the pseudo-time to
107 /// freeze at, is still known).
108 ///
109 /// Granting mutable access discards arc-length continuation history and
110 /// cached factorizations: the caller may change loads or the model, so
111 /// the next arc step re-validates equilibrium and load sensitivity.
112 pub fn domain_mut(&mut self) -> &mut Domain<NDIM, NDOF, NId, E> {
113 self.arc = None;
114 self.cached_factorization = None;
115 &mut self.domain
116 }
117
118 /// Swap the integrator without rebuilding `Analysis` — no
119 /// `Domain::number_dofs`, no new `SparseSolver`, `step_count`/
120 /// `load_factor` carry over unchanged. This is what makes a cyclic/
121 /// quasi-static protocol (many small `DisplacementControl` legs with
122 /// varying sign/magnitude) cheap: build one `Analysis`, then loop
123 /// `analysis.set_integrator(Integrator::DisplacementControl { increment: next, .. }); analysis.step()?;`
124 /// over the protocol's prescribed excursions — the protocol itself
125 /// (a `Vec<f64>` of signed increments) is caller-side data, not
126 /// something `Analysis` needs to model. For a bigger phase transition
127 /// (e.g. gravity → pushover, where `Algorithm`/`ConvergenceTest` also
128 /// typically change and a pattern needs freezing), use
129 /// `into_domain()` + a fresh `AnalysisBuilder` instead.
130 ///
131 /// Arc-length continuation history survives only a switch between two
132 /// `ArcLength` configurations with the same path definition (scales,
133 /// direction, seed, predictor) — e.g. new radius bounds or stop
134 /// criteria. Anything else starts the next arc step afresh.
135 pub fn set_integrator(&mut self, integrator: Integrator<NId>)
136 where
137 NId: PartialEq,
138 {
139 if integrator_change_invalidates(&self.integrator, &integrator) {
140 self.arc = None;
141 } else if let Some(state) = &mut self.arc {
142 state.reopen();
143 }
144 self.integrator = integrator;
145 }
146
147 /// Advance one step: the integrator predicts this step's load factor,
148 /// then the algorithm resolves equilibrium at that (fixed) load factor.
149 ///
150 /// On failure, the domain (nodal displacement — mutated eagerly every
151 /// Newton iteration, which is correct Newton behavior, but shouldn't
152 /// be left half-applied if the step as a whole doesn't converge) is
153 /// restored to its state before this call. Materials never need this:
154 /// see `Material`'s doc comment for why they're never mutated until
155 /// `Domain::commit`, which only runs on the success path below.
156 pub fn step(&mut self) -> Result<StepResult, AnalysisError> {
157 self.step_prescribed(&[])
158 }
159
160 /// `step`, after first imposing `(node, dof, value)` displacements on
161 /// fixed DOFs (non-homogeneous single-point constraints, see
162 /// `Domain::prescribe_displacement`): the free DOFs then equilibrate
163 /// against the displaced supports at this step's load factor. Pair it
164 /// with `Integrator::LoadControl { increment: 0.0 }` for a pure
165 /// prescribed-displacement step that needs no reference load and no
166 /// tangent at the imposed DOF — unlike `DisplacementControl`, it is
167 /// well-defined on a zero-tangent branch. Read the force back with
168 /// `Domain::reaction`.
169 ///
170 /// Targets are validated before anything changes: an unfixed DOF or
171 /// nonfinite value is `InvalidConstraint` and leaves the analysis
172 /// untouched. Once validated, the update is atomic with the step — on
173 /// failure the imposed displacements are rolled back along with the
174 /// rest of the domain, so the last committed state stays intact for a
175 /// retry. Arc-length continuation has no meaningful prescribed-value
176 /// step and rejects any targets.
177 pub fn step_prescribed(
178 &mut self,
179 targets: &[(NId, usize, f64)],
180 ) -> Result<StepResult, AnalysisError> {
181 if !targets
182 .iter()
183 .all(|&(node, dof, value)| self.domain.can_prescribe(node, dof, value))
184 {
185 return Err(AnalysisError::InvalidConstraint);
186 }
187 if let Integrator::ArcLength(config) = &self.integrator {
188 if !targets.is_empty() {
189 return Err(AnalysisError::InvalidConstraint);
190 }
191 let config = config.clone();
192 return self.arc_step(&config);
193 }
194 let snapshot = self.domain.clone();
195 for &(node, dof, value) in targets {
196 self.domain.prescribe_displacement(node, dof, value);
197 }
198 if !targets.is_empty() {
199 // The tangent at the displaced state is not the cached one.
200 self.cached_factorization = None;
201 }
202 let (snapshot_step_count, snapshot_load_factor) = (self.step_count, self.load_factor);
203
204 let result = self.try_step();
205
206 if result.is_err() {
207 self.domain = snapshot;
208 self.step_count = snapshot_step_count;
209 self.load_factor = snapshot_load_factor;
210 } else {
211 self.domain.commit(self.load_factor);
212 }
213
214 result
215 }
216
217 fn try_step(&mut self) -> Result<StepResult, AnalysisError> {
218 self.step_count += 1;
219 // `Combined`'s per-equation reference (committed and predicted
220 // external force, committed internal force), frozen for the step.
221 let committed_forces = matches!(self.test, ConvergenceTest::Combined { .. }).then(|| {
222 let (_k, internal) = self
223 .domain
224 .assemble_tangent_and_resistance(self.load_factor);
225 (
226 self.domain.assemble_reference_load(self.load_factor),
227 internal,
228 )
229 });
230 let (predicted_load_factor, predictor) =
231 self.integrator
232 .predict(&self.domain, &self.solver, self.load_factor)?;
233 self.load_factor = predicted_load_factor;
234 let force_tolerance = committed_forces.and_then(|(external, internal)| {
235 let predicted = self.domain.assemble_reference_load(self.load_factor);
236 ForceTolerance::for_test(
237 &self.test,
238 &self.domain.rotational_equations(),
239 &[&external, &predicted, &internal],
240 )
241 });
242
243 let (iterations, factorizations) = match self.algorithm {
244 // A single tangent formation + solve, unconditionally accepted
245 // — definitionally converged for `Linear` (see algorithm.rs).
246 // Kept as its own trivial arm rather than routed through
247 // `iterate_to_equilibrium` — there's no iteration to share the
248 // driver's machinery with (`docs/algorithms.md` §5's driver
249 // doc comment).
250 Algorithm::Linear => {
251 let (k, residual) = self.domain.form_tangent_and_residual(self.load_factor);
252 let du = self.solver.solve(&k, &residual)?;
253 self.domain.apply_displacement_increment(&du);
254 (1, 1)
255 }
256 Algorithm::Newton { .. } | Algorithm::KrylovNewton { .. } => {
257 // `Linear` keeps `predict`'s pseudo-time only: its single
258 // tangent solve already is the step's displacement.
259 if let Some(du) = &predictor {
260 self.domain.apply_displacement_increment(du);
261 }
262 let integrator = &self.integrator;
263 let solver = &self.solver;
264 let (outcome, load_factor) = iterate_to_equilibrium(
265 self.step_count,
266 &self.algorithm,
267 &self.test,
268 solver,
269 &mut self.cached_factorization,
270 &mut self.domain,
271 self.load_factor,
272 |domain, load_factor| domain.form_tangent_and_residual(load_factor),
273 |domain, k, current_factorization, du_bar, load_factor, _iteration| {
274 let (delta_lambda, du) = integrator.correct(
275 domain,
276 solver,
277 k,
278 current_factorization,
279 du_bar,
280 load_factor,
281 )?;
282 Ok((du, delta_lambda))
283 },
284 |_domain| {},
285 force_tolerance.as_ref(),
286 )?;
287 self.load_factor = load_factor;
288 (outcome.iterations, outcome.factorizations)
289 }
290 };
291
292 Ok(StepResult {
293 step: self.step_count,
294 load_factor: self.load_factor,
295 iterations,
296 factorizations,
297 arc: None,
298 })
299 }
300}
301
302/// `Analysis`'s spatial instantiation — see `Domain3`'s doc comment for why
303/// this is a type alias rather than a hand-duplicated struct.
304pub type Analysis3 = Analysis<SPATIAL_NDIM, SPATIAL_NDF, Node3Id, Element3>;