Skip to main content

carapace_core/model/elements/
quad4.rs

1//! `Quad4`: the 4-node bilinear isoparametric quadrilateral, 2x2 Gauss
2//! integration, `[ux, uy]` per node (8 DOFs), plane stress or strain through
3//! its `PlaneMaterial`. Each Gauss point owns an independent copy of the
4//! material and commits it individually.
5//!
6//! # Formulations
7//!
8//! [`Quad4Formulation::Full`] is the plain element. It shear-locks in bending.
9//!
10//! [`Quad4Formulation::Enhanced`] is the Wilson-Taylor incompatible-modes
11//! element (QM6/Q6). For each displacement component it adds the two shapes
12//! `1 - xi^2` and `1 - eta^2`, four internal parameters `alpha` per element,
13//! discontinuous across edges and therefore condensed out locally. The
14//! enhanced strain is `G~(xi, eta) alpha` with
15//! `G~ = (detJ0 / detJ(xi, eta)) * [J0^{-1} dM/dxi]` expanded into strain
16//! rows; `J0` is the Jacobian at the element center, and the `detJ0 / detJ`
17//! factor (Taylor-Beresford-Wilson) is what preserves the constant-strain
18//! patch test on distorted elements. The element stiffness is
19//! `K_uu - K_ua K_aa^{-1} K_au`, the resistance is that stiffness times the
20//! nodal displacements (the element is linear), and strains and stresses at
21//! the Gauss points are recovered as `B u + G~ alpha` and `D` times that, with
22//! `alpha = -K_aa^{-1} K_au u`.
23//!
24//! Scope: `Enhanced` accepts only linear `PlaneMaterial`s (for a nonlinear one
25//! `alpha` would be element internal state needing its own local Newton
26//! iteration). It is designed to cure shear (bending) locking. It is **not** a
27//! mixed or B-bar formulation and carries no volumetric-locking guarantee for
28//! nearly incompressible plane strain (`nu -> 0.5`); the plain element locks
29//! completely there, and in the tests (Cook's membrane, a pressurized thick
30//! cylinder at `nu = 0.4999`) the enhanced element was observed to do much
31//! better, but that is an observation about those problems, not a property of
32//! the formulation. Incompatible-mode elements also remain somewhat sensitive
33//! to trapezoidal distortion.
34
35use nalgebra::{SMatrix, SVector};
36
37use super::plane_common::{
38    b_matrix, check_section, load_vector, plane_coords, plane_displacement, plane_dofs, PlaneView,
39};
40use super::{DofMask, ElementForce, GaussResponse, TangentSink, VectorSink};
41use crate::model::continuum::{
42    physical_derivatives, quad4_dshape, quad4_shape, validate_quad, Jacobian, QUAD4_GAUSS_2X2,
43};
44use crate::model::{ElementLoad, NodeId, PlaneMaterial, PlaneMatrix, PlaneVector};
45
46/// Which displacement field the element uses.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
48pub enum Quad4Formulation {
49    /// Plain bilinear isoparametric.
50    #[default]
51    Full,
52    /// Wilson-Taylor incompatible modes, condensed (linear materials only).
53    Enhanced,
54}
55
56#[derive(Debug, Clone)]
57pub struct Quad4 {
58    pub nodes: [NodeId; 4],
59    pub thickness: f64,
60    pub density: f64,
61    formulation: Quad4Formulation,
62    materials: [PlaneMaterial; 4],
63    cache: Option<Cache>,
64}
65
66/// Geometry-only quantities, computed once by `prepare`.
67#[derive(Debug, Clone, Copy, PartialEq)]
68pub(super) struct Cache {
69    points: [PointGeometry; 4],
70    enhanced: Option<Enhanced>,
71}
72
73/// The condensed enhanced element: `k = K_uu - K_ua K_aa^{-1} K_au` and
74/// `alpha = recovery * u`.
75#[derive(Debug, Clone, Copy, PartialEq)]
76struct Enhanced {
77    k: SMatrix<f64, 8, 8>,
78    recovery: SMatrix<f64, 4, 8>,
79    /// `G~` at each Gauss point.
80    g: [SMatrix<f64, 3, 4>; 4],
81}
82
83/// What depends only on the (fixed) coordinates at one Gauss point.
84#[derive(Debug, Clone, Copy, PartialEq)]
85pub(super) struct PointGeometry {
86    pub b: SMatrix<f64, 3, 8>,
87    /// `det J * w`.
88    pub weight: f64,
89    /// Shape-function values `N_i`.
90    pub n: [f64; 4],
91}
92
93pub(super) fn point_geometry(coords: &[[f64; 2]; 4]) -> [PointGeometry; 4] {
94    std::array::from_fn(|g| {
95        let (xi, eta, w) = QUAD4_GAUSS_2X2[g];
96        let dn = quad4_dshape(xi, eta);
97        let jacobian = Jacobian::new(coords, &dn);
98        PointGeometry {
99            b: b_matrix(&physical_derivatives(&jacobian, &dn)),
100            weight: jacobian.det * w,
101            n: quad4_shape(xi, eta),
102        }
103    })
104}
105
106/// `G~` at each Gauss point (see the module doc comment).
107fn enhanced_strain_matrices(coords: &[[f64; 2]; 4]) -> [SMatrix<f64, 3, 4>; 4] {
108    let j0 = Jacobian::new(coords, &quad4_dshape(0.0, 0.0));
109    std::array::from_fn(|g| {
110        let (xi, eta, _) = QUAD4_GAUSS_2X2[g];
111        let det = Jacobian::new(coords, &quad4_dshape(xi, eta)).det;
112        // Reference derivatives of the modes 1 - xi^2 and 1 - eta^2, mapped by J0.
113        let dm = [[-2.0 * xi, 0.0], [0.0, -2.0 * eta]];
114        let gm = physical_derivatives(&j0, &dm);
115        let scale = j0.det / det;
116        let (g1, g2) = (gm[0], gm[1]);
117        scale
118            * SMatrix::<f64, 3, 4>::new(
119                g1[0], g2[0], 0.0, 0.0, //
120                0.0, 0.0, g1[1], g2[1], //
121                g1[1], g2[1], g1[0], g2[0],
122            )
123    })
124}
125
126/// Condenses the incompatible modes out of the element.
127fn condense(
128    points: &[PointGeometry; 4],
129    d: &[PlaneMatrix; 4],
130    thickness: f64,
131    coords: &[[f64; 2]; 4],
132) -> Result<Enhanced, &'static str> {
133    let g = enhanced_strain_matrices(coords);
134    let mut k_uu = SMatrix::<f64, 8, 8>::zeros();
135    let mut k_ua = SMatrix::<f64, 8, 4>::zeros();
136    let mut k_aa = SMatrix::<f64, 4, 4>::zeros();
137    for i in 0..4 {
138        let scale = thickness * points[i].weight;
139        let (b, gi) = (&points[i].b, &g[i]);
140        k_uu += scale * b.transpose() * d[i] * b;
141        k_ua += scale * b.transpose() * d[i] * gi;
142        k_aa += scale * gi.transpose() * d[i] * gi;
143    }
144    let k_aa_inverse = k_aa
145        .try_inverse()
146        .filter(|inverse| inverse.iter().all(|v| v.is_finite()))
147        .ok_or("enhanced Quad4 mode matrix is singular")?;
148    let recovery = -k_aa_inverse * k_ua.transpose();
149    Ok(Enhanced {
150        k: k_uu + k_ua * recovery,
151        recovery,
152        g,
153    })
154}
155
156impl Quad4 {
157    pub fn new(nodes: [NodeId; 4], thickness: f64, material: PlaneMaterial) -> Self {
158        Quad4 {
159            nodes,
160            thickness,
161            density: 0.0,
162            formulation: Quad4Formulation::Full,
163            materials: std::array::from_fn(|_| material.clone()),
164            cache: None,
165        }
166    }
167
168    /// Selects the formulation; `Enhanced` requires a linear material
169    /// (checked by `validate`).
170    pub fn with_formulation(mut self, formulation: Quad4Formulation) -> Self {
171        self.formulation = formulation;
172        self
173    }
174
175    pub fn formulation(&self) -> Quad4Formulation {
176        self.formulation
177    }
178
179    pub fn with_density(mut self, density: f64) -> Self {
180        self.density = density;
181        self
182    }
183
184    /// The Gauss points' materials, in `QUAD4_GAUSS_2X2` order.
185    pub fn materials(&self) -> &[PlaneMaterial; 4] {
186        &self.materials
187    }
188
189    fn compute_cache(&self, view: &PlaneView<'_>) -> Result<Cache, &'static str> {
190        let coords = plane_coords(&self.nodes, view);
191        let points = point_geometry(&coords);
192        let enhanced = match self.formulation {
193            Quad4Formulation::Full => None,
194            Quad4Formulation::Enhanced => {
195                let d: [PlaneMatrix; 4] = std::array::from_fn(|g| self.materials[g].d_matrix());
196                Some(condense(&points, &d, self.thickness, &coords)?)
197            }
198        };
199        Ok(Cache { points, enhanced })
200    }
201
202    /// The cache, or a fresh computation (an unvalidated enhanced element with a
203    /// singular mode matrix gets zero stiffness rather than a panic here;
204    /// `validate` is where that is reported).
205    fn cache_or_compute(&self, view: &PlaneView<'_>) -> Cache {
206        self.cache.unwrap_or_else(|| {
207            self.compute_cache(view).unwrap_or_else(|_| Cache {
208                points: point_geometry(&plane_coords(&self.nodes, view)),
209                enhanced: Some(Enhanced {
210                    k: SMatrix::zeros(),
211                    recovery: SMatrix::zeros(),
212                    g: [SMatrix::zeros(); 4],
213                }),
214            })
215        })
216    }
217
218    pub(super) fn dof_mask(&self) -> DofMask {
219        DofMask::none().with(0).with(1)
220    }
221
222    pub(super) fn validate(&self, view: &PlaneView<'_>) -> Result<(), &'static str> {
223        check_section(self.thickness, self.density, &self.materials[0])?;
224        validate_quad(&plane_coords(&self.nodes, view))?;
225        if self.formulation == Quad4Formulation::Enhanced {
226            if !self.materials.iter().all(PlaneMaterial::is_linear) {
227                return Err("enhanced Quad4 requires a linear plane material");
228            }
229            self.compute_cache(view)?;
230        }
231        Ok(())
232    }
233
234    pub(super) fn prepare(&mut self, view: &PlaneView<'_>) {
235        self.cache = self.compute_cache(view).ok();
236    }
237
238    /// Strain at Gauss point `g`: `B u`, plus `G~ alpha` for the enhanced element.
239    fn strains(&self, cache: &Cache, u: &SVector<f64, 8>) -> [PlaneVector; 4] {
240        let alpha = cache.enhanced.as_ref().map(|e| e.recovery * u);
241        std::array::from_fn(|g| {
242            let mut strain = cache.points[g].b * u;
243            if let (Some(e), Some(alpha)) = (&cache.enhanced, &alpha) {
244                strain += e.g[g] * alpha;
245            }
246            strain
247        })
248    }
249
250    pub(super) fn assemble_tangent<S: TangentSink<NodeId>>(
251        &self,
252        view: &PlaneView<'_>,
253        sink: &mut S,
254    ) {
255        let u = plane_displacement::<4, 8>(&self.nodes, view);
256        let cache = self.cache_or_compute(view);
257        if let Some(enhanced) = &cache.enhanced {
258            sink.add(
259                &plane_dofs::<4, 8>(&self.nodes),
260                &enhanced.k,
261                &(enhanced.k * u),
262            );
263            return;
264        }
265        let mut k = SMatrix::<f64, 8, 8>::zeros();
266        let mut r = SVector::<f64, 8>::zeros();
267        for (g, material) in cache.points.iter().zip(&self.materials) {
268            let (stress, d) = material.trial_stress_tangent(&(g.b * u));
269            let scale = self.thickness * g.weight;
270            k += scale * g.b.transpose() * d * g.b;
271            r += scale * g.b.transpose() * stress;
272        }
273        sink.add(&plane_dofs::<4, 8>(&self.nodes), &k, &r);
274    }
275
276    /// `t * integral(N_i dA)` per node: the body-force share, and (times density) the
277    /// row-sum lumped mass `m_i = sum_g rho t N_i(g) det J(g) w(g)`. Exact for the
278    /// consistent mass here (bilinear `N_i` times linear `det J`), and equal to
279    /// `t A / 4` only for parallelograms.
280    fn nodal_weights(&self, view: &PlaneView<'_>) -> [f64; 4] {
281        let mut weights = [0.0; 4];
282        for g in self.cache_or_compute(view).points {
283            for (i, w) in weights.iter_mut().enumerate() {
284                *w += self.thickness * g.n[i] * g.weight;
285            }
286        }
287        weights
288    }
289
290    /// Row-sum lumped mass, applied to each translational DOF of node `i`.
291    pub(super) fn assemble_mass<S: VectorSink<NodeId>>(&self, view: &PlaneView<'_>, sink: &mut S) {
292        let weights = self.nodal_weights(view);
293        let m = SVector::<f64, 8>::from_fn(|a, _| self.density * weights[a / 2]);
294        sink.add(&plane_dofs::<4, 8>(&self.nodes), &m);
295    }
296
297    /// Consistent nodal forces of `load` (global DOF order), sharing the mass weights
298    /// for the body force.
299    pub(super) fn load_vector(&self, view: &PlaneView<'_>, load: &ElementLoad) -> SVector<f64, 8> {
300        load_vector(
301            &plane_coords(&self.nodes, view),
302            &self.nodal_weights(view),
303            self.thickness,
304            load,
305        )
306    }
307
308    pub(super) fn assemble_load<S: VectorSink<NodeId>>(
309        &self,
310        view: &PlaneView<'_>,
311        load: &ElementLoad,
312        sink: &mut S,
313    ) {
314        sink.add(
315            &plane_dofs::<4, 8>(&self.nodes),
316            &self.load_vector(view, load),
317        );
318    }
319
320    pub(super) fn commit(&mut self, view: &PlaneView<'_>) {
321        let u = plane_displacement::<4, 8>(&self.nodes, view);
322        let strains = self.strains(&self.cache_or_compute(view), &u);
323        for (material, strain) in self.materials.iter_mut().zip(&strains) {
324            *material = material.commit(strain);
325        }
326    }
327
328    pub(super) fn local_force(&self, view: &PlaneView<'_>) -> ElementForce {
329        let u = plane_displacement::<4, 8>(&self.nodes, view);
330        let cache = self.cache_or_compute(view);
331        if let Some(enhanced) = &cache.enhanced {
332            return (enhanced.k * u).into();
333        }
334        let mut r = SVector::<f64, 8>::zeros();
335        for (g, material) in cache.points.iter().zip(&self.materials) {
336            let (stress, _) = material.trial_stress_tangent(&(g.b * u));
337            r += self.thickness * g.weight * g.b.transpose() * stress;
338        }
339        r.into()
340    }
341
342    /// Committed strain and stress at the four Gauss points; for the enhanced
343    /// element these include the internal-mode contribution.
344    pub(super) fn gauss_responses(&self, view: &PlaneView<'_>) -> Vec<GaussResponse> {
345        let u = plane_displacement::<4, 8>(&self.nodes, view);
346        let strains = self.strains(&self.cache_or_compute(view), &u);
347        strains
348            .iter()
349            .zip(&self.materials)
350            .map(|(strain, material)| {
351                let (stress, _) = material.trial_stress_tangent(strain);
352                GaussResponse {
353                    strain: (*strain).into(),
354                    stress: stress.into(),
355                }
356            })
357            .collect()
358    }
359
360    #[cfg(test)]
361    pub(super) fn cache(&self) -> Option<Cache> {
362        self.cache
363    }
364}
365
366#[cfg(test)]
367mod tests {
368    use nalgebra::SymmetricEigen;
369    use slotmap::SlotMap;
370
371    use super::*;
372    use crate::model::{DofRef, ElementLoad, Node};
373
374    struct Collect {
375        k: SMatrix<f64, 8, 8>,
376        r: SVector<f64, 8>,
377    }
378
379    impl TangentSink<NodeId> for Collect {
380        fn add<const N: usize>(
381            &mut self,
382            _dofs: &[DofRef<NodeId>; N],
383            k: &SMatrix<f64, N, N>,
384            r: &SVector<f64, N>,
385        ) {
386            self.k = SMatrix::from_fn(|i, j| k[(i, j)]);
387            self.r = SVector::from_fn(|i, _| r[i]);
388        }
389    }
390
391    struct Mass(SVector<f64, 8>);
392
393    impl VectorSink<NodeId> for Mass {
394        fn add<const N: usize>(&mut self, _dofs: &[DofRef<NodeId>; N], v: &SVector<f64, N>) {
395            self.0 = SVector::from_fn(|i, _| v[i]);
396        }
397    }
398
399    fn setup(coords: [[f64; 2]; 4]) -> (SlotMap<NodeId, Node>, [NodeId; 4]) {
400        let mut nodes = SlotMap::with_key();
401        let ids = coords.map(|c| nodes.insert(Node::new(c)));
402        (nodes, ids)
403    }
404
405    fn assemble(el: &Quad4, nodes: &SlotMap<NodeId, Node>) -> Collect {
406        let mut sink = Collect {
407            k: SMatrix::zeros(),
408            r: SVector::zeros(),
409        };
410        el.assemble_tangent(&PlaneView::new(nodes), &mut sink);
411        sink
412    }
413
414    fn masses(el: &Quad4, nodes: &SlotMap<NodeId, Node>) -> SVector<f64, 8> {
415        let mut sink = Mass(SVector::zeros());
416        el.assemble_mass(&PlaneView::new(nodes), &mut sink);
417        sink.0
418    }
419
420    const SKEW: [[f64; 2]; 4] = [[0.0, 0.0], [2.0, 0.2], [2.3, 1.7], [-0.1, 1.4]];
421
422    fn material() -> PlaneMaterial {
423        PlaneMaterial::plane_stress(30e3, 0.2).unwrap()
424    }
425
426    #[test]
427    fn stiffness_matches_the_closed_form_for_the_unit_square() {
428        // E = 1, nu = 0, t = 1, unit square (hand integration of the bilinear field):
429        // K[ux0, ux0] = 1/3 + 1/6 = 1/2, K[ux0, uy0] = 1/8, K[ux0, ux1] = -1/3 + 1/12 = -1/4.
430        let (nodes, ids) = setup([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
431        let el = Quad4::new(ids, 1.0, PlaneMaterial::plane_stress(1.0, 0.0).unwrap());
432        let k = assemble(&el, &nodes).k;
433        assert!((k[(0, 0)] - 0.5).abs() < 1e-14);
434        assert!((k[(0, 1)] - 0.125).abs() < 1e-14);
435        assert!((k[(0, 2)] + 0.25).abs() < 1e-14);
436        // Rigid translation: every row sums (per direction) to zero.
437        for row in 0..8 {
438            let sum_x: f64 = (0..4).map(|n| k[(row, 2 * n)]).sum();
439            let sum_y: f64 = (0..4).map(|n| k[(row, 2 * n + 1)]).sum();
440            assert!(sum_x.abs() < 1e-14 && sum_y.abs() < 1e-14);
441        }
442    }
443
444    #[test]
445    fn stiffness_is_symmetric_with_exactly_three_rigid_body_modes() {
446        let (nodes, ids) = setup(SKEW);
447        let el = Quad4::new(ids, 0.4, material());
448        let k = assemble(&el, &nodes).k;
449        assert!((k - k.transpose()).abs().max() < 1e-9 * k.abs().max());
450        let eigen = SymmetricEigen::new(k).eigenvalues;
451        let largest = eigen.max();
452        assert_eq!(
453            eigen.iter().filter(|&&l| l.abs() < 1e-9 * largest).count(),
454            3,
455            "{eigen}"
456        );
457        assert!(eigen.iter().all(|&l| l > -1e-9 * largest), "{eigen}");
458    }
459
460    #[test]
461    fn a_rigid_motion_is_stress_free() {
462        let (mut nodes, ids) = setup(SKEW);
463        let el = Quad4::new(ids, 0.4, material());
464        let (t, theta) = ([0.01, -0.02], 1e-5);
465        for id in ids {
466            let [x, y] = nodes[id].coords;
467            nodes[id].displacement = [t[0] - theta * y, t[1] + theta * x, 0.0];
468        }
469        assert!(assemble(&el, &nodes).r.abs().max() < 1e-9);
470    }
471
472    #[test]
473    fn cyclic_relabeling_of_the_nodes_gives_the_same_stiffness() {
474        let (nodes, ids) = setup(SKEW);
475        let base = assemble(&Quad4::new(ids, 0.4, material()), &nodes).k;
476        for shift in 1..4 {
477            let rotated: [NodeId; 4] = std::array::from_fn(|i| ids[(i + shift) % 4]);
478            let k = assemble(&Quad4::new(rotated, 0.4, material()), &nodes).k;
479            // Local node i of the rotated element is global node (i + shift) % 4.
480            let perm = |a: usize| 2 * ((a / 2 + shift) % 4) + a % 2;
481            for a in 0..8 {
482                for b in 0..8 {
483                    assert!(
484                        (k[(a, b)] - base[(perm(a), perm(b))]).abs() < 1e-9 * base.abs().max(),
485                        "shift {shift} ({a},{b})"
486                    );
487                }
488            }
489        }
490    }
491
492    #[test]
493    fn lumped_masses_are_the_row_sums_of_the_consistent_mass() {
494        // Trapezoid with rho t = 1: nodal masses 5/12, 5/12, 1/3, 1/3 (not A / 4 = 0.375).
495        let (nodes, ids) = setup([[0.0, 0.0], [2.0, 0.0], [1.5, 1.0], [0.5, 1.0]]);
496        let m = masses(&Quad4::new(ids, 1.0, material()).with_density(1.0), &nodes);
497        for (node, expected) in [5.0 / 12.0, 5.0 / 12.0, 1.0 / 3.0, 1.0 / 3.0]
498            .into_iter()
499            .enumerate()
500        {
501            assert!(
502                (m[2 * node] - expected).abs() < 1e-14
503                    && (m[2 * node + 1] - expected).abs() < 1e-14
504            );
505        }
506        // Parallelogram: A / 4 each.
507        let (nodes, ids) = setup([[0.0, 0.0], [2.0, 0.0], [3.0, 1.5], [1.0, 1.5]]);
508        let m = masses(&Quad4::new(ids, 0.5, material()).with_density(4.0), &nodes);
509        assert!(m.iter().all(|&v| (v - 4.0 * 0.5 * 3.0 / 4.0).abs() < 1e-14));
510    }
511
512    #[test]
513    fn mass_sums_to_rho_t_a_in_each_direction() {
514        let (nodes, ids) = setup(SKEW);
515        // Shoelace area of SKEW.
516        let area = 0.5
517            * (0..4)
518                .map(|i| SKEW[i][0] * SKEW[(i + 1) % 4][1] - SKEW[(i + 1) % 4][0] * SKEW[i][1])
519                .sum::<f64>();
520        let (rho, t) = (2.5, 0.2);
521        let m = masses(&Quad4::new(ids, t, material()).with_density(rho), &nodes);
522        let sum_x: f64 = (0..4).map(|i| m[2 * i]).sum();
523        let sum_y: f64 = (0..4).map(|i| m[2 * i + 1]).sum();
524        assert!((sum_x - rho * t * area).abs() < 1e-12 && (sum_y - rho * t * area).abs() < 1e-12);
525    }
526
527    #[test]
528    fn prepare_is_idempotent_and_the_cache_equals_recomputation() {
529        let (nodes, ids) = setup(SKEW);
530        let mut el = Quad4::new(ids, 0.4, material());
531        let before = assemble(&el, &nodes).k;
532        assert!(el.cache().is_none());
533        let view = PlaneView::new(&nodes);
534        el.prepare(&view);
535        el.prepare(&view);
536        assert_eq!(before, assemble(&el, &nodes).k);
537        assert_eq!(el.cache(), Some(el.compute_cache(&view).unwrap()));
538    }
539
540    #[test]
541    fn invalid_geometry_is_reported_with_the_offending_corner() {
542        let (nodes, ids) = setup([[0.0, 0.0], [1.0, 0.0], [0.4, 0.4], [0.0, 1.0]]);
543        let view = PlaneView::new(&nodes);
544        let error = Quad4::new(ids, 1.0, material())
545            .validate(&view)
546            .unwrap_err();
547        assert!(error.contains("corner 2"), "{error}");
548        let (nodes, ids) = setup(SKEW);
549        assert!(Quad4::new(ids, 1.0, material())
550            .validate(&PlaneView::new(&nodes))
551            .is_ok());
552    }
553
554    #[test]
555    fn each_gauss_point_commits_its_own_material_history() {
556        let (mut nodes, ids) = setup([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
557        let mut el = Quad4::new(ids, 1.0, PlaneMaterial::Probe { last: [0.0; 3] });
558        // Pure bending-like field: u_x = k x y gives gamma_xy = k x varying across Gauss points.
559        for id in ids {
560            let [x, y] = nodes[id].coords;
561            nodes[id].displacement = [0.01 * x * y, 0.0, 0.0];
562        }
563        el.commit(&PlaneView::new(&nodes));
564        let lasts: Vec<[f64; 3]> = el
565            .materials()
566            .iter()
567            .map(|m| match m {
568                PlaneMaterial::Probe { last } => *last,
569                _ => unreachable!(),
570            })
571            .collect();
572        assert!(lasts.windows(2).any(|w| w[0] != w[1]), "{lasts:?}");
573        // The committed strain at each point equals what the element reports there.
574        let reported = el.gauss_responses(&PlaneView::new(&nodes));
575        for (last, point) in lasts.iter().zip(&reported) {
576            assert!((PlaneVector::from(*last) - PlaneVector::from(point.strain)).norm() < 1e-15);
577        }
578    }
579
580    fn enhanced_on_skew() -> (Quad4, SlotMap<NodeId, Node>) {
581        let (mut nodes, ids) = setup(SKEW);
582        let el = Quad4::new(ids, 0.4, material()).with_formulation(Quad4Formulation::Enhanced);
583        // A generic displacement state, bending-like so the internal modes engage.
584        for (k, id) in ids.into_iter().enumerate() {
585            let [x, y] = nodes[id].coords;
586            nodes[id].displacement = [1e-3 * x * y + 1e-4 * k as f64, -5e-4 * x * x, 0.0];
587        }
588        (el, nodes)
589    }
590
591    #[test]
592    fn recovered_stress_includes_the_internal_modes_and_differs_from_d_b_u() {
593        let (el, nodes) = enhanced_on_skew();
594        let view = PlaneView::new(&nodes);
595        let cache = el.compute_cache(&view).unwrap();
596        let enhanced = cache.enhanced.unwrap();
597        let u = plane_displacement::<4, 8>(&el.nodes, &view);
598        let alpha = enhanced.recovery * u;
599        assert!(alpha.norm() > 1e-6, "internal modes should engage");
600        let d = material().d_matrix();
601        let responses = el.gauss_responses(&view);
602        for g in 0..4 {
603            let strain = cache.points[g].b * u + enhanced.g[g] * alpha;
604            let stress = d * strain;
605            assert!((PlaneVector::from(responses[g].stress) - stress).norm() < 1e-9);
606            assert!((PlaneVector::from(responses[g].strain) - strain).norm() < 1e-15);
607            let compatible_only = d * (cache.points[g].b * u);
608            assert!((stress - compatible_only).norm() > 1e-3 * stress.norm());
609        }
610    }
611
612    #[test]
613    fn condensation_reproduces_the_resistance_from_the_recovered_stresses() {
614        let (el, nodes) = enhanced_on_skew();
615        let view = PlaneView::new(&nodes);
616        let mut sink = Collect {
617            k: SMatrix::zeros(),
618            r: SVector::zeros(),
619        };
620        el.assemble_tangent(&view, &mut sink);
621        let cache = el.compute_cache(&view).unwrap();
622        let mut from_stress = SVector::<f64, 8>::zeros();
623        for (g, response) in cache.points.iter().zip(el.gauss_responses(&view)) {
624            from_stress +=
625                el.thickness * g.weight * g.b.transpose() * PlaneVector::from(response.stress);
626        }
627        assert!((from_stress - sink.r).norm() < 1e-9 * sink.r.norm());
628        assert_eq!(
629            sink.r,
630            sink.k * plane_displacement::<4, 8>(&el.nodes, &view)
631        );
632        // Condensation made K = K_uu - K_ua K_aa^{-1} K_au: symmetric, with three rigid-body modes.
633        assert!((sink.k - sink.k.transpose()).abs().max() < 1e-9 * sink.k.abs().max());
634        let eigen = SymmetricEigen::new(sink.k).eigenvalues;
635        let largest = eigen.max();
636        assert_eq!(
637            eigen.iter().filter(|&&l| l.abs() < 1e-9 * largest).count(),
638            3,
639            "{eigen}"
640        );
641    }
642
643    #[test]
644    fn a_nonlinear_material_is_rejected_by_the_enhanced_element_only() {
645        let (nodes, ids) = setup(SKEW);
646        let view = PlaneView::new(&nodes);
647        let probe = PlaneMaterial::Probe { last: [0.0; 3] };
648        let full = Quad4::new(ids, 1.0, probe.clone());
649        assert!(full.validate(&view).is_ok());
650        let enhanced = full.with_formulation(Quad4Formulation::Enhanced);
651        assert_eq!(
652            enhanced.validate(&view),
653            Err("enhanced Quad4 requires a linear plane material")
654        );
655    }
656
657    #[test]
658    fn the_enhanced_stiffness_is_softer_than_the_full_one_in_bending_only() {
659        let (nodes, ids) = setup([[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]]);
660        let full = Quad4::new(ids, 1.0, material());
661        let enhanced = full.clone().with_formulation(Quad4Formulation::Enhanced);
662        let (kf, ke) = (assemble(&full, &nodes).k, assemble(&enhanced, &nodes).k);
663        // A bending displacement pattern (u_x = y * x-independent shear-free bending shape).
664        let bend = SVector::<f64, 8>::from_row_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0])
665            + SVector::from_fn(|i, _| {
666                let node = i / 2;
667                let [x, y] = [[0.0, 0.0], [2.0, 0.0], [2.0, 1.0], [0.0, 1.0]][node];
668                if i % 2 == 0 {
669                    x * (y - 0.5)
670                } else {
671                    -0.5 * x * x
672                }
673            });
674        assert!((bend.transpose() * ke * bend)[0] < 0.5 * (bend.transpose() * kf * bend)[0]);
675        // A constant-strain pattern costs the same energy.
676        let stretch = SVector::<f64, 8>::from_fn(|i, _| {
677            if i % 2 == 0 {
678                1e-3 * [0.0, 2.0, 2.0, 0.0][i / 2]
679            } else {
680                0.0
681            }
682        });
683        let (ef, ee) = (
684            (stretch.transpose() * kf * stretch)[0],
685            (stretch.transpose() * ke * stretch)[0],
686        );
687        assert!((ef - ee).abs() < 1e-12 * ef);
688    }
689
690    fn force_sums(v: &SVector<f64, 8>) -> [f64; 2] {
691        [
692            (0..4).map(|i| v[2 * i]).sum(),
693            (0..4).map(|i| v[2 * i + 1]).sum(),
694        ]
695    }
696
697    #[test]
698    fn body_force_totals_b_t_a_and_edge_loads_total_traction_times_length() {
699        let (nodes, ids) = setup([[0.0, 0.0], [2.0, 0.0], [1.5, 1.0], [0.5, 1.0]]);
700        let el = Quad4::new(ids, 0.4, material());
701        let view = PlaneView::new(&nodes);
702        let area = 1.5; // trapezoid with parallel sides 2 and 1, height 1
703        let body = force_sums(&el.load_vector(&view, &ElementLoad::body(3.0, -2.0)));
704        assert!(
705            (body[0] - 3.0 * 0.4 * area).abs() < 1e-14
706                && (body[1] + 2.0 * 0.4 * area).abs() < 1e-14
707        );
708        // Edge 1 joins node 1 (2, 0) to node 2 (1.5, 1): length sqrt(1.25).
709        let length = 1.25_f64.sqrt();
710        let traction =
711            force_sums(&el.load_vector(&view, &ElementLoad::edge_traction(1, 5.0, -7.0)));
712        assert!((traction[0] - 5.0 * 0.4 * length).abs() < 1e-13);
713        assert!((traction[1] + 7.0 * 0.4 * length).abs() < 1e-13);
714        // Only the edge's own two nodes receive it.
715        let v = el.load_vector(&view, &ElementLoad::edge_traction(1, 5.0, -7.0));
716        assert!(v[0] == 0.0 && v[1] == 0.0 && v[6] == 0.0 && v[7] == 0.0);
717    }
718
719    #[test]
720    fn pressure_acts_along_the_inward_normal() {
721        let (nodes, ids) = setup([[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0]]);
722        let el = Quad4::new(ids, 1.0, material());
723        let view = PlaneView::new(&nodes);
724        // Counter-clockwise square: the inward normals of edges 0..4 are +y, -x, -y, +x.
725        for (edge, expected) in [[0.0, 1.0], [-1.0, 0.0], [0.0, -1.0], [1.0, 0.0]]
726            .into_iter()
727            .enumerate()
728        {
729            let sum = force_sums(&el.load_vector(&view, &ElementLoad::edge_pressure(edge, 2.0)));
730            assert!(
731                (sum[0] - 2.0 * expected[0]).abs() < 1e-14,
732                "edge {edge}: {sum:?}"
733            );
734            assert!(
735                (sum[1] - 2.0 * expected[1]).abs() < 1e-14,
736                "edge {edge}: {sum:?}"
737            );
738        }
739    }
740
741    #[test]
742    fn gauss_responses_equal_d_b_u_for_the_plain_element() {
743        let (mut nodes, ids) = setup(SKEW);
744        let mat = material();
745        let el = Quad4::new(ids, 1.0, mat.clone());
746        // A bilinear displacement field: strains vary across the four Gauss points.
747        for id in ids {
748            let [x, y] = nodes[id].coords;
749            nodes[id].displacement = [1e-3 * x * y, -2e-3 * x + 5e-4 * y, 0.0];
750        }
751        let view = PlaneView::new(&nodes);
752        let u = plane_displacement::<4, 8>(&el.nodes, &view);
753        let geometry = point_geometry(&plane_coords(&el.nodes, &view));
754        for (g, response) in el.gauss_responses(&view).iter().enumerate() {
755            let expected = mat.d_matrix() * (geometry[g].b * u);
756            assert!((PlaneVector::from(response.stress) - expected).norm() < 1e-12);
757        }
758    }
759}