Files
OneCAD-Simulation/crates/decks/src/lib.rs
T
jyvikoandClaude Opus 5.5 ac1037e1af 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
2026-10-16 10:38:20 -04:00

317 lines
14 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Solver decks: the solver-neutral model written as Abaqus input, Nastran bulk data and LS-DYNA
//! keyword files. Each format is its own module (`abaqus`, `nastran`, `lsdyna`), written from the
//! vendor's public reference manual in our own words; examples and expected files are our own.
//! Writers read [`fieldcraft_model::Model`] and its `resolve()` result, never another format.
//!
//! Writers produce text; saving it is the caller's business (the engine's `deck.write`), so the
//! crate stays free of the file system. [`FORMATS`] lists every writer by the name `deck.write`
//! takes.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
#![forbid(unsafe_code)]
pub mod abaqus;
pub mod lsdyna;
pub mod nastran;
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_model::nodal::edge_pressure;
use fieldcraft_model::{BoundaryCondition, Diagnostic, Load, Model, Resolved, ResolvedDirection};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
/// The model has something this format cannot express (named, with the format).
#[error("not supported by {format}: {what}")]
Unsupported { format: &'static str, what: String },
/// The model is not ready to write (its check failed).
#[error(transparent)]
Model(#[from] fieldcraft_model::Error),
#[error("limit reached: {0}")]
Limit(String),
}
pub type Result<T> = std::result::Result<T, Error>;
/// A deck format.
pub struct Format {
/// The name commands use (`abaqus`, …).
pub name: &'static str,
/// The usual file extension, without the dot.
pub extension: &'static str,
writer: fn(&Model, &Resolved) -> Result<String>,
}
/// A written deck: its text and the model check's warnings.
#[derive(Debug, Clone, PartialEq)]
pub struct Deck {
pub text: String,
pub warnings: Vec<Diagnostic>,
}
impl Format {
/// Resolve the model once and write it in this format.
pub fn write(&self, model: &Model) -> Result<Deck> {
let resolved = model.resolve()?;
let text = (self.writer)(model, &resolved)?;
Ok(Deck { text, warnings: resolved.warnings })
}
/// Write only the named steps (in the model's order), or every step when `steps` is `None`;
/// for runs that hold some steps only, such as a Nastran run (static or frequency steps).
pub fn write_steps(&self, model: &Model, steps: Option<&[String]>) -> Result<Deck> {
match steps {
None => self.write(model),
Some(names) => self.write(&with_steps(model, names)?),
}
}
}
/// The model with only the named steps, in its own order. Definitions the dropped steps used
/// stay (the check warns about the ones no step uses).
pub fn with_steps(model: &Model, names: &[String]) -> Result<Model> {
if names.is_empty() {
return Err(Error::Model(fieldcraft_model::Error::Invalid("choose at least one step to write".into())));
}
if let Some(n) = names.iter().find(|n| model.step(n).is_none()) {
let shown: String = n.chars().take(fieldcraft_model::MAX_NAME_LEN).collect();
return Err(Error::Model(fieldcraft_model::Error::NotFound(format!("step `{shown}`"))));
}
let mut out = model.clone();
let dropped: Vec<String> = model.steps().iter().map(|s| s.name.clone()).filter(|s| !names.contains(s)).collect();
for name in dropped {
out.remove_step(&name)?;
}
Ok(out)
}
/// A fluid step: solved by FieldCraft's lattice Boltzmann solver, never written to a deck.
pub(crate) fn fluid_step(format: &'static str, step: &str) -> Error {
Error::Unsupported { format, what: format!("fluid step `{step}` (solve it with solve.fluid; write the structural steps only)") }
}
/// Every writer's first check: the model has no fluid step.
pub(crate) fn refuse_fluid(format: &'static str, model: &Model) -> Result<()> {
match model.steps().iter().find(|s| s.procedure.fluid_domain().is_some()) {
Some(step) => Err(fluid_step(format, &step.name)),
None => Ok(()),
}
}
/// Every deck format, one row per writer.
pub const FORMATS: &[Format] = &[
Format { name: "abaqus", extension: "inp", writer: abaqus::write_resolved },
Format { name: "nastran", extension: "bdf", writer: nastran::write_resolved },
Format { name: "lsdyna", extension: "k", writer: lsdyna::write_resolved },
];
/// The format called `name`.
pub fn format(name: &str) -> Option<&'static Format> {
FORMATS.iter().find(|f| f.name == name)
}
/// Fail with [`Error::Unsupported`] naming every item, or succeed when there are none. Writers
/// collect everything their format cannot express and report it at once.
pub(crate) fn unsupported(format: &'static str, items: BTreeSet<String>) -> Result<()> {
if items.is_empty() { Ok(()) } else { Err(Error::Unsupported { format, what: items.into_iter().collect::<Vec<_>>().join("; ") }) }
}
/// Heat transfer in a model, which no writer expresses yet: heat transfer and coupled
/// temperature–displacement steps, temperature boundary conditions, heat loads and temperature
/// fields. Thermal material properties alone are left out of the decks (nothing uses them).
pub(crate) fn thermal_items(model: &Model) -> BTreeSet<String> {
let mut out = BTreeSet::new();
for step in model.steps().iter().filter(|s| s.procedure.is_thermal()) {
out.insert(format!("step `{}`: {} steps (not written to decks yet)", step.name, step.procedure.name()));
}
for name in model.boundary_conditions().iter().filter(|(_, b)| b.is_thermal()).map(|(n, _)| n) {
out.insert(format!("boundary condition `{name}`: prescribed temperatures (not written to decks yet)"));
}
for (name, load) in model.loads().iter().filter(|(_, l)| l.is_thermal() || matches!(l, Load::Temperature { .. })) {
out.insert(format!("load `{name}`: {} loads (not written to decks yet)", load.kind()));
}
out
}
/// The concentrated loads of a set of active loads: the resolved nodal values of every force,
/// traction (degrees of freedom 1–3) and moment (4–6), summed per node and degree of freedom so
/// loads sharing a node add up; zero sums are left out.
pub fn concentrated_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIterator<Item = &'a String>) -> BTreeMap<(u32, u8), f64> {
let mut out: BTreeMap<(u32, u8), f64> = BTreeMap::new();
for name in active {
let first_dof = match model.load(name) {
Some(Load::Moment { .. }) => 4,
Some(Load::Force { .. } | Load::Traction { .. }) => 1,
_ => continue,
};
for (node, v) in r.nodal_loads.get(name).map(Vec::as_slice).unwrap_or_default() {
for (dof, c) in (first_dof..).zip(v) {
*out.entry((*node, dof)).or_default() += c;
}
}
}
out.retain(|_, v| *v != 0.0);
out
}
/// The consistent nodal forces (degrees of freedom 1 and 2) of the active pressures on a 2D
/// model's element edges, for formats that write them as concentrated loads: the model's edge
/// integration ([`fieldcraft_model::nodal::edge_pressure`]), a positive pressure pushing
/// into the element, summed per node and direction; zero sums are left out.
pub fn edge_pressure_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIterator<Item = &'a str>) -> Result<BTreeMap<(u32, u8), f64>> {
let mut out: BTreeMap<(u32, u8), f64> = BTreeMap::new();
for name in active {
let Some(Load::Pressure { set, magnitude }) = model.load(name) else { continue };
for g in r.planar_edges(set) {
let Some(extent) = r.planar.get(&g.element).copied() else { continue };
let labels = model.edge_nodes(g).unwrap_or_default();
let xyz: Vec<[f64; 3]> = labels.iter().filter_map(|n| model.node(*n)).map(|n| n.xyz).collect();
for (node, f) in labels.iter().zip(edge_pressure(&xyz, extent, *magnitude)?) {
for (dof, c) in (1u8..).zip(&f[..2]) {
*out.entry((*node, dof)).or_default() += c;
}
}
}
}
out.retain(|_, v| *v != 0.0);
Ok(out)
}
/// How a degree of freedom is constrained apart from the inclined symmetry planes, for choosing
/// the dependent one of a plane's equation (formats eliminate it, so it may carry no other
/// constraint).
#[derive(Debug, Clone, Copy, PartialEq)]
pub(crate) enum Held {
Free,
/// Held at this value wherever the equation applies: it cannot depend on the others, and an
/// equation of held ones alone is redundant when the values satisfy it.
Always(f64),
/// Held in some steps only, or made dependent by a coupling or tie: it cannot be dependent,
/// nor can the equation be dropped.
Sometimes,
}
/// A linear equation Σ cⱼ u(node, dofⱼ) = 0 of an inclined symmetry plane, dependent degree of
/// freedom first.
#[derive(Debug, Clone, PartialEq)]
pub(crate) struct Equation {
pub node: u32,
pub terms: Vec<(u8, f64)>,
}
/// A coefficient below this fraction of its row's largest is round-off of the elimination.
const NEGLIGIBLE: f64 = 1e-12;
/// The equations of the inclined symmetry conditions `names` (others are skipped), node by node
/// in label order: n · u = 0 and, at a node in `rotating`, t₁ · θ = 0 and t₂ · θ = 0 for the
/// plane's in-plane axes ([`fieldcraft_model::Symmetry`]). The rows of one node (several planes
/// meet at an edge) are reduced against each other, so each has a dependent degree of freedom
/// of its own: the largest coefficient among those `held` says are free. A row of held degrees
/// of freedom only is left out when their values satisfy it; otherwise it is a problem, as is a
/// row with none free that cannot be left out.
pub(crate) fn symmetry_equations<'a>(
model: &Model,
r: &Resolved,
names: impl IntoIterator<Item = &'a String>,
rotating: &BTreeSet<u32>,
held: impl Fn(u32, u8) -> Held,
problems: &mut BTreeSet<String>,
) -> Vec<Equation> {
let mut rows: BTreeMap<u32, Vec<(&str, [f64; 6])>> = BTreeMap::new();
let mut seen = BTreeSet::new();
for name in names {
if !seen.insert(name.as_str()) {
continue;
}
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 };
for &node in r.target_nodes(target) {
let at = rows.entry(node).or_default();
at.push((name, [n[0], n[1], n[2], 0.0, 0.0, 0.0]));
if rotating.contains(&node) || target.reference_point().is_some() {
at.push((name, [0.0, 0.0, 0.0, t1[0], t1[1], t1[2]]));
at.push((name, [0.0, 0.0, 0.0, t2[0], t2[1], t2[2]]));
}
}
}
let mut out = Vec::new();
for (node, rows) in rows {
// The equations of this node so far: (dependent index, coefficients).
let mut done: Vec<(usize, [f64; 6])> = Vec::new();
for (name, mut c) in rows {
let scale = c.iter().fold(0.0f64, |m, v| m.max(v.abs()));
for (d, e) in &done {
let f = c[*d] / e[*d];
for k in 0..6 {
c[k] -= f * e[k];
}
c[*d] = 0.0;
}
for v in &mut c {
if v.abs() <= NEGLIGIBLE * scale {
*v = 0.0;
}
}
if c.iter().all(|v| *v == 0.0) {
continue;
}
let dof = |k: usize| k as u8 + 1;
let pivot = (0..6).filter(|&k| c[k] != 0.0 && held(node, dof(k)) == Held::Free).fold(None, |best: Option<usize>, k| match best {
Some(b) if c[b].abs() >= c[k].abs() => Some(b),
_ => Some(k),
});
let Some(pivot) = pivot else {
let mut residual = 0.0;
let mut size = 0.0;
let mut always = true;
for k in (0..6).filter(|&k| c[k] != 0.0) {
match held(node, dof(k)) {
Held::Always(v) => {
residual += c[k] * v;
size += (c[k] * v).abs();
}
_ => always = false,
}
}
if !always {
problems.insert(format!(
"boundary condition `{name}`: at node {node} every degree of freedom the inclined symmetry plane relates is held in some steps only or follows a coupling or tie, so none can be eliminated by its equation"
));
} else if residual.abs() > 1e-9 * size {
problems.insert(format!("boundary condition `{name}`: node {node} is held off the inclined symmetry plane"));
}
continue;
};
let mut terms = vec![(dof(pivot), c[pivot])];
terms.extend((0..6).filter(|&k| k != pivot && c[k] != 0.0).map(|k| (dof(k), c[k])));
out.push(Equation { node, terms });
done.push((pivot, c));
}
}
out
}
/// The global degree of freedom (1–6) a spring or dashpot direction acts along, when it is one
/// (a vector or line along an axis counts: the sign does not matter to a spring).
pub(crate) fn axis_dof(d: ResolvedDirection) -> Option<u8> {
match d {
ResolvedDirection::Dof(k) => Some(k),
ResolvedDirection::Vector(n) | ResolvedDirection::Line(n) => {
let axis = n.iter().position(|c| c.abs() == 1.0)?;
(n.iter().filter(|c| **c != 0.0).count() == 1).then_some(axis as u8 + 1)
}
}
}
/// `base`, or `base-2`, `base-3`, … : the first that is not in `taken` (compared without case),
/// which it is then added to.
pub(crate) fn unique_name(base: &str, taken: &mut BTreeSet<String>) -> String {
let mut candidate = base.to_string();
let mut k = 2u32;
while taken.contains(&candidate.to_ascii_uppercase()) {
candidate = format!("{base}-{k}");
k = k.saturating_add(1);
}
taken.insert(candidate.to_ascii_uppercase());
candidate
}