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

+103 -6
View File
@@ -148,7 +148,7 @@ use std::fmt::Write as _;
use fieldcraft_model::{
BeamProfile, BoundaryCondition, ContactSurfaces, CouplingKind, Elastic, Element, ElementKind, Hardening, Hyperelastic, InitialPenetration,
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Symmetry, Target,
Integration, Load, Model, OutputVariable, Plastic, Procedure, Resolved, Section, SectionKind, Step, Target,
};
use crate::{Error, Result, unsupported};
@@ -193,6 +193,7 @@ pub fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
d.couplings(model, r);
d.contacts(model, r)?;
d.ties(model)?;
d.equations(model, r)?;
d.steps(model, r)?;
Ok(d.out)
}
@@ -370,9 +371,36 @@ fn check_expressible(model: &Model) -> Result<()> {
bad.insert(format!("coupling `{name}`: an Abaqus distributing coupling always couples translations 1-3"));
}
}
for name in inclined_symmetries(model) {
if !model.steps().iter().all(|s| s.boundary_conditions.contains(name)) {
bad.insert(format!(
"boundary condition `{name}`: a symmetry plane not normal to X, Y or Z active in some steps only (it is written as `*EQUATION`, which is model data and holds in every step)"
));
}
}
unsupported(FORMAT, bad)
}
/// The symmetry conditions on inclined planes some step uses: linear equations rather than held
/// degrees of freedom.
fn inclined_symmetries(model: &Model) -> BTreeSet<&String> {
model
.boundary_conditions()
.iter()
.filter(|(n, bc)| {
matches!(bc, BoundaryCondition::Symmetry { plane, .. } if plane.axis().is_none()) && !model.boundary_condition_users(n).is_empty()
})
.map(|(n, _)| n)
.collect()
}
/// The boundary conditions of `names` that `*BOUNDARY` writes (inclined symmetry planes are
/// `*EQUATION`s).
fn held_conditions(model: &Model, names: &BTreeSet<String>) -> BTreeSet<String> {
let inclined = inclined_symmetries(model);
names.iter().filter(|n| !inclined.contains(n)).cloned().collect()
}
/// The property of a group of discrete elements.
enum DiscreteProperty {
Mass(f64),
@@ -1076,10 +1104,12 @@ impl Deck {
match bc {
BoundaryCondition::Fixed { .. } => self.line(&format!("{target}, ENCASTRE")),
BoundaryCondition::Symmetry { plane, .. } => {
let s = match plane {
Symmetry::X => "XSYMM",
Symmetry::Y => "YSYMM",
Symmetry::Z => "ZSYMM",
let s = match plane.axis() {
Some(0) => "XSYMM",
Some(1) => "YSYMM",
Some(2) => "ZSYMM",
// An inclined plane is an `*EQUATION` (model data), never here.
_ => continue,
};
self.line(&format!("{target}, {s}"));
}
@@ -1097,8 +1127,74 @@ impl Deck {
Ok(())
}
/// The inclined symmetry planes: one `*EQUATION` per node and held direction (n · u = 0, and
/// the rotations about the plane's axes at shell and beam nodes), whose first term Abaqus
/// eliminates; it is chosen among the degrees of freedom no boundary condition, coupling
/// or tie constrains ([`crate::symmetry_equations`]).
fn equations(&mut self, model: &Model, r: &Resolved) -> Result<()> {
let inclined = inclined_symmetries(model);
if inclined.is_empty() {
return Ok(());
}
let steps = model.steps();
// Per node and degree of freedom: the value it is held at in each step that holds it.
let mut held: BTreeMap<(u32, u8), Vec<f64>> = BTreeMap::new();
for step in steps {
for name in held_conditions(model, &step.boundary_conditions) {
let Some(bc) = model.boundary_condition(&name) else { continue };
for &node in r.target_nodes(bc.target()) {
for dof in bc.dofs().list() {
let v = if matches!(step.procedure, Procedure::Frequency { .. } | Procedure::Buckle { .. }) {
0.0
} else {
bc.value(dof).unwrap_or(0.0)
};
held.entry((node, dof)).or_default().push(v);
}
}
}
}
let mut dependent: BTreeSet<(u32, u8)> = BTreeSet::new();
for c in model.couplings().values().filter(|c| c.kind == CouplingKind::Kinematic) {
for &node in r.sets.get(&c.set).map(|s| s.nodes.as_slice()).unwrap_or_default() {
dependent.extend(c.dofs.list().into_iter().map(|d| (node, d)));
}
}
for t in model.ties().values() {
for &node in r.sets.get(&t.secondary).map(|s| s.nodes.as_slice()).unwrap_or_default() {
dependent.extend((1..=6).map(|d| (node, d)));
}
}
let state = |node: u32, dof: u8| {
if dependent.contains(&(node, dof)) {
return crate::Held::Sometimes;
}
match held.get(&(node, dof)) {
None => crate::Held::Free,
Some(v) if v.len() == steps.len() && v.iter().all(|x| *x == v[0]) => crate::Held::Always(v[0]),
Some(_) => crate::Held::Sometimes,
}
};
let mut problems = BTreeSet::new();
let equations = crate::symmetry_equations(model, r, inclined, &model.rotating_nodes(), state, &mut problems);
unsupported(FORMAT, problems)?;
self.comment("Symmetry planes not normal to X, Y or Z: per node, no motion along the normal (and no rotation about the plane's axes)");
for e in &equations {
self.line("*EQUATION");
self.line(&e.terms.len().to_string());
let mut fields = Vec::new();
for (dof, c) in &e.terms {
fields.push(format!("{}, {dof}", e.node));
fields.push(number(*c)?);
}
self.line(&fields.join(", "));
}
Ok(())
}
/// Boundary conditions of a step against the state it starts from.
fn step_boundaries(&mut self, model: &Model, r: &Resolved, base: &BTreeSet<String>, active: &BTreeSet<String>) -> Result<()> {
let (base, active) = (&held_conditions(model, base), &held_conditions(model, active));
if active == base {
self.comment("boundary conditions: as in the base state");
Ok(())
@@ -1188,7 +1284,8 @@ impl Deck {
BTreeSet::new()
} else {
let in_every_step = |n: &String| steps.iter().all(|s| s.boundary_conditions.contains(n));
model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect()
let all = model.boundary_conditions().iter().filter(|(n, bc)| holds_at_zero(bc) && in_every_step(n)).map(|(n, _)| n.clone()).collect();
held_conditions(model, &all)
};
if !initial.is_empty() {
self.comment("Initial state: boundary conditions active in every step");
+115 -1
View File
@@ -16,7 +16,7 @@ pub mod nastran;
use std::collections::{BTreeMap, BTreeSet};
use fieldcraft_model::nodal::edge_pressure;
use fieldcraft_model::{Diagnostic, Load, Model, Resolved, ResolvedDirection};
use fieldcraft_model::{BoundaryCondition, Diagnostic, Load, Model, Resolved, ResolvedDirection};
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum Error {
@@ -176,6 +176,120 @@ pub fn edge_pressure_loads<'a>(model: &Model, r: &Resolved, active: impl IntoIte
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> {
+20 -1
View File
@@ -40,7 +40,8 @@
//! LS-DYNA's defaults; a step without nonlinear geometry and hyperelastic materials in nonlinear
//! steps are refused); boundary conditions
//! as `*BOUNDARY_SPC_*_ID` (named) and `*BOUNDARY_PRESCRIBED_MOTION_*_ID` for non-zero
//! displacements; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
//! displacements, a symmetry plane not normal to X, Y or Z as `*BOUNDARY_SPC_*_ID` in a
//! `*DEFINE_COORDINATE_SYSTEM` whose x axis is the plane's normal; forces, moments and tractions as `*LOAD_NODE_SET`/`_POINT` from the
//! resolved nodal values; pressure as `*LOAD_SEGMENT_SET`; gravity as `*LOAD_BODY_X/Y/Z`
//! (with `*LOAD_BODY_PARTS` when it acts on a set); output as `*DATABASE_BINARY_D3PLOT`,
//! `*DATABASE_SPCFORC` and `*DATABASE_EXTENT_BINARY`.
@@ -1048,6 +1049,24 @@ fn boundary_condition(
(r.reference_point_nodes.get(p).copied().ok_or_else(|| broken(format!("boundary condition `{name}`: reference point `{p}`")))?, "NODE")
}
};
// A symmetry plane not normal to an axis: held in a coordinate system whose x axis is its
// normal and whose xy plane holds its first in-plane axis (translation x, rotations y and z).
if let BoundaryCondition::Symmetry { plane, .. } = bc
&& let (Some(n), Some([t1, _])) = (plane.inclined(), plane.in_plane())
{
*constraint_id = constraint_id.saturating_add(1);
let cid = *constraint_id;
d.line("*DEFINE_COORDINATE_SYSTEM");
d.header(&["cid", "xo", "yo", "zo", "xl", "yl", "zl", "cidl"]);
d.card([Field::from(cid), Field::from(0.0), Field::from(0.0), Field::from(0.0), Field::from(n[0]), Field::from(n[1]), Field::from(n[2])]);
d.header(&["xp", "yp", "zp"]);
d.card([Field::from(t1[0]), Field::from(t1[1]), Field::from(t1[2])]);
d.line(&format!("*BOUNDARY_SPC_{suffix}_ID"));
id_card(d, constraint_id, name);
d.header(&["nsid", "cid", "dofx", "dofy", "dofz", "dofrx", "dofry", "dofrz"]);
d.card([Field::from(entity), Field::from(cid)].into_iter().chain([1, 0, 0, 0, 1, 1].map(|v: i32| Field::from(v))));
return Ok(());
}
let dofs = bc.dofs();
// Held degrees of freedom (in a frequency step every constrained one is held).
let held: Vec<u8> = dofs.list().into_iter().filter(|dof| !is_static || bc.value(*dof).is_none_or(|v| v == 0.0)).collect();
+46 -2
View File
@@ -22,6 +22,9 @@
//! the basic X axis through a `CORD2R`;
//! - boundary conditions: per step, one constraint set of `SPC1` (held degrees of freedom) and
//! `SPC` (prescribed values); in a modes step every constrained degree of freedom is held;
//! symmetry about a plane not normal to X, Y or Z: one `MPC` set per step, an equation per node
//! and held direction (n · u = 0, and t · θ = 0 about the plane's two axes at shell and beam
//! grids), its dependent component one no `SPC` or rigid element constrains;
//! - forces, moments and tractions: `FORCE` and `MOMENT` at the nodal values the model resolves,
//! so every writer and the solver share one distribution; pressure: `PLOAD4` per element face;
//! gravity: `GRAV`; a step with several loads combines them with `LOAD`;
@@ -241,6 +244,8 @@ type Constraints = BTreeMap<u32, [Option<f64>; 6]>;
struct Subcase<'m> {
step: &'m Step,
spc: Option<u32>,
/// The equations of its inclined symmetry planes.
mpc: Option<u32>,
load: Option<u32>,
method: Option<u32>,
}
@@ -269,6 +274,8 @@ struct Deck<'m> {
dependent: BTreeMap<u32, (Dofs, &'m str)>,
/// Constraint sets by id.
spc_sets: Vec<(u32, Constraints)>,
/// Multipoint constraint sets (inclined symmetry planes) by id.
mpc_sets: Vec<(u32, Vec<crate::Equation>)>,
/// Load name → load set id.
load_sids: BTreeMap<&'m str, u32>,
/// Combinations: (id, load set ids).
@@ -310,7 +317,7 @@ impl<'m> Deck<'m> {
model,
r,
sol,
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, load: None, method: None }).collect(),
subcases: steps.into_iter().map(|step| Subcase { step, spc: None, mpc: None, load: None, method: None }).collect(),
problems,
next_sid: 1,
materials: BTreeMap::new(),
@@ -321,6 +328,7 @@ impl<'m> Deck<'m> {
discrete: Vec::new(),
dependent: BTreeMap::new(),
spc_sets: Vec::new(),
mpc_sets: Vec::new(),
load_sids: BTreeMap::new(),
combinations: Vec::new(),
}
@@ -587,6 +595,7 @@ impl<'m> Deck<'m> {
/// One constraint set per distinct step constraint, checked against coupled nodes.
fn plan_constraints(&mut self) -> Result<()> {
let (model, r) = (self.model, self.r);
let rotating = model.rotating_nodes();
// Conflicts, one problem per pair of definitions: the nodes they meet on.
let mut clashes: BTreeMap<(&str, &str, &str), BTreeSet<u32>> = BTreeMap::new();
let mut on_dependent: BTreeMap<(&str, &str), BTreeSet<u32>> = BTreeMap::new();
@@ -598,6 +607,10 @@ impl<'m> Deck<'m> {
for name in &step.boundary_conditions {
let Some(bc) = model.boundary_condition(name) else { continue };
let dofs = bc.dofs();
// Inclined symmetry planes hold no global degree of freedom: they are equations.
if dofs.is_empty() {
continue;
}
// In a modes step every constrained degree of freedom is held.
let values: Vec<(u8, f64)> = dofs.list().into_iter().filter_map(|d| Some((d, if modes { 0.0 } else { bc.value(d)? }))).collect();
for &node in r.target_nodes(bc.target()) {
@@ -622,6 +635,19 @@ impl<'m> Deck<'m> {
}
}
}
let dependent = &self.dependent;
let held = |node: u32, dof: u8| match (
dependent.get(&node),
set.get(&node).and_then(|v| v.get(usize::from(dof).checked_sub(1)?).copied().flatten()),
) {
(Some((d, _)), _) if d.contains(dof) => crate::Held::Sometimes,
(_, Some(v)) => crate::Held::Always(v),
_ => crate::Held::Free,
};
let equations = crate::symmetry_equations(model, r, &step.boundary_conditions, &rotating, held, &mut self.problems);
if !equations.is_empty() {
sub.mpc = Some(intern(&mut self.mpc_sets, &mut self.next_sid, equations)?);
}
if !set.is_empty() {
sub.spc = Some(intern(&mut self.spc_sets, &mut self.next_sid, set)?);
}
@@ -707,7 +733,7 @@ impl<'m> Deck<'m> {
let _ = writeln!(out, "SUBCASE {}", i + 1);
let title: String = format!(" SUBTITLE = {}", sub.step.name).chars().take(72).collect();
let _ = writeln!(out, "{title}");
for (key, sid) in [("SPC", sub.spc), ("LOAD", sub.load), ("METHOD", sub.method)] {
for (key, sid) in [("SPC", sub.spc), ("MPC", sub.mpc), ("LOAD", sub.load), ("METHOD", sub.method)] {
if let Some(sid) = sid {
let _ = writeln!(out, " {key} = {sid}");
}
@@ -715,6 +741,7 @@ impl<'m> Deck<'m> {
for v in &sub.step.output {
let request = match v {
OutputVariable::Displacement => "DISPLACEMENT",
OutputVariable::ReactionForce if sub.mpc.is_some() => "SPCFORCES = ALL\n MPCFORCES",
OutputVariable::ReactionForce => "SPCFORCES",
OutputVariable::Stress => "STRESS",
OutputVariable::Strain => "STRAIN",
@@ -892,6 +919,23 @@ impl<'m> Deck<'m> {
Card::new("SPC").int(*sid).int(node).int(component(Dofs::from_bits(mask))).real(value).write(out)?;
}
}
for (sid, equations) in &self.mpc_sets {
for sub in self.subcases.iter().filter(|s| s.mpc == Some(*sid)) {
comment(out, &format!("step {}: inclined symmetry planes, MPC {sid} (the first grid component is dependent)", sub.step.name));
}
for e in equations {
// SID, then (G, C, A) triples: two on the first line (its last field blank), two
// per continuation after a blank first field.
let mut card = Card::new("MPC").int(*sid);
for (i, (dof, c)) in e.terms.iter().enumerate() {
if i % 2 == 0 && i > 0 {
card = card.blank().blank();
}
card = card.int(e.node).int(i64::from(*dof)).real(*c);
}
card.write(out)?;
}
}
Ok(())
}