forked from 1b-agent/fieldcraft-upstream
Model: planar section kinds with Extent, element edges in sets, edge-weighted 2D loads,
centrifugal load, 2D model checks. fem: Plane kernels on Basis::{Tri3,Tri6,Quad4,Quad8}
declaring ElementDofs::new(DofSet::PLANE); QM6 incompatible modes with the
Wilson-Ibrahimbegovic correction. Solver: Kernel enum on the DofLayout; 3D results
bit-identical (hash-checked). Decks: Abaqus CPS/CPE/CAX, Nastran membranes and RFORCE,
LS-DYNA ELFORM 12/13. Mesh: free triangle mesher, planar region meshing, face boundaries.
Engine: mesh.planar with tagged edges. Analytic verification suite in solver/tests/plane.rs.
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
398 lines
17 KiB
Rust
398 lines
17 KiB
Rust
//! Sections: what the elements of a set are made of, how thick they are, and how they are
|
||
//! integrated.
|
||
//!
|
||
//! A section is assigned to one set and carries the element formulation for its elements, so one
|
||
//! region has one formulation and every solver can express it: the writers map the element kind
|
||
//! plus the [`Formulation`] to each format's element names, property cards or formulation
|
||
//! numbers.
|
||
|
||
use std::collections::BTreeMap;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::{Error, Model, Result, context, finite, insert_new, positive, remove_existing, replace_existing, require};
|
||
|
||
/// Most integration points through a shell's thickness.
|
||
pub const MAX_SHELL_POINTS: u32 = 15;
|
||
|
||
/// A beam's cross-section shape. Dimensions are in the section's local axes: `width` along the
|
||
/// beam's orientation vector (local 1), `height` across it (local 2).
|
||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||
pub enum BeamProfile {
|
||
Rectangle {
|
||
width: f64,
|
||
height: f64,
|
||
},
|
||
Circle {
|
||
radius: f64,
|
||
},
|
||
/// A tube: outer radius and wall thickness.
|
||
Pipe {
|
||
radius: f64,
|
||
thickness: f64,
|
||
},
|
||
/// A doubly symmetric I: overall height, flange width, web and flange thicknesses.
|
||
I {
|
||
height: f64,
|
||
width: f64,
|
||
web: f64,
|
||
flange: f64,
|
||
},
|
||
/// A rectangular tube with one wall thickness.
|
||
Box {
|
||
width: f64,
|
||
height: f64,
|
||
thickness: f64,
|
||
},
|
||
}
|
||
|
||
impl BeamProfile {
|
||
pub fn validate(&self) -> Result<()> {
|
||
match *self {
|
||
BeamProfile::Rectangle { width, height } => {
|
||
positive("width", width)?;
|
||
positive("height", height)?;
|
||
}
|
||
BeamProfile::Circle { radius } => {
|
||
positive("radius", radius)?;
|
||
}
|
||
BeamProfile::Pipe { radius, thickness } => {
|
||
positive("radius", radius)?;
|
||
positive("thickness", thickness)?;
|
||
if thickness > radius {
|
||
return Err(Error::Invalid(format!("a pipe's wall ({thickness}) cannot be thicker than its radius ({radius})")));
|
||
}
|
||
}
|
||
BeamProfile::I { height, width, web, flange } => {
|
||
for (what, v) in [("height", height), ("width", width), ("web", web), ("flange", flange)] {
|
||
positive(what, v)?;
|
||
}
|
||
if web >= width || 2.0 * flange >= height {
|
||
return Err(Error::Invalid(
|
||
"an I profile's web must be narrower than its flanges, and its flanges thinner than half its height".into(),
|
||
));
|
||
}
|
||
}
|
||
BeamProfile::Box { width, height, thickness } => {
|
||
for (what, v) in [("width", width), ("height", height), ("thickness", thickness)] {
|
||
positive(what, v)?;
|
||
}
|
||
if 2.0 * thickness >= width.min(height) {
|
||
return Err(Error::Invalid("a box's walls must be thinner than half its width and height".into()));
|
||
}
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
/// Cross-sectional area.
|
||
pub fn area(&self) -> f64 {
|
||
match *self {
|
||
BeamProfile::Rectangle { width, height } => width * height,
|
||
BeamProfile::Circle { radius } => std::f64::consts::PI * radius * radius,
|
||
BeamProfile::Pipe { radius, thickness } => std::f64::consts::PI * (radius * radius - (radius - thickness).powi(2)),
|
||
BeamProfile::I { height, width, web, flange } => 2.0 * width * flange + (height - 2.0 * flange) * web,
|
||
BeamProfile::Box { width, height, thickness } => width * height - (width - 2.0 * thickness) * (height - 2.0 * thickness),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// What a section describes.
|
||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
|
||
pub enum SectionKind {
|
||
/// Continuum solid elements.
|
||
Solid {},
|
||
/// Shell elements: thickness, the reference surface's offset from the mid-surface as a
|
||
/// fraction of the thickness (0 mid-surface, 0.5 on the side the normal points to, -0.5 on
|
||
/// the other), and integration points through the thickness.
|
||
Shell {
|
||
thickness: f64,
|
||
#[serde(default)]
|
||
offset: f64,
|
||
#[serde(default = "default_shell_points")]
|
||
points: u32,
|
||
},
|
||
/// Beam elements with a library profile. `orientation` is a vector giving the profile's
|
||
/// local 1 direction (projected normal to each beam's axis); it must not be parallel to a
|
||
/// beam.
|
||
Beam { profile: BeamProfile, orientation: [f64; 3] },
|
||
/// Axial members (rods, trusses) with a cross-sectional area.
|
||
Rod { area: f64 },
|
||
/// 2D continuum in plane stress (σzz = 0: thin plates loaded in their plane), of the given
|
||
/// thickness. The elements lie in the XY plane.
|
||
PlaneStress { thickness: f64 },
|
||
/// 2D continuum in plane strain (εzz = 0: a slice of a long body), of the given thickness
|
||
/// (1 by default, so results are per unit length). The elements lie in the XY plane.
|
||
PlaneStrain {
|
||
#[serde(default = "unit_thickness")]
|
||
thickness: f64,
|
||
},
|
||
/// Axisymmetric continuum: the elements lie in the XY half plane x ≥ 0, which is the r–z
|
||
/// plane of a body of revolution about the Y axis (x = radius, y = axial position; the hoop
|
||
/// direction is the third). Forces, reactions and masses are totals over the full
|
||
/// circumference (2π radians).
|
||
Axisymmetric {},
|
||
}
|
||
|
||
fn default_shell_points() -> u32 {
|
||
5
|
||
}
|
||
|
||
fn unit_thickness() -> f64 {
|
||
1.0
|
||
}
|
||
|
||
/// How a 2D continuum section extends out of its plane: what turns a length along an element
|
||
/// edge into an area, and an element's area into a volume.
|
||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||
pub enum Extent {
|
||
/// Plane stress and plane strain: a uniform thickness.
|
||
Thickness(f64),
|
||
/// Axisymmetric: the full circumference 2πr at radius r = x.
|
||
Revolved,
|
||
}
|
||
|
||
impl Extent {
|
||
/// The out-of-plane measure at a point: the thickness, or 2π x.
|
||
pub fn at(&self, xyz: [f64; 3]) -> f64 {
|
||
match *self {
|
||
Extent::Thickness(t) => t,
|
||
Extent::Revolved => 2.0 * std::f64::consts::PI * xyz[0],
|
||
}
|
||
}
|
||
}
|
||
|
||
impl SectionKind {
|
||
/// The element dimension this section applies to.
|
||
pub fn dimension(&self) -> u8 {
|
||
match self {
|
||
SectionKind::Solid {} => 3,
|
||
SectionKind::Shell { .. } | SectionKind::PlaneStress { .. } | SectionKind::PlaneStrain { .. } | SectionKind::Axisymmetric {} => 2,
|
||
SectionKind::Beam { .. } | SectionKind::Rod { .. } => 1,
|
||
}
|
||
}
|
||
|
||
/// Whether this is a continuum section (solid or 2D), the kinds with incompatible-mode
|
||
/// formulations.
|
||
pub fn is_continuum(&self) -> bool {
|
||
self.dimension() == 3 || self.is_planar()
|
||
}
|
||
|
||
/// Whether this is a 2D continuum section (plane stress, plane strain or axisymmetric).
|
||
pub fn is_planar(&self) -> bool {
|
||
self.extent().is_some()
|
||
}
|
||
|
||
/// How a 2D continuum section extends out of its plane (`None` for other kinds).
|
||
pub fn extent(&self) -> Option<Extent> {
|
||
match *self {
|
||
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => Some(Extent::Thickness(thickness)),
|
||
SectionKind::Axisymmetric {} => Some(Extent::Revolved),
|
||
_ => None,
|
||
}
|
||
}
|
||
|
||
/// `solid`, `shell`, `beam`, `rod`, `plane_stress`, `plane_strain` or `axisymmetric`.
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
SectionKind::Solid {} => "solid",
|
||
SectionKind::Shell { .. } => "shell",
|
||
SectionKind::Beam { .. } => "beam",
|
||
SectionKind::Rod { .. } => "rod",
|
||
SectionKind::PlaneStress { .. } => "plane_stress",
|
||
SectionKind::PlaneStrain { .. } => "plane_strain",
|
||
SectionKind::Axisymmetric {} => "axisymmetric",
|
||
}
|
||
}
|
||
|
||
pub fn validate(&self) -> Result<()> {
|
||
match self {
|
||
SectionKind::Solid {} => {}
|
||
SectionKind::Shell { thickness, offset, points } => {
|
||
positive("thickness", *thickness)?;
|
||
if !(finite("offset", *offset)?.abs() <= 0.5) {
|
||
return Err(Error::Invalid(format!("a shell offset is a fraction of the thickness in -0.5..=0.5, got {offset}")));
|
||
}
|
||
if !(1..=MAX_SHELL_POINTS).contains(points) {
|
||
return Err(Error::Invalid(format!(
|
||
"a shell has 1 to {MAX_SHELL_POINTS} integration points through its thickness, got {points}"
|
||
)));
|
||
}
|
||
}
|
||
SectionKind::Beam { profile, orientation } => {
|
||
profile.validate()?;
|
||
for c in orientation {
|
||
finite("orientation", *c)?;
|
||
}
|
||
if orientation.iter().all(|c| *c == 0.0) {
|
||
return Err(Error::Invalid("a beam's orientation vector must not be zero".into()));
|
||
}
|
||
}
|
||
SectionKind::Rod { area } => {
|
||
positive("area", *area)?;
|
||
}
|
||
SectionKind::PlaneStress { thickness } | SectionKind::PlaneStrain { thickness } => {
|
||
positive("thickness", *thickness)?;
|
||
}
|
||
SectionKind::Axisymmetric {} => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// Number of integration points per element direction.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
#[serde(rename_all = "snake_case")]
|
||
pub enum Integration {
|
||
/// The full rule for the element's order.
|
||
#[default]
|
||
Full,
|
||
/// One order lower (cheaper, no shear locking, needs hourglass control).
|
||
Reduced,
|
||
}
|
||
|
||
/// How the section's elements are formulated, independent of any solver's element names.
|
||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct Formulation {
|
||
#[serde(default)]
|
||
pub integration: Integration,
|
||
/// Enhanced (incompatible) bending modes: linear hexahedra (solid sections) and linear
|
||
/// quadrilaterals (2D sections) only.
|
||
#[serde(default)]
|
||
pub incompatible_modes: bool,
|
||
/// Mixed displacement–pressure formulation for nearly incompressible materials: solids only.
|
||
#[serde(default)]
|
||
pub hybrid: bool,
|
||
}
|
||
|
||
impl Formulation {
|
||
/// Check the options against each other and against the section kind (the element kinds are
|
||
/// checked when the model is resolved).
|
||
pub fn validate_for(&self, kind: &SectionKind) -> Result<()> {
|
||
if self.incompatible_modes && self.integration == Integration::Reduced {
|
||
return Err(Error::Invalid("incompatible modes use full integration".into()));
|
||
}
|
||
if !kind.is_continuum() && self.incompatible_modes {
|
||
return Err(Error::Invalid(format!("incompatible modes are for solid and 2D sections, not {}", kind.name())));
|
||
}
|
||
if kind.dimension() != 3 && self.hybrid {
|
||
return Err(Error::Invalid(format!("hybrid formulations are for solid sections, not {}", kind.name())));
|
||
}
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
/// A section: a material and a kind, assigned to the elements of a set.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct Section {
|
||
pub set: String,
|
||
pub material: String,
|
||
pub kind: SectionKind,
|
||
#[serde(default)]
|
||
pub formulation: Formulation,
|
||
}
|
||
|
||
impl Section {
|
||
pub fn validate(&self) -> Result<()> {
|
||
self.kind.validate()?;
|
||
self.formulation.validate_for(&self.kind)
|
||
}
|
||
}
|
||
|
||
impl Model {
|
||
pub fn sections(&self) -> &BTreeMap<String, Section> {
|
||
&self.sections
|
||
}
|
||
|
||
pub fn section(&self, name: &str) -> Option<&Section> {
|
||
self.sections.get(name)
|
||
}
|
||
|
||
fn check_section(&self, name: &str, section: &Section) -> Result<()> {
|
||
section.validate().map_err(|e| context("section", name, e))?;
|
||
require(&self.sets, "set", §ion.set).map_err(|e| context("section", name, e))?;
|
||
require(&self.materials, "material", §ion.material).map_err(|e| context("section", name, e))
|
||
}
|
||
|
||
pub fn add_section(&mut self, name: &str, section: Section) -> Result<()> {
|
||
self.check_section(name, §ion)?;
|
||
insert_new(&mut self.sections, "section", name, section)
|
||
}
|
||
|
||
pub fn replace_section(&mut self, name: &str, section: Section) -> Result<()> {
|
||
self.check_section(name, §ion)?;
|
||
replace_existing(&mut self.sections, "section", name, section)
|
||
}
|
||
|
||
pub fn remove_section(&mut self, name: &str) -> Result<()> {
|
||
remove_existing(&mut self.sections, "section", name).map(|_| ())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn profiles_and_formulations_validate() {
|
||
assert!(BeamProfile::Pipe { radius: 1.0, thickness: 1.5 }.validate().is_err());
|
||
assert!(BeamProfile::I { height: 10.0, width: 5.0, web: 6.0, flange: 1.0 }.validate().is_err());
|
||
assert!(BeamProfile::Box { width: 10.0, height: 4.0, thickness: 2.0 }.validate().is_err());
|
||
assert!(BeamProfile::Rectangle { width: 1.0, height: f64::NAN }.validate().is_err());
|
||
let i = BeamProfile::I { height: 10.0, width: 5.0, web: 1.0, flange: 1.0 };
|
||
i.validate().unwrap();
|
||
assert_eq!(i.area(), 2.0 * 5.0 + 8.0);
|
||
assert!((BeamProfile::Pipe { radius: 2.0, thickness: 2.0 }.area() - BeamProfile::Circle { radius: 2.0 }.area()).abs() < 1e-12);
|
||
|
||
let shell = SectionKind::Shell { thickness: 2.0, offset: 0.0, points: 5 };
|
||
assert!(Formulation { hybrid: true, ..Formulation::default() }.validate_for(&shell).is_err());
|
||
assert!(
|
||
Formulation { incompatible_modes: true, integration: Integration::Reduced, hybrid: false }.validate_for(&SectionKind::Solid {}).is_err()
|
||
);
|
||
Formulation { incompatible_modes: true, hybrid: true, ..Formulation::default() }.validate_for(&SectionKind::Solid {}).unwrap();
|
||
for bad in [
|
||
SectionKind::Shell { thickness: 2.0, offset: 0.6, points: 5 },
|
||
SectionKind::Shell { thickness: 2.0, offset: 0.0, points: 0 },
|
||
SectionKind::Shell { thickness: 0.0, offset: 0.0, points: 5 },
|
||
SectionKind::Beam { profile: BeamProfile::Circle { radius: 1.0 }, orientation: [0.0; 3] },
|
||
SectionKind::Rod { area: -1.0 },
|
||
] {
|
||
assert!(bad.validate().is_err(), "{bad:?}");
|
||
}
|
||
let parsed: SectionKind = serde_json::from_value(serde_json::json!({"type": "shell", "thickness": 1.5})).unwrap();
|
||
assert_eq!(parsed, SectionKind::Shell { thickness: 1.5, offset: 0.0, points: 5 });
|
||
assert!(
|
||
serde_json::from_value::<SectionKind>(serde_json::json!({"type": "shell", "thickness": 1.5, "thicknes": 2})).is_err(),
|
||
"typos are errors"
|
||
);
|
||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "solid", "x": 1})).is_err());
|
||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "membrane"})).is_err());
|
||
}
|
||
|
||
#[test]
|
||
fn planar_sections() {
|
||
let strain: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_strain"})).unwrap();
|
||
assert_eq!(strain, SectionKind::PlaneStrain { thickness: 1.0 });
|
||
let stress: SectionKind = serde_json::from_value(serde_json::json!({"type": "plane_stress", "thickness": 2.5})).unwrap();
|
||
assert_eq!(stress.extent(), Some(Extent::Thickness(2.5)));
|
||
assert!(serde_json::from_value::<SectionKind>(serde_json::json!({"type": "plane_stress"})).is_err(), "plane stress needs a thickness");
|
||
let axi: SectionKind = serde_json::from_value(serde_json::json!({"type": "axisymmetric"})).unwrap();
|
||
assert_eq!(axi.extent(), Some(Extent::Revolved));
|
||
assert!((Extent::Revolved.at([2.0, 7.0, 0.0]) - 4.0 * std::f64::consts::PI).abs() < 1e-15);
|
||
assert!(axi.is_planar() && stress.is_planar() && !SectionKind::Solid {}.is_planar());
|
||
assert!(!SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }.is_planar());
|
||
for bad in [SectionKind::PlaneStress { thickness: 0.0 }, SectionKind::PlaneStrain { thickness: f64::NAN }] {
|
||
assert!(bad.validate().is_err(), "{bad:?}");
|
||
}
|
||
let im = Formulation { incompatible_modes: true, ..Formulation::default() };
|
||
im.validate_for(&stress).unwrap();
|
||
im.validate_for(&axi).unwrap();
|
||
assert!(Formulation { hybrid: true, ..Formulation::default() }.validate_for(&strain).is_err());
|
||
assert!(im.validate_for(&SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }).is_err());
|
||
}
|
||
}
|