forked from 1b-agent/fieldcraft-upstream
Model definition: sets by geometry name, materials, sections, couplings, BCs, loads, steps, check (#16)
* model: split nodes and elements into a mesh module (no behaviour change) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * model: sets by geometry name, materials and units, sections, couplings, BCs, loads, steps and the check Sets bind to persistent geometry names through the mesh's geometry tags (element, element face/edge and node classification) and resolve against the current mesh, so they survive a remesh. Steps list their active loads and boundary conditions explicitly. Model::check names every missing or bad item; Model::resolve gives the writers' view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * engine: model definition commands (sets, materials, sections, RPs, couplings, BCs, loads, steps, tags, check) Each definition has create/edit/delete with its model JSON form as parameters; model.step.activate and model.activity drive the explicit step table; mesh.tag records where the mesh lies on the geometry; model.check and model.set.resolve report against the current mesh. Hostile params run against an empty and a fully defined session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: model definition in architecture and roadmap Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * model, engine: review cleanups Validated deserialization (a file replays through the checked methods), interned geometry tags by index, set resolution with tag bitsets and sorted vectors, one delete policy (a load or BC listed by a step is in use), shared helpers (context, remove_existing, step_mut, opt/req/name/listed), dead code removed, Resolved without a per-element section map. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * model: mesh-independent force distribution, tractions, stable reference point labels Force and moment on a set default to a total, split by consistent nodal weights over the set's faces (or equally over a node set); per_node is an explicit opt-in. Traction is force per area. resolve() expands them to nodal values for every writer and the solver. Reference points keep a stored node label taken from the top of the label range. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> 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>
This commit is contained in:
23 files changed
+4296
-268
No files matched your search
@@ -0,0 +1,306 @@
|
||||
//! 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 },
|
||||
}
|
||||
|
||||
fn default_shell_points() -> u32 {
|
||||
5
|
||||
}
|
||||
|
||||
impl SectionKind {
|
||||
/// The element dimension this section applies to.
|
||||
pub fn dimension(&self) -> u8 {
|
||||
match self {
|
||||
SectionKind::Solid {} => 3,
|
||||
SectionKind::Shell { .. } => 2,
|
||||
SectionKind::Beam { .. } | SectionKind::Rod { .. } => 1,
|
||||
}
|
||||
}
|
||||
|
||||
/// `solid`, `shell`, `beam` or `rod`.
|
||||
pub fn name(&self) -> &'static str {
|
||||
match self {
|
||||
SectionKind::Solid {} => "solid",
|
||||
SectionKind::Shell { .. } => "shell",
|
||||
SectionKind::Beam { .. } => "beam",
|
||||
SectionKind::Rod { .. } => "rod",
|
||||
}
|
||||
}
|
||||
|
||||
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)?;
|
||||
}
|
||||
}
|
||||
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 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.dimension() != 3 && (self.incompatible_modes || self.hybrid) {
|
||||
return Err(Error::Invalid(format!("incompatible modes and 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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user