Files
OneCAD-Simulation/crates/decks/src/lsdyna/mod.rs
T
a17752a49f Lattice Boltzmann flow on the CPU: model, solver, engine (M12) (#36)
Model: Newtonian fluid materials; fluid domains (box, spacing, conditions on
the box faces, obstacle bodies with conditions on their named faces, initial
velocity, body acceleration); fluid steps with caps. Deck writers refuse
fluid steps; a fluid-only model needs no mesh in model.check; one step
selector (Model::select_step) for every solver.

New L2 crate modelcraft-lbm: D3Q19 two-relaxation-time collision with Guo
forcing; halfway bounce-back walls (moving walls after Ladd), slip faces,
periodic pairs; Zou–He velocity and pressure faces with the D3Q19
transverse correction; obstacles voxelised from the face-tagged boundary
surface with exact predicates and symbolic perturbation; momentum-exchange
forces per box face and per B-rep face; unit conversion, automatic time step
and Mach/tau limits; bounded runs with frames and force histories.

Engine: model.fluid_domain.create/edit/delete, model.fluid_domain.boundary,
solve.fluid (obstacles from the open design or imported surfaces, lowerable
caps), solve.probe for fluid fields and wall force histories. Fluid Domain,
Inlet & Outlet and Lattice Boltzmann toolbar entries are ready.

Verification (analytic): plane Poiseuille exact with Λ = 3/16 and second
order (2.000) with Λ = 1/4; Couette exact with the wall shear; Taylor–Green
decay second order (2.01, 2.00); staircase pipe; Stokes drag of a sphere
array within 1.7% of Hasimoto at R = 6; Zou–He channel; mass conserved to
round-off; bit-for-bit reproducibility. Benchmark: Schäfer–Turek 2D-1.


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>
2026-10-13 20:03:12 -04:00

738 lines
33 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.
//! LS-DYNA keyword decks for implicit linear static and eigenvalue analyses.
//!
//! The deck is written from the public LS-DYNA keyword manual (R14), in our own words and with
//! our own examples. What it writes:
//! - `*KEYWORD long=y`: every field 20 columns wide (see [`card`] for why), then `*NODE` for the
//! mesh and the reference points;
//! - one `*PART` per section and element kind, with `*SECTION_SOLID`, `*SECTION_SHELL` or
//! `*SECTION_BEAM` (rods as trusses) and the element formulation mapped from the section's
//! ([`elements`]); `*HOURGLASS` where a reduced-integration formulation needs it;
//! - `*MAT_ELASTIC` for isotropic and `*MAT_ORTHOTROPIC_ELASTIC` (global material axes) for
//! orthotropic materials;
//! - `*ELEMENT_SOLID` (tetrahedra and wedges as degenerate hexahedra, 10-node tetrahedra),
//! `*ELEMENT_SOLID_H20`, `*ELEMENT_SHELL` and `*ELEMENT_BEAM`;
//! - `*SET_NODE_LIST` and `*SET_SEGMENT` for the sets boundary conditions, loads and couplings
//! use (with the set's name as title);
//! - couplings: kinematic as `*CONSTRAINED_NODAL_RIGID_BODY` (the reference point first in its
//! node set), distributing as `*CONSTRAINED_INTERPOLATION` (the reference point dependent on
//! the set's translations, equal weights);
//! - per step: `*CONTROL_IMPLICIT_GENERAL`, `*CONTROL_IMPLICIT_SOLUTION` (linear) or
//! `*CONTROL_IMPLICIT_EIGENVALUE`, `*CONTROL_TERMINATION` (a static step is one implicit step
//! from time 0 to 1 with every load ramped by one curve, `*DEFINE_CURVE`); 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
//! 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`.
//!
//! Several steps are independent analyses in the model (nothing carries over), which is what
//! LS-DYNA's `*CASE` runs: each step is a case between `*CASE_BEGIN_n` and `*CASE_END_n`, run in
//! order when LS-DYNA is started with `CASE` on its command line. A single step is written
//! without cases.
//!
//! What LS-DYNA cannot take is an [`Error::Unsupported`] naming the item: beam sections (not yet
//! written), body forces per volume, kinematic couplings of some degrees of freedom only, boundary
//! conditions on a coupled reference point, gravity on different sets, hybrid formulations other
//! than on tet4, reduced integration of quadratic elements, materials without a density, more
//! than 10 shell integration points.
mod card;
mod elements;
use std::collections::{BTreeMap, BTreeSet};
use modelcraft_model::{
BoundaryCondition, CouplingKind, Diagnostic, Dofs, Elastic, Element, ElementKind, Load, Model, OutputVariable, Procedure, Resolved, Section,
SectionKind, Step, Target,
};
use crate::{Error, Result};
use card::{Deck, Field};
use card::{WIDTH, ints};
use elements::{Hourglass, Keyword};
/// The format's name in [`Error::Unsupported`].
pub(crate) const FORMAT: &str = "LS-DYNA";
/// The positions of an element's model-order nodes in the order LS-DYNA takes them (repeats
/// for degenerate forms), for readers and checks of written decks.
pub fn node_order(kind: ElementKind) -> &'static [usize] {
elements::order(kind)
}
/// A written deck.
#[derive(Debug, Clone, PartialEq)]
pub struct Written {
/// The deck, ASCII.
pub text: String,
/// Reals that did not fit a 20-column field exactly and were rounded (to at least 13
/// significant digits).
pub rounded_values: usize,
/// Number of `*CASE` analyses (0 when the model has one step and no cases are written).
pub cases: usize,
/// The model check's warnings (a written model has no errors).
pub warnings: Vec<Diagnostic>,
}
/// Largest deck written, in bytes (a deck is built in memory).
pub const MAX_DECK_BYTES: usize = 1 << 31;
/// Most shell integration points LS-DYNA's Gauss rule takes.
const MAX_SHELL_POINTS: u32 = 10;
/// A resolved model that breaks its own invariants (unreachable after `resolve`).
fn broken(what: String) -> Error {
Error::Model(modelcraft_model::Error::Invalid(format!("inconsistent resolved model: {what}")))
}
/// One `*PART`: a section's elements of one kind.
struct Part<'a> {
id: u32,
name: &'a str,
section: &'a Section,
kind: ElementKind,
elform: i32,
material: u32,
hourglass: Option<u32>,
elements: Vec<(u32, &'a Element)>,
}
/// Every id the deck uses, decided before anything is written.
struct Plan<'a> {
parts: Vec<Part<'a>>,
/// Material name → MID, for the materials sections use.
materials: BTreeMap<&'a str, u32>,
hourglasses: BTreeMap<Hourglass, u32>,
/// Set name → SID (node and segment sets have separate id spaces; both use this number).
sets: BTreeMap<&'a str, u32>,
/// Sets written as node sets, and as segment sets.
node_sets: BTreeSet<&'a str>,
segment_sets: BTreeSet<&'a str>,
/// Forces and moments written on a node set: load → (value per node, SID).
uniform: BTreeMap<&'a str, ([f64; 3], u32)>,
/// The part set gravity acts on, when it acts on a set.
gravity_parts: Option<Vec<u32>>,
}
/// Write the model as an LS-DYNA keyword deck.
pub fn write(model: &Model) -> Result<Written> {
let r = model.resolve()?;
write_with(model, &r)
}
/// The writer of [`crate::FORMATS`]: the deck's text from a resolved model.
pub(crate) fn write_resolved(model: &Model, r: &Resolved) -> Result<String> {
write_with(model, r).map(|w| w.text)
}
fn write_with(model: &Model, r: &Resolved) -> Result<Written> {
crate::refuse_fluid(FORMAT, model)?;
let estimate = (model.nodes().len() + model.reference_points().len()) * 130 + model.elements().len() * 260;
if estimate > MAX_DECK_BYTES {
return Err(Error::Limit(format!("the deck would be about {estimate} bytes; at most {MAX_DECK_BYTES} are written")));
}
let plan = plan(model, r)?;
let mut d = Deck { text: String::with_capacity(estimate), rounded: 0 };
let steps = model.steps();
let cases = if steps.len() > 1 { steps.len() } else { 0 };
d.line("*KEYWORD long=y");
d.comment("Written by ModelCraft: implicit linear analysis, long format (20-column fields).");
d.comment(&format!("Units: {} (consistent; nothing is converted).", model.units().label()));
if cases > 0 {
d.comment(&format!("{cases} steps are written as *CASE analyses: run LS-DYNA with CASE on its command line."));
}
nodes(&mut d, model);
materials(&mut d, model, &plan)?;
parts(&mut d, &plan)?;
element_cards(&mut d, &plan)?;
sets(&mut d, model, r, &plan)?;
couplings(&mut d, model, r, &plan)?;
if let Some(parts) = &plan.gravity_parts {
d.line("*SET_PART_LIST");
d.header(&["sid"]);
d.card([Field::from(1u32)]);
d.list(parts);
d.line("*LOAD_BODY_PARTS");
d.header(&["psid"]);
d.card([Field::from(1u32)]);
}
let mut constraint_id = 0u32;
for (i, step) in steps.iter().enumerate() {
let n = u32::try_from(i + 1).map_err(|_| Error::Limit("too many steps".into()))?;
if cases > 0 {
d.line(&format!("*CASE_BEGIN_{n}"));
}
write_step(&mut d, model, r, &plan, step, n, &mut constraint_id)?;
if cases > 0 {
d.line(&format!("*CASE_END_{n}"));
}
}
d.line("*END");
if d.text.len() > MAX_DECK_BYTES {
return Err(Error::Limit(format!("the deck is {} bytes; at most {MAX_DECK_BYTES} are written", d.text.len())));
}
Ok(Written { text: d.text, rounded_values: d.rounded, cases, warnings: r.warnings.clone() })
}
fn plan<'a>(model: &'a Model, r: &Resolved) -> Result<Plan<'a>> {
let used: BTreeSet<&str> = model.sections().values().map(|s| s.material.as_str()).collect();
let materials: BTreeMap<&str, u32> = used.into_iter().enumerate().map(|(i, m)| Ok((m, id(i)?))).collect::<Result<_>>()?;
// Everything LS-DYNA cannot express, reported at once.
let mut refused = BTreeSet::new();
let mut parts = Vec::new();
let mut hourglasses: BTreeMap<Hourglass, u32> = BTreeMap::new();
for (name, sec) in model.sections() {
let mut by_kind: BTreeMap<ElementKind, Vec<(u32, &Element)>> = BTreeMap::new();
for e in r.sets.get(&sec.set).map(|s| s.elements.as_slice()).unwrap_or_default() {
let el = model.element(*e).ok_or_else(|| broken(format!("element {e} of section `{name}`")))?;
by_kind.entry(el.kind).or_default().push((*e, el));
}
if let SectionKind::Shell { points, .. } = sec.kind
&& points > MAX_SHELL_POINTS
{
refused.insert(format!(
"section `{name}`: {points} integration points through the thickness (LS-DYNA's Gauss rule takes at most {MAX_SHELL_POINTS})"
));
}
for (kind, list) in by_kind {
let f = match elements::elform(kind, &sec.kind, sec.formulation, name) {
Ok(f) => f,
Err(why) => {
refused.insert(why);
continue;
}
};
let next = id(hourglasses.len())?;
let hourglass = f.hourglass.map(|h| *hourglasses.entry(h).or_insert(next));
parts.push(Part {
id: id(parts.len())?,
name,
section: sec,
kind,
elform: f.elform,
material: materials.get(sec.material.as_str()).copied().unwrap_or_default(),
hourglass,
elements: list,
});
}
}
let sets: BTreeMap<&str, u32> = model.sets().keys().enumerate().map(|(i, n)| Ok((n.as_str(), id(i)?))).collect::<Result<_>>()?;
// Which sets are written, and what the steps need that LS-DYNA may not express.
let coupled: BTreeSet<&str> = model.couplings().values().map(|c| c.reference_point.as_str()).collect();
let mut node_sets = BTreeSet::new();
let mut segment_sets = BTreeSet::new();
let mut uniform = BTreeMap::new();
for (name, c) in model.couplings() {
if c.kind == CouplingKind::Kinematic && c.dofs != Dofs::all() {
refused.insert(format!(
"coupling `{name}`: a kinematic coupling of some degrees of freedom only (LS-DYNA's nodal rigid bodies release degrees of freedom reliably only for two nodes)"
));
}
if c.kind == CouplingKind::Distributing {
node_sets.insert(c.set.as_str());
}
}
let mut gravity_sets: BTreeSet<Option<&str>> = BTreeSet::new();
for step in model.steps() {
if matches!(step.procedure, Procedure::ExplicitDynamic(_)) {
refused.insert(format!("step `{}`: explicit dynamic steps (this writer writes implicit analyses so far)", step.name));
}
let is_static = matches!(step.procedure, Procedure::Static {});
for b in &step.boundary_conditions {
let Some(bc) = model.boundary_conditions().get(b) else { continue };
match bc.target() {
Target::Set(s) => {
node_sets.insert(s.as_str());
}
Target::ReferencePoint(p) if coupled.contains(p.as_str()) => {
refused.insert(format!(
"boundary condition `{b}`: reference point `{p}` is coupled, and LS-DYNA takes no constraint on a node of a nodal rigid body or on an interpolation's dependent node; constrain the coupled set instead"
));
}
Target::ReferencePoint(_) => {}
}
}
if !is_static {
continue;
}
for l in &step.loads {
let Some(load) = model.loads().get(l) else { continue };
match load {
Load::Force { target, .. } | Load::Moment { target, .. } => {
if let Target::Set(s) = target
&& let Some(v) = uniform_on_set(r, l, s)
&& let Some(sid) = sets.get(s.as_str())
{
node_sets.insert(s.as_str());
uniform.insert(l.as_str(), (v, *sid));
}
}
Load::Traction { .. } => {}
Load::Pressure { set, .. } => {
segment_sets.insert(set.as_str());
}
Load::Gravity { set, .. } => {
gravity_sets.insert(set.as_deref());
}
Load::BodyForce { .. } => {
refused.insert(format!("load `{l}`: a body force per unit volume (LS-DYNA's body loads are accelerations; use gravity)"));
}
}
}
}
let gravity_parts = match gravity_sets.into_iter().collect::<Vec<_>>().as_slice() {
[] | [None] => None,
[Some(set)] => parts_of_set(r, &parts, set).map_err(|why| refused.insert(why)).ok(),
_ => {
refused.insert("gravity on different sets (LS-DYNA applies every body load to one part subset per deck)".into());
None
}
};
for m in materials.keys() {
if model.material(m).is_some_and(|m| m.density.is_none()) {
refused.insert(format!("material `{m}` has no density, and LS-DYNA requires a mass density on every material"));
}
}
crate::unsupported(FORMAT, refused)?;
Ok(Plan { parts, materials, hourglasses, sets, node_sets, segment_sets, uniform, gravity_parts })
}
/// A 1-based id from a 0-based index.
fn id(i: usize) -> Result<u32> {
u32::try_from(i + 1).map_err(|_| Error::Limit("more than 4294967295 items of one kind".into()))
}
/// The parts that make up a set's elements exactly, or why not.
fn parts_of_set(r: &Resolved, parts: &[Part], set: &str) -> std::result::Result<Vec<u32>, String> {
let elements: BTreeSet<u32> = r.sets.get(set).map(|s| s.elements.iter().copied().collect()).unwrap_or_default();
let mut ids = Vec::new();
let mut covered = 0;
for p in parts {
let inside = p.elements.iter().filter(|(e, _)| elements.contains(e)).count();
if inside == p.elements.len() {
ids.push(p.id);
covered += inside;
} else if inside > 0 {
return Err(format!(
"gravity on set `{set}`, which holds part of section `{}`'s elements (LS-DYNA applies body loads to whole parts)",
p.name
));
}
}
if covered != elements.len() || ids.is_empty() {
return Err(format!("gravity on set `{set}`, whose elements are not whole sections"));
}
Ok(ids)
}
/// The value every node of a force or moment gets, when it is one value on exactly the set's
/// nodes (then the load is written on the node set).
fn uniform_on_set(r: &Resolved, load: &str, set: &str) -> Option<[f64; 3]> {
let values = r.nodal_loads.get(load)?;
let first = values.first()?.1;
let nodes = &r.sets.get(set)?.nodes;
(values.len() == nodes.len() && values.iter().zip(nodes).all(|((n, v), m)| n == m && *v == first)).then_some(first)
}
fn nodes(d: &mut Deck, model: &Model) {
// The mesh nodes are sorted; the few reference points are merged in by label.
let mut points: Vec<(u32, [f64; 3])> = model.reference_points().values().map(|p| (p.label, p.xyz)).collect();
points.sort_unstable_by_key(|p| p.0);
let mut points = points.into_iter().peekable();
d.line("*NODE");
d.header(&["nid", "x", "y", "z"]);
let card = |d: &mut Deck, label: u32, [x, y, z]: [f64; 3]| d.card([Field::from(label), Field::from(x), Field::from(y), Field::from(z)]);
for (label, n) in model.nodes() {
while let Some((l, xyz)) = points.next_if(|p| p.0 < *label) {
card(d, l, xyz);
}
card(d, *label, n.xyz);
}
for (l, xyz) in points {
card(d, l, xyz);
}
}
fn materials(d: &mut Deck, model: &Model, plan: &Plan) -> Result<()> {
for (name, &mid) in &plan.materials {
let m = model.material(name).ok_or_else(|| broken(format!("material `{name}`")))?;
let rho = m.density.unwrap_or_default();
match m.elastic {
Some(Elastic::Isotropic { young, poisson }) => {
d.line("*MAT_ELASTIC_TITLE");
d.line(name);
d.header(&["mid", "ro", "e", "pr"]);
d.card([Field::from(mid), Field::from(rho), Field::from(young), Field::from(poisson)]);
}
Some(Elastic::EngineeringConstants { young: [e1, e2, e3], poisson: [n12, n13, n23], shear: [g12, g13, g23] }) => {
// LS-DYNA takes the minor ratios: ν_ba = ν_ab·E_b/E_a.
d.line("*MAT_ORTHOTROPIC_ELASTIC_TITLE");
d.line(name);
d.header(&["mid", "ro", "ea", "eb", "ec", "prba", "prca", "prcb"]);
let mut card = vec![Field::from(mid)];
card.extend([rho, e1, e2, e3, n12 * e2 / e1, n13 * e3 / e1, n23 * e3 / e2].map(Field::from));
d.card(card);
d.header(&["gab", "gbc", "gca", "aopt"]);
d.card([g12, g23, g13, 2.0].map(Field::from));
d.header(&["xp", "yp", "zp", "a1", "a2", "a3"]);
d.card([Field::Blank, Field::Blank, Field::Blank, Field::from(1.0), Field::from(0.0), Field::from(0.0)]);
d.header(&["v1", "v2", "v3", "d1", "d2", "d3"]);
d.card([Field::Blank, Field::Blank, Field::Blank, Field::from(0.0), Field::from(1.0), Field::from(0.0)]);
}
None => return Err(broken(format!("material `{name}` has no elastic constants"))),
}
}
Ok(())
}
fn parts(d: &mut Deck, plan: &Plan) -> Result<()> {
for p in &plan.parts {
match p.section.kind {
SectionKind::Solid {} => {
d.line("*SECTION_SOLID");
d.header(&["secid", "elform"]);
d.card([Field::from(p.id), Field::from(p.elform)]);
}
SectionKind::Shell { thickness, offset, points } => {
d.line("*SECTION_SHELL");
d.header(&["secid", "elform", "shrf", "nip"]);
d.card([Field::from(p.id), Field::from(p.elform), Field::from(5.0 / 6.0), Field::from(points)]);
d.header(&["t1", "t2", "t3", "t4", "nloc"]);
// The model's offset is the reference (node) surface's distance from the
// mid-surface along the normal, as a fraction of the thickness; NLOC puts the
// mid-surface at −NLOC·t/2 from the nodes.
let nloc = if offset == 0.0 { Field::Blank } else { Field::from(2.0 * offset) };
d.card([Field::from(thickness), Field::Blank, Field::Blank, Field::Blank, nloc]);
}
SectionKind::Rod { area } => {
d.line("*SECTION_BEAM");
d.header(&["secid", "elform"]);
d.card([Field::from(p.id), Field::from(p.elform)]);
d.header(&["a"]);
d.card([Field::from(area)]);
}
SectionKind::Beam { .. } => return Err(broken(format!("section `{}`: beams have no LS-DYNA formulation", p.name))),
}
}
for (h, hgid) in &plan.hourglasses {
d.line("*HOURGLASS");
d.header(&["hgid", "ihq", "qm"]);
match h {
Hourglass::Solid => d.card([Field::from(*hgid), Field::from(6), Field::from(1.0)]),
Hourglass::Shell => d.card([Field::from(*hgid), Field::from(4)]),
}
}
let several: BTreeMap<&str, usize> = plan.parts.iter().fold(BTreeMap::new(), |mut m, p| {
*m.entry(p.name).or_default() += 1;
m
});
for p in &plan.parts {
d.line("*PART");
if several.get(p.name).copied().unwrap_or_default() > 1 {
d.line(&format!("{} {}", p.name, p.kind.name()));
} else {
d.line(p.name);
}
d.header(&["pid", "secid", "mid", "eosid", "hgid"]);
let hg = p.hourglass.map_or(Field::Blank, Field::from);
d.card([Field::from(p.id), Field::from(p.id), Field::from(p.material), Field::Blank, hg]);
}
Ok(())
}
fn element_cards(d: &mut Deck, plan: &Plan) -> Result<()> {
let mut by_keyword: BTreeMap<Keyword, Vec<(u32, u32, &Element)>> = BTreeMap::new();
for p in &plan.parts {
by_keyword.entry(Keyword::of(p.kind)).or_default().extend(p.elements.iter().map(|(label, el)| (*label, p.id, *el)));
}
for (keyword, mut elements) in by_keyword {
elements.sort_unstable_by_key(|e| e.0);
d.line(keyword.name());
match keyword {
Keyword::Shell | Keyword::Beam => d.header(&["eid", "pid", "n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8"]),
Keyword::Solid | Keyword::SolidH20 => {
d.header(&["eid", "pid"]);
d.header(&["n1", "n2", "n3", "n4", "n5", "n6", "n7", "n8", "n9", "n10"]);
}
}
for (label, pid, el) in elements {
let nodes = elements::connectivity(el.kind, &el.nodes).ok_or_else(|| broken(format!("element {label} has {} nodes", el.nodes.len())))?;
match keyword {
Keyword::Shell | Keyword::Beam => d.card([Field::from(label), Field::from(pid)].into_iter().chain(ints(&nodes))),
Keyword::Solid | Keyword::SolidH20 => {
d.card([Field::from(label), Field::from(pid)]);
for chunk in nodes.chunks(10) {
d.card(ints(chunk));
}
}
}
}
}
Ok(())
}
fn sets(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan) -> Result<()> {
for name in &plan.node_sets {
let (Some(sid), Some(set)) = (plan.sets.get(name), r.sets.get(*name)) else { continue };
d.line("*SET_NODE_LIST_TITLE");
d.line(name);
d.header(&["sid"]);
d.card([Field::from(*sid)]);
d.list(&set.nodes);
}
for name in &plan.segment_sets {
let (Some(sid), Some(set)) = (plan.sets.get(name), r.sets.get(*name)) else { continue };
d.line("*SET_SEGMENT_TITLE");
d.line(name);
d.header(&["sid"]);
d.card([Field::from(*sid)]);
d.header(&["n1", "n2", "n3", "n4"]);
for f in &set.faces {
let nodes = model.face_nodes(*f).ok_or_else(|| broken(format!("element {} face {}", f.element, f.face)))?;
// Corner nodes only: LS-DYNA recognises the quadratic face from its corners and
// spreads the pressure over the mid-side nodes too.
let corners = match nodes.len() {
3 | 6 => nodes.get(..3).zip(nodes.get(2..3)),
_ => nodes.get(..4).zip(Some(&[][..])),
};
let (first, repeat) = corners.ok_or_else(|| broken(format!("element {} face {} has {} nodes", f.element, f.face, nodes.len())))?;
d.card(ints(first).chain(ints(repeat)));
}
}
Ok(())
}
fn couplings(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan) -> Result<()> {
let mut next_set = id(model.sets().len())?;
let mut next_part = id(plan.parts.len())?;
let mut next_interpolation = 1u32;
for (name, c) in model.couplings() {
let rp =
r.reference_point_nodes.get(&c.reference_point).copied().ok_or_else(|| broken(format!("coupling `{name}`: no reference point node")))?;
let nodes = r.sets.get(&c.set).map(|s| s.nodes.as_slice()).unwrap_or_default();
match c.kind {
CouplingKind::Kinematic => {
// The reference point leads: it is the first node of the rigid body's set.
d.line("*SET_NODE_LIST_TITLE");
d.line(&format!("{name} (rigid body)"));
d.header(&["sid"]);
d.card([Field::from(next_set)]);
let members: Vec<u32> = std::iter::once(rp).chain(nodes.iter().copied().filter(|n| *n != rp)).collect();
d.list(&members);
d.line("*CONSTRAINED_NODAL_RIGID_BODY_TITLE");
d.line(name);
d.header(&["pid", "cid", "nsid"]);
d.card([Field::from(next_part), Field::Blank, Field::from(next_set)]);
next_set = next_set.checked_add(1).ok_or_else(|| Error::Limit("too many node sets".into()))?;
next_part = next_part.checked_add(1).ok_or_else(|| Error::Limit("too many parts".into()))?;
}
CouplingKind::Distributing => {
let sid = plan.sets.get(c.set.as_str()).copied().ok_or_else(|| broken(format!("coupling `{name}`: set `{}`", c.set)))?;
let ddof: i64 = c.dofs.list().iter().fold(0, |acc, d| acc * 10 + i64::from(*d));
d.comment(&format!("coupling `{name}`"));
d.line("*CONSTRAINED_INTERPOLATION");
d.header(&["icid", "dnid", "ddof", "cidd", "ityp", "idnsw", "fgm"]);
// FGM = 1: the dependent node is not part of the mesh.
d.card([
Field::from(next_interpolation),
Field::from(rp),
Field::Int(ddof),
Field::Blank,
Field::from(1),
Field::Blank,
Field::from(1),
]);
d.header(&["inid", "idof"]);
d.card([Field::from(sid), Field::from(123)]);
next_interpolation += 1;
}
}
}
Ok(())
}
/// LS-DYNA's degree-of-freedom code for prescribed motion and nodal loads: 1–3 translations,
/// 5–7 rotations.
fn dof_code(dof: u8) -> i32 {
if dof <= 3 { i32::from(dof) } else { i32::from(dof) + 1 }
}
fn write_step(d: &mut Deck, model: &Model, r: &Resolved, plan: &Plan, step: &Step, n: u32, constraint_id: &mut u32) -> Result<()> {
d.line("*TITLE");
d.line(&step.name);
let is_static = matches!(step.procedure, Procedure::Static {});
d.line("*CONTROL_IMPLICIT_GENERAL");
d.header(&["imflag", "dt0"]);
d.card([Field::from(1), Field::from(1.0)]);
match step.procedure {
Procedure::Fluid { .. } => return Err(crate::fluid_step(FORMAT, &step.name)),
Procedure::Static {} => {
d.line("*CONTROL_IMPLICIT_SOLUTION");
d.header(&["nsolvr"]);
d.card([Field::from(1)]);
}
Procedure::Frequency { modes, min_hz, max_hz } => {
d.line("*CONTROL_IMPLICIT_EIGENVALUE");
d.header(&["neig", "center", "lflag", "lftend", "rflag", "rhtend"]);
let bound = |b: Option<f64>| match b {
Some(v) => [Field::from(1), Field::from(v)],
None => [Field::Blank, Field::Blank],
};
let mut card = vec![Field::from(modes), Field::Blank];
card.extend(bound(min_hz));
card.extend(bound(max_hz));
d.card(card);
if step.output.contains(&OutputVariable::Stress) || step.output.contains(&OutputVariable::Strain) {
d.header(&["isolid", "ibeam", "ishell", "itshell", "mstres"]);
d.card([Field::Blank, Field::Blank, Field::Blank, Field::Blank, Field::from(1)]);
}
}
Procedure::ExplicitDynamic(_) => {
return Err(Error::Unsupported { format: FORMAT, what: format!("step `{}`: explicit dynamic steps", step.name) });
}
}
d.line("*CONTROL_TERMINATION");
d.header(&["endtim"]);
d.card([Field::from(1.0)]);
// Every load and prescribed motion of a static step follows one ramp from 0 to 1.
let prescribes = step
.boundary_conditions
.iter()
.filter_map(|b| model.boundary_conditions().get(b))
.any(|b| (1..=6).any(|dof| b.value(dof).is_some_and(|v| v != 0.0)));
let loads: Vec<(&String, &Load)> = step.loads.iter().filter_map(|l| model.loads().get_key_value(l)).collect();
if is_static && (prescribes || !loads.is_empty()) {
d.line("*DEFINE_CURVE_TITLE");
d.line(&format!("{} ramp", step.name));
d.header(&["lcid"]);
d.card([Field::from(n)]);
d.header(&["a1", "o1"]);
d.card([Field::from(0.0), Field::from(0.0)]);
d.card([Field::from(1.0), Field::from(1.0)]);
}
for b in &step.boundary_conditions {
let Some(bc) = model.boundary_conditions().get(b) else { continue };
boundary_condition(d, r, plan, b, bc, is_static, n, constraint_id)?;
}
if is_static {
let mut gravity = [0.0f64; 3];
let mut pointwise = Vec::new();
for (name, load) in &loads {
match load {
Load::Force { .. } | Load::Moment { .. } if plan.uniform.contains_key(name.as_str()) => {
on_node_set(d, plan, name, matches!(load, Load::Moment { .. }), n);
}
Load::Force { .. } | Load::Moment { .. } | Load::Traction { .. } => pointwise.push(*name),
Load::Pressure { set, magnitude } => {
let sid = plan.sets.get(set.as_str()).copied().unwrap_or_default();
d.comment(&format!("load `{name}`"));
d.line("*LOAD_SEGMENT_SET");
d.header(&["ssid", "lcid", "sf"]);
d.card([Field::from(sid), Field::from(n), Field::from(*magnitude)]);
}
Load::Gravity { acceleration, .. } => {
for (g, a) in gravity.iter_mut().zip(acceleration) {
*g += a;
}
}
Load::BodyForce { .. } => return Err(broken(format!("load `{name}`: a body force"))),
}
}
// The other concentrated loads node by node, summed where they share a node.
let point = crate::concentrated_loads(model, r, pointwise);
if !point.is_empty() {
d.line("*LOAD_NODE_POINT");
d.header(&["nid", "dof", "lcid", "sf"]);
for ((node, dof), v) in point {
d.card([Field::from(node), Field::from(dof_code(dof)), Field::from(n), Field::from(v)]);
}
}
// A base acceleration loads the model against its direction, so gravity g is written
// with scale factor −g.
for (axis, g) in ["X", "Y", "Z"].iter().zip(gravity) {
if g != 0.0 {
d.line(&format!("*LOAD_BODY_{axis}"));
d.header(&["lcid", "sf"]);
d.card([Field::from(n), Field::from(-g)]);
}
}
d.line("*DATABASE_BINARY_D3PLOT");
d.header(&["dt"]);
d.card([Field::from(1.0)]);
if step.output.contains(&OutputVariable::ReactionForce) {
d.line("*DATABASE_SPCFORC");
d.header(&["dt"]);
d.card([Field::from(1.0)]);
}
if step.output.contains(&OutputVariable::Strain) {
d.line("*DATABASE_EXTENT_BINARY");
d.header(&["neiph", "neips", "maxint", "strflg"]);
d.card([Field::Blank, Field::Blank, Field::Blank, Field::from(1)]);
}
} else if !loads.is_empty() {
d.comment("Loads have no effect on natural frequencies and are not written in this step.");
}
Ok(())
}
fn boundary_condition(
d: &mut Deck,
r: &Resolved,
plan: &Plan,
name: &str,
bc: &BoundaryCondition,
is_static: bool,
lcid: u32,
constraint_id: &mut u32,
) -> Result<()> {
let (entity, suffix) = match bc.target() {
Target::Set(s) => (plan.sets.get(s.as_str()).copied().ok_or_else(|| broken(format!("boundary condition `{name}`: set `{s}`")))?, "SET"),
Target::ReferencePoint(p) => {
(r.reference_point_nodes.get(p).copied().ok_or_else(|| broken(format!("boundary condition `{name}`: reference point `{p}`")))?, "NODE")
}
};
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();
if !held.is_empty() {
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::Blank].into_iter().chain((1..=6u8).map(|dof| Field::from(i32::from(held.contains(&dof))))));
}
if is_static {
for dof in dofs.list() {
let Some(v) = bc.value(dof).filter(|v| *v != 0.0) else { continue };
d.line(&format!("*BOUNDARY_PRESCRIBED_MOTION_{suffix}_ID"));
id_card(d, constraint_id, name);
d.header(&["nsid", "dof", "vad", "lcid", "sf"]);
d.card([Field::from(entity), Field::from(dof_code(dof)), Field::from(2), Field::from(lcid), Field::from(v)]);
}
}
Ok(())
}
/// The ID card of an `_ID` keyword: the next id and a heading.
fn id_card(d: &mut Deck, id: &mut u32, heading: &str) {
*id = id.saturating_add(1);
d.line(&format!("{id:>WIDTH$}{heading}"));
}
/// A force or moment with one value on every node of its set ([`Plan::uniform`]), on the node set.
fn on_node_set(d: &mut Deck, plan: &Plan, name: &str, moment: bool, lcid: u32) {
let Some(&(v, sid)) = plan.uniform.get(name) else { return };
d.comment(&format!("load `{name}`"));
d.line("*LOAD_NODE_SET");
d.header(&["nsid", "dof", "lcid", "sf"]);
let first = if moment { 4 } else { 1 };
for (dof, c) in (first..).zip(v) {
if c != 0.0 {
d.card([Field::from(sid), Field::from(dof_code(dof)), Field::from(lcid), Field::from(c)]);
}
}
}