//! 2D continuum elements: plane stress, plane strain and axisymmetric. //! //! The elements lie in the XY plane and have two displacement components per node, u (along X) //! and v (along Y). The strain is the 4-vector (εxx, εyy, εzz, γxy), where the third component is //! the out-of-plane one: //! - **plane strain** (a slice of a long body): εzz = 0, and σzz = D₃₁εxx + D₃₂εyy + D₃₄γxy is //! reported; //! - **plane stress** (a thin plate loaded in its plane): σzz = 0, the elasticity is condensed to //! D̃ᵢⱼ = Dᵢⱼ − Dᵢ₃D₃ⱼ/D₃₃ and εzz = −(D₃₁εxx + D₃₂εyy + D₃₄γxy)/D₃₃ is reported; //! - **axisymmetric** (a body of revolution about the Y axis): x is the radius r, y the axial //! coordinate, and εzz is the hoop strain εθθ = u/r; volumes are 2πr dA, so forces, reactions //! and masses are totals over the full circumference. //! //! D is the leading 4 × 4 block (xx, yy, zz, xy) of the 3D elasticity, so orthotropic materials //! with axes along the global axes work as they do in solids; stresses and strains are reported //! as the solid elements' 6-vectors (xx, yy, zz, xy, xz, yz) with xz = yz = 0 (axisymmetric: rr, //! axial, hoop, r-axial). Plane elements integrate over their thickness t, so stiffness, mass and //! loads are those of the whole thickness. (O. C. Zienkiewicz, R. L. Taylor, J. Z. Zhu, *The //! Finite Element Method: Its Basis and Fundamentals*, 7th ed., Butterworth-Heinemann 2013, //! ch. 6 (plane stress and strain) and §6.6 (axisymmetric); T. J. R. Hughes, *The Finite Element //! Method*, Dover 2000, §2.7 and §3.5.) //! //! Elements and rules: tri3 (one point), tri6 (three points, degree 2), quad4 (2 × 2) and quad8 //! (3 × 3, or 2 × 2 reduced). Reduced quad4 needs hourglass control and is not offered. Quad4 has //! an **incompatible modes** formulation (QM6): the internal modes (1 − ξ²) and (1 − η²) in u //! and v (E. L. Wilson, R. L. Taylor, W. P. Doherty, J. Ghaboussi, *Incompatible displacement //! models*, 1973), their natural derivatives mapped with the centre Jacobian and scaled by //! det J₀/det J (R. L. Taylor, P. J. Beresford, E. L. Wilson, *A non-conforming element for //! stress analysis*, IJNME 10(6):1211–1219, 1976), condensed out of the element. In an //! axisymmetric element the modes also strain the hoop direction (P/r), and Taylor's scaling no //! longer makes their strains integrate to zero over the element's volume; their strain //! operator is therefore shifted by its volume mean, the patch-test correction of E. L. Wilson //! and A. Ibrahimbegovic (*Use of incompatible displacement modes for the calculation of element //! stiffnesses or stresses*, Finite Elements in Analysis and Design 7(3):229–241, 1990), which //! leaves the plane case unchanged (its mean is zero) and makes the axisymmetric element pass the //! patch test. //! //! **Hybrid** (mixed displacement–pressure, plane strain and axisymmetric quad4, quad8 and tri6): //! one constant pressure per element, the mean dilatation method (J. C. Nagtegaal, D. M. Parks, //! J. R. Rice, *On numerically accurate finite element solutions in the fully plastic range*, //! CMAME 4(2):153–177, 1974; T. J. R. Hughes, *The Finite Element Method*, Dover 2000, §4.5 for //! the axisymmetric B-bar): the volumetric strain, hoop part included, is replaced by its volume //! mean, so K = ∫ Bᵀ D_dev B dV + κ g gᵀ / V with g = ∫ Bᵀ m dV, m = (1, 1, 1, 0). The linear //! triangle is refused (one pressure per element still locks it), plane stress too (σ_zz = 0 //! leaves no volume constraint to relax). The finite-strain form is [`crate::finite`]'s. //! //! On the axis (r = 0) the hoop strain u/r is evaluated only at integration points, which lie //! inside the element (r > 0) for every element with some node off the axis; the solver holds //! the radial displacement of nodes on the axis at zero, as symmetry requires. use fieldcraft_model::{ElementKind, Extent, SectionKind}; use crate::basis::Basis; use crate::dense::{Cholesky, Matrix}; use crate::dof::{DofSet, ElementDofs, Internal}; use crate::element::{ElementType, Family, Formulation, PointStress, ThermalStrain, extrapolation, lump, symmetrize}; use crate::material::Elasticity; use crate::quadrature::{Point, Rule}; use crate::shape::{MAX_NODES, Shape}; use crate::{Error, Result}; /// Which 2D idealisation an element follows. #[derive(Debug, Clone, Copy, PartialEq)] pub enum Theory { PlaneStress { thickness: f64, }, PlaneStrain { thickness: f64, }, /// About the Y axis; x is the radius. Axisymmetric, } /// The 4 × 4 elasticity of a 2D element, in the order (xx, yy, zz, xy). pub type Elasticity2 = [[f64; 4]; 4]; impl Theory { /// The theory of a 2D section (`None` for solid, shell, beam and rod sections). pub fn from_section(kind: &SectionKind) -> Option { match *kind { SectionKind::PlaneStress { thickness } => Some(Theory::PlaneStress { thickness }), SectionKind::PlaneStrain { thickness } => Some(Theory::PlaneStrain { thickness }), SectionKind::Axisymmetric {} => Some(Theory::Axisymmetric), _ => None, } } /// How the element extends out of its plane (thickness, or revolved). pub fn extent(&self) -> Extent { match *self { Theory::PlaneStress { thickness } | Theory::PlaneStrain { thickness } => Extent::Thickness(thickness), Theory::Axisymmetric => Extent::Revolved, } } /// The 4 × 4 elasticity of this theory from the 3D one. pub fn elasticity(&self, d: &Elasticity) -> Result { let mut out = [[0.0; 4]; 4]; for i in 0..4 { for j in 0..4 { out[i][j] = d[i][j]; } } if let Theory::PlaneStress { .. } = self { let d33 = d[2][2]; if !(d33 > 0.0) { return Err(Error::Invalid("the material has no stiffness through the thickness".into())); } for i in 0..4 { for j in 0..4 { out[i][j] = if i == 2 || j == 2 { 0.0 } else { d[i][j] - d[i][2] * d[2][j] / d33 }; } } } Ok(out) } } /// The 2D element table: chosen by the section kind (a 2D section on tri3, tri6, quad4 or quad8), /// separately from shells on the same element kinds. pub static PLANE_TYPES: [ElementType; 4] = [ ElementType { kind: ElementKind::Tri3, family: Family::Triangle, nodes: 3, corners: 3, shape: |xi| Basis::Tri3.eval(xi), full: Rule::TriCentroid, reduced: None, mass: Rule::TriConical(3), incompatible_modes: false, }, ElementType { kind: ElementKind::Tri6, family: Family::Triangle, nodes: 6, corners: 3, shape: |xi| Basis::Tri6.eval(xi), full: Rule::Tri3, reduced: None, mass: Rule::TriConical(4), incompatible_modes: false, }, ElementType { kind: ElementKind::Quad4, family: Family::Quadrilateral, nodes: 4, corners: 4, shape: |xi| Basis::Quad4.eval(xi), full: Rule::Quad(2), reduced: None, mass: Rule::Quad(2), incompatible_modes: true, }, ElementType { kind: ElementKind::Quad8, family: Family::Quadrilateral, nodes: 8, corners: 4, shape: |xi| Basis::Quad8.eval(xi), full: Rule::Quad(3), reduced: Some(Rule::Quad(2)), mass: Rule::Quad(3), incompatible_modes: false, }, ]; /// The 2D table row of an element kind. pub fn plane_type(kind: ElementKind) -> Result<&'static ElementType> { PLANE_TYPES .iter() .find(|t| t.kind == kind) .ok_or_else(|| Error::NotSupported(format!("{} elements in a 2D section (2D sections take tri3, tri6, quad4 and quad8)", kind.name()))) } /// The 2D element kinds with a hybrid (constant pressure) formulation. const HYBRID_PRESSURES: [ElementKind; 3] = [ElementKind::Quad4, ElementKind::Quad8, ElementKind::Tri6]; /// The formulation a 2D section asks for, if this element type has it. pub fn formulation(kind: ElementKind, f: &fieldcraft_model::Formulation) -> Result { let ty = plane_type(kind)?; if f.hybrid { if f.incompatible_modes || f.integration == fieldcraft_model::Integration::Reduced { return Err(Error::NotSupported("hybrid 2D elements with incompatible modes or reduced integration".into())); } return if HYBRID_PRESSURES.contains(&kind) { Ok(Formulation::Hybrid { pressures: 1 }) } else { Err(Error::NotSupported(format!( "hybrid 2D {} elements (one pressure per element still locks the linear triangle; use quad4, quad8 or tri6)", kind.name() ))) }; } if f.incompatible_modes { return if ty.incompatible_modes { Ok(Formulation::IncompatibleModes) } else { Err(Error::Invalid(format!("incompatible modes apply to quad4 elements, not {}", kind.name()))) }; } match f.integration { fieldcraft_model::Integration::Full => Ok(Formulation::Full), fieldcraft_model::Integration::Reduced if ty.reduced.is_some() => Ok(Formulation::Reduced), fieldcraft_model::Integration::Reduced => Err(Error::NotSupported(format!( "reduced integration of 2D {} elements (quad4 needs hourglass control; use full integration or incompatible modes)", kind.name() ))), } } /// One column of the strain operator: (∂N/∂x, ∂N/∂y, hoop N/r), for a node or an internal mode. pub(crate) type Column = [f64; 3]; /// What the kernels need at one integration point. pub(crate) struct At { pub(crate) shape: Shape, /// Strain columns of the nodes. pub(crate) g: [Column; MAX_NODES], pub(crate) det: f64, pub(crate) x: [f64; 3], /// det J times the point's weight times the out-of-plane extent: the point's share of the /// volume. pub(crate) dv: f64, } /// The incompatible modes' count of internal degrees of freedom: two modes, two directions. const INCOMPATIBLE_DOFS: usize = 4; /// A 2D element: its type, node coordinates (z = 0), formulation and theory. #[derive(Debug, Clone, Copy)] pub struct Plane<'a> { pub ty: &'static ElementType, pub(crate) xyz: &'a [[f64; 3]], pub(crate) formulation: Formulation, pub(crate) theory: Theory, } /// out[2i + r][2j + c] += w (Bᵢᵀ D Bⱼ)[r][c] over every pair of columns (row-major, `cols` wide), /// computed for j ≥ i and mirrored, so the result is exactly symmetric. pub(crate) fn accumulate(out: &mut [f64], columns: &[Column], d: &Elasticity2, w: f64) { let cols = 2 * columns.len(); let block = |g: &Column| -> [[f64; 2]; 4] { [[g[0], 0.0], [0.0, g[1]], [g[2], 0.0], [g[1], g[0]]] }; for (j, gj) in columns.iter().enumerate() { let bj = block(gj); let mut db = [[0.0; 2]; 4]; for s in 0..4 { for c in 0..2 { db[s][c] = (0..4).map(|t| d[s][t] * bj[t][c]).sum(); } } for (i, gi) in columns.iter().enumerate().take(j + 1) { let bi = block(gi); for r in 0..2 { for c in if i == j { r } else { 0 }..2 { let v = w * (0..4).map(|s| bi[s][r] * db[s][c]).sum::(); let (row, col) = (2 * i + r, 2 * j + c); if let Some(x) = out.get_mut(row * cols + col) { *x += v; } if row != col && let Some(x) = out.get_mut(col * cols + row) { *x += v; } } } } } } impl<'a> Plane<'a> { pub fn new(kind: ElementKind, xyz: &'a [[f64; 3]], formulation: Formulation, theory: Theory) -> Result> { let ty = plane_type(kind)?; if xyz.len() != ty.nodes { return Err(Error::Invalid(format!("a {} element has {} nodes, got {}", kind.name(), ty.nodes, xyz.len()))); } fieldcraft_model::check_coordinates(xyz.as_flattened()).map_err(|e| Error::Invalid(e.to_string()))?; if xyz.iter().any(|p| p[2] != 0.0) { return Err(Error::Invalid(format!("a 2D {} element must lie in the XY plane (z = 0)", kind.name()))); } if theory == Theory::Axisymmetric && xyz.iter().any(|p| p[0] < 0.0) { return Err(Error::Invalid("an axisymmetric element must lie in x ≥ 0 (x is the radius)".into())); } if let Theory::PlaneStress { thickness } | Theory::PlaneStrain { thickness } = theory && !(thickness > 0.0 && thickness.is_finite()) { return Err(Error::Invalid(format!("the thickness must be a positive number, got {thickness}"))); } let ok = match formulation { Formulation::Full => true, Formulation::Reduced => ty.reduced.is_some(), Formulation::IncompatibleModes => ty.incompatible_modes, Formulation::Hybrid { pressures } => pressures == 1 && HYBRID_PRESSURES.contains(&kind), }; if !ok { return Err(Error::NotSupported(format!("{formulation:?} formulation of 2D {} elements", kind.name()))); } if matches!(formulation, Formulation::Hybrid { .. }) && matches!(theory, Theory::PlaneStress { .. }) { return Err(Error::NotSupported("hybrid plane stress elements (σ_zz = 0 leaves no volume constraint; use full integration)".into())); } Ok(Plane { ty, xyz, formulation, theory }) } /// The element's unknowns: u and v at every node ([`DofSet::PLANE`]), and the incompatible /// modes it condenses out. pub fn dofs(&self) -> ElementDofs { let incompatible_modes = if self.formulation == Formulation::IncompatibleModes { INCOMPATIBLE_DOFS } else { 0 }; let pressure = if let Formulation::Hybrid { pressures } = self.formulation { pressures } else { 0 }; ElementDofs { node: DofSet::PLANE, internal: Internal { incompatible_modes, pressure } } } /// The stiffness integration rule. pub fn rule(&self) -> Rule { match (self.formulation, self.ty.reduced) { (Formulation::Reduced, Some(r)) => r, _ => self.ty.full, } } pub(crate) fn degenerate(&self) -> Error { Error::Degenerate(format!("a 2D {} element is inverted, clockwise or degenerate (its Jacobian is not positive)", self.ty.kind.name())) } /// The 2 × 2 Jacobian J[a][b] = ∂x_b/∂ξ_a and its determinant. fn jacobian(&self, s: &Shape) -> ([[f64; 2]; 2], f64) { let mut j = [[0.0; 2]; 2]; for (d, x) in s.derivatives().iter().zip(self.xyz) { for a in 0..2 { for b in 0..2 { j[a][b] += d[a] * x[b]; } } } (j, j[0][0] * j[1][1] - j[0][1] * j[1][0]) } pub(crate) fn at(&self, p: &Point) -> Result { let basis = Basis::of(self.ty.kind); let shape = basis.eval(p.xi); let grads = basis.gradients(&shape, self.xyz).map_err(|_| self.degenerate())?; let det = grads.det; let mut x = [0.0; 3]; for (n, xi) in shape.values().iter().zip(self.xyz) { for k in 0..2 { x[k] += n * xi[k]; } } let axisymmetric = self.theory == Theory::Axisymmetric; if axisymmetric && !(x[0] > 0.0) { return Err(Error::Degenerate(format!("an axisymmetric {} element has an integration point on the axis", self.ty.kind.name()))); } let mut g = [[0.0; 3]; MAX_NODES]; for ((gi, d), n) in g.iter_mut().zip(grads.gradients()).zip(shape.values()) { *gi = [d[0], d[1], if axisymmetric { n / x[0] } else { 0.0 }]; } let dv = det * p.weight * self.theory.extent().at(x); Ok(At { shape, g, det, x, dv }) } /// The element's volume (area times thickness, or the volume of the ring). pub fn volume(&self) -> Result { let mut v = 0.0; for p in self.ty.mass.points() { v += self.at(&p)?.dv; } Ok(v) } /// The incompatible modes' strain columns at every stiffness point: Taylor's mapped /// derivatives (and the hoop value P/r when axisymmetric), shifted by their volume mean. fn incompatible_columns(&self, points: &[(Point, At)]) -> Result> { let s0 = (self.ty.shape)([0.0; 3]); let (j0, det0) = self.jacobian(&s0); if !(det0 > 0.0 && det0.is_finite()) { return Err(self.degenerate()); } let axisymmetric = self.theory == Theory::Axisymmetric; let mut out = Vec::with_capacity(points.len()); let mut mean = [[0.0; 3]; 2]; let mut volume = 0.0; for (p, a) in points { let mut cols = [[0.0; 3]; 2]; for (k, col) in cols.iter_mut().enumerate() { // P_k = 1 − ξ_k²: ∂P/∂ξ_k = −2ξ_k, the other derivative zero. let mut dp = [0.0; 2]; dp[k] = -2.0 * p.xi[k]; // (det J₀ / det J) J₀⁻¹ ∂P/∂ξ, with J₀⁻¹ = adj(J₀) / det J₀. col[0] = (j0[1][1] * dp[0] - j0[0][1] * dp[1]) / a.det; col[1] = (-j0[1][0] * dp[0] + j0[0][0] * dp[1]) / a.det; col[2] = if axisymmetric { (1.0 - p.xi[k] * p.xi[k]) / a.x[0] } else { 0.0 }; for c in 0..3 { mean[k][c] += col[c] * a.dv; } } volume += a.dv; out.push(cols); } if !(volume > 0.0) { return Err(self.degenerate()); } for cols in &mut out { for k in 0..2 { for c in 0..3 { cols[k][c] -= mean[k][c] / volume; } } } Ok(out) } /// The integration points with their data. fn points(&self) -> Result> { self.rule().points().into_iter().map(|p| self.at(&p).map(|a| (p, a))).collect() } /// The stiffness over nodes and, with incompatible modes, the internal modes after them: /// (2n + 4) × (2n + 4). fn full_stiffness(&self, d: &Elasticity2) -> Result<(Matrix, Vec<(Point, At)>, Option>)> { let n = self.ty.nodes; let points = self.points()?; let modes = if self.formulation == Formulation::IncompatibleModes { Some(self.incompatible_columns(&points)?) } else { None }; let width = 2 * n + modes.as_ref().map_or(0, |_| INCOMPATIBLE_DOFS); let mut k = Matrix::zeros(width); let mut columns: Vec = Vec::with_capacity(n + 2); for (i, (_, a)) in points.iter().enumerate() { columns.clear(); columns.extend_from_slice(a.g.get(..n).unwrap_or_default()); if let Some(m) = modes.as_ref().and_then(|m| m.get(i)) { columns.extend_from_slice(m); } accumulate(&mut k.data, &columns, d, a.dv); } Ok((k, points, modes)) } /// The stiffness matrix, 2n × 2n, degrees of freedom ordered node by node (u, v). pub fn stiffness(&self, d: &Elasticity) -> Result { if let Some(dev) = self.deviatoric(d)? { // K_dev + κ g gᵀ / V. let (mut k, points, _) = self.full_stiffness(&dev)?; let mixed = self.mixed(d, &points, None)?; add_outer(&mut k, &mixed.g, mixed.bulk / mixed.volume); return Ok(k); } let d2 = self.theory.elasticity(d)?; let (full, _, modes) = self.full_stiffness(&d2)?; if modes.is_none() { return Ok(full); } // K = K_uu − K_ua K_aa⁻¹ K_au. let n = 2 * self.ty.nodes; let kaa = sub_block(&full, n, n, INCOMPATIBLE_DOFS); let chol = Cholesky::new(&kaa)?; let mut k = sub_block(&full, 0, 0, n); for i in 0..n { let mut col: Vec = (0..INCOMPATIBLE_DOFS).map(|a| full.get(n + a, i)).collect(); chol.solve(&mut col); for j in 0..n { let v: f64 = (0..INCOMPATIBLE_DOFS).map(|a| full.get(j, n + a) * col.get(a).copied().unwrap_or(0.0)).sum(); k.add(j, i, -v); } } symmetrize(&mut k); Ok(k) } /// A hybrid element's deviatoric elasticity in the theory's form; `None` for the other /// formulations. fn deviatoric(&self, d: &Elasticity) -> Result> { if !matches!(self.formulation, Formulation::Hybrid { .. }) { return Ok(None); } Ok(Some(self.theory.elasticity(&crate::hybrid::split(d)?.0)?)) } /// A hybrid element's parts over its stiffness `points`: the bulk modulus, g = ∫ Bᵀ m dV /// (2n), the volume and, with a thermal strain, ∫ tr ε_th dV. pub(crate) fn mixed(&self, d: &Elasticity, points: &[(Point, At)], thermal: Option<&ThermalStrain>) -> Result { let bulk = crate::hybrid::split(d)?.1; let (mut g, mut volume, mut thermal_volume) = (vec![0.0; 2 * self.ty.nodes], 0.0, 0.0); for (_, a) in points { volume += a.dv; divergence(&mut g, a.g.get(..self.ty.nodes).unwrap_or_default(), a.dv); if let Some(t) = thermal { let e = Self::thermal_strain(t, a.shape.values()); thermal_volume += a.dv * (e[0] + e[1] + e[2]); } } if !(volume > 0.0) { return Err(self.degenerate()); } Ok(Mixed2 { bulk, g, volume, thermal_volume }) } /// The consistent mass matrix per displacement component, n × n: m_ij = ∫ ρ Nᵢ Nⱼ dV. pub fn mass(&self, density: f64) -> Result { let n = self.ty.nodes; let mut m = Matrix::zeros(n); for p in self.ty.mass.points() { let a = self.at(&p)?; let w = density * a.dv; let v = a.shape.values(); for (i, x) in v.iter().enumerate() { for (j, y) in v.iter().enumerate() { m.add(i, j, w * x * y); } } } Ok(m) } /// The lumped (diagonal) mass per node: the consistent diagonal scaled to the element's mass /// (Hinton, Rock and Zienkiewicz 1976). pub fn lumped_mass(&self, density: f64) -> Result> { lump(&self.mass(density)?).ok_or_else(|| self.degenerate()) } /// Consistent nodal forces of a force per volume b(x) in the plane: fᵢ = ∫ Nᵢ b(x) dV (the Z /// component of `b` is ignored). pub fn body_force_field(&self, b: impl Fn([f64; 3]) -> [f64; 3]) -> Result> { let mut f = vec![[0.0; 2]; self.ty.nodes]; for p in self.ty.mass.points() { let a = self.at(&p)?; let b = b(a.x); for (fi, n) in f.iter_mut().zip(a.shape.values()) { for k in 0..2 { fi[k] += n * b[k] * a.dv; } } } Ok(f) } /// The thermal strain α ΔT at a point with shape values `n` (xx, yy, zz, xy; zz is the hoop /// direction of an axisymmetric element). fn thermal_strain(thermal: &ThermalStrain, n: &[f64]) -> [f64; 4] { let dt: f64 = n.iter().zip(&thermal.delta).map(|(a, b)| a * b).sum(); [thermal.alpha[0] * dt, thermal.alpha[1] * dt, thermal.alpha[2] * dt, 0.0] } /// ∫ Bᵀ D ε_th dV on the nodes' (u, v) and on the incompatible modes. fn thermal_forces( d2: &Elasticity2, thermal: &ThermalStrain, points: &[(Point, At)], modes: Option<&Vec<[Column; 2]>>, nodes: usize, ) -> (Vec, Vec) { let mut fu = vec![0.0; 2 * nodes]; let mut fa = vec![0.0; INCOMPATIBLE_DOFS]; for (i, (_, a)) in points.iter().enumerate() { let eps = Self::thermal_strain(thermal, a.shape.values()); let s: [f64; 4] = std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * eps[c]).sum()); // Bᵀ σ for one strain column g: u takes εxx, εzz and γxy; v takes εyy and γxy. let add = |out: &mut [f64], k: usize, g: &Column| { if let Some(x) = out.get_mut(2 * k) { *x += a.dv * (g[0] * s[0] + g[2] * s[2] + g[1] * s[3]); } if let Some(x) = out.get_mut(2 * k + 1) { *x += a.dv * (g[1] * s[1] + g[0] * s[3]); } }; for (k, g) in a.g.iter().take(nodes).enumerate() { add(&mut fu, k, g); } if let Some(cols) = modes.and_then(|m| m.get(i)) { for (k, g) in cols.iter().enumerate() { add(&mut fa, k, g); } } } (fu, fa) } /// The equivalent nodal forces (u, v) of a thermal strain, ∫ Bᵀ D ε_th dV with the /// theory's elasticity (plane stress leaves εzz free, plane strain holds it, axisymmetric /// strains the hoop direction too); incompatible modes are condensed as in the stiffness. pub fn thermal_load(&self, d: &Elasticity, thermal: &ThermalStrain) -> Result> { thermal.check(self.ty.nodes)?; let n = 2 * self.ty.nodes; if let Some(dev) = self.deviatoric(d)? { // ∫ Bᵀ D_dev ε_th dV + κ g (∫ tr ε_th dV) / V. let points = self.points()?; let mixed = self.mixed(d, &points, Some(thermal))?; let (mut fu, _) = Self::thermal_forces(&dev, thermal, &points, None, self.ty.nodes); for (f, g) in fu.iter_mut().zip(&mixed.g) { *f += mixed.bulk * g * mixed.thermal_volume / mixed.volume; } return Ok(fu.as_chunks::<2>().0.to_vec()); } let d2 = self.theory.elasticity(d)?; let mut fu = if self.formulation == Formulation::IncompatibleModes { let (full, points, modes) = self.full_stiffness(&d2)?; let (mut fu, mut fa) = Self::thermal_forces(&d2, thermal, &points, modes.as_ref(), self.ty.nodes); Cholesky::new(&sub_block(&full, n, n, INCOMPATIBLE_DOFS))?.solve(&mut fa); for (i, x) in fu.iter_mut().enumerate() { *x -= (0..INCOMPATIBLE_DOFS).map(|a| full.get(i, n + a) * fa.get(a).copied().unwrap_or(0.0)).sum::(); } fu } else { Self::thermal_forces(&d2, thermal, &self.points()?, None, self.ty.nodes).0 }; fu.resize(n, 0.0); Ok(fu.as_chunks::<2>().0.to_vec()) } /// Strain and stress at every integration point for the nodal displacements `u` (u, v). pub fn stresses(&self, d: &Elasticity, u: &[[f64; 2]]) -> Result> { self.stresses_with(d, u, None) } /// Strain and stress at every integration point for the nodal displacements `u` (u, v) and, /// if given, a thermal strain: the stress is D (ε − ε_th), the strain reported the total. pub fn stresses_with(&self, d: &Elasticity, u: &[[f64; 2]], thermal: Option<&ThermalStrain>) -> Result> { if u.len() != self.ty.nodes { return Err(Error::Invalid(format!("{} displacements for a {}-node element", u.len(), self.ty.nodes))); } if let Some(t) = thermal { t.check(self.ty.nodes)?; } let deviatoric = self.deviatoric(d)?; let d2 = match deviatoric { Some(dev) => dev, None => self.theory.elasticity(d)?, }; let (full, points, modes) = if self.formulation == Formulation::IncompatibleModes { self.full_stiffness(&d2)? } else { (Matrix::zeros(0), self.points()?, None) }; // Hybrid: the element's pressure κ (gᵀ u − ∫ tr ε_th dV) / V. let pressure = if deviatoric.is_some() { let m = self.mixed(d, &points, thermal)?; let gu: f64 = m.g.iter().zip(u.iter().flatten()).map(|(g, x)| g * x).sum(); m.bulk * (gu - m.thermal_volume) / m.volume } else { 0.0 }; // Incompatible modes: K_aa α = f_a − K_au u (f_a the thermal load on the modes). let alpha = if modes.is_some() { let n = 2 * self.ty.nodes; let flat: Vec = u.iter().flatten().copied().collect(); let mut rhs: Vec = (0..INCOMPATIBLE_DOFS).map(|a| -flat.iter().enumerate().map(|(i, x)| full.get(n + a, i) * x).sum::()).collect(); if let Some(t) = thermal { let (_, fa) = Self::thermal_forces(&d2, t, &points, modes.as_ref(), self.ty.nodes); for (r, f) in rhs.iter_mut().zip(fa) { *r += f; } } Cholesky::new(&sub_block(&full, n, n, INCOMPATIBLE_DOFS))?.solve(&mut rhs); rhs } else { Vec::new() }; let mut out = Vec::with_capacity(points.len()); for (i, (_, a)) in points.iter().enumerate() { let mut e = [0.0; 4]; let mut add = |g: &Column, uv: [f64; 2]| { e[0] += g[0] * uv[0]; e[1] += g[1] * uv[1]; e[2] += g[2] * uv[0]; e[3] += g[1] * uv[0] + g[0] * uv[1]; }; for (g, uv) in a.g.iter().zip(u) { add(g, *uv); } if let Some(cols) = modes.as_ref().and_then(|m| m.get(i)) { for (k, g) in cols.iter().enumerate() { add(g, [alpha.get(2 * k).copied().unwrap_or(0.0), alpha.get(2 * k + 1).copied().unwrap_or(0.0)]); } } let eth = thermal.map(|t| Self::thermal_strain(t, a.shape.values())); if let Theory::PlaneStress { .. } = self.theory { e[2] = match eth { // σzz = 0 with the thermal strain: εzz = εth,zz − D₃·(ε − εth)/D₃₃ in plane. Some(t) => t[2] - (d[2][0] * (e[0] - t[0]) + d[2][1] * (e[1] - t[1]) + d[2][3] * e[3]) / d[2][2], None => -(d[2][0] * e[0] + d[2][1] * e[1] + d[2][3] * e[3]) / d[2][2], }; } // Plane stress: D̃ has no zz row or column, so the εzz just recovered adds nothing. let mut s: [f64; 4] = match eth { Some(t) => std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * (e[c] - t[c])).sum()), None => std::array::from_fn(|r| (0..4).map(|c| d2[r][c] * e[c]).sum()), }; s.iter_mut().take(3).for_each(|v| *v += pressure); out.push(PointStress { xyz: a.x, strain: [e[0], e[1], e[2], e[3], 0.0, 0.0], stress: [s[0], s[1], s[2], s[3], 0.0, 0.0] }); } Ok(out) } /// Extrapolate values at the integration points to the element's nodes. pub fn extrapolate(&self, values: &[[f64; 6]]) -> Result> { let e = extrapolation(self.ty, self.rule())?; if values.len() != e.points { return Err(Error::Invalid(format!("{} values for {} integration points", values.len(), e.points))); } Ok(e.apply(values)) } } /// The parts of a hybrid 2D element ([`Plane::mixed`]). pub(crate) struct Mixed2 { bulk: f64, pub(crate) g: Vec, volume: f64, thermal_volume: f64, } /// g += w Bᵀ m over strain columns `cols` (u takes ∂N/∂x and the hoop N/r, v takes ∂N/∂y): the /// divergence of each nodal motion, which the hybrid elements' volume constraint acts on. pub(crate) fn divergence(g: &mut [f64], cols: &[Column], w: f64) { for (pair, col) in g.as_chunks_mut::<2>().0.iter_mut().zip(cols) { pair[0] += w * (col[0] + col[2]); pair[1] += w * col[1]; } } /// k += c g gᵀ. pub(crate) fn add_outer(k: &mut Matrix, g: &[f64], c: f64) { for (i, gi) in g.iter().enumerate() { for (j, gj) in g.iter().enumerate() { k.add(i, j, c * gi * gj); } } } /// The `size` × `size` block of `m` starting at (`row`, `col`). fn sub_block(m: &Matrix, row: usize, col: usize, size: usize) -> Matrix { let mut out = Matrix::zeros(size); for i in 0..size { for j in 0..size { out.add(i, j, m.get(row + i, col + j)); } } out } #[cfg(test)] mod tests { use super::*; use crate::material::isotropic; /// A distorted element of `kind` with corners in x ∈ [1, 3] (so axisymmetric elements stay off /// the axis); mid-side nodes off the chord midpoints by a little, so quadratic elements are /// curved. fn element(kind: ElementKind) -> Vec<[f64; 3]> { let corners: Vec<[f64; 3]> = match kind { ElementKind::Tri3 | ElementKind::Tri6 => vec![[1.0, 0.2, 0.0], [3.0, 0.0, 0.0], [1.6, 1.9, 0.0]], _ => vec![[1.0, 0.1, 0.0], [3.0, 0.0, 0.0], [2.7, 2.2, 0.0], [1.2, 1.6, 0.0]], }; let mut xyz = corners.clone(); for (k, e) in kind.edges().iter().enumerate() { if let [a, b, _] = **e { let bump = 0.05 * (k as f64 + 1.0); xyz.push([(corners[a][0] + corners[b][0]) / 2.0 + bump, (corners[a][1] + corners[b][1]) / 2.0 - bump, 0.0]); } } xyz } fn variants() -> Vec<(ElementKind, Formulation)> { vec![ (ElementKind::Tri3, Formulation::Full), (ElementKind::Tri6, Formulation::Full), (ElementKind::Quad4, Formulation::Full), (ElementKind::Quad4, Formulation::IncompatibleModes), (ElementKind::Quad8, Formulation::Full), (ElementKind::Quad8, Formulation::Reduced), ] } const THEORIES: [Theory; 3] = [Theory::PlaneStress { thickness: 0.7 }, Theory::PlaneStrain { thickness: 1.0 }, Theory::Axisymmetric]; #[test] fn hybrid_elements_reproduce_a_uniform_strain_and_do_not_lock() { // u = a x, v = b y: a uniform strain (axisymmetric: the hoop strain a too), whose // stress the mean dilatation reproduces exactly at every point. let d = isotropic(1000.0, 0.4999).unwrap(); let (a, b) = (0.002, -0.003); for theory in [Theory::PlaneStrain { thickness: 1.5 }, Theory::Axisymmetric] { for kind in [ElementKind::Quad4, ElementKind::Quad8, ElementKind::Tri6] { let x = element(kind); let plane = Plane::new(kind, &x, Formulation::Hybrid { pressures: 1 }, theory).unwrap(); let u: Vec<[f64; 2]> = x.iter().map(|p| [a * p[0], b * p[1]]).collect(); let hoop = if theory == Theory::Axisymmetric { a } else { 0.0 }; let e = [a, b, hoop, 0.0, 0.0, 0.0]; let exact = crate::material::stress(&d, &e); for p in plane.stresses(&d, &u).unwrap() { for c in 0..4 { assert!((p.stress[c] - exact[c]).abs() < 1e-9 * exact[0].abs().max(1.0), "{theory:?} {kind:?} {c}: {:?} {exact:?}", p.stress); } } // K u is the forces of that stress: the stiffness agrees with the stresses. let k = plane.stiffness(&d).unwrap(); let flat: Vec = u.iter().flatten().copied().collect(); let full = Plane::new(kind, &x, Formulation::Full, theory).unwrap().stiffness(&d).unwrap(); let (ku, kf): (Vec, Vec) = (0..flat.len()) .map(|i| (0..flat.len()).fold((0.0, 0.0), |(s, t), j| (s + k.get(i, j) * flat[j], t + full.get(i, j) * flat[j]))) .unzip(); for (p, q) in ku.iter().zip(&kf) { assert!((p - q).abs() < 1e-8 * kf.iter().fold(0.0f64, |m, v| m.max(v.abs())), "{p} {q}"); } } } // Nearly incompressible: the hybrid element's volume is one constraint, so a motion that // keeps the element's volume (orthogonal to g) costs only shear, where the full element's // four point constraints lock it. let x = element(ElementKind::Quad4); let hybrid = Plane::new(ElementKind::Quad4, &x, Formulation::Hybrid { pressures: 1 }, Theory::PlaneStrain { thickness: 1.0 }).unwrap(); let g = hybrid.mixed(&d, &hybrid.points().unwrap(), None).unwrap().g; let raw: Vec = (0..8).map(|i| ((i * 7 % 5) as f64 - 2.0) * 0.01).collect(); let along = raw.iter().zip(&g).map(|(a, b)| a * b).sum::() / g.iter().map(|v| v * v).sum::(); let shear: Vec = raw.iter().zip(&g).map(|(a, b)| a - along * b).collect(); let energy = |f: Formulation| { let k = Plane::new(ElementKind::Quad4, &x, f, Theory::PlaneStrain { thickness: 1.0 }).unwrap().stiffness(&d).unwrap(); (0..8).map(|i| (0..8).map(|j| shear[i] * k.get(i, j) * shear[j]).sum::()).sum::() }; assert!(energy(Formulation::Hybrid { pressures: 1 }) < 0.01 * energy(Formulation::Full)); } #[test] fn rigid_motion_strains_nothing_and_matrices_are_symmetric() { let d = isotropic(1000.0, 0.3).unwrap(); for (kind, f) in variants() { let xyz = element(kind); for theory in THEORIES { let e = Plane::new(kind, &xyz, f, theory).unwrap(); let k = e.stiffness(&d).unwrap(); let n = k.n; for i in 0..n { assert!(k.get(i, i) > 0.0); for j in 0..n { assert_eq!(k.get(i, j), k.get(j, i), "{kind:?} {f:?} {theory:?}"); } } // Translation along Y always; along X and the rotation only in plane models. let mut motions: Vec> = vec![xyz.iter().flat_map(|_| [0.0, 1.0]).collect()]; if theory != Theory::Axisymmetric { motions.push(xyz.iter().flat_map(|_| [1.0, 0.0]).collect()); motions.push(xyz.iter().flat_map(|p| [-p[1], p[0]]).collect()); } for u in motions { let f = k.mul_vec(&u); let scale = k.data.iter().fold(0.0f64, |m, v| m.max(v.abs())); assert!(f.iter().all(|v| v.abs() < 1e-12 * scale), "{kind:?} {theory:?}: {f:?}"); } } } } #[test] fn linear_fields_give_exact_constant_stress() { let (young, nu) = (1000.0, 0.3); let d = isotropic(young, nu).unwrap(); for (kind, f) in variants() { let xyz = element(kind); // Plane: u = 0.01 x + 0.002 y, v = −0.003 x + 0.004 y. for theory in [Theory::PlaneStress { thickness: 0.7 }, Theory::PlaneStrain { thickness: 1.0 }] { let e = Plane::new(kind, &xyz, f, theory).unwrap(); let u: Vec<[f64; 2]> = xyz.iter().map(|p| [0.01 * p[0] + 0.002 * p[1], -0.003 * p[0] + 0.004 * p[1]]).collect(); let (exx, eyy, gxy) = (0.01, 0.004, -0.001); let (sxx, syy, szz, ezz) = match theory { Theory::PlaneStress { .. } => { let c = young / (1.0 - nu * nu); (c * (exx + nu * eyy), c * (eyy + nu * exx), 0.0, -nu / (1.0 - nu) * (exx + eyy)) } _ => { let l = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu)); let m = young / (2.0 * (1.0 + nu)); (l * (exx + eyy) + 2.0 * m * exx, l * (exx + eyy) + 2.0 * m * eyy, l * (exx + eyy), 0.0) } }; let txy = young / (2.0 * (1.0 + nu)) * gxy; for p in e.stresses(&d, &u).unwrap() { let want = [sxx, syy, szz, txy, 0.0, 0.0]; assert!(p.stress.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-10), "{kind:?} {f:?} {theory:?}: {:?} vs {want:?}", p.stress); assert!((p.strain[2] - ezz).abs() < 1e-14, "{kind:?} {theory:?}"); } // The extrapolated nodal values are the constant too. let values: Vec<[f64; 6]> = e.stresses(&d, &u).unwrap().iter().map(|p| p.stress).collect(); for v in e.extrapolate(&values).unwrap() { assert!((v[0] - sxx).abs() < 1e-10 && (v[3] - txy).abs() < 1e-10); } } // Axisymmetric: u_r = 0.01 r, u_z = 0.004 z gives εrr = εθθ = 0.01, εzz = 0.004. let e = Plane::new(kind, &xyz, f, Theory::Axisymmetric).unwrap(); let u: Vec<[f64; 2]> = xyz.iter().map(|p| [0.01 * p[0], 0.004 * p[1]]).collect(); let l = young * nu / ((1.0 + nu) * (1.0 - 2.0 * nu)); let m = young / (2.0 * (1.0 + nu)); let tr = 0.024; let want = [l * tr + 2.0 * m * 0.01, l * tr + 2.0 * m * 0.004, l * tr + 2.0 * m * 0.01, 0.0, 0.0, 0.0]; for p in e.stresses(&d, &u).unwrap() { assert!(p.stress.iter().zip(want).all(|(a, b)| (a - b).abs() < 1e-10), "{kind:?} {f:?} axisymmetric: {:?}", p.stress); } // And its stiffness has no out-of-balance force for it inside the element: K u = f // where f are the consistent forces of the boundary tractions; their total along Y // vanishes (no Y body force). let k = e.stiffness(&d).unwrap(); let flat: Vec = u.iter().flatten().copied().collect(); let fy: f64 = k.mul_vec(&flat).iter().skip(1).step_by(2).sum(); assert!(fy.abs() < 1e-10, "{kind:?} {f:?}: {fy}"); } } #[test] fn volumes_masses_and_loads() { let pi = std::f64::consts::PI; // A unit square at x ∈ [1, 2]: area 1, ring volume 2π · 1.5. let sq = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [1.0, 1.0, 0.0]]; let e = Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::PlaneStress { thickness: 3.0 }).unwrap(); assert!((e.volume().unwrap() - 3.0).abs() < 1e-14); assert!((e.mass(2.0).unwrap().data.iter().sum::() - 6.0).abs() < 1e-13); let e = Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::Axisymmetric).unwrap(); assert!((e.volume().unwrap() - 3.0 * pi).abs() < 1e-13); assert!((e.lumped_mass(1.0).unwrap().iter().sum::() - 3.0 * pi).abs() < 1e-12); // A centrifugal field ω² r: total ∫ ρ ω² r dV = 2π ∫∫ r² dr dz = 2π · 7/3. let f = e.body_force_field(|x| [x[0], 0.0, 0.0]).unwrap(); assert!((f.iter().map(|v| v[0]).sum::() - 2.0 * pi * 7.0 / 3.0).abs() < 1e-12); // Pressure on the outer edge (x = 2) of the ring pushes inward with p · 2π · 2 · 1. let fp = fieldcraft_model::nodal::edge_pressure(&[sq[1], sq[2]], Theory::Axisymmetric.extent(), 1.0).unwrap(); assert!((fp.iter().map(|v| v[0]).sum::() + 4.0 * pi).abs() < 1e-12, "{fp:?}"); } #[test] fn bad_input_is_an_error() { let d = isotropic(1000.0, 0.3).unwrap(); let sq = [[1.0, 0.0, 0.0], [2.0, 0.0, 0.0], [2.0, 1.0, 0.0], [1.0, 1.0, 0.0]]; let cw = [sq[0], sq[3], sq[2], sq[1]]; let e = Plane::new(ElementKind::Quad4, &cw, Formulation::Full, Theory::PlaneStrain { thickness: 1.0 }).unwrap(); assert!(matches!(e.stiffness(&d), Err(Error::Degenerate(_)))); assert!(Plane::new(ElementKind::Quad4, &sq, Formulation::Reduced, Theory::Axisymmetric).is_err()); assert!(Plane::new(ElementKind::Tri3, &sq[..3], Formulation::IncompatibleModes, Theory::Axisymmetric).is_err()); assert!(Plane::new(ElementKind::Hex8, &sq, Formulation::Full, Theory::Axisymmetric).is_err()); let off = [[1.0, 0.0, 0.5], sq[1], sq[2], sq[3]]; assert!(Plane::new(ElementKind::Quad4, &off, Formulation::Full, Theory::Axisymmetric).is_err()); let neg = sq.map(|p| [p[0] - 1.5, p[1], 0.0]); assert!(Plane::new(ElementKind::Quad4, &neg, Formulation::Full, Theory::Axisymmetric).is_err()); assert!(Plane::new(ElementKind::Quad4, &sq, Formulation::Full, Theory::PlaneStress { thickness: f64::NAN }).is_err()); // A ring element touching the axis is fine (its integration points are off it). let on_axis = sq.map(|p| [p[0] - 1.0, p[1], 0.0]); let e = Plane::new(ElementKind::Quad4, &on_axis, Formulation::IncompatibleModes, Theory::Axisymmetric).unwrap(); assert!(e.stiffness(&d).is_ok()); let hybrid = fieldcraft_model::Formulation { hybrid: true, ..Default::default() }; assert_eq!(formulation(ElementKind::Quad4, &hybrid).unwrap(), Formulation::Hybrid { pressures: 1 }); assert!(formulation(ElementKind::Tri3, &hybrid).is_err()); assert!(formulation(ElementKind::Quad4, &fieldcraft_model::Formulation { incompatible_modes: true, ..hybrid }).is_err()); } }