carapace_core/model/domain.rs
1use std::collections::HashSet;
2
3use faer::sparse::Triplet;
4use nalgebra::{DVector, SMatrix, SVector};
5use slotmap::{Key, SlotMap};
6
7use super::constraint::{self, LinearConstraint, ResolveError};
8use super::dof_table::{DofEntry, DofTable};
9use super::load_pattern::{
10 active_element_patterns, effective_element_load, ElementLoadComponents, LoadPattern,
11};
12use super::{
13 Axis3, DofRef, Element, Element3, ElementForce, ElementOps, GaussResponse, LoadPatternId,
14 LoadSeries, ModelError, Node, Node3Id, NodeId, NodeView, ShellResponse, SparseMatrix,
15 TangentSink, VectorSink, NDF, PLANAR_NDIM, SPATIAL_NDF, SPATIAL_NDIM,
16};
17
18/// Every `(equation, coefficient)` term one node-dof contributes to the
19/// assembled free-DOF system: none for a fixed or inactive dof, a single
20/// `(eq, 1.0)` for an ordinary free or identity-tied dof, or any number of
21/// terms for a constrained dof (a slave's value is a linear combination of
22/// free unknowns). Borrowed from the `DofTable`'s term arena, so there is no
23/// cap on the number of terms and no allocation in the assembly loop.
24#[derive(Debug, Clone, Copy)]
25enum DofTerms<'a> {
26 None,
27 One([(usize, f64); 1]),
28 Many(&'a [(usize, f64)]),
29}
30
31impl DofTerms<'_> {
32 fn as_slice(&self) -> &[(usize, f64)] {
33 match self {
34 DofTerms::None => &[],
35 DofTerms::One(term) => term,
36 DofTerms::Many(terms) => terms,
37 }
38 }
39
40 fn iter(&self) -> std::slice::Iter<'_, (usize, f64)> {
41 self.as_slice().iter()
42 }
43}
44
45/// Owns all nodes, elements, and load patterns. No serialization/broker
46/// machinery — this is the whole model, in memory, for one worker.
47/// `Clone` backs `Analysis`/`TransientAnalysis`'s snapshot-and-restore-on-
48/// failure (a failed step shouldn't leave nodal displacement/velocity/
49/// acceleration partway through a discarded Newton iteration) — see
50/// `Material`'s doc comment for why materials themselves don't need this
51/// protection. Also what makes multi-phase analysis composition possible:
52/// `Analysis::into_domain`/`TransientAnalysis::into_domain` hand this same
53/// `Domain` (nodal state, committed material history, and load-pattern
54/// freeze state all intact) to the next phase's builder.
55///
56/// Generic over the kinematic profile: `NDIM`/`NDOF`/`ELEMENT_DOF` (const
57/// generics, kept as separate parameters rather than computed from each
58/// other — stable Rust can't evaluate `2 * NDOF` inside a generic item), the
59/// node ID type, and `E: ElementOps<...>` (the element catalog — `Element`
60/// or `Element3`; its `Domain`-element-store key comes from `E::Id`, an
61/// `ElementOps` associated type rather than a further generic parameter
62/// here — see `ElementOps::Id`'s doc comment for why: a truly independent
63/// `EId` parameter had nothing else to constrain it and broke type
64/// inference at every `Domain::new()` call site). This bookkeeping
65/// (equation numbering, sparse assembly, state gather/scatter) doesn't care
66/// about element physics, only about DOF counts and `ElementOps`'s uniform
67/// shape, so it's genuinely one implementation rather than two — unlike
68/// `Truss`/`Truss3`, whose actual formulas differ and stay separate
69/// concrete types (see `ElementOps`'s doc comment for why that part can't
70/// be unified the same way).
71///
72/// All five parameters default to the planar profile, the same trick `Node`
73/// uses, so every existing bare `Domain` usage (`Domain::new()`, `fn f(d:
74/// &Domain)`, ...) keeps compiling unchanged. `Domain3` is the spatial
75/// instantiation.
76#[derive(Debug)]
77pub struct Domain<
78 const NDIM: usize = PLANAR_NDIM,
79 const NDOF: usize = NDF,
80 NId = NodeId,
81 E = Element,
82> where
83 NId: Key,
84 E: ElementOps<NDIM, NDOF, NId>,
85{
86 nodes: SlotMap<NId, Node<NDIM, NDOF>>,
87 elements: SlotMap<E::Id, E>,
88 constraints: Vec<LinearConstraint<NId>>,
89 /// The first structural problem `number_dofs` found in `constraints`
90 /// (duplicate slave, cycle, fixed slave); reported by `validate`.
91 constraint_error: Option<ModelError>,
92 load_patterns: SlotMap<LoadPatternId, LoadPattern<NDOF, NId, E::Id, E::Load>>,
93 default_pattern: LoadPatternId,
94 num_free_dofs: usize,
95 /// Pseudo-time of the last `commit` — what analyses that run on the
96 /// committed state without a time of their own (modal) evaluate element
97 /// loads at.
98 committed_time: f64,
99 /// How every node DOF maps onto the free-DOF system (equation number,
100 /// or resolved terms for constrained DOFs),
101 /// rebuilt by `number_dofs`.
102 dofs: DofTable<NId, NDOF>,
103}
104
105/// `Domain`'s spatial instantiation — six-DOF `Node3`/`Element3`. See
106/// `Domain`'s doc comment for why this is a type alias over one generic
107/// implementation rather than a hand-duplicated struct.
108pub type Domain3 = Domain<SPATIAL_NDIM, SPATIAL_NDF, Node3Id, Element3>;
109
110/// Manual rather than `#[derive(Clone)]`: the derive macro only adds `E:
111/// Clone`, not `E::Load: Clone` (it can't see through the associated type),
112/// so it under-constrains this struct and fails to compile at every call
113/// site instead. `Analysis::step`'s snapshot-and-restore-on-failure needs
114/// this — see `Domain`'s doc comment.
115impl<const NDIM: usize, const NDOF: usize, NId, E> Clone for Domain<NDIM, NDOF, NId, E>
116where
117 NId: Key,
118 E: ElementOps<NDIM, NDOF, NId> + Clone,
119 E::Load: Clone,
120{
121 fn clone(&self) -> Self {
122 Domain {
123 nodes: self.nodes.clone(),
124 elements: self.elements.clone(),
125 constraints: self.constraints.clone(),
126 constraint_error: self.constraint_error,
127 load_patterns: self.load_patterns.clone(),
128 default_pattern: self.default_pattern,
129 num_free_dofs: self.num_free_dofs,
130 committed_time: self.committed_time,
131 dofs: self.dofs.clone(),
132 }
133 }
134}
135
136impl<const NDIM: usize, const NDOF: usize, NId, E> Default for Domain<NDIM, NDOF, NId, E>
137where
138 NId: Key,
139 E: ElementOps<NDIM, NDOF, NId>,
140{
141 fn default() -> Self {
142 Self::new()
143 }
144}
145
146impl<const NDIM: usize, const NDOF: usize, NId, E> Domain<NDIM, NDOF, NId, E>
147where
148 NId: Key,
149 E: ElementOps<NDIM, NDOF, NId>,
150{
151 /// A fresh, empty model — already carrying one `LoadPattern`
152 /// (`default_pattern`, `LoadSeries::Linear { slope: 1.0 }`, unscaled)
153 /// so simple single-pattern models (the common case) don't need to
154 /// name a pattern at all: `domain.load_node(id, dof, value)` reaches
155 /// for it implicitly. Reproduces, exactly, the single-pattern behavior
156 /// every `Analysis` had before multi-pattern support existed.
157 pub fn new() -> Self {
158 let mut load_patterns = SlotMap::default();
159 let default_pattern =
160 load_patterns.insert(LoadPattern::new(LoadSeries::Linear { slope: 1.0 }));
161 Domain {
162 nodes: SlotMap::default(),
163 elements: SlotMap::default(),
164 constraints: Vec::new(),
165 constraint_error: None,
166 load_patterns,
167 default_pattern,
168 num_free_dofs: 0,
169 committed_time: 0.0,
170 dofs: DofTable::default(),
171 }
172 }
173
174 pub fn add_node(&mut self, node: Node<NDIM, NDOF>) -> NId {
175 self.nodes.insert(node)
176 }
177
178 pub fn add_element(&mut self, element: E) -> E::Id {
179 self.elements.insert(element)
180 }
181
182 pub fn node(&self, id: NId) -> &Node<NDIM, NDOF> {
183 &self.nodes[id]
184 }
185
186 pub fn element(&self, id: E::Id) -> &E {
187 &self.elements[id]
188 }
189
190 fn node_view(&self) -> NodeView<'_, NDIM, NDOF, NId> {
191 NodeView::new(&self.nodes)
192 }
193
194 /// An element's local nodal force at its current committed state — see
195 /// `ElementOps::local_force`'s doc comment. Never called from the
196 /// assembly hot loop (only from results recording), so looking the
197 /// element and its nodes up here rather than caching anything is fine.
198 pub fn element_local_force(&self, id: E::Id) -> ElementForce {
199 self.elements[id].local_force(&self.node_view())
200 }
201
202 /// Component `component` of the load `id` carries at `pseudo_time` — every
203 /// pattern's load on it, scaled by that pattern's factor (frozen patterns
204 /// at their frozen value), summed; `0.0` for an unloaded element. Local
205 /// axes: `ElementLoad` is `[wx, wy]`, `ElementLoad3` is `[wx, wy, wz]`.
206 pub fn element_load_component(&self, id: E::Id, pseudo_time: f64, component: usize) -> f64 {
207 let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
208 effective_element_load(&active, id).map_or(0.0, |load| load.component(component))
209 }
210
211 /// `element_local_force` including the fixed-end effect of element loads:
212 /// the member end forces a loaded element actually carries
213 /// (`k·d − Σ factor·f_eq`, OpenSees's `K·u + p0`), with every pattern's
214 /// factor evaluated at `pseudo_time`. Identical to `element_local_force`
215 /// for an element with no load.
216 pub fn element_end_force(&self, id: E::Id, pseudo_time: f64) -> ElementForce {
217 let element = &self.elements[id];
218 let view = self.node_view();
219 let mut force = element.local_force(&view);
220 for (_, pattern) in self.load_patterns.iter() {
221 let factor = pattern.factor(pseudo_time);
222 if factor == 0.0 {
223 continue;
224 }
225 if let Some(load) = pattern.element_load(id) {
226 force.subtract_scaled(factor, &element.local_load_force(&view, Some(load)));
227 }
228 }
229 force
230 }
231
232 /// Every integration point's per-fiber `(strain, stress)` at `id`'s
233 /// current committed state — `None` for every element kind that isn't
234 /// fiber-discretized (`ElementOps::fiber_responses`'s doc comment).
235 /// Outer `Vec` (when `Some`) is one entry per integration point, inner
236 /// `Vec` one entry per fiber, in the order each was originally given to
237 /// the element's constructor — a fiber recorder resolves its `(section,
238 /// fiber)` indices against that same order.
239 pub fn element_fiber_responses(&self, id: E::Id) -> Option<Vec<Vec<(f64, f64)>>> {
240 self.elements[id].fiber_responses(&self.node_view())
241 }
242
243 /// Number of Gauss points of the continuum element `id` (zero for any other kind).
244 pub fn element_gauss_point_count(&self, id: E::Id) -> usize {
245 self.elements[id].gauss_point_count()
246 }
247
248 /// Components per Gauss-point strain/stress vector of `id` (3 for plane elements, 8 for shells).
249 pub fn element_gauss_component_count(&self, id: E::Id) -> usize {
250 self.elements[id].gauss_component_count()
251 }
252
253 /// Every Gauss point's committed strain and stress at `id` — `None` for
254 /// every element kind that is not a continuum element.
255 pub fn element_gauss_responses(&self, id: E::Id) -> Option<Vec<GaussResponse>> {
256 self.elements[id].gauss_responses(&self.node_view())
257 }
258
259 /// Every Gauss point's committed generalized strain and resultants at the shell `id` — `None`
260 /// for every element kind that is not a shell.
261 pub fn element_shell_responses(&self, id: E::Id) -> Option<Vec<ShellResponse>> {
262 self.elements[id].shell_responses(&self.node_view())
263 }
264
265 /// Support/equilibrium reaction at `(node, dof)`: net internal element
266 /// resistance there minus the total applied reference load at
267 /// `pseudo_time` (Chopra's standard reaction definition) — most useful
268 /// at a *fixed* DOF (a free DOF's residual is, by construction, what
269 /// `Algorithm::Newton` already drives to ~0 at convergence, so
270 /// reading its "reaction" is redundant but harmless). Needs no
271 /// equation number — unlike every other accessor here, this bypasses
272 /// `dof_terms`/the free-DOF system entirely by summing every element's
273 /// and pattern's own contribution to this one `(node, dof)` directly
274 /// (the same per-element/per-pattern loops `assemble_stiffness_
275 /// triplets`/`assemble_load_with` run, just for one DOF instead of
276 /// every free one), so it's well-defined even at a DOF `number_dofs`
277 /// never assigned an equation number to.
278 ///
279 /// Ground-motion excitation (`TransientAnalysis`) never needs a
280 /// separate term here. This returns the structural reaction;
281 /// `TransientAnalysis::reaction` adds the equivalent ground inertial load
282 /// at restrained DOFs. Relative velocity/acceleration remain zero there.
283 pub fn reaction(&self, node: NId, dof: usize, pseudo_time: f64) -> f64 {
284 let view = self.node_view();
285 let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
286 let touches = |element: &E| element.nodes().contains(&node);
287
288 let mut resistance = PickDof::new(node, dof);
289 for (element_id, element) in self.elements.iter() {
290 if !touches(element) {
291 continue;
292 }
293 let load = effective_element_load(&active, element_id);
294 element.assemble_tangent(&view, load.as_ref(), &mut resistance);
295 }
296
297 let mut applied = 0.0;
298 for (_, pattern) in self.load_patterns.iter() {
299 let factor = pattern.factor(pseudo_time);
300 if factor == 0.0 {
301 continue;
302 }
303 if let Some(nodal_load) = pattern.nodal_load(node) {
304 applied += factor * nodal_load[dof];
305 }
306 for (element_id, element) in self.elements.iter() {
307 let Some(element_load) = pattern.element_load(element_id) else {
308 continue;
309 };
310 if !touches(element) {
311 continue;
312 }
313 let mut load = PickDof::new(node, dof);
314 element.assemble_load(&view, Some(element_load), &mut load);
315 applied += factor * load.total;
316 }
317 }
318
319 resistance.total - applied
320 }
321
322 /// Total lumped mass at a physical node/DOF, including attached elements.
323 /// Unlike the reduced mass diagonal this also includes restrained DOFs.
324 pub fn lumped_mass_at(&self, node: NId, dof: usize) -> f64 {
325 let view = self.node_view();
326 let mut mass = PickDof::new(node, dof);
327 for element in self.elements.values() {
328 if element.nodes().contains(&node) {
329 element.assemble_mass(&view, &mut mass);
330 }
331 }
332 self.nodes[node].mass[dof] + mass.total
333 }
334
335 /// Imposes `value` as the displacement of a *fixed* (single-point
336 /// constrained) DOF — a non-homogeneous SP constraint. A fixed DOF has
337 /// no equation number, so the imposed value simply lives in the node's
338 /// displacement and every element reads it like any other nodal
339 /// displacement; the free DOFs then respond to it on the next
340 /// `Analysis::step` (see `Analysis::step_prescribed`, which also makes
341 /// the update atomic with respect to a failed step). `reaction` reads
342 /// the force needed to hold it there. Returns `false`, changing
343 /// nothing, if the DOF isn't fixed or `value` is nonfinite.
344 pub fn prescribe_displacement(&mut self, node: NId, dof: usize, value: f64) -> bool {
345 if !self.can_prescribe(node, dof, value) {
346 return false;
347 }
348 self.nodes[node].displacement[dof] = value;
349 true
350 }
351
352 /// Whether `prescribe_displacement` would accept these arguments.
353 ///
354 /// A DOF that a constraint uses as a master is refused: constraints are
355 /// homogeneous and have already eliminated a fixed master's
356 /// contribution, so moving it would silently leave its slaves behind.
357 pub fn can_prescribe(&self, node: NId, dof: usize, value: f64) -> bool {
358 value.is_finite()
359 && dof < NDOF
360 && self.nodes.get(node).is_some_and(|n| n.fixed[dof])
361 && !self
362 .constraints
363 .iter()
364 .any(|c| c.terms.iter().any(|&(n, d, _)| n == node && d == dof))
365 }
366
367 /// `Domain::new()`'s always-present pattern — see its doc comment.
368 pub fn default_pattern(&self) -> LoadPatternId {
369 self.default_pattern
370 }
371
372 /// Add a new, independently-scaled `LoadPattern` (scale factor 1.0;
373 /// chain `.with_scale_factor` via `add_load_pattern_scaled` if needed).
374 /// Xara/OpenSees's `pattern Plain <tag> <series> {...}`.
375 pub fn add_load_pattern(&mut self, series: LoadSeries) -> LoadPatternId {
376 self.load_patterns.insert(LoadPattern::new(series))
377 }
378
379 /// Same as `add_load_pattern`, with an explicit scale factor (e.g. a
380 /// ground-motion PGA scale, or a load-factor-vs-displacement-target
381 /// unit conversion) applied on top of the series' own factor.
382 pub fn add_load_pattern_scaled(
383 &mut self,
384 series: LoadSeries,
385 scale_factor: f64,
386 ) -> LoadPatternId {
387 self.load_patterns
388 .insert(LoadPattern::new(series).with_scale_factor(scale_factor))
389 }
390
391 /// Add a nodal load to `pattern` — Xara/OpenSees's `load <node> <...>`
392 /// inside a `pattern` block.
393 pub fn add_nodal_load(&mut self, pattern: LoadPatternId, node: NId, dof: usize, value: f64) {
394 self.load_patterns[pattern].add_nodal_load(node, dof, value);
395 }
396
397 /// Convenience for the common single-pattern case: adds `value` to
398 /// `default_pattern()` directly, so simple models don't need to name a
399 /// pattern (`domain.load_node(id, 0, force)` instead of
400 /// `domain.add_nodal_load(domain.default_pattern(), id, 0, force)`).
401 pub fn load_node(&mut self, node: NId, dof: usize, value: f64) {
402 self.add_nodal_load(self.default_pattern, node, dof, value);
403 }
404
405 /// Add an element load (e.g. a beam-column's uniform transverse load)
406 /// to `pattern` — Xara/OpenSees's `eleLoad` inside a `pattern` block.
407 /// `load`'s type is this profile's `ElementOps::Load` — `ElementLoad`
408 /// for the planar profile, `ElementLoad3` for the spatial one (see
409 /// `ElementOps`'s doc comment).
410 pub fn add_element_load(&mut self, pattern: LoadPatternId, element: E::Id, load: E::Load) {
411 self.load_patterns[pattern].add_element_load(element, load);
412 }
413
414 /// Freeze `pattern`'s contribution at its value as of `pseudo_time` —
415 /// Xara/OpenSees's `loadConst -time <pseudo_time>` (there, applied to
416 /// every pattern in the domain; here, one pattern at a time, so a
417 /// caller composing phases can freeze only the pattern(s) that should
418 /// stop ramping, e.g. gravity, while others continue). Call this with
419 /// whatever pseudo-time the phase you're ending actually stopped at
420 /// (`StepResult::load_factor` / `TransientStepResult::time`) — see
421 /// `LoadPattern`'s doc comment for why the frozen value is captured
422 /// explicitly here rather than lazily cached on every assemble call.
423 pub fn hold_pattern_constant(&mut self, pattern: LoadPatternId, pseudo_time: f64) {
424 self.load_patterns[pattern].hold_constant(pseudo_time);
425 }
426
427 /// Constrains `slave`'s DOF to a linear combination of master DOFs on
428 /// any nodes: `u_slave = sum(coeff * u_master)`. Repeated masters are
429 /// merged and vanishing terms dropped; an empty result means
430 /// `u_slave = 0`. Problems that cannot be seen from one constraint (a
431 /// slave defined twice, a fixed slave, a dependency cycle, a master that
432 /// carries a prescribed value, an inconsistent initial state) are
433 /// reported by `validate`. Requires `ConstraintHandler::Transformation`.
434 pub fn add_constraint(&mut self, slave: (NId, usize), terms: &[(NId, usize, f64)]) {
435 assert!(
436 slave.1 < NDOF,
437 "constraint slave DOF {} out of range",
438 slave.1
439 );
440 assert!(
441 terms.iter().all(|t| t.1 < NDOF),
442 "constraint master DOF out of range"
443 );
444 self.constraints.push(LinearConstraint {
445 slave,
446 terms: constraint::normalize(terms),
447 });
448 }
449
450 /// Tie `dofs` of `constrained` exactly to the same DOFs of `retained`
451 /// (`u_c = u_r` for each listed dof) — Xara/OpenSees's `equalDOF`.
452 /// Requires `ConstraintHandler::Transformation`; see its doc comment
453 /// for how this is resolved.
454 pub fn equal_dof(&mut self, retained: NId, constrained: NId, dofs: &[usize]) {
455 for &dof in dofs {
456 self.add_constraint((constrained, dof), &[(retained, dof, 1.0)]);
457 }
458 }
459
460 /// Equation number of a node's DOF **if it is a free unknown** (a DOF
461 /// identity-tied to one shares its equation), or `None` for a fixed DOF,
462 /// an inactive DOF, or a constrained DOF that is a combination of
463 /// several unknowns. It says where an unknown lives, not what a DOF's
464 /// value is: to read a node DOF out of a free-DOF-indexed vector use
465 /// `value_at`. Used
466 /// internally by `Integrator::DisplacementControl` to locate its
467 /// controlled DOF in the free-DOF system, and `pub` (not just
468 /// `pub(crate)`) so a caller holding a free-DOF-indexed vector — e.g.
469 /// `modal_analysis`'s `Mode::shape` — can map one of its entries back
470 /// to a specific node/DOF without `core` needing to expose any of
471 /// `gather_displacement`/`scatter_state`'s broader internal-state
472 /// surface just for that one lookup.
473 pub fn equation_of(&self, node: NId, dof: usize) -> Option<usize> {
474 match self.dofs.entry(node, dof) {
475 DofEntry::Free(eq) => Some(eq),
476 DofEntry::Fixed | DofEntry::Inactive | DofEntry::Affine { .. } => None,
477 }
478 }
479
480 /// The value of `(node, dof)` in the free-DOF-indexed vector `q`: the
481 /// entry for a free unknown, the constraint's combination of entries for
482 /// a constrained DOF, and `0.0` for a fixed or inactive DOF. This is how
483 /// a mode shape, an influence vector, or any other free-DOF vector is
484 /// read back onto nodes; `equation_of` cannot do it for a constrained DOF
485 /// that depends on several unknowns (a rigid-diaphragm slave).
486 pub fn value_at(&self, q: &DVector<f64>, node: NId, dof: usize) -> f64 {
487 match self.dofs.entry(node, dof) {
488 DofEntry::Free(eq) => q[eq],
489 DofEntry::Affine { start, len } => self
490 .dofs
491 .terms(start, len)
492 .iter()
493 .map(|&(eq, coeff)| coeff * q[eq])
494 .sum(),
495 DofEntry::Fixed | DofEntry::Inactive => 0.0,
496 }
497 }
498
499 /// `true` for every free-DOF equation that is a rotation (local DOF
500 /// index `>= NDIM` — `rz` in the planar profile, `rx`/`ry`/`rz` in the
501 /// spatial one). Identity-tied DOFs share their retained equation and
502 /// DOF index, and a rigid diaphragm's retained rotation keeps its own,
503 /// so the classification is per independent coordinate. Used to pick
504 /// moment rather than force tolerances and rotation rather than
505 /// translation scales.
506 pub(crate) fn rotational_equations(&self) -> Vec<bool> {
507 let mut rotational = vec![false; self.num_free_dofs];
508 for (id, _) in self.nodes.iter() {
509 for dof in NDIM..NDOF {
510 if let DofEntry::Free(eq) = self.dofs.entry(id, dof) {
511 rotational[eq] = true;
512 }
513 }
514 }
515 rotational
516 }
517
518 /// Whether any load pattern still follows an unfrozen
519 /// `LoadSeries::Path` (see `LoadPattern::is_unfrozen_path`).
520 pub(crate) fn has_unfrozen_path_series(&self) -> bool {
521 self.load_patterns
522 .iter()
523 .any(|(_, pattern)| pattern.is_unfrozen_path())
524 }
525
526 /// Whether `(node, dof)` can take part in the analysis: it is fixed,
527 /// constrained, stiffened by some element, used as a constraint master,
528 /// or carries a nodal mass. A DOF that is none of these has no equation
529 /// and nothing to resist a load. Reflects the last numbering
530 /// (`validate`/`AnalysisBuilder::build` run it).
531 pub fn is_active(&self, node: NId, dof: usize) -> bool {
532 self.dofs.entry(node, dof) != DofEntry::Inactive
533 }
534
535 /// Numbers the DOFs and checks the model for mistakes that would
536 /// otherwise be silently wrong: constraint problems (a slave defined
537 /// twice or fixed, a dependency cycle, a master carrying a prescribed
538 /// value, mass on a slave that combines several unknowns), an element
539 /// rejecting its own geometry, nodal loads on DOFs nothing uses, and a
540 /// constrained slave whose initial state contradicts its masters. Also
541 /// makes the initial state consistent with the constraints: a slave
542 /// whose initial displacement, velocity or acceleration is exactly zero
543 /// (the default) takes the value its masters imply; a nonzero value that
544 /// disagrees is an error, never overwritten. Called by
545 /// `AnalysisBuilder::build`, `modal_analysis` and `TransientAnalysis`.
546 pub fn validate(&mut self) -> Result<(), ModelError> {
547 self.number_dofs();
548 if let Some(error) = self.constraint_error {
549 return Err(error);
550 }
551 let view = NodeView::new(&self.nodes);
552 for (index, (_, element)) in self.elements.iter_mut().enumerate() {
553 element
554 .validate(&view)
555 .map_err(|reason| ModelError::InvalidElement {
556 element: index,
557 reason,
558 })?;
559 element.prepare(&view);
560 }
561 for pattern in self.load_patterns.values() {
562 for (id, load) in pattern.element_loads() {
563 if !self.elements[*id].accepts_load(load) {
564 let element = self.elements.keys().position(|key| key == *id).unwrap_or(0);
565 return Err(ModelError::IncompatibleElementLoad { element });
566 }
567 }
568 }
569 for (index, (id, _)) in self.nodes.iter().enumerate() {
570 for dof in 0..NDOF {
571 if self.dofs.entry(id, dof) != DofEntry::Inactive {
572 continue;
573 }
574 let loaded = self
575 .load_patterns
576 .values()
577 .any(|pattern| pattern.nodal_load(id).is_some_and(|load| load[dof] != 0.0));
578 if loaded {
579 return Err(ModelError::LoadOnInactiveDof { node: index, dof });
580 }
581 }
582 }
583 self.validate_constraint_state()?;
584 self.validate_element_mass_on_constraints()
585 }
586
587 /// Element mass (continuum and frame elements with a density) landing on a DOF that
588 /// is constrained to several unknowns cannot go on a diagonal mass vector; report it
589 /// as a structured error instead of letting `assemble_mass_diagonal` panic.
590 fn validate_element_mass_on_constraints(&self) -> Result<(), ModelError> {
591 if self.constraints.is_empty() {
592 return Ok(());
593 }
594 struct Probe<'a, const NDIM: usize, const NDOF: usize, NId, E>
595 where
596 NId: Key,
597 E: ElementOps<NDIM, NDOF, NId>,
598 {
599 domain: &'a Domain<NDIM, NDOF, NId, E>,
600 offender: Option<(NId, usize)>,
601 }
602 impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId> for Probe<'_, NDIM, NDOF, NId, E>
603 where
604 NId: Key,
605 E: ElementOps<NDIM, NDOF, NId>,
606 {
607 fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
608 for (a, &(node, slot)) in dofs.iter().enumerate() {
609 let terms = self.domain.dof_terms(node, slot as usize);
610 if v[a] != 0.0 && terms.as_slice().len() > 1 && self.offender.is_none() {
611 self.offender = Some((node, slot as usize));
612 }
613 }
614 }
615 }
616 let view = self.node_view();
617 let mut probe = Probe {
618 domain: self,
619 offender: None,
620 };
621 for element in self.elements.values() {
622 element.assemble_mass(&view, &mut probe);
623 }
624 match probe.offender {
625 Some((node, dof)) => Err(ModelError::MassOnConstrainedDof {
626 node: self.node_index(node),
627 dof,
628 }),
629 None => Ok(()),
630 }
631 }
632
633 fn node_index(&self, id: NId) -> usize {
634 self.nodes.keys().position(|key| key == id).unwrap_or(0)
635 }
636
637 /// The constraint-related half of `validate` that needs node state.
638 fn validate_constraint_state(&mut self) -> Result<(), ModelError> {
639 for constraint in &self.constraints {
640 for &(node, dof, _) in &constraint.terms {
641 // A fixed master's contribution has been eliminated, which is
642 // only right while it stays zero.
643 if self.nodes[node].fixed[dof] && self.nodes[node].displacement[dof] != 0.0 {
644 return Err(ModelError::ConstraintOnPrescribedDof {
645 node: self.node_index(node),
646 dof,
647 });
648 }
649 }
650 let (slave, dof) = constraint.slave;
651 let several_unknowns = matches!(
652 self.dofs.entry(slave, dof),
653 DofEntry::Affine { len, .. } if len > 1
654 );
655 if several_unknowns && self.nodes[slave].mass[dof] != 0.0 {
656 return Err(ModelError::MassOnConstrainedDof {
657 node: self.node_index(slave),
658 dof,
659 });
660 }
661 }
662
663 // Project zero-valued slave state from the masters, in dependency
664 // order (repeat until nothing changes; a chain settles in at most
665 // one pass per link).
666 for _ in 0..=self.constraints.len() {
667 let mut changed = false;
668 for index in 0..self.constraints.len() {
669 let (slave, dof) = self.constraints[index].slave;
670 let implied = self.implied_state(index);
671 let node = &mut self.nodes[slave];
672 let targets = [
673 &mut node.displacement[dof],
674 &mut node.velocity[dof],
675 &mut node.acceleration[dof],
676 ];
677 for (target, value) in targets.into_iter().zip(implied) {
678 if *target == 0.0 && value != 0.0 {
679 *target = value;
680 changed = true;
681 }
682 }
683 }
684 if !changed {
685 break;
686 }
687 }
688 for index in 0..self.constraints.len() {
689 let (slave, dof) = self.constraints[index].slave;
690 let implied = self.implied_state(index);
691 let node = &self.nodes[slave];
692 let actual = [
693 node.displacement[dof],
694 node.velocity[dof],
695 node.acceleration[dof],
696 ];
697 for (actual, implied) in actual.into_iter().zip(implied) {
698 if (actual - implied).abs() > 1e-12 * (1.0 + actual.abs() + implied.abs()) {
699 return Err(ModelError::InconsistentInitialState {
700 node: self.node_index(slave),
701 dof,
702 });
703 }
704 }
705 }
706 Ok(())
707 }
708
709 /// The displacement, velocity and acceleration constraint `index`'s
710 /// masters imply for its slave.
711 fn implied_state(&self, index: usize) -> [f64; 3] {
712 let mut implied = [0.0; 3];
713 for &(node, dof, coeff) in &self.constraints[index].terms {
714 let node = &self.nodes[node];
715 implied[0] += coeff * node.displacement[dof];
716 implied[1] += coeff * node.velocity[dof];
717 implied[2] += coeff * node.acceleration[dof];
718 }
719 implied
720 }
721
722 /// Assign a sequential equation number to every free, unconstrained
723 /// DOF, in node insertion order, then alias every multi-point-
724 /// constrained DOF to its retained node's equation number for that DOF
725 /// (`ConstraintHandler::Transformation`). Fixed
726 /// DOFs, and constrained DOFs whose retained DOF is itself fixed, get
727 /// no equation number. This is the entire numbering pass — done once,
728 /// not re-checked every step (no `hasDomainChanged()`-style
729 /// machinery).
730 ///
731 /// Constrained DOFs must be excluded from the *first* pass (not just
732 /// overwritten afterwards) — otherwise the equation number allocated
733 /// to them before being overwritten is never referenced by any node,
734 /// leaving an all-zero row/column in the assembled system.
735 ///
736 /// Returns the number of free DOFs (the size of the global system).
737 pub(crate) fn number_dofs(&mut self) -> usize {
738 let slaves: HashSet<(NId, usize)> = self.constraints.iter().map(|c| c.slave).collect();
739 self.constraint_error = None;
740
741 // A DOF is active if an element stiffens it, a constraint uses it as
742 // a master (a diaphragm's retained node typically has no element at
743 // all), or it carries a nodal mass. Everything else is not an
744 // equation.
745 let mut active: HashSet<(NId, usize)> = HashSet::new();
746 for (_, element) in self.elements.iter() {
747 let mask = element.dof_mask();
748 for node in element.nodes() {
749 for dof in (0..NDOF).filter(|&dof| mask.contains(dof)) {
750 active.insert((node, dof));
751 }
752 }
753 }
754 // A nodal mass gives its DOF inertia even with no stiffness (a free
755 // mass integrates fine in a transient analysis), so it is active.
756 for (id, node) in self.nodes.iter() {
757 for dof in (0..NDOF).filter(|&dof| node.mass[dof] != 0.0) {
758 active.insert((id, dof));
759 }
760 }
761 for constraint in &self.constraints {
762 for &(node, dof, _) in &constraint.terms {
763 active.insert((node, dof));
764 }
765 }
766
767 let mut table = DofTable::<NId, NDOF>::for_nodes(self.nodes.keys());
768 let mut next = 0;
769 for (index, (id, node)) in self.nodes.iter().enumerate() {
770 for dof in 0..NDOF {
771 if slaves.contains(&(id, dof)) {
772 if node.fixed[dof] {
773 self.constraint_error
774 .get_or_insert(ModelError::SlaveIsFixed { node: index, dof });
775 }
776 continue;
777 }
778 if node.fixed[dof] {
779 continue;
780 }
781 if !active.contains(&(id, dof)) {
782 table.set(id, dof, DofEntry::Inactive);
783 continue;
784 }
785 table.set(id, dof, DofEntry::Free(next));
786 next += 1;
787 }
788 }
789
790 if let Err(error) = constraint::resolve(&self.constraints, &mut table) {
791 let (ResolveError::DuplicateSlave((id, dof)) | ResolveError::Cycle((id, dof))) = error;
792 let node = self.nodes.keys().position(|k| k == id).unwrap_or(0);
793 self.constraint_error.get_or_insert(match error {
794 ResolveError::DuplicateSlave(_) => ModelError::DuplicateSlave { node, dof },
795 ResolveError::Cycle(_) => ModelError::ConstraintCycle { node, dof },
796 });
797 }
798
799 self.dofs = table;
800 self.num_free_dofs = next;
801 next
802 }
803
804 /// Pseudo-time of the last `commit` (zero before any).
805 pub(crate) fn committed_time(&self) -> f64 {
806 self.committed_time
807 }
808
809 pub fn num_free_dofs(&self) -> usize {
810 self.num_free_dofs
811 }
812
813 /// Whether any `equal_dof`/`rigid_diaphragm` constraint has been added —
814 /// used by `AnalysisBuilder<Ready>::build` to reject `ConstraintHandler
815 /// ::Plain` (which can't resolve them) early, at model-construction
816 /// time rather than as a silently-wrong solve. Also used by callers
817 /// (e.g. `carapace-wasm`'s decoder) to pick `ConstraintHandler::
818 /// Transformation` automatically whenever a domain actually has any.
819 pub fn has_mp_constraints(&self) -> bool {
820 !self.constraints.is_empty()
821 }
822
823 /// Every `(equation, coefficient)` term `node_id`'s `dof` contributes
824 /// to the assembled free-DOF system — the single source every assembly
825 /// hot loop (`assemble_stiffness_triplets`, `assemble_mass_diagonal`,
826 /// `assemble_load_with`) reads instead of `node.equation[dof]`
827 /// directly, so each of them handles ordinary free dofs, identity-tied
828 /// dofs, and affine-tied (rigid-diaphragm) dofs uniformly rather than
829 /// three separate code paths.
830 fn dof_terms(&self, node_id: NId, dof: usize) -> DofTerms<'_> {
831 match self.dofs.entry(node_id, dof) {
832 DofEntry::Free(eq) => DofTerms::One([(eq, 1.0)]),
833 DofEntry::Affine { start, len } => DofTerms::Many(self.dofs.terms(start, len)),
834 DofEntry::Fixed | DofEntry::Inactive => DofTerms::None,
835 }
836 }
837
838 /// Debug-build guard behind `ElementOps::dof_mask`: an element that
839 /// produces a stiffness, resistance, load or mass on a DOF that is
840 /// inactive (not fixed, not constrained, not stiffened by any element,
841 /// not a constraint master, no nodal mass) has under-declared its mask, and the
842 /// contribution would be silently dropped. Contributions below a
843 /// relative 1e-10 of the element's largest are treated as zero (an
844 /// orientation frame leaves round-off in entries that are zero in exact
845 /// arithmetic).
846 #[cfg(debug_assertions)]
847 fn assert_declared<const N: usize>(
848 &self,
849 dofs: &[DofRef<NId>; N],
850 k: Option<&SMatrix<f64, N, N>>,
851 values: &[f64],
852 ) {
853 let scale = |it: &mut dyn Iterator<Item = f64>| it.fold(0.0_f64, |m, v| m.max(v.abs()));
854 let vector_scale = scale(&mut values.iter().copied());
855 let diagonal_scale = k.map_or(0.0, |k| scale(&mut (0..N).map(|i| k[(i, i)])));
856 for (a, &(node, slot)) in dofs.iter().enumerate() {
857 if self.dofs.entry(node, slot as usize) != DofEntry::Inactive {
858 continue;
859 }
860 let stray_value = values[a].abs() > 1e-10 * vector_scale;
861 let stray_stiffness = k.is_some_and(|k| k[(a, a)].abs() > 1e-10 * diagonal_scale);
862 assert!(
863 !(stray_value || stray_stiffness),
864 "an element contributes to DOF {slot} of a node on which it did not declare a DOF in \
865 `ElementOps::dof_mask` (and nothing else uses that DOF), so the contribution would be \
866 silently dropped"
867 );
868 }
869 }
870
871 /// Number DOFs (idempotent given a fixed set of nodes) and assemble the
872 /// lumped-mass matrix's diagonal over free DOFs — user-assigned
873 /// `Node::mass` plus each element's own lumped mass (`Element::
874 /// form_mass`; zero unless a `Truss`/`ElasticBeamColumn` was given
875 /// a nonzero `density`). Needed by modal and time-history analysis. Just the diagonal, not a full (dense or sparse) N×N
876 /// matrix — `M` is diagonal by construction (lumped mass), so storing
877 /// anything more is pure waste, the same class of mistake a dense
878 /// stiffness matrix would be (see `SparseMatrix`'s doc comment).
879 pub fn assemble_mass_diagonal(&mut self) -> DVector<f64> {
880 self.number_dofs();
881 let n = self.num_free_dofs;
882 let mut mass = DVector::<f64>::zeros(n);
883
884 for (id, node) in self.nodes.iter() {
885 for dof in 0..NDOF {
886 accumulate_mass(&mut mass, self.dof_terms(id, dof), node.mass[dof]);
887 }
888 }
889
890 let view = self.node_view();
891 let mut sink = MassSink {
892 domain: self,
893 mass: &mut mass,
894 };
895 for (_, element) in self.elements.iter() {
896 element.assemble_mass(&view, &mut sink);
897 }
898
899 mass
900 }
901
902 /// Per-element tangent-stiffness triplets and the assembled internal
903 /// resisting force, over free DOFs, at the current nodal displacement
904 /// state. Duplicate `(row, col)` triplets (every DOF shared by more
905 /// than one element) are summed by whoever consumes them — `faer`'s
906 /// triplet constructor does this automatically.
907 ///
908 /// `pseudo_time` fixes the element loads that state-dependent elements
909 /// (`ForceBeamColumn`) fold into their own resistance — see
910 /// `ElementOps::form_tangent_and_resistance`.
911 fn assemble_stiffness_triplets(
912 &self,
913 pseudo_time: f64,
914 ) -> (Vec<Triplet<usize, usize, f64>>, DVector<f64>) {
915 let n = self.num_free_dofs;
916 let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
917 let view = self.node_view();
918 let mut sink = TripletSink {
919 domain: self,
920 triplets: Vec::new(),
921 resistance: DVector::<f64>::zeros(n),
922 };
923 for (element_id, element) in self.elements.iter() {
924 let load = effective_element_load(&active, element_id);
925 element.assemble_tangent(&view, load.as_ref(), &mut sink);
926 }
927 (sink.triplets, sink.resistance)
928 }
929
930 /// Assemble the global tangent stiffness (sparse) and internal
931 /// resisting force over free DOFs only, at the current nodal
932 /// displacement state. No load-pattern contribution — see
933 /// `assemble_reference_load`.
934 pub(crate) fn assemble_tangent_and_resistance(
935 &self,
936 pseudo_time: f64,
937 ) -> (SparseMatrix, DVector<f64>) {
938 let n = self.num_free_dofs;
939 let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
940 let k = SparseMatrix::try_new_from_triplets(n, n, &triplets)
941 .expect("equation numbers are always in [0, num_free_dofs)");
942 (k, resistance)
943 }
944
945 /// Benchmark hook (`core/examples/benchmark_continuum.rs`): wall time of
946 /// the element tangent loop, sparse-matrix construction, the first
947 /// factorization (symbolic + numeric), a repeat factorization (numeric
948 /// only) and one solve, as `[elements, matrix, first_factor,
949 /// repeat_factor, solve]` seconds plus the triplet count. Not for wasm
950 /// (it reads `Instant`).
951 #[doc(hidden)]
952 pub fn bench_assembly(&self, pseudo_time: f64) -> ([f64; 5], usize) {
953 let n = self.num_free_dofs;
954 let lap = |start: std::time::Instant| start.elapsed().as_secs_f64();
955 let start = std::time::Instant::now();
956 let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
957 let elements = lap(start);
958 let start = std::time::Instant::now();
959 let k = SparseMatrix::try_new_from_triplets(n, n, &triplets)
960 .expect("equation numbers are always in [0, num_free_dofs)");
961 let matrix = lap(start);
962 let solver = crate::analysis::SparseSolver::new();
963 let start = std::time::Instant::now();
964 let factor = solver.factor(&k).expect("benchmark system is nonsingular");
965 let first = lap(start);
966 let start = std::time::Instant::now();
967 let _ = solver.factor(&k).expect("benchmark system is nonsingular");
968 let repeat = lap(start);
969 let rhs = DVector::from_element(n, 1.0) + resistance;
970 let start = std::time::Instant::now();
971 let _ = factor.solve(&rhs);
972 (
973 [elements, matrix, first, repeat, lap(start)],
974 triplets.len(),
975 )
976 }
977
978 /// Assemble the total applied load (every `LoadPattern`'s nodal +
979 /// element equivalent loads, each scaled by its own factor at
980 /// `pseudo_time` — frozen patterns use their frozen value regardless of
981 /// `pseudo_time`, see `LoadPattern::factor`) over free DOFs. Already
982 /// fully scaled — unlike the single-pattern design this replaced,
983 /// callers no longer multiply the result by an external load factor
984 /// (see `form_tangent_and_residual`).
985 pub(crate) fn assemble_reference_load(&self, pseudo_time: f64) -> DVector<f64> {
986 self.assemble_load_with(|p| p.factor(pseudo_time))
987 }
988
989 /// `d(assemble_reference_load)/d(pseudo_time)` at `pseudo_time` — the
990 /// unit-load probe `Integrator::DisplacementControl` needs (how much
991 /// the *total* applied load changes per unit pseudo-time increment;
992 /// frozen/`Constant`-series patterns contribute zero, see `LoadPattern::
993 /// sensitivity`/`LoadSeries::slope`'s doc comments). With the single
994 /// default `Linear { slope: 1.0 }` pattern (every model that hasn't
995 /// added a second pattern), this is numerically identical to
996 /// `assemble_reference_load` — no behavior change for single-pattern
997 /// models.
998 pub(crate) fn assemble_reference_load_sensitivity(&self, pseudo_time: f64) -> DVector<f64> {
999 self.assemble_load_with(|p| p.sensitivity(pseudo_time))
1000 }
1001
1002 /// Shared core of `assemble_reference_load`/`_sensitivity`: sum every
1003 /// pattern's nodal + element reference load, each scaled by whatever
1004 /// `pattern_factor` computes for that pattern (its current factor, or
1005 /// its sensitivity — the only difference between the two callers).
1006 fn assemble_load_with(
1007 &self,
1008 pattern_factor: impl Fn(&LoadPattern<NDOF, NId, E::Id, E::Load>) -> f64,
1009 ) -> DVector<f64> {
1010 let n = self.num_free_dofs;
1011 let mut load = DVector::<f64>::zeros(n);
1012
1013 for (_, pattern) in self.load_patterns.iter() {
1014 let factor = pattern_factor(pattern);
1015 if factor == 0.0 {
1016 continue;
1017 }
1018
1019 for (node_id, _node) in self.nodes.iter() {
1020 let Some(nodal_load) = pattern.nodal_load(node_id) else {
1021 continue;
1022 };
1023 for (dof, &value) in nodal_load.iter().enumerate() {
1024 for &(eq, coeff) in self.dof_terms(node_id, dof).iter() {
1025 load[eq] += coeff * factor * value;
1026 }
1027 }
1028 }
1029
1030 let view = self.node_view();
1031 for (element_id, element) in self.elements.iter() {
1032 let Some(element_load) = pattern.element_load(element_id) else {
1033 continue;
1034 };
1035 let mut sink = LoadSink {
1036 domain: self,
1037 factor,
1038 load: &mut load,
1039 };
1040 element.assemble_load(&view, Some(element_load), &mut sink);
1041 }
1042 }
1043
1044 load
1045 }
1046
1047 /// Assemble the global tangent stiffness and unbalanced-force residual
1048 /// (`reference_load(pseudo_time) - internal_resistance`) over free DOFs
1049 /// only. Called once per Newton iteration by `Analysis::step`. Unlike
1050 /// before multi-pattern support, `pseudo_time` is *not* an external
1051 /// multiplier on a single reference-load vector — each `LoadPattern`
1052 /// scales its own contribution internally (`assemble_reference_load`),
1053 /// so this is just their difference from internal resistance.
1054 pub(crate) fn form_tangent_and_residual(
1055 &self,
1056 pseudo_time: f64,
1057 ) -> (SparseMatrix, DVector<f64>) {
1058 let (k, resistance) = self.assemble_tangent_and_resistance(pseudo_time);
1059 let residual = self.assemble_reference_load(pseudo_time) - resistance;
1060 (k, residual)
1061 }
1062
1063 /// A unit "influence vector" over free DOFs: `1.0` at every DOF whose
1064 /// local index is `dof_direction`, `0.0` elsewhere — Chopra's ι, what
1065 /// `TransientAnalysis`'s ground-motion excitation needs to turn a
1066 /// scalar ground acceleration into an effective nodal force vector
1067 /// (`-M·ι·ag(t)`, mass-proportional, not reference-load-proportional —
1068 /// see `GroundMotion`). Computed once at `TransientAnalysis::new` time,
1069 /// not every step (like `mass` itself).
1070 pub fn direction_incidence(&self, dof_direction: usize) -> DVector<f64> {
1071 let mut incidence = DVector::<f64>::zeros(self.num_free_dofs);
1072 for (id, _) in self.nodes.iter() {
1073 if let DofEntry::Free(eq) = self.dofs.entry(id, dof_direction) {
1074 incidence[eq] = 1.0;
1075 }
1076 }
1077 incidence
1078 }
1079
1080 /// Commit every element's material(s) at the current (final, converged)
1081 /// nodal state — called once per successful `Analysis`/
1082 /// `TransientAnalysis` step, never during Newton iteration itself. See
1083 /// `Material`'s doc comment for the trial/commit design this closes
1084 /// the loop on.
1085 ///
1086 /// `pseudo_time` is the converged step's pseudo-time: a state-dependent
1087 /// element (`ForceBeamColumn`) commits the state that is consistent with
1088 /// the element load at that time.
1089 pub(crate) fn commit(&mut self, pseudo_time: f64) {
1090 self.committed_time = pseudo_time;
1091 let active = active_element_patterns(self.load_patterns.values(), pseudo_time);
1092 // `self.nodes`, `self.elements` and `self.load_patterns` are
1093 // disjoint fields, so the node view can borrow `nodes` while
1094 // `elements` is iterated mutably.
1095 let view = NodeView::new(&self.nodes);
1096 for (element_id, element) in self.elements.iter_mut() {
1097 let load = effective_element_load(&active, element_id);
1098 element.commit(&view, load.as_ref());
1099 }
1100 }
1101
1102 /// Scatter a global free-DOF displacement increment back onto nodes.
1103 /// Every ordinarily-numbered dof (free, or identity-tied via
1104 /// an identity tie) reads its increment directly out of `du`; every
1105 /// constrained dof (a rigid diaphragm's lever-arm
1106 /// coupling) then gets its increment *derived* from the same `du`,
1107 /// applying the constraint's linear combination directly to the
1108 /// increment rather than to an absolute value — valid because the
1109 /// relation is linear and homogeneous (constant geometry-derived
1110 /// coefficients, no offset term), so it holds identically for an
1111 /// increment as it does for an absolute displacement.
1112 pub(crate) fn apply_displacement_increment(&mut self, du: &DVector<f64>) {
1113 for (id, node) in self.nodes.iter_mut() {
1114 for dof in 0..NDOF {
1115 match self.dofs.entry(id, dof) {
1116 DofEntry::Free(eq) => node.displacement[dof] += du[eq],
1117 DofEntry::Affine { start, len } => {
1118 let delta: f64 = self
1119 .dofs
1120 .terms(start, len)
1121 .iter()
1122 .map(|&(eq, coeff)| coeff * du[eq])
1123 .sum();
1124 node.displacement[dof] += delta;
1125 }
1126 DofEntry::Fixed | DofEntry::Inactive => {}
1127 }
1128 }
1129 }
1130 }
1131
1132 /// Gather free-DOF displacement/velocity/acceleration into dense
1133 /// vectors — `TransientAnalysis` state, unlike static `Analysis`
1134 /// which only ever mutates displacement incrementally.
1135 pub(crate) fn gather_displacement(&self) -> DVector<f64> {
1136 self.gather(|node, dof| node.displacement[dof])
1137 }
1138 pub(crate) fn gather_velocity(&self) -> DVector<f64> {
1139 self.gather(|node, dof| node.velocity[dof])
1140 }
1141 pub(crate) fn gather_acceleration(&self) -> DVector<f64> {
1142 self.gather(|node, dof| node.acceleration[dof])
1143 }
1144 fn gather(&self, get: impl Fn(&Node<NDIM, NDOF>, usize) -> f64) -> DVector<f64> {
1145 let mut v = DVector::<f64>::zeros(self.num_free_dofs);
1146 for (id, node) in self.nodes.iter() {
1147 for dof in 0..NDOF {
1148 if let DofEntry::Free(eq) = self.dofs.entry(id, dof) {
1149 v[eq] = get(node, dof);
1150 }
1151 }
1152 }
1153 v
1154 }
1155
1156 /// Replace (not increment — every `TransientAnalysis` step computes a
1157 /// full new state, not a correction) each free DOF's displacement,
1158 /// velocity, and acceleration. Affine-tied dofs (see
1159 /// `apply_displacement_increment`'s doc comment) are derived from `u`/
1160 /// `v`/`a` the same way — the linear relation holds identically for
1161 /// displacement, velocity, and acceleration.
1162 pub(crate) fn scatter_state(&mut self, u: &DVector<f64>, v: &DVector<f64>, a: &DVector<f64>) {
1163 for (id, node) in self.nodes.iter_mut() {
1164 for dof in 0..NDOF {
1165 match self.dofs.entry(id, dof) {
1166 DofEntry::Free(eq) => {
1167 node.displacement[dof] = u[eq];
1168 node.velocity[dof] = v[eq];
1169 node.acceleration[dof] = a[eq];
1170 }
1171 DofEntry::Affine { start, len } => {
1172 let (mut du, mut dv, mut da) = (0.0, 0.0, 0.0);
1173 for &(eq, coeff) in self.dofs.terms(start, len) {
1174 du += coeff * u[eq];
1175 dv += coeff * v[eq];
1176 da += coeff * a[eq];
1177 }
1178 node.displacement[dof] = du;
1179 node.velocity[dof] = dv;
1180 node.acceleration[dof] = da;
1181 }
1182 DofEntry::Fixed | DofEntry::Inactive => {}
1183 }
1184 }
1185 }
1186 }
1187
1188 /// `K * v` for an arbitrary free-DOF vector `v` — used once, to compute
1189 /// `TransientAnalysis`'s initial acceleration from equilibrium (`M*a0 =
1190 /// F0 - K*u0 - C*v0`, and `C` has a `K*v0` term). Reuses the same
1191 /// triplets `assemble_tangent_and_resistance` builds its sparse matrix
1192 /// from, computed directly (no `faer` matrix construction needed for a
1193 /// single matrix-vector product).
1194 pub(crate) fn multiply_stiffness(&self, v: &DVector<f64>, pseudo_time: f64) -> DVector<f64> {
1195 let (triplets, _resistance) = self.assemble_stiffness_triplets(pseudo_time);
1196 let mut result = DVector::<f64>::zeros(self.num_free_dofs);
1197 for t in &triplets {
1198 result[t.row] += t.val * v[t.col];
1199 }
1200 result
1201 }
1202
1203 /// Newmark's effective dynamic operator and internal resistance, at the
1204 /// domain's *current* state (whatever trial displacement is on it when
1205 /// called): `K_eff = stiffness_coeff*K(u) + mass_coeff*diag(mass)`,
1206 /// `resistance = F_int(u)` — the `Op`/`F_int` half of `docs/
1207 /// algorithms.md` §6.1's Newton corrector residual (`TransientAnalysis::
1208 /// try_step` composes the rest — the damping/inertial terms, which need
1209 /// `v_trial`/`a_trial`, not just `u` — around this). `stiffness_coeff`/
1210 /// `mass_coeff` are `docs/algorithms.md` §6.1's `c1`/`c3` (Newmark's
1211 /// `a1 + a4*alpha_m`/`1 + a4*beta_k`).
1212 pub(crate) fn assemble_newmark_system(
1213 &self,
1214 mass: &DVector<f64>,
1215 mass_coeff: f64,
1216 stiffness_coeff: f64,
1217 pseudo_time: f64,
1218 ) -> (SparseMatrix, DVector<f64>) {
1219 let n = self.num_free_dofs;
1220 let (triplets, resistance) = self.assemble_stiffness_triplets(pseudo_time);
1221
1222 let mut eff_triplets = Vec::with_capacity(triplets.len() + n);
1223 for t in &triplets {
1224 eff_triplets.push(Triplet::new(t.row, t.col, stiffness_coeff * t.val));
1225 }
1226 for i in 0..n {
1227 eff_triplets.push(Triplet::new(i, i, mass_coeff * mass[i]));
1228 }
1229
1230 let k_eff = SparseMatrix::try_new_from_triplets(n, n, &eff_triplets)
1231 .expect("equation numbers are always in [0, num_free_dofs)");
1232 (k_eff, resistance)
1233 }
1234}
1235
1236/// Adds a lumped-mass value on one node DOF to the diagonal mass vector.
1237/// A DOF that is a single scaled unknown (`u = c * q`) contributes
1238/// `c^2 * m`; one that combines several unknowns would need the full
1239/// `T^T M T`, which a diagonal mass vector cannot hold.
1240fn accumulate_mass(mass: &mut DVector<f64>, terms: DofTerms<'_>, value: f64) {
1241 if value == 0.0 {
1242 return;
1243 }
1244 match terms.as_slice() {
1245 [] => {}
1246 [(eq, coeff)] => mass[*eq] += coeff * coeff * value,
1247 _ => panic!(
1248 "assemble_mass_diagonal: mass on a DOF constrained to a combination of several unknowns (a \
1249 rigid diaphragm or rigid link slave) isn't supported — the lumped mass diagonal can't \
1250 represent the rotational inertia this would induce at the master; assign the mass at the \
1251 master node (or an unconstrained node) instead"
1252 ),
1253 }
1254}
1255
1256/// Scatters element tangents into triplets and element resistances into the
1257/// free-DOF resistance vector, through each DOF's equation terms.
1258struct TripletSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1259where
1260 NId: Key,
1261 E: ElementOps<NDIM, NDOF, NId>,
1262{
1263 domain: &'a Domain<NDIM, NDOF, NId, E>,
1264 triplets: Vec<Triplet<usize, usize, f64>>,
1265 resistance: DVector<f64>,
1266}
1267
1268impl<const NDIM: usize, const NDOF: usize, NId, E> TangentSink<NId>
1269 for TripletSink<'_, NDIM, NDOF, NId, E>
1270where
1271 NId: Key,
1272 E: ElementOps<NDIM, NDOF, NId>,
1273{
1274 fn add<const N: usize>(
1275 &mut self,
1276 dofs: &[DofRef<NId>; N],
1277 k: &SMatrix<f64, N, N>,
1278 r: &SVector<f64, N>,
1279 ) {
1280 #[cfg(debug_assertions)]
1281 self.domain.assert_declared(dofs, Some(k), r.as_slice());
1282 let dof_terms: [DofTerms<'_>; N] =
1283 std::array::from_fn(|a| self.domain.dof_terms(dofs[a].0, dofs[a].1 as usize));
1284 for (a, terms_a) in dof_terms.iter().enumerate() {
1285 for &(eq_a, coeff_a) in terms_a.iter() {
1286 self.resistance[eq_a] += coeff_a * r[a];
1287 for (b, terms_b) in dof_terms.iter().enumerate() {
1288 for &(eq_b, coeff_b) in terms_b.iter() {
1289 self.triplets
1290 .push(Triplet::new(eq_a, eq_b, coeff_a * coeff_b * k[(a, b)]));
1291 }
1292 }
1293 }
1294 }
1295 }
1296}
1297
1298/// Scatters scaled element load vectors into the free-DOF load vector.
1299struct LoadSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1300where
1301 NId: Key,
1302 E: ElementOps<NDIM, NDOF, NId>,
1303{
1304 domain: &'a Domain<NDIM, NDOF, NId, E>,
1305 factor: f64,
1306 load: &'a mut DVector<f64>,
1307}
1308
1309impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId>
1310 for LoadSink<'_, NDIM, NDOF, NId, E>
1311where
1312 NId: Key,
1313 E: ElementOps<NDIM, NDOF, NId>,
1314{
1315 fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1316 #[cfg(debug_assertions)]
1317 self.domain.assert_declared(dofs, None, v.as_slice());
1318 for (a, &(node, slot)) in dofs.iter().enumerate() {
1319 for &(eq, coeff) in self.domain.dof_terms(node, slot as usize).iter() {
1320 self.load[eq] += coeff * self.factor * v[a];
1321 }
1322 }
1323 }
1324}
1325
1326/// Scatters element lumped masses into the free-DOF mass diagonal.
1327struct MassSink<'a, const NDIM: usize, const NDOF: usize, NId, E>
1328where
1329 NId: Key,
1330 E: ElementOps<NDIM, NDOF, NId>,
1331{
1332 domain: &'a Domain<NDIM, NDOF, NId, E>,
1333 mass: &'a mut DVector<f64>,
1334}
1335
1336impl<const NDIM: usize, const NDOF: usize, NId, E> VectorSink<NId>
1337 for MassSink<'_, NDIM, NDOF, NId, E>
1338where
1339 NId: Key,
1340 E: ElementOps<NDIM, NDOF, NId>,
1341{
1342 fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1343 #[cfg(debug_assertions)]
1344 self.domain.assert_declared(dofs, None, v.as_slice());
1345 for (a, &(node, slot)) in dofs.iter().enumerate() {
1346 accumulate_mass(self.mass, self.domain.dof_terms(node, slot as usize), v[a]);
1347 }
1348 }
1349}
1350
1351/// Picks out the entry of an element's resistance or load vector that
1352/// belongs to one `(node, dof)` — what `Domain::reaction` needs, without
1353/// involving equation numbers at all.
1354struct PickDof<NId> {
1355 node: NId,
1356 dof: u8,
1357 total: f64,
1358}
1359
1360impl<NId> PickDof<NId> {
1361 fn new(node: NId, dof: usize) -> Self {
1362 PickDof {
1363 node,
1364 dof: dof as u8,
1365 total: 0.0,
1366 }
1367 }
1368}
1369
1370impl<NId: Key> TangentSink<NId> for PickDof<NId> {
1371 fn add<const N: usize>(
1372 &mut self,
1373 dofs: &[DofRef<NId>; N],
1374 _k: &SMatrix<f64, N, N>,
1375 r: &SVector<f64, N>,
1376 ) {
1377 for (a, &(node, slot)) in dofs.iter().enumerate() {
1378 if node == self.node && slot == self.dof {
1379 self.total += r[a];
1380 }
1381 }
1382 }
1383}
1384
1385impl<NId: Key> VectorSink<NId> for PickDof<NId> {
1386 fn add<const N: usize>(&mut self, dofs: &[DofRef<NId>; N], v: &SVector<f64, N>) {
1387 for (a, &(node, slot)) in dofs.iter().enumerate() {
1388 if node == self.node && slot == self.dof {
1389 self.total += v[a];
1390 }
1391 }
1392 }
1393}
1394
1395/// Tie the x-translation DOF of every node in `constrained` to `retained`'s
1396/// — the standard 2D-frame simplification for a rigid floor diaphragm
1397/// (Xara/OpenSees's `rigidDiaphragm` is inherently 3D: it ties in-plane
1398/// translations *and* rotation-about-axis through a lever arm to nodes in a
1399/// plane perpendicular to a given axis, which doesn't map onto a
1400/// single-plane 2D model). Equivalent to calling `equal_dof(retained, c,
1401/// &[0])` for each `c` in `constrained`.
1402///
1403/// Planar-only, in its own impl block rather than the shared generic one:
1404/// naively reusing this identity-tie approach for the spatial profile would
1405/// silently produce a wrong diaphragm (missing the lever-arm term) instead
1406/// of refusing to compile — see `Domain3::rigid_diaphragm` for the real
1407/// (affine, lever-arm-aware) spatial version below.
1408impl Domain {
1409 pub fn rigid_diaphragm(&mut self, retained: NodeId, constrained: &[NodeId]) {
1410 for &c in constrained {
1411 self.equal_dof(retained, c, &[0]);
1412 }
1413 }
1414
1415 /// Rigid link: `slave` moves with `master` as a rigid body. Its rotation
1416 /// equals the master's and its translations equal the master's plus the
1417 /// small-rotation lever arm, `u_s = u_m + theta_m x d` with
1418 /// `d = pos(slave) - pos(master)` from the reference coordinates at the
1419 /// time of the call (`ux_s = ux_m - theta*dy`, `uy_s = uy_m + theta*dx`).
1420 /// Requires `ConstraintHandler::Transformation`.
1421 pub fn rigid_link(&mut self, master: NodeId, slave: NodeId) {
1422 let m = self.nodes[master].coords;
1423 let s = self.nodes[slave].coords;
1424 let (dx, dy) = (s[0] - m[0], s[1] - m[1]);
1425 self.add_constraint((slave, 0), &[(master, 0, 1.0), (master, 2, -dy)]);
1426 self.add_constraint((slave, 1), &[(master, 1, 1.0), (master, 2, dx)]);
1427 self.add_constraint((slave, 2), &[(master, 2, 1.0)]);
1428 }
1429}
1430
1431/// Spatial rigid diaphragm: for each node in `constrained`, ties its two
1432/// in-plane translational dofs (the two coordinate axes other than
1433/// `normal`) to `retained`'s same in-plane translations plus a lever-arm
1434/// term from `retained`'s rotation about `normal` — a genuine
1435/// general linear constraint (`ConstraintHandler::Transformation`), not a further
1436/// identity alias like planar `Domain::rigid_diaphragm` (see its doc
1437/// comment for why identity aliasing can't express a real diaphragm's
1438/// lever-arm kinematics).
1439///
1440/// Deliberately narrower than Xara/OpenSees's `rigidDiaphragm`: only the
1441/// two in-plane translational dofs of each constrained node are tied.
1442/// Every rotational dof of the constrained node — including its rotation
1443/// about `normal` itself — and its out-of-plane translation are left
1444/// completely free, unconstrained by this call. A floor diaphragm is rigid
1445/// in its own plane; it doesn't impose a shared rotation on whatever's
1446/// attached to it, and a linear frame model gives it no out-of-plane
1447/// (vertical) stiffness of its own. This matches planar `rigid_diaphragm`'s
1448/// existing scope (which likewise only ties translation, never `rz`), just
1449/// generalized from "the one translational in-plane dof a 2D model has" to
1450/// "the two a 3D diaphragm plane has, with the lever-arm term a real 3D
1451/// diaphragm needs".
1452///
1453/// Kinematics (small-rotation rigid-body motion in the plane perpendicular
1454/// to `normal`, at retained node `r`, evaluated at constrained node `c`):
1455/// with `n̂` the unit normal and `d = pos(c) - pos(r)`, a rotation
1456/// `theta_n` about `n̂` displaces `c` by `theta_n * (n̂ × d)`. Writing `a =
1457/// (normal+1) % 3`, `b = (normal+2) % 3` for the other two axes (so `(n, a,
1458/// b)` is a right-handed triad):
1459///
1460/// ```text
1461/// u_c[a] = u_r[a] - theta_r[normal] * (coord[b]_c - coord[b]_r)
1462/// u_c[b] = u_r[b] + theta_r[normal] * (coord[a]_c - coord[a]_r)
1463/// ```
1464///
1465/// Cross-checked against Xara/OpenSees's `RigidDiaphragm` constraint
1466/// equations for the z-normal case (`perpDirn = 3`, their default: `Ux_c =
1467/// Ux_r - Rz_r*(Yc-Yr)`, `Uy_c = Uy_r + Rz_r*(Xc-Xr)`) — substituting
1468/// `normal = Z` (`a = X, b = Y`) into the formula above reproduces exactly
1469/// those two equations. Verified through the full `Domain3`/`Analysis3`
1470/// stack for rigid-rotation invariance and lever-arm displacement magnitude
1471/// (`core/tests/constraints/rigid_diaphragm_3d.rs`), not just asserted by that
1472/// cross-check.
1473///
1474/// Lever arms are computed once here, from each node's fixed reference
1475/// `coords` at the time this is called — not re-evaluated from current
1476/// displacement, matching every other `Linear`/`Linear3` (small-
1477/// displacement) geometric transform's scope in this crate.
1478///
1479/// Planar-only, in its own impl block: `normal` requires a full 3D
1480/// coordinate/rotation set (`Axis3`, and a rotation-about-`normal` dof)
1481/// that only the spatial profile's `Node3`/`SpatialDof` provide.
1482impl Domain3 {
1483 /// `rigid_diaphragm_about` with `normal = Axis3::Y` — the "up" axis in
1484 /// this crate's y-up global convention,
1485 /// so the diaphragm plane is the horizontal `x`-`z` plane: the common
1486 /// case (an ordinary floor diaphragm).
1487 pub fn rigid_diaphragm(&mut self, retained: Node3Id, constrained: &[Node3Id]) {
1488 self.rigid_diaphragm_about(retained, constrained, Axis3::Y);
1489 }
1490
1491 /// `rigid_diaphragm` with an explicit diaphragm-plane normal — see this
1492 /// impl block's doc comment for the kinematics and scope.
1493 pub fn rigid_diaphragm_about(
1494 &mut self,
1495 retained: Node3Id,
1496 constrained: &[Node3Id],
1497 normal: Axis3,
1498 ) {
1499 let n = normal as usize;
1500 let a = (n + 1) % SPATIAL_NDIM;
1501 let b = (n + 2) % SPATIAL_NDIM;
1502 let rot_normal = SPATIAL_NDIM + n;
1503
1504 let retained_coords = self.nodes[retained].coords;
1505 for &c in constrained {
1506 let coords = self.nodes[c].coords;
1507 let lever_a = coords[a] - retained_coords[a];
1508 let lever_b = coords[b] - retained_coords[b];
1509 self.add_constraint(
1510 (c, a),
1511 &[(retained, a, 1.0), (retained, rot_normal, -lever_b)],
1512 );
1513 self.add_constraint(
1514 (c, b),
1515 &[(retained, b, 1.0), (retained, rot_normal, lever_a)],
1516 );
1517 }
1518 }
1519
1520 /// Rigid link: `slave` moves with `master` as a rigid body. All six slave
1521 /// DOFs are tied: its rotations equal the master's, and its translations
1522 /// equal the master's plus the small-rotation lever arm,
1523 /// `u_s = u_m + theta_m x d` with `d = pos(slave) - pos(master)` taken
1524 /// from the reference coordinates at the time of the call. Joins, for
1525 /// example, a beam end to the edge of a shell, or an eccentric load point
1526 /// to the member it loads. Requires `ConstraintHandler::Transformation`.
1527 pub fn rigid_link(&mut self, master: Node3Id, slave: Node3Id) {
1528 let m = self.nodes[master].coords;
1529 let s = self.nodes[slave].coords;
1530 let (dx, dy, dz) = (s[0] - m[0], s[1] - m[1], s[2] - m[2]);
1531 // Rotation dofs are 3 (rx), 4 (ry), 5 (rz).
1532 self.add_constraint(
1533 (slave, 0),
1534 &[(master, 0, 1.0), (master, 4, dz), (master, 5, -dy)],
1535 );
1536 self.add_constraint(
1537 (slave, 1),
1538 &[(master, 1, 1.0), (master, 5, dx), (master, 3, -dz)],
1539 );
1540 self.add_constraint(
1541 (slave, 2),
1542 &[(master, 2, 1.0), (master, 3, dy), (master, 4, -dx)],
1543 );
1544 for rotation in 3..6 {
1545 self.add_constraint((slave, rotation), &[(master, rotation, 1.0)]);
1546 }
1547 }
1548}
1549
1550#[cfg(test)]
1551mod domain3_tests {
1552 use super::*;
1553 use crate::analysis::SparseSolver;
1554 use crate::model::{Material, SpatialDof, Truss3};
1555
1556 /// A skew 3D truss cantilever: end load projected onto the bar axis
1557 /// should reproduce simple axial-bar elongation, exercised through the
1558 /// full `Domain3` assembly/equation-numbering path (not just `Truss3`
1559 /// in isolation, which `truss.rs`'s own tests already cover).
1560 #[test]
1561 fn cantilever_truss3_matches_analytical_axial_stiffness() {
1562 let mut domain = Domain3::new();
1563 let fixed = domain.add_node(
1564 crate::model::Node3::new([0.0, 0.0, 0.0])
1565 .fix(SpatialDof::Ux as usize)
1566 .fix(SpatialDof::Uy as usize)
1567 .fix(SpatialDof::Uz as usize)
1568 .fix(SpatialDof::Rx as usize)
1569 .fix(SpatialDof::Ry as usize)
1570 .fix(SpatialDof::Rz as usize),
1571 );
1572 let free = domain.add_node(
1573 crate::model::Node3::new([3.0, 4.0, 0.0])
1574 .fix(SpatialDof::Uz as usize)
1575 .fix(SpatialDof::Rx as usize)
1576 .fix(SpatialDof::Ry as usize)
1577 .fix(SpatialDof::Rz as usize),
1578 );
1579
1580 let area = 2.0;
1581 let e = 1000.0;
1582 let length = 5.0; // 3-4-5 triangle
1583 domain.add_element(Element3::Truss3(Truss3::new(
1584 fixed,
1585 free,
1586 area,
1587 Material::Elastic { e },
1588 )));
1589
1590 let force = 100.0;
1591 domain.load_node(free, SpatialDof::Ux as usize, force * 3.0 / 5.0);
1592 domain.load_node(free, SpatialDof::Uy as usize, force * 4.0 / 5.0);
1593
1594 domain.number_dofs();
1595 assert_eq!(domain.num_free_dofs(), 2);
1596
1597 let (k, residual) = domain.form_tangent_and_residual(1.0);
1598 let du = SparseSolver::new()
1599 .solve(&k, &residual)
1600 .expect("well-posed system");
1601 domain.apply_displacement_increment(&du);
1602
1603 let expected_elongation = force * length / (area * e);
1604 let node = domain.node(free);
1605 let axial_disp = node.displacement[SpatialDof::Ux as usize] * 3.0 / 5.0
1606 + node.displacement[SpatialDof::Uy as usize] * 4.0 / 5.0;
1607 assert!((axial_disp - expected_elongation).abs() < 1e-9);
1608 }
1609}
1610
1611#[cfg(test)]
1612mod numbering_tests {
1613 use super::*;
1614 use crate::model::{Material, Node3, SpatialDof, Truss, ZeroLength3};
1615
1616 /// Equation numbers are assigned to free, unconstrained DOFs in node
1617 /// insertion order; an identity-tied DOF shares its retained DOF's
1618 /// equation; a fixed retained DOF leaves its tied DOF without one.
1619 #[test]
1620 fn identity_ties_alias_equations_and_fixed_retained_dofs_drop_out() {
1621 let mut domain = Domain::new();
1622 let a = domain.add_node(Node::new([0.0, 0.0]).fix(0).fix(1).fix(2));
1623 let b = domain.add_node(Node::new([1.0, 0.0]).fix(2));
1624 let c = domain.add_node(Node::new([2.0, 0.0]).fix(1).fix(2));
1625 let d = domain.add_node(Node::new([3.0, 0.0]).fix(2));
1626 domain.add_element(Element::Truss(Truss::new(
1627 a,
1628 b,
1629 1.0,
1630 Material::Elastic { e: 1.0 },
1631 )));
1632 domain.equal_dof(b, c, &[0]);
1633 // `a` is fully fixed, so `d`'s tied DOF has no equation.
1634 domain.equal_dof(a, d, &[1]);
1635
1636 assert_eq!(domain.number_dofs(), 2);
1637 assert_eq!(domain.equation_of(a, 0), None);
1638 assert_eq!(domain.equation_of(b, 0), Some(0));
1639 assert_eq!(domain.equation_of(b, 1), Some(1));
1640 assert_eq!(
1641 domain.equation_of(c, 0),
1642 Some(0),
1643 "tied dof shares the retained equation"
1644 );
1645 assert_eq!(domain.equation_of(c, 1), None);
1646 // No element stiffens `d`'s ux, so it is not an equation at all.
1647 assert_eq!(domain.equation_of(d, 0), None);
1648 assert!(!domain.is_active(d, 0));
1649 assert_eq!(domain.equation_of(d, 1), None);
1650 assert_eq!(
1651 domain.dof_terms(c, 0).iter().copied().collect::<Vec<_>>(),
1652 vec![(0, 1.0)]
1653 );
1654 assert!(domain.dof_terms(d, 1).iter().next().is_none());
1655 }
1656
1657 /// A 3D rigid diaphragm: the slave's in-plane translations are affine
1658 /// in the master's translation and rotation about the normal, with the
1659 /// lever-arm coefficients, and the slave has no equation of its own.
1660 #[test]
1661 fn rigid_diaphragm_slave_dofs_resolve_to_affine_terms() {
1662 let fixed_but = |free: &[SpatialDof]| {
1663 let mut node = Node3::new([0.0, 0.0, 0.0]);
1664 for dof in [
1665 SpatialDof::Ux,
1666 SpatialDof::Uy,
1667 SpatialDof::Uz,
1668 SpatialDof::Rx,
1669 SpatialDof::Ry,
1670 SpatialDof::Rz,
1671 ] {
1672 if !free.iter().any(|f| *f as usize == dof as usize) {
1673 node = node.fix(dof as usize);
1674 }
1675 }
1676 node
1677 };
1678 let mut domain = Domain3::new();
1679 let master = domain.add_node(fixed_but(&[SpatialDof::Ux, SpatialDof::Uz, SpatialDof::Ry]));
1680 let mut slave_node = fixed_but(&[SpatialDof::Ux, SpatialDof::Uz]);
1681 slave_node.coords = [2.0, 0.0, 3.0];
1682 let slave = domain.add_node(slave_node);
1683 let ground = domain.add_node(fixed_but(&[]));
1684 domain.add_element(Element3::ZeroLength3(
1685 ZeroLength3::new(ground, slave).with_material(0, Material::Elastic { e: 1.0 }),
1686 ));
1687 domain.rigid_diaphragm(master, &[slave]);
1688
1689 assert_eq!(domain.number_dofs(), 3);
1690 let (ux, uz, ry) = (
1691 domain.equation_of(master, SpatialDof::Ux as usize).unwrap(),
1692 domain.equation_of(master, SpatialDof::Uz as usize).unwrap(),
1693 domain.equation_of(master, SpatialDof::Ry as usize).unwrap(),
1694 );
1695 assert_eq!(domain.equation_of(slave, SpatialDof::Ux as usize), None);
1696 // Normal Y: a = Z, b = X, so u_c[Z] = u_r[Z] - theta_Y * (x_c - x_r)
1697 // and u_c[X] = u_r[X] + theta_Y * (z_c - z_r).
1698 let terms = |dof: SpatialDof| {
1699 domain
1700 .dof_terms(slave, dof as usize)
1701 .iter()
1702 .copied()
1703 .collect::<Vec<_>>()
1704 };
1705 assert_eq!(terms(SpatialDof::Ux), vec![(ux, 1.0), (ry, 3.0)]);
1706 assert_eq!(terms(SpatialDof::Uz), vec![(uz, 1.0), (ry, -2.0)]);
1707 }
1708}