Skip to main content

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>;