solver, decks: symmetry about an inclined plane (#242)

The symmetry condition takes any plane normal ({"normal": [nx, ny, nz]}).
An inclined one holds n·u = 0 and, at shell, beam and reference point
nodes, the rotations about the two in-plane axes: constraint rows the
implicit elimination already handles, its support forces reported as
reactions. Decks: Abaqus *EQUATION, Nastran MPC sets per subcase, LS-DYNA
*BOUNDARY_SPC in a *DEFINE_COORDINATE_SYSTEM along the normal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-16 10:38:20 -04:00
1 parent a079e51f9d
commit ac1037e1af
11 files changed
+1009 -53

No files matched your search

+8 -1
View File
@@ -12,7 +12,7 @@ use serde::Serialize;
use fieldcraft_parallel as parallel;
use crate::cholesky::{Factor, FactorError, UpperColumns};
use crate::constraints::{Budget, Reduction, coupling_rows};
use crate::constraints::{Budget, Reduction, coupling_rows, symmetry_rows};
use crate::eigen::{Operator, Wanted, lanczos};
use crate::ldlt::{Kind, Symmetric};
use crate::ordering::nested_dissection;
@@ -342,6 +342,13 @@ pub(crate) struct Setup<'a> {
pub(crate) fn reduction(s: &Setup, values: bool, order: &[usize], budget: Budget, warnings: &mut Vec<String>) -> Result<Reduction> {
let mut rows = coupling_rows(s.model, s.resolved, s.mesh)?;
rows.extend(crate::tie::rows(s.model, s.resolved, s.mesh, warnings)?);
// Reference point directions exist when a coupling, tie or discrete element uses them.
let used: std::collections::BTreeSet<usize> = rows.iter().flat_map(|r| r.terms.iter().map(|t| t.0)).chain(s.mesh.discrete.dofs()).collect();
let node_dofs = s.mesh.layout.node_dofs();
// Boundary conditions first, as the elimination takes them.
let mut symmetry = symmetry_rows(s.model, s.resolved, s.mesh, s.step, |d| d < node_dofs || used.contains(&d))?;
symmetry.append(&mut rows);
let rows = symmetry;
Reduction::new(
&s.mesh.layout,
s.mesh.discrete.dofs(),
+104 -21
View File
@@ -53,7 +53,7 @@ use std::collections::BTreeMap;
use fieldcraft_fem::dense::inverse3;
use fieldcraft_fem::{DofKind, DofLayout, DofSet};
use fieldcraft_model::{CouplingKind, Model, Resolved};
use fieldcraft_model::{BoundaryCondition, CouplingKind, Model, Resolved, Step};
use crate::sparse::Csr;
use crate::system::Mesh;
@@ -74,6 +74,9 @@ pub struct Row<'m> {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Source {
Coupling,
/// A symmetry boundary condition on an inclined plane: n · u = 0, and θ ∥ n at nodes with
/// rotations.
Symmetry,
/// A tie: its secondary node follows a weighted average of main nodes.
Tie,
}
@@ -82,6 +85,7 @@ impl Source {
fn what(self) -> &'static str {
match self {
Source::Coupling => "coupling",
Source::Symmetry => "boundary condition",
Source::Tie => "tie",
}
}
@@ -224,6 +228,64 @@ pub fn coupling_rows<'m>(model: &'m Model, resolved: &Resolved, mesh: &Mesh) ->
Ok(rows)
}
/// The constraint rows of a step's symmetry conditions on inclined planes (those on a plane
/// normal to an axis hold global degrees of freedom and are prescribed values instead), in the
/// step's order, nodes by label. At every target node the translation along the unit normal n
/// is zero, n · u = 0, eliminating the translation of the largest |nₖ|; at a node with
/// rotations (shells, beams, a reference point whose rotations exist) the rotation about each of
/// two in-plane axes t₁, t₂ is zero too, tᵢ · θ = 0: a mirror image turns only about the normal.
/// `exists` says whether a reference point direction exists (mesh node ones always do).
pub fn symmetry_rows<'m>(model: &Model, resolved: &Resolved, mesh: &Mesh, step: &'m Step, exists: impl Fn(usize) -> bool) -> Result<Vec<Row<'m>>> {
let mut rows = Vec::new();
for name in &step.boundary_conditions {
let Some(BoundaryCondition::Symmetry { target, plane }) = model.boundary_condition(name) else { continue };
let (Some(n), Some([t1, t2])) = (plane.inclined(), plane.in_plane()) else { continue };
let rp = target.reference_point();
if rp.is_some_and(|r| mesh.point(r).is_none()) {
// Reported by `prescribed` as holding nothing, like any condition on such a point.
continue;
}
for &label in resolved.target_nodes(target) {
let slot = match rp {
Some(r) => mesh.point(r).map(|k| mesh.layout.point_slot(k)),
None => mesh.layout.slot(label),
};
let Some(slot) = slot else { continue };
let kinds = mesh.layout.kinds(slot);
let dof = |kind: Option<DofKind>| kind.and_then(|k| kinds.contains(k).then(|| mesh.layout.dof(slot, k)).flatten()).filter(|d| exists(*d));
let row = |axis: [f64; 3], rotation: bool| -> Result<Option<Row<'m>>> {
let mut terms = Vec::with_capacity(3);
let mut missing = false;
for (k, c) in axis.into_iter().enumerate().filter(|(_, c)| *c != 0.0) {
match dof(if rotation { DofKind::rotation(k) } else { DofKind::translation(k) }) {
Some(d) => terms.push((d, c)),
None => missing = true,
}
}
if missing {
// No rotation exists at a solid node: only its translation is held.
if rotation && terms.is_empty() {
return Ok(None);
}
return Err(Error::Invalid(format!(
"boundary condition `{name}`: node {label} cannot move along the symmetry plane's normal {n:?} (it has no {} along some axis the normal leans on); in a 2D model the plane must contain the Z axis",
if rotation { "rotation" } else { "translation" }
)));
}
let pivot = terms.iter().fold(None, |best: Option<(usize, f64)>, t| match best {
Some(b) if b.1.abs() >= t.1.abs() => Some(b),
_ => Some(*t),
});
Ok(pivot.map(|(pivot, _)| Row { terms, pivot, coupling: name, source: Source::Symmetry }))
};
rows.extend(row(n, false)?);
rows.extend(row(t1, true)?);
rows.extend(row(t2, true)?);
}
}
Ok(rows)
}
/// The degrees of freedom that exist: every mesh node's, and the reference point directions
/// some constraint row or `extra` (the discrete elements') involves.
fn active_dofs(layout: &DofLayout, extra: impl Iterator<Item = usize>, rows: &[Row]) -> Vec<bool> {
@@ -346,6 +408,10 @@ pub struct Reduction {
prescribed_motion: Vec<f64>,
/// Whether any degree of freedom depends on others (T is not a plain selection).
has_dependents: bool,
/// The degrees of freedom an inclined symmetry plane supports (those of its rows that are not
/// prescribed): their share of K u − f is the plane's reaction on them, n λ, and is reported
/// where it acts rather than carried to prescribed degrees of freedom.
supports: Vec<bool>,
budget: Budget,
}
@@ -447,7 +513,7 @@ impl Reduction {
for row in rows {
let scale = row.terms.iter().fold(0.0f64, |m, t| m.max(t.1.abs()));
if !(scale > 0.0 && scale.is_finite()) {
return Err(Error::Invalid(format!("coupling `{}`: a constraint with no finite coefficient", row.coupling)));
return Err(Error::Invalid(format!("{} `{}`: a constraint with no finite coefficient", row.source.what(), row.coupling)));
}
// The row in current free and prescribed degrees of freedom.
let mut expanded = Vec::with_capacity(row.terms.len());
@@ -497,7 +563,8 @@ impl Reduction {
let size: f64 = terms.iter().map(|t| value(t).abs()).sum();
if residual.abs() > 1e-9 * size.max(f64::MIN_POSITIVE) && residual.abs() > 0.0 {
return Err(Error::Invalid(format!(
"coupling `{}` contradicts the prescribed values (boundary conditions or other couplings hold its nodes in a way it cannot follow)",
"{} `{}` contradicts the prescribed values (boundary conditions or couplings hold its nodes in a way it cannot follow)",
row.source.what(),
row.coupling
)));
}
@@ -572,7 +639,17 @@ impl Reduction {
}
let prescribed_motion = (0..n).map(|d| fixed.row(d).map(|(p, c)| c * values.get(p).copied().unwrap_or(0.0)).sum()).collect();
let columns = free.transpose(eq_dof.len());
Ok(Reduction { eq_dof, active, free, columns, fixed, prescribed_motion, has_dependents: !exprs.is_empty(), budget })
let mut supports = vec![false; n];
for row in rows.iter().filter(|r| r.source == Source::Symmetry) {
for &(dof, _) in &row.terms {
if state.get(dof) != Some(&State::Fixed)
&& let Some(x) = supports.get_mut(dof)
{
*x = true;
}
}
}
Ok(Reduction { eq_dof, active, free, columns, fixed, prescribed_motion, has_dependents: !exprs.is_empty(), supports, budget })
}
/// Free equations.
@@ -737,10 +814,17 @@ impl Reduction {
}
/// T_pᵀ g for a vector g over every degree of freedom (the reactions of the out-of-balance
/// forces g = f_int − f_ext), zero where nothing is prescribed.
/// forces g = f_int − f_ext), zero where nothing is prescribed; at the degrees of freedom an
/// inclined symmetry plane supports, g itself.
pub fn reactions_of(&self, g: &[f64]) -> Vec<f64> {
let mut out = vec![0.0; self.prescribed_motion.len()];
for (dof, v) in g.iter().enumerate() {
if self.supports.get(dof).copied().unwrap_or(false) {
if let Some(x) = out.get_mut(dof) {
*x += v;
}
continue;
}
for (p, c) in self.fixed.row(dof) {
if let Some(x) = out.get_mut(p) {
*x += c * v;
@@ -750,24 +834,23 @@ impl Reduction {
out
}
/// The reactions at the prescribed degrees of freedom, T_pᵀ (K u − f), over every degree of
/// freedom (zero where nothing is prescribed).
/// The reactions at the prescribed degrees of freedom, T_pᵀ (K u − f), and at those an
/// inclined symmetry plane supports ([`Reduction::reactions_of`]), over every degree of
/// freedom (zero elsewhere).
pub fn reactions(&self, k: &Csr, u: &[f64], f: &[f64]) -> Vec<f64> {
let mut out = vec![0.0; self.prescribed_motion.len()];
for (dof, fv) in f.iter().enumerate() {
if self.fixed.is_empty_row(dof) {
continue;
}
let (cols, vals) = k.row(dof);
let ku: f64 = cols.iter().zip(vals).map(|(j, v)| v * u.get(*j).copied().unwrap_or(0.0)).sum();
let r = ku - fv;
for (p, c) in self.fixed.row(dof) {
if let Some(x) = out.get_mut(p) {
*x += c * r;
let residual: Vec<f64> = f
.iter()
.enumerate()
.map(|(dof, fv)| {
if self.fixed.is_empty_row(dof) && !self.supports.get(dof).copied().unwrap_or(false) {
return 0.0;
}
}
}
out
let (cols, vals) = k.row(dof);
let ku: f64 = cols.iter().zip(vals).map(|(j, v)| v * u.get(*j).copied().unwrap_or(0.0)).sum();
ku - fv
})
.collect();
self.reactions_of(&residual)
}
}
+289
View File
@@ -0,0 +1,289 @@
//! Symmetry about an inclined plane (#242): n · u = 0 on the plane, and zero rotation about the
//! two axes in it at shell nodes, eliminated as linear constraints.
//!
//! The checks need no reference program: a finite element model turned by a rotation R solves
//! to R times the displacements of the unturned one (the elements are frame-indifferent), so an
//! inclined plane is checked against the axis-aligned plane it is the image of; and half of a
//! part whose mesh is mirror-symmetric about the plane, with the condition and half the load,
//! solves exactly as the whole does at the nodes they share.
mod common;
use std::collections::BTreeMap;
use common::{full, grid, iso, near, reduced};
use fieldcraft_model::{
BoundaryCondition, Elastic, ElementFace, ElementKind, Formulation, Load, Material, Model, Procedure, Section, SectionKind, Set, Step, Symmetry,
Target,
};
use fieldcraft_solver::{FrequencyOptions, Limits, StaticResult, solve_frequency, solve_static};
/// A rotation by `angle` about the unit axis `a` (Rodrigues).
fn rotation(a: [f64; 3], angle: f64) -> [[f64; 3]; 3] {
let l = (a[0] * a[0] + a[1] * a[1] + a[2] * a[2]).sqrt();
let a = a.map(|c| c / l);
let (s, c) = angle.sin_cos();
let mut r = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
let k = [[0.0, -a[2], a[1]], [a[2], 0.0, -a[0]], [-a[1], a[0], 0.0]][i][j];
r[i][j] = if i == j { c } else { 0.0 } + (1.0 - c) * a[i] * a[j] + s * k;
}
}
r
}
fn apply(r: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|i| (0..3).map(|j| r[i][j] * v[j]).sum())
}
fn norm(v: [f64; 3]) -> f64 {
v.iter().map(|c| c * c).sum::<f64>().sqrt()
}
fn key(x: [f64; 3]) -> [i64; 3] {
x.map(|c| (c * 1e6).round() as i64)
}
/// The displacements of a result by node position (rounded).
fn by_position(model: &Model, r: &StaticResult) -> BTreeMap<[i64; 3], ([f64; 3], f64)> {
r.nodes.iter().enumerate().map(|(i, l)| (key(model.nodes()[l].xyz), (r.displacement[i], r.von_mises[i]))).collect()
}
/// A cantilever L × 2b × h along x (y from −b to b when `whole`, 0 to b otherwise) clamped at
/// x = 0, under a downward traction on its end; the half has the symmetry condition on y = 0.
/// Everything is turned by `turn` (nodes, the traction and the plane's normal).
fn cantilever(kind: ElementKind, formulation: Formulation, whole: bool, turn: &[[f64; 3]; 3]) -> (Model, StaticResult) {
let (l, b, h) = (10.0, 1.0, 1.0);
let n = if whole { [8, 4, 2] } else { [8, 2, 2] };
let mut g = grid(n, kind, |p| apply(turn, [l * p[0], if whole { b * (2.0 * p[1] - 1.0) } else { b * p[1] }, h * p[2]]));
g.define(iso(210e3, 0.3), None, formulation, Procedure::Static {});
g.fixed("root", |p| near(p[0], 0.0));
g.face_set("tip", |p| near(p[0], 1.0));
g.load("push", Load::Traction { set: "tip".into(), vector: apply(turn, [0.0, 0.0, -10.0]) });
if !whole {
let normal = apply(turn, [0.0, 1.0, 0.0]);
g.symmetry("mid", Symmetry::from_normal(normal).unwrap(), |p| near(p[1], 0.0));
}
let r = solve_static(&g.model, None, &Limits::default()).unwrap();
(g.model, r)
}
/// The turned half cantilever with its inclined plane solves to the turned displacements of the
/// unturned half (and, for the mirror-symmetric hexahedral meshes, of the whole), at every node;
/// the von Mises stress is the same; the plane's reactions balance the load.
#[test]
fn a_turned_half_solid_matches_the_axis_aligned_half_and_the_whole() {
let turn = rotation([1.0, 2.0, 3.0], 0.7);
let identity = rotation([0.0, 0.0, 1.0], 0.0);
for (kind, formulation, mirrored) in [
(ElementKind::Hex8, full(), true),
(ElementKind::Hex20, full(), true),
(ElementKind::Hex20, reduced(), true),
(ElementKind::Tet10, full(), false),
(ElementKind::Wedge6, full(), false),
] {
let (m_axis, axis) = cantilever(kind, formulation, false, &identity);
let (m_turned, turned) = cantilever(kind, formulation, false, &turn);
let size = axis.displacement.iter().fold(0.0f64, |m, u| m.max(norm(*u)));
let stress = axis.von_mises.iter().fold(0.0f64, |m, s| m.max(*s));
let reference = by_position(&m_axis, &axis);
let mut worst = (0.0f64, 0.0f64);
let unturn = |x: [f64; 3]| [0, 1, 2].map(|i| (0..3).map(|j| turn[j][i] * x[j]).sum::<f64>());
for (i, l) in turned.nodes.iter().enumerate() {
let (u, s) = reference[&key(unturn(m_turned.nodes()[l].xyz))];
let want = apply(&turn, u);
let got = turned.displacement[i];
worst.0 = worst.0.max(norm([0, 1, 2].map(|k| got[k] - want[k])) / size);
worst.1 = worst.1.max((turned.von_mises[i] - s).abs() / stress);
}
eprintln!(
"{kind:?} {formulation:?}: tip deflection {size:.6e}, turned vs axis-aligned: displacement {:.1e}, von Mises {:.1e}",
worst.0, worst.1
);
assert!(worst.0 < 1e-9 && worst.1 < 1e-8, "{kind:?}: {worst:?}");
for r in [&axis, &turned] {
let imbalance = (0..3).map(|c| (r.applied_total[c] + r.reaction_total[c]).abs()).fold(0.0, f64::max);
assert!(imbalance < 1e-8 * norm(r.applied_total), "{kind:?}: {:?} vs {:?}", r.applied_total, r.reaction_total);
assert!(!r.warnings.iter().any(|w| w.contains("reactions")), "{:?}", r.warnings);
}
if mirrored {
let (m_whole, whole) = cantilever(kind, formulation, true, &identity);
let all = by_position(&m_whole, &whole);
let mut worst = 0.0f64;
for (x, (u, _)) in &reference {
let w = all[x].0;
worst = worst.max(norm([0, 1, 2].map(|k| u[k] - w[k])) / size);
}
eprintln!("{kind:?}: half with the plane vs whole: displacement {worst:.1e}");
assert!(worst < 1e-9, "{kind:?}: {worst}");
}
}
}
/// A shell model: one material and step; `n` × `n` cells of the quarter of a simply supported
/// square plate (side 1), two triangles per cell split along x = y, so the mesh is mirror-
/// symmetric about the diagonal plane x = y; `eighth` keeps the cells below the diagonal and
/// puts the inclined symmetry plane (normal (1, −1, 0)) on it.
fn plate(n: usize, eighth: bool, procedure: Procedure) -> (Model, BTreeMap<u32, [f64; 3]>) {
let (a, t) = (1.0, 0.01);
let mut m = Model::default();
m.add_material("Mat", Material { elastic: Some(Elastic::Isotropic { young: 1e7, poisson: 0.3 }), density: Some(1.0), ..Material::default() })
.unwrap();
m.add_step(Step::new("Step", procedure), None).unwrap();
let label = |i: usize, j: usize| (1 + i + (n + 1) * j) as u32;
let mut xyz = BTreeMap::new();
for j in 0..=n {
for i in 0..=n {
if eighth && j > i {
continue;
}
let p = [i as f64 / n as f64 * a / 2.0, j as f64 / n as f64 * a / 2.0, 0.0];
m.set_node(label(i, j), p).unwrap();
xyz.insert(label(i, j), p);
}
}
let mut elements = Vec::new();
for j in 0..n {
for i in 0..n {
let (p, q, r, s) = (label(i, j), label(i + 1, j), label(i + 1, j + 1), label(i, j + 1));
for (tri, below) in [([p, q, r], true), ([p, r, s], false)] {
if eighth && (j > i || (i == j && !below)) {
continue;
}
let e = elements.len() as u32 + 1;
m.set_element(e, ElementKind::Tri3, tri.to_vec()).unwrap();
elements.push(e);
}
}
}
let faces = elements.iter().map(|e| ElementFace { element: *e, face: 1 }).collect();
m.add_set("plate", Set { elements, ..Set::default() }).unwrap();
m.add_section(
"plate",
Section {
set: "plate".into(),
material: "Mat".into(),
kind: SectionKind::Shell { thickness: t, offset: 0.0, points: 5 },
formulation: Formulation::default(),
},
)
.unwrap();
let on = |x: f64, c: f64| (x - c).abs() < 1e-9;
let free = None;
let hold = |m: &mut Model, name: &str, bc: BoundaryCondition, f: &dyn Fn([f64; 3]) -> bool| {
let nodes = xyz.iter().filter(|(_, x)| f(**x)).map(|(l, _)| *l).collect();
m.add_set(name, Set { nodes, ..Set::default() }).unwrap();
m.add_boundary_condition(name, bc).unwrap();
m.set_boundary_condition_active("Step", name, true).unwrap();
};
let held = |values| move |name: &str| BoundaryCondition::Displacement { target: Target::Set(name.into()), values };
// Hard simple support on x = 0 and y = 0; symmetry on x = a/2 and y = a/2.
let x0 = held([free, free, Some(0.0), Some(0.0), free, free]);
let y0 = held([free, free, Some(0.0), free, Some(0.0), free]);
let sym = |name: &str, plane| BoundaryCondition::Symmetry { target: Target::Set(name.into()), plane };
if !eighth {
hold(&mut m, "x0", x0("x0"), &|p| on(p[0], 0.0));
hold(&mut m, "ys", sym("ys", Symmetry::Y), &|p| on(p[1], a / 2.0));
}
hold(&mut m, "y0", y0("y0"), &|p| on(p[1], 0.0));
hold(&mut m, "xs", sym("xs", Symmetry::X), &|p| on(p[0], a / 2.0));
if eighth {
hold(&mut m, "diagonal", sym("diagonal", Symmetry::from_normal([1.0, -1.0, 0.0]).unwrap()), &|p| on(p[0], p[1]));
}
if matches!(m.steps()[0].procedure, Procedure::Static {}) {
m.add_set("top", Set { faces, ..Set::default() }).unwrap();
m.add_load("q", Load::Pressure { set: "top".into(), magnitude: 1.0 }).unwrap();
m.set_load_active("Step", "q", true).unwrap();
}
(m, xyz)
}
/// An eighth of the simply supported plate, cut on the diagonal with the inclined plane, solves
/// as the quarter (deflections and rotations at every shared node, to round-off; the first
/// frequency, whose mode is symmetric about the diagonal), and both match Navier's series.
#[test]
fn an_eighth_plate_on_the_diagonal_plane_matches_the_quarter() {
let n = 16;
let (mq, quarter) = plate(n, false, Procedure::Static {});
let (me, eighth) = plate(n, true, Procedure::Static {});
let rq = solve_static(&mq, None, &Limits::default()).unwrap();
let re = solve_static(&me, None, &Limits::default()).unwrap();
let at = |r: &StaticResult, l: u32| r.nodes.binary_search(&l).unwrap();
let size = rq.displacement.iter().fold(0.0f64, |m, u| m.max(norm(*u)));
let turn = rq.rotation.iter().fold(0.0f64, |m, u| m.max(norm(*u)));
let (mut du, mut dr) = (0.0f64, 0.0f64);
for l in eighth.keys() {
assert!(quarter.contains_key(l));
let (a, b) = (at(&rq, *l), at(&re, *l));
du = du.max(norm([0, 1, 2].map(|k| rq.displacement[a][k] - re.displacement[b][k])) / size);
dr = dr.max(norm([0, 1, 2].map(|k| rq.rotation[a][k] - re.rotation[b][k])) / turn);
}
// The diagonal turns only about its normal: θx + θy = 0 and θz = 0 there.
for (l, x) in &eighth {
if (x[0] - x[1]).abs() < 1e-9 {
let r = re.rotation[at(&re, *l)];
assert!((r[0] + r[1]).abs() < 1e-12 * turn && r[2].abs() < 1e-12 * turn, "node {l}: {r:?}");
}
}
let centre = *eighth.iter().find(|(_, x)| (x[0] - 0.5).abs() < 1e-9 && (x[1] - 0.5).abs() < 1e-9).unwrap().0;
let w = -re.displacement[at(&re, centre)][2];
let d = 1e7 * 0.01f64.powi(3) / (12.0 * (1.0 - 0.09));
let navier = navier(1.0, 1.0, d);
eprintln!("eighth vs quarter plate: displacement {du:.1e}, rotation {dr:.1e}; centre deflection {w:.6e} vs Navier {navier:.6e}");
assert!(du < 1e-9 && dr < 1e-9, "{du} {dr}");
assert!((w - navier).abs() < 0.01 * navier, "{w} vs {navier}");
let imbalance = (0..3).map(|c| (re.applied_total[c] + re.reaction_total[c]).abs()).fold(0.0, f64::max);
assert!(imbalance < 1e-9 * norm(re.applied_total), "{:?} vs {:?}", re.applied_total, re.reaction_total);
assert!(!re.warnings.iter().any(|w| w.contains("reactions")), "{:?}", re.warnings);
// An eighth carries an eighth of the plate's load (the quarter a quarter).
assert!((2.0 * re.applied_total[2] - rq.applied_total[2]).abs() < 1e-12 * rq.applied_total[2].abs());
let frequency = |eighth| {
let (m, _) = plate(n, eighth, Procedure::Frequency { modes: 1, min_hz: None, max_hz: None });
solve_frequency(&m, None, &FrequencyOptions::default(), &Limits::default()).unwrap().modes[0].frequency
};
let (fq, fe) = (frequency(false), frequency(true));
eprintln!("first frequency: quarter {fq:.9}, eighth {fe:.9}");
assert!((fq - fe).abs() < 1e-8 * fq, "{fq} vs {fe}");
}
/// Navier's series for the centre deflection of a simply supported square plate under uniform
/// pressure (S. Timoshenko, S. Woinowsky-Krieger, *Theory of Plates and Shells*, 2nd ed.,
/// McGraw-Hill 1959, §30).
fn navier(q: f64, a: f64, d: f64) -> f64 {
let mut s = 0.0;
for m in (1..400).step_by(2) {
for n in (1..400).step_by(2) {
let (mf, nf) = (f64::from(m), f64::from(n));
let sign = if ((m + n) / 2 - 1) % 2 == 0 { 1.0 } else { -1.0 };
s += sign / (mf * nf * (mf * mf + nf * nf).powi(2));
}
}
16.0 * q * a.powi(4) / (std::f64::consts::PI.powi(6) * d) * s
}
/// A node that is also held, or listed in two planes, is no conflict; a prescribed motion off
/// the plane is.
#[test]
fn conflicts() {
// The clamp's nodes on y = 0 are also on the plane: held there, the plane's rows are redundant.
let (_, r) = cantilever(ElementKind::Hex8, full(), false, &rotation([0.0, 0.0, 1.0], 0.3));
assert!(!r.warnings.iter().any(|w| w.contains("reactions")), "{:?}", r.warnings);
let mut g = grid([4, 1, 1], ElementKind::Hex8, |p| [4.0 * p[0], p[1], p[2]]);
g.define(iso(1e3, 0.3), None, full(), Procedure::Static {});
g.fixed("root", |p| near(p[0], 0.0));
g.symmetry("a", Symmetry::Normal([1.0, 1.0, 0.0]), |p| near(p[1], 0.0) && near(p[0], 1.0));
g.symmetry("b", Symmetry::Normal([2.0, 2.0, 0.0]), |p| near(p[1], 0.0) && near(p[0], 1.0));
g.face_set("tip", |p| near(p[0], 1.0));
g.load("push", Load::Traction { set: "tip".into(), vector: [0.0, 0.0, -1.0] });
let r = solve_static(&g.model, None, &Limits::default()).unwrap();
let u = r.displacement[r.nodes.binary_search(&g.nodes_where(|p| near(p[1], 0.0) && near(p[0], 1.0))[0]).unwrap()];
assert!((u[0] + u[1]).abs() < 1e-12 * norm(u), "{u:?}");
// A prescribed motion the plane cannot follow is a contradiction, reported by name.
let nodes = g.nodes_where(|p| near(p[1], 0.0) && near(p[0], 1.0));
g.model.add_set("pulled", Set { nodes, ..Set::default() }).unwrap();
g.bc("pull", BoundaryCondition::Displacement { target: Target::Set("pulled".into()), values: [Some(0.1), Some(0.1), None, None, None, None] });
let e = solve_static(&g.model, None, &Limits::default()).unwrap_err().to_string();
assert!(e.contains("boundary condition `") && e.contains("contradicts"), "{e}");
}