From 81fa8d722031af690b850fd9ed2f8a2d67aac072 Mon Sep 17 00:00:00 2001 From: Irakli Kourtis Date: Tue, 13 Oct 2026 17:39:45 -0400 Subject: [PATCH] LS-DYNA keyword writer (implicit static and eigenvalue, M3) (#25) * decks: LS-DYNA keyword writer (long format, implicit static and eigenvalue) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * decks: LS-DYNA golden files, Jacobian-sign node-order tests, re-reader and refusals Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * docs: LS-DYNA writer status and deck notes Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * decks: simplify the LS-DYNA writer (plan decides once, cards written in place, shared test tables) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * decks: LS-DYNA joins FORMATS (deck.write lsdyna), refusals reported together, shared concentrated loads Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm * decks: LS-DYNA on main's shared deck API (steps parameter, model helpers) Co-Authored-By: Claude Opus 5.5 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 --- ROADMAP.md | 2 +- crates/decks/src/lib.rs | 2 + crates/decks/src/lsdyna/card.rs | 238 ++++++ crates/decks/src/lsdyna/elements.rs | 191 +++++ crates/decks/src/lsdyna/mod.rs | 729 +++++++++++++++++++ crates/decks/tests/lsdyna.rs | 676 +++++++++++++++++ crates/decks/tests/lsdyna/cantilever.k | 93 +++ crates/decks/tests/lsdyna/cantilever_modes.k | 124 ++++ crates/decks/tests/lsdyna/plate.k | 94 +++ crates/decks/tests/lsdyna/solids.k | 162 +++++ crates/engine/src/cmd/deck.rs | 28 +- crates/engine/src/lib.rs | 3 + docs/parity.md | 14 +- 13 files changed, 2353 insertions(+), 3 deletions(-) create mode 100644 crates/decks/src/lsdyna/card.rs create mode 100644 crates/decks/src/lsdyna/elements.rs create mode 100644 crates/decks/src/lsdyna/mod.rs create mode 100644 crates/decks/tests/lsdyna.rs create mode 100644 crates/decks/tests/lsdyna/cantilever.k create mode 100644 crates/decks/tests/lsdyna/cantilever_modes.k create mode 100644 crates/decks/tests/lsdyna/plate.k create mode 100644 crates/decks/tests/lsdyna/solids.k diff --git a/ROADMAP.md b/ROADMAP.md index 74c1dfb..690cc53 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -21,7 +21,7 @@ hex meshes today and it gives the best elements where stresses are read. | M0 Foundation | Workspace, quality gates, FE model, command engine with rollback and panic guard, CLI, geometry boundary on SolidCraft | done | | M1 Geometry in and surface preparation | Open `.solidcraft` designs with persistent face names, assemblies placed (done); face-tagged 3MF (done); test corpus, `cargo xtask corpus` (done); exact predicates (done); quality metrics and certified validity gate (done); weld, closed/manifold/orientation and self-intersection checks, feature edges (`modelcraft-mesh::surface`, `mesh.surface.check`, done) | in progress | | M2 Tet meshing and size field | Size field (global, per face and edge seeds, curvature and proximity; the `modelcraft_mesh::size::SizeField` trait and edge-division helpers are in); Bowyer–Watson Delaunay (exact, symbolic perturbation, verified; `modelcraft-mesh::delaunay`, done); robust CDT boundary recovery (Diazzi et al. 2023); Delaunay refinement, ODT smoothing, sliver removal; tet4 and tet10 with mid-side nodes on the B-rep; mesh verify per set | in progress | -| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit; the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | +| M3 First deck | Sets and surfaces bound to SolidCraft face, edge, vertex and body names, resolved after meshing; materials (isotropic and orthotropic elastic, density) with a unit-system label and consistency check; sections (solid, shell, beam profiles) assigned by set; element formulation per region mapped to each solver; BCs (fixed, displacement, symmetry); loads (force and moment on reference points, pressure, gravity); reference points with kinematic and distributing coupling; steps (linear static, frequency) with explicit per-step load and BC tables and output presets; `model.check` naming every missing or bad item; `model.*` and `mesh.tag` commands (model definition: done); writers for Abaqus input (done: `deck.write`), Nastran SOL 101/103 (done: `deck.write`) and LS-DYNA implicit (done); the `.modelcraft` file (text definition, binary mesh, geometry references reopened on load, journal; atomic saves, autosave and recovery; undo/redo on the journal: done) | in progress | | M4 Own solver and first results | Linear static and modal solver (tet4/10, hex8, hex20; sparse direct, Lanczos), verified against analytic solutions; deformed shape, contours, mode shapes, probes, reaction sums | planned | | M5 Structured hex | 2D quad meshing of faces (`modelcraft-mesh::quad`: transfinite mapped meshes, free all-quad meshes of polygons with holes on prescribed boundary nodes, exact planar quad validity, shared `SizeField` and even edge divisions; done for planar faces); mapped hex of six-sided regions; sweep meshing; conforming interfaces and tet–hex transition; automatic decomposition into sweepable and mappable regions, proposed as SolidCraft splits | started | | M6 Automatic hex | Octree/grid-dual all-hex for any part; hex-dominant by tet recombination; polycube for blocky parts; smoothing, untangling, boundary padding, layers through thin walls; mesh editing and orphan-mesh import | planned | diff --git a/crates/decks/src/lib.rs b/crates/decks/src/lib.rs index a8381d7..cb3ba32 100644 --- a/crates/decks/src/lib.rs +++ b/crates/decks/src/lib.rs @@ -10,6 +10,7 @@ #![forbid(unsafe_code)] pub mod abaqus; +pub mod lsdyna; pub mod nastran; use std::collections::{BTreeMap, BTreeSet}; @@ -86,6 +87,7 @@ pub fn with_steps(model: &Model, names: &[String]) -> Result { 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`. diff --git a/crates/decks/src/lsdyna/card.rs b/crates/decks/src/lsdyna/card.rs new file mode 100644 index 0000000..846ac63 --- /dev/null +++ b/crates/decks/src/lsdyna/card.rs @@ -0,0 +1,238 @@ +//! Cards in LS-DYNA's long keyword format. +//! +//! The deck is written in long format (`*KEYWORD long=y`): every numeric field is 20 columns +//! wide, on the same number of lines as the standard format. Standard format gives most reals +//! 10 columns (16 for node coordinates), which holds only about 5 to 9 significant digits; 20 +//! columns hold the shortest decimal that reads back to the same `f64` for every value with up to +//! 17 significant digits and a magnitude from 0.01 to 1e17 (a leading `0` before the point is +//! dropped when that makes it fit: `-.01234…`), and for every value with up to 13 significant +//! digits at any exponent. A value that does not fit (17 digits and a large negative exponent, +//! say) is rounded to the most significant digits that fit, never fewer than +//! 13, and counted, so the writer reports how many values did not round-trip exactly. +//! +//! Fields are right-aligned in their columns; trailing blank fields are left out, so a card ends +//! at its last given value (blank fields take the keyword's defaults). + +use std::fmt::Write as _; + +/// Columns per field in long format. +pub(crate) const WIDTH: usize = 20; + +/// One field of a card. +#[derive(Debug, Clone, PartialEq)] +pub(crate) enum Field { + Int(i64), + Real(f64), + /// Left empty: the keyword's default. + Blank, +} + +impl From for Field { + fn from(v: u32) -> Field { + Field::Int(i64::from(v)) + } +} + +impl From for Field { + fn from(v: i32) -> Field { + Field::Int(i64::from(v)) + } +} + +impl From for Field { + fn from(v: f64) -> Field { + Field::Real(v) + } +} + +/// A real as the shortest decimal that fits a field, and whether it reads back exactly. +pub(crate) fn real(v: f64) -> (String, bool) { + if v == 0.0 { + return ("0.0".into(), true); + } + let positional = with_point(format!("{v}")); + // Positional reads best for everyday magnitudes (the common case: no other form is built). + let everyday = (1e-4..1e15).contains(&v.abs()); + if everyday && positional.len() <= WIDTH { + return (positional, true); + } + // Without the leading zero (`-.0123`), which every Fortran reader takes. + let bare = (v.abs() < 1.0).then(|| positional.replacen("0.", ".", 1)); + let scientific = match format!("{v:e}").split_once('e') { + Some((m, e)) => format!("{}e{e}", with_point(m.to_string())), + None => positional.clone(), + }; + // Everyday magnitudes keep the positional digits if dropping the zero is enough; others + // take the shortest form, scientific first among equals. + let fits = |s: &String| s.len() <= WIDTH; + let chosen = if everyday { + bare.filter(fits).or_else(|| Some(scientific).filter(fits)) + } else { + [Some(scientific), Some(positional), bare].into_iter().flatten().filter(fits).min_by_key(String::len) + }; + if let Some(s) = chosen { + return (s, true); + } + // Round to the most significant digits that fit: 13 always do (sign, 13 digits, the point + // and a four-character exponent make 20), so the loop always returns. + let mut s = String::new(); + for digits in (13..=16usize).rev() { + s = trim_mantissa(&format!("{v:.*e}", digits - 1)); + if s.len() <= WIDTH { + break; + } + } + let exact = s.parse::().is_ok_and(|r| r == v); + (s, exact) +} + +/// Add `.0` to a number written without a decimal point (Fortran reads both, but an explicit +/// point keeps every real a real). +fn with_point(s: String) -> String { + if s.contains('.') { s } else { format!("{s}.0") } +} + +/// `1.2300e5` → `1.23e5` (keeping one digit after the point). +fn trim_mantissa(s: &str) -> String { + match s.split_once('e') { + Some((m, e)) => { + let m = if m.contains('.') { m.trim_end_matches('0') } else { m }; + let m = if m.ends_with('.') { format!("{m}0") } else { m.to_string() }; + format!("{m}e{e}") + } + None => s.to_string(), + } +} + +/// A keyword deck being written. +#[derive(Debug, Default)] +pub(crate) struct Deck { + pub(crate) text: String, + /// Reals that did not fit their field exactly and were rounded. + pub(crate) rounded: usize, +} + +impl Deck { + /// A line written as given: a keyword, or a title or heading card. + pub(crate) fn line(&mut self, text: &str) { + self.text.push_str(text); + self.text.push('\n'); + } + + /// A comment line (`$` in column 1). + pub(crate) fn comment(&mut self, text: &str) { + self.text.push_str("$ "); + self.line(text); + } + + /// A comment naming the fields of the cards that follow, each over its column. + pub(crate) fn header(&mut self, names: &[&str]) { + self.text.push('$'); + for (i, name) in names.iter().enumerate() { + let width = if i == 0 { WIDTH - 1 } else { WIDTH }; + let _ = write!(self.text, "{name:>width$}"); + } + self.end_card(); + } + + /// A card of fields. + pub(crate) fn card(&mut self, fields: impl IntoIterator) { + for f in fields { + match f { + Field::Int(v) => { + let _ = write!(self.text, "{v:>WIDTH$}"); + } + Field::Real(v) => { + let (s, exact) = real(v); + self.rounded += usize::from(!exact); + let _ = write!(self.text, "{s:>WIDTH$}"); + } + Field::Blank => { + let _ = write!(self.text, "{:WIDTH$}", ""); + } + } + } + self.end_card(); + } + + /// End a card: trailing blanks (left-out fields) dropped, then the line break. + fn end_card(&mut self) { + let kept = self.text.trim_end_matches(' ').len(); + self.text.truncate(kept); + self.text.push('\n'); + } + + /// Cards of up to eight integers each (set member lists). + pub(crate) fn list(&mut self, values: &[u32]) { + for chunk in values.chunks(8) { + self.card(ints(chunk)); + } + } +} + +/// Integer fields. +pub(crate) fn ints(values: &[u32]) -> impl Iterator + '_ { + values.iter().map(|v| Field::from(*v)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reals_are_the_shortest_exact_form_that_fits() { + for (v, s) in [ + (0.0, "0.0"), + (-0.0, "0.0"), + (1.0, "1.0"), + (-2.5, "-2.5"), + (210000.0, "210000.0"), + (7.85e-9, "7.85e-9"), + (1e21, "1.0e21"), + (0.1 + 0.2, "0.30000000000000004"), + (-9810.0, "-9810.0"), + (6.02214076e23, "6.02214076e23"), + (-1.5e-300, "-1.5e-300"), + ] { + let (got, exact) = real(v); + assert_eq!(got, s, "{v:e}"); + assert!(exact && got.len() <= WIDTH, "{v:e}"); + assert_eq!(got.parse::().unwrap(), if v == 0.0 { 0.0 } else { v }); + } + } + + #[test] + fn values_that_cannot_fit_are_rounded_to_at_least_13_digits() { + let mut count = 0; + let mut x = 0.123_456_789_012_345_67_f64; + for i in 0..2000 { + // Wide exponent range, full 17-digit mantissas, both signs. + let v = if i % 2 == 0 { x } else { -x } * 10f64.powi((i % 600) - 300); + let (s, exact) = real(v); + assert!(s.len() <= WIDTH, "{s}"); + let back: f64 = s.parse().unwrap(); + if exact { + assert_eq!(back, v); + } else { + count += 1; + assert!(((back - v) / v).abs() < 5e-13, "{v:e} -> {s}"); + } + x = (x * 7.000_000_000_000_1).fract() + 0.1; + } + assert!(count > 0, "the sample includes values that need rounding"); + // Model coordinates (|x| <= 1e9) with up to 15 significant digits always fit. + for v in [123_456_789.123_456_7_f64, -0.000_123_456_789_012_3, 1e-9, -987_654_321.0] { + assert!(real(v).1, "{v:e}"); + } + } + + #[test] + fn cards_are_right_aligned_and_trimmed() { + let mut d = Deck::default(); + d.card([Field::from(1u32), Field::Blank, Field::from(2.5), Field::Blank]); + assert_eq!(d.text, format!("{:>20}{:20}{:>20}\n", 1, "", "2.5")); + d.list(&[1, 2, 3, 4, 5, 6, 7, 8, 9]); + assert_eq!(d.text.lines().count(), 3); + assert_eq!(d.rounded, 0); + } +} diff --git a/crates/decks/src/lsdyna/elements.rs b/crates/decks/src/lsdyna/elements.rs new file mode 100644 index 0000000..11b10bd --- /dev/null +++ b/crates/decks/src/lsdyna/elements.rs @@ -0,0 +1,191 @@ +//! Element connectivity in LS-DYNA's node order, and the element formulation (ELFORM) for each +//! element kind and section formulation. +//! +//! Node order (from the element figures of the public keyword manual): +//! - hexahedra: nodes 1–4 on one face, 5–8 over them, as in the model; the 20-node hexahedron's +//! mid-side nodes are on edges 1-2, 2-3, 3-4, 4-1, then 5-6 … 8-5, then 1-5 … 4-8, also as in +//! the model; +//! - tetrahedra are degenerate hexahedra `n1 n2 n3 n4 n4 n4 n4 n4`; +//! - wedges are degenerate hexahedra `n1 n2 n3 n4 n5 n5 n6 n6` whose nodes 1–4 are a +//! quadrilateral face, 5 is over edge 1-2 and 6 over edge 3-4. The model's wedge (triangles +//! a1-a2-a3 and a4-a5-a6) becomes `a2 a1 a4 a5 a3 a3 a6 a6`: the side a2-a1-a4-a5, then the +//! ridge a3–a6; +//! - the 10-node tetrahedron has its mid-side nodes 5, 6, 7 on edges 1-2, 2-3, 3-1, and 8, 9, 10 +//! on edges 2-4, 3-4, 1-4 (the model puts 8, 9, 10 on 1-4, 2-4, 3-4); +//! - shells: counter-clockwise corners (the top surface), triangles as `n1 n2 n3 n3` and six-node +//! triangles as `n1 n2 n3 n3 n4 n5 n6 n6`; eight-node quadrilaterals as in the model. +//! +//! Every mapping is checked by the sign of the element Jacobian in the tests. + +use modelcraft_model::{ElementKind, Formulation, Integration, SectionKind}; + +/// How an element is written: its keyword and its node list in LS-DYNA order. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Keyword { + /// `*ELEMENT_SOLID`: up to 10 nodes on the second card. + Solid, + /// `*ELEMENT_SOLID_H20`: 20 nodes on two cards. + SolidH20, + /// `*ELEMENT_SHELL`: 4 or 8 nodes. + Shell, + /// `*ELEMENT_BEAM`: 2 nodes. + Beam, +} + +impl Keyword { + pub(crate) fn of(kind: ElementKind) -> Keyword { + match kind { + ElementKind::Hex20 => Keyword::SolidH20, + ElementKind::Tet4 | ElementKind::Tet10 | ElementKind::Wedge6 | ElementKind::Hex8 => Keyword::Solid, + ElementKind::Tri3 | ElementKind::Tri6 | ElementKind::Quad4 | ElementKind::Quad8 => Keyword::Shell, + ElementKind::Rod2 | ElementKind::Beam2 => Keyword::Beam, + } + } + + pub(crate) fn name(self) -> &'static str { + match self { + Keyword::Solid => "*ELEMENT_SOLID", + Keyword::SolidH20 => "*ELEMENT_SOLID_H20", + Keyword::Shell => "*ELEMENT_SHELL", + Keyword::Beam => "*ELEMENT_BEAM", + } + } +} + +/// Positions in the model's node list, in LS-DYNA's order (repeats for degenerate forms). +pub(crate) fn order(kind: ElementKind) -> &'static [usize] { + match kind { + ElementKind::Rod2 | ElementKind::Beam2 => &[0, 1], + ElementKind::Tri3 => &[0, 1, 2, 2], + ElementKind::Tri6 => &[0, 1, 2, 2, 3, 4, 5, 5], + ElementKind::Quad4 => &[0, 1, 2, 3], + ElementKind::Quad8 => &[0, 1, 2, 3, 4, 5, 6, 7], + ElementKind::Tet4 => &[0, 1, 2, 3, 3, 3, 3, 3], + ElementKind::Tet10 => &[0, 1, 2, 3, 4, 5, 6, 8, 9, 7], + ElementKind::Wedge6 => &[1, 0, 3, 4, 2, 2, 5, 5], + ElementKind::Hex8 => &[0, 1, 2, 3, 4, 5, 6, 7], + ElementKind::Hex20 => &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19], + } +} + +/// The element's node labels in LS-DYNA's order. +pub(crate) fn connectivity(kind: ElementKind, nodes: &[u32]) -> Option> { + order(kind).iter().map(|&i| nodes.get(i).copied()).collect() +} + +/// Hourglass control a reduced-integration formulation needs (`*HOURGLASS`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub(crate) enum Hourglass { + /// Solids: the assumed-strain stiffness form (type 6) with coefficient 1, which gives + /// accurate coarse-mesh bending for elastic materials. + Solid, + /// Quadrilateral shells: the stiffness form (type 4) with the default coefficient. + Shell, +} + +/// A part's element formulation. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct Elform { + pub(crate) elform: i32, + pub(crate) hourglass: Option, +} + +/// The formulation of a section's elements of one kind. +/// +/// Solids: hex8 2 (eight-point), reduced 1 (one-point) with hourglass control, incompatible +/// modes −18 (eight-point enhanced strain with incompatible modes); wedges 2 (as degenerate +/// hexahedra), reduced 15 (two-point pentahedron); tet4 10 (one point, exact for a linear +/// tetrahedron), hybrid 13 (nodal pressure); tet10 16; hex20 23. Shells: quad4 16 (fully +/// integrated), reduced 2 (one-point) with hourglass control; tri3 4 (C0 triangle); quad8 23; +/// tri6 24. Rods: 3 (truss). +/// What it cannot express is the reason, naming the section. +pub(crate) fn elform(kind: ElementKind, section: &SectionKind, f: Formulation, section_name: &str) -> Result { + let plain = |elform| Ok(Elform { elform, hourglass: None }); + let reduced = f.integration == Integration::Reduced; + let no = |what: &str| Err(format!("section `{section_name}`: {what} {}", kind.name())); + match section { + SectionKind::Solid {} => { + if f.hybrid { + return match kind { + ElementKind::Tet4 => plain(13), + _ => no( + "the hybrid (mixed displacement-pressure) formulation has a counterpart only for 4-node tetrahedra (nodal pressure, ELFORM 13), not", + ), + }; + } + match kind { + ElementKind::Hex8 if f.incompatible_modes => plain(-18), + ElementKind::Hex8 if reduced => Ok(Elform { elform: 1, hourglass: Some(Hourglass::Solid) }), + ElementKind::Hex8 => plain(2), + ElementKind::Wedge6 if reduced => plain(15), + ElementKind::Wedge6 => plain(2), + ElementKind::Tet4 => plain(10), + _ if reduced && kind.is_quadratic() => no("there is no reduced-integration form of"), + ElementKind::Tet10 => plain(16), + ElementKind::Hex20 => plain(23), + _ => no("a solid section cannot hold"), + } + } + SectionKind::Shell { .. } => match kind { + ElementKind::Quad4 if reduced => Ok(Elform { elform: 2, hourglass: Some(Hourglass::Shell) }), + ElementKind::Quad4 => plain(16), + ElementKind::Tri3 => plain(4), + _ if reduced && kind.is_quadratic() => { + no("reduced integration of quadratic shells is a whole-model setting in LS-DYNA, not written per section, for") + } + ElementKind::Quad8 => plain(23), + ElementKind::Tri6 => plain(24), + _ => no("a shell section cannot hold"), + }, + SectionKind::Rod { .. } => match kind { + ElementKind::Rod2 => plain(3), + _ => no("a rod section cannot hold"), + }, + SectionKind::Beam { .. } => no("beam sections are not written to LS-DYNA decks yet (planned with beam profiles):"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn every_kind_maps_every_node_once_except_degenerate_repeats() { + for kind in ElementKind::ALL { + let o = order(kind); + let mut seen: Vec = o.to_vec(); + seen.sort_unstable(); + seen.dedup(); + assert_eq!(seen, (0..kind.node_count()).collect::>(), "{}", kind.name()); + let nodes: Vec = (1..=kind.node_count() as u32).collect(); + assert_eq!(connectivity(kind, &nodes).unwrap().len(), o.len()); + assert!(connectivity(kind, &nodes[..1]).is_none()); + } + } + + #[test] + fn formulations_map_or_say_why_not() { + let solid = SectionKind::Solid {}; + let full = Formulation::default(); + let reduced = Formulation { integration: Integration::Reduced, ..full }; + let im = Formulation { incompatible_modes: true, ..full }; + let hybrid = Formulation { hybrid: true, ..full }; + assert_eq!(elform(ElementKind::Hex8, &solid, full, "s").unwrap().elform, 2); + assert_eq!(elform(ElementKind::Hex8, &solid, reduced, "s").unwrap(), Elform { elform: 1, hourglass: Some(Hourglass::Solid) }); + assert_eq!(elform(ElementKind::Hex8, &solid, im, "s").unwrap().elform, -18); + assert_eq!(elform(ElementKind::Tet4, &solid, hybrid, "s").unwrap().elform, 13); + assert_eq!(elform(ElementKind::Tet10, &solid, full, "s").unwrap().elform, 16); + assert_eq!(elform(ElementKind::Hex20, &solid, full, "s").unwrap().elform, 23); + for (k, f) in [(ElementKind::Hex8, hybrid), (ElementKind::Tet10, reduced), (ElementKind::Hex20, reduced)] { + let e = elform(k, &solid, f, "web").unwrap_err(); + assert!(e.contains("section `web`") && e.contains(k.name()), "{e}"); + } + let shell = SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 5 }; + assert_eq!(elform(ElementKind::Quad4, &shell, full, "s").unwrap().elform, 16); + assert_eq!(elform(ElementKind::Quad4, &shell, reduced, "s").unwrap().hourglass, Some(Hourglass::Shell)); + assert_eq!(elform(ElementKind::Tri6, &shell, full, "s").unwrap().elform, 24); + assert!(elform(ElementKind::Quad8, &shell, reduced, "s").is_err()); + assert!(elform(ElementKind::Hex8, &shell, full, "s").is_err()); + assert_eq!(elform(ElementKind::Rod2, &SectionKind::Rod { area: 1.0 }, full, "s").unwrap().elform, 3); + } +} diff --git a/crates/decks/src/lsdyna/mod.rs b/crates/decks/src/lsdyna/mod.rs new file mode 100644 index 0000000..66e8532 --- /dev/null +++ b/crates/decks/src/lsdyna/mod.rs @@ -0,0 +1,729 @@ +//! 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, +} + +/// 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, + elements: Vec<(u32, &'a Element)>, +} + +/// Every id the deck uses, decided before anything is written. +struct Plan<'a> { + parts: Vec>, + /// Material name → MID, for the materials sections use. + materials: BTreeMap<&'a str, u32>, + hourglasses: BTreeMap, + /// 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>, +} + +/// Write the model as an LS-DYNA keyword deck. +pub fn write(model: &Model) -> Result { + 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 { + write_with(model, r).map(|w| w.text) +} + +fn write_with(model: &Model, r: &Resolved) -> Result { + 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> { + 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::>()?; + // Everything LS-DYNA cannot express, reported at once. + let mut refused = BTreeSet::new(); + let mut parts = Vec::new(); + let mut hourglasses: BTreeMap = BTreeMap::new(); + for (name, sec) in model.sections() { + let mut by_kind: BTreeMap> = 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::>()?; + + // 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> = BTreeSet::new(); + for step in model.steps() { + 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::>().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::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, String> { + let elements: BTreeSet = 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> = 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 = 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::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| 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)]); + } + } + } + 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 = 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)]); + } + } +} diff --git a/crates/decks/tests/lsdyna.rs b/crates/decks/tests/lsdyna.rs new file mode 100644 index 0000000..8c75cee --- /dev/null +++ b/crates/decks/tests/lsdyna.rs @@ -0,0 +1,676 @@ +//! LS-DYNA deck writer: golden files of our own small models, node-order mappings checked by the +//! sign of the element Jacobian, a re-read of our own output, and what the format cannot take. +//! +//! Golden files live in `tests/lsdyna/`; `MODELCRAFT_BLESS=1 cargo test -p modelcraft-decks` +//! rewrites them after an intended change (review the diff). + +use std::collections::{BTreeMap, BTreeSet}; + +use modelcraft_decks::{Error, lsdyna}; +use modelcraft_model::*; + +// --------------------------------------------------------------------------------------------- +// Our own models. + +fn steel() -> Material { + Material { elastic: Some(Elastic::Isotropic { young: 210000.0, poisson: 0.3 }), density: Some(7.85e-9) } +} + +fn step(name: &str, procedure: Procedure, bcs: &[&str], loads: &[&str]) -> Step { + let mut s = Step::new(name, procedure); + s.boundary_conditions = bcs.iter().map(|b| b.to_string()).collect(); + s.loads = loads.iter().map(|l| l.to_string()).collect(); + s +} + +/// A 4 × 1 × 1 cantilever of four hex8 elements along x, fixed at x = 0. Nodes are numbered +/// x-fastest: label = 1 + i + 5·(j + 2·k). +fn cantilever_mesh(m: &mut Model) { + m.set_units(UnitSystem::MmTonneS); + let label = |i: u32, j: u32, k: u32| 1 + i + 5 * (j + 2 * k); + for k in 0..2 { + for j in 0..2 { + for i in 0..5 { + m.set_node(label(i, j, k), [f64::from(i), f64::from(j), f64::from(k)]).unwrap(); + } + } + } + for i in 0..4 { + let nodes = vec![ + label(i, 0, 0), + label(i + 1, 0, 0), + label(i + 1, 1, 0), + label(i, 1, 0), + label(i, 0, 1), + label(i + 1, 0, 1), + label(i + 1, 1, 1), + label(i, 1, 1), + ]; + m.set_element(i + 1, ElementKind::Hex8, nodes).unwrap(); + } + m.add_set("all", Set { elements: vec![1, 2, 3, 4], ..Set::default() }).unwrap(); + m.add_set("root", Set { nodes: vec![label(0, 0, 0), label(0, 1, 0), label(0, 0, 1), label(0, 1, 1)], ..Set::default() }).unwrap(); + // Face 4 of a hex8 is the side along edge 2-3: x = 4 for the last element. + m.add_set("tip", Set { faces: vec![ElementFace { element: 4, face: 4 }], ..Set::default() }).unwrap(); + m.add_boundary_condition("clamp", BoundaryCondition::Fixed { target: Target::Set("root".into()) }).unwrap(); +} + +fn cantilever() -> Model { + let mut m = Model::default(); + cantilever_mesh(&mut m); + m.add_material("Steel", steel()).unwrap(); + m.add_section("beam", Section { set: "all".into(), material: "Steel".into(), kind: SectionKind::Solid {}, formulation: Formulation::default() }) + .unwrap(); + m.add_load("tip_load", Load::Force { target: Target::Set("tip".into()), vector: [0.0, 0.0, -100.0], distribution: Distribution::Total }).unwrap(); + m.add_step(step("Bend", Procedure::Static {}, &["clamp"], &["tip_load"]), None).unwrap(); + m +} + +/// The cantilever in an orthotropic material with incompatible modes, loaded through a coupled +/// reference point, then its natural frequencies: two steps, so two cases. +fn cantilever_modes() -> Model { + let mut m = Model::default(); + cantilever_mesh(&mut m); + let ortho = Elastic::EngineeringConstants { young: [140000.0, 10000.0, 10000.0], poisson: [0.3, 0.3, 0.45], shear: [5000.0, 5000.0, 3500.0] }; + m.add_material("Ply", Material { elastic: Some(ortho), density: Some(1.6e-9) }).unwrap(); + let f = Formulation { incompatible_modes: true, ..Formulation::default() }; + m.add_section("beam", Section { set: "all".into(), material: "Ply".into(), kind: SectionKind::Solid {}, formulation: f }).unwrap(); + m.add_reference_point("RP", ReferencePoint { xyz: [5.0, 0.5, 0.5], label: 0 }).unwrap(); + m.add_coupling("pad", Coupling { reference_point: "RP".into(), set: "tip".into(), kind: CouplingKind::Kinematic, dofs: Dofs::all() }).unwrap(); + m.add_load("twist", Load::Moment { target: Target::ReferencePoint("RP".into()), vector: [250.0, 0.0, 0.0], distribution: Distribution::Total }) + .unwrap(); + m.add_load("push", Load::Force { target: Target::ReferencePoint("RP".into()), vector: [0.0, -10.0, 0.0], distribution: Distribution::Total }) + .unwrap(); + m.add_step(step("Bend", Procedure::Static {}, &["clamp"], &["push", "twist"]), None).unwrap(); + m.add_step(step("Modes", Procedure::Frequency { modes: 6, min_hz: Some(1.0), max_hz: Some(5000.0) }, &["clamp"], &[]), None).unwrap(); + m +} + +/// A 2 × 1 plate at z = 0: two quad4 and two tri3 shells, held on the edge x = 0, pressed and +/// under gravity. +fn plate() -> Model { + let mut m = Model::default(); + m.set_units(UnitSystem::MmTonneS); + for (l, x, y) in + [(1, 0.0, 0.0), (2, 10.0, 0.0), (3, 20.0, 0.0), (4, 0.0, 10.0), (5, 10.0, 10.0), (6, 20.0, 10.0), (7, 30.0, 0.0), (8, 30.0, 10.0)] + { + m.set_node(l, [x, y, 0.0]).unwrap(); + } + m.set_element(1, ElementKind::Quad4, vec![1, 2, 5, 4]).unwrap(); + m.set_element(2, ElementKind::Quad4, vec![2, 3, 6, 5]).unwrap(); + m.set_element(3, ElementKind::Tri3, vec![3, 7, 8]).unwrap(); + m.set_element(4, ElementKind::Tri3, vec![3, 8, 6]).unwrap(); + m.add_set("plate", Set { elements: vec![1, 2, 3, 4], ..Set::default() }).unwrap(); + m.add_set("edge", Set { nodes: vec![1, 4], ..Set::default() }).unwrap(); + let top: Vec = (1..=4).map(|element| ElementFace { element, face: 1 }).collect(); + m.add_set("top", Set { faces: top, ..Set::default() }).unwrap(); + m.add_material("Steel", steel()).unwrap(); + m.add_section( + "skin", + Section { + set: "plate".into(), + material: "Steel".into(), + kind: SectionKind::Shell { thickness: 0.5, offset: 0.25, points: 5 }, + formulation: Formulation::default(), + }, + ) + .unwrap(); + m.add_boundary_condition("hold", BoundaryCondition::Fixed { target: Target::Set("edge".into()) }).unwrap(); + m.add_load("press", Load::Pressure { set: "top".into(), magnitude: 0.01 }).unwrap(); + m.add_load("weight", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: None }).unwrap(); + m.add_step(step("Sag", Procedure::Static {}, &["hold"], &["press", "weight"]), None).unwrap(); + m +} + +/// One element of each solid kind besides hex8, side by side (each its own section), with a +/// distributing coupling, a prescribed displacement and a traction. +fn solids() -> Model { + let mut m = Model::default(); + m.set_units(UnitSystem::MKgS); + let mut next = 0u32; + let mut node = |m: &mut Model, xyz: [f64; 3]| { + next += 1; + m.set_node(next, xyz).unwrap(); + next + }; + // tet4 + let t: Vec = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]].iter().map(|p| node(&mut m, *p)).collect(); + m.set_element(1, ElementKind::Tet4, t.clone()).unwrap(); + // tet10: corners then mid-sides on 1-2, 2-3, 3-1, 1-4, 2-4, 3-4. + let c = [[2.0, 0.0, 0.0], [3.0, 0.0, 0.0], [2.0, 1.0, 0.0], [2.0, 0.0, 1.0]]; + let mid = |a: usize, b: usize| [(c[a][0] + c[b][0]) / 2.0, (c[a][1] + c[b][1]) / 2.0, (c[a][2] + c[b][2]) / 2.0]; + let mut t10: Vec = c.iter().map(|p| node(&mut m, *p)).collect(); + for (a, b) in [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)] { + t10.push(node(&mut m, mid(a, b))); + } + m.set_element(2, ElementKind::Tet10, t10).unwrap(); + // wedge6 + let w: Vec = [[4.0, 0.0, 0.0], [5.0, 0.0, 0.0], [4.0, 1.0, 0.0], [4.0, 0.0, 1.0], [5.0, 0.0, 1.0], [4.0, 1.0, 1.0]] + .iter() + .map(|p| node(&mut m, *p)) + .collect(); + m.set_element(3, ElementKind::Wedge6, w).unwrap(); + // hex20 + let h = hex20_nodes([6.0, 0.0, 0.0]); + let h: Vec = h.iter().map(|p| node(&mut m, *p)).collect(); + m.set_element(4, ElementKind::Hex20, h).unwrap(); + + m.add_material("Aluminium", Material { elastic: Some(Elastic::Isotropic { young: 70e9, poisson: 0.33 }), density: Some(2700.0) }).unwrap(); + for (e, name) in [(1, "tet4"), (2, "tet10"), (3, "wedge"), (4, "hex20")] { + m.add_set(name, Set { elements: vec![e], ..Set::default() }).unwrap(); + let f = Formulation { hybrid: e == 1, ..Formulation::default() }; + m.add_section(name, Section { set: name.into(), material: "Aluminium".into(), kind: SectionKind::Solid {}, formulation: f }).unwrap(); + } + m.add_set("base", Set { nodes: t.clone(), ..Set::default() }).unwrap(); + // Face 2 of the hex20 is its top (nodes 5-8 and their mid-sides). + m.add_set("lid", Set { faces: vec![ElementFace { element: 4, face: 2 }], ..Set::default() }).unwrap(); + m.add_reference_point("RP", ReferencePoint { xyz: [6.5, 0.5, 2.0], label: 0 }).unwrap(); + m.add_coupling("spider", Coupling { reference_point: "RP".into(), set: "lid".into(), kind: CouplingKind::Distributing, dofs: Dofs::all() }) + .unwrap(); + m.add_boundary_condition("hold", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap(); + m.add_boundary_condition( + "lift", + BoundaryCondition::Displacement { target: Target::Set("tet10".into()), values: [None, None, Some(0.001), None, None, None] }, + ) + .unwrap(); + m.add_load("drag", Load::Traction { set: "lid".into(), vector: [1000.0, 0.0, 0.0] }).unwrap(); + m.add_load("pull", Load::Force { target: Target::ReferencePoint("RP".into()), vector: [0.0, 0.0, 50.0], distribution: Distribution::Total }) + .unwrap(); + m.add_step(step("Load", Procedure::Static {}, &["hold", "lift"], &["drag", "pull"]), None).unwrap(); + m +} + +/// A unit 20-node hexahedron at `o` in model order. +fn hex20_nodes(o: [f64; 3]) -> Vec<[f64; 3]> { + let corners = CUBE.map(|c| c.map(|v| (v + 1.0) / 2.0)); + let mut out: Vec<[f64; 3]> = corners.to_vec(); + out.extend(HEX_EDGES.map(|(a, b)| [0, 1, 2].map(|k| (corners[a][k] + corners[b][k]) / 2.0))); + out.iter().map(|p| [p[0] + o[0], p[1] + o[1], p[2] + o[2]]).collect() +} + +// --------------------------------------------------------------------------------------------- +// Golden files. + +fn golden(name: &str, model: &Model) -> lsdyna::Written { + let written = lsdyna::write(model).unwrap(); + let again = lsdyna::write(model).unwrap(); + assert_eq!(written, again, "deterministic"); + let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/lsdyna").join(format!("{name}.k")); + if std::env::var_os("MODELCRAFT_BLESS").is_some() { + std::fs::write(&path, &written.text).unwrap(); + } + let expected = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{}: {e} (run with MODELCRAFT_BLESS=1)", path.display())); + assert!(written.text == expected, "{name}.k differs from the golden file (MODELCRAFT_BLESS=1 rewrites it)"); + assert!(written.text.is_ascii()); + for line in written.text.lines() { + assert!(line.len() <= 200 && line == line.trim_end(), "{line:?}"); + assert!(!line.contains('\t')); + } + assert_eq!(written.rounded_values, 0); + written +} + +#[test] +fn golden_hex8_cantilever() { + let w = golden("cantilever", &cantilever()); + assert_eq!(w.cases, 0); + let deck = read(&w.text); + assert_eq!(deck.nodes.len(), 20); + // 100 N on the tip face: 25 N on each of its four nodes, written once on the node set. + assert!(w.text.contains("*LOAD_NODE_SET")); + assert_eq!(deck.node_sets["tip"], vec![5, 10, 15, 20]); + assert_eq!(deck.node_sets["root"], vec![1, 6, 11, 16]); +} + +#[test] +fn golden_cantilever_modes_in_cases() { + let w = golden("cantilever_modes", &cantilever_modes()); + assert_eq!(w.cases, 2); + for k in ["*CASE_BEGIN_1", "*CASE_END_1", "*CASE_BEGIN_2", "*CASE_END_2", "*CONTROL_IMPLICIT_EIGENVALUE", "*MAT_ORTHOTROPIC_ELASTIC_TITLE"] { + assert!(w.text.contains(k), "{k}"); + } + // The rigid body's set leads with the reference point. + let deck = read(&w.text); + let rp = cantilever_modes().reference_point("RP").unwrap().label; + assert_eq!(deck.node_sets["pad (rigid body)"].first(), Some(&rp)); + assert_eq!(deck.nodes[&rp], [5.0, 0.5, 0.5]); +} + +#[test] +fn golden_plate_with_pressure_and_gravity() { + let w = golden("plate", &plate()); + assert!(w.text.contains("*LOAD_SEGMENT_SET") && w.text.contains("*LOAD_BODY_Z")); + let deck = read(&w.text); + // Triangles are degenerate quadrilaterals. + assert_eq!(deck.elements[&3], vec![3, 7, 8, 8]); + assert_eq!(deck.parts.len(), 2, "quad4 and tri3 take different formulations"); +} + +#[test] +fn golden_solids_and_distributing_coupling() { + let w = golden("solids", &solids()); + for k in ["*ELEMENT_SOLID_H20", "*CONSTRAINED_INTERPOLATION", "*BOUNDARY_PRESCRIBED_MOTION_SET_ID", "*LOAD_NODE_POINT"] { + assert!(w.text.contains(k), "{k}"); + } +} + +// --------------------------------------------------------------------------------------------- +// A minimal reader of our own output: nodes, elements and node sets. + +#[derive(Default)] +struct Read { + nodes: BTreeMap, + /// Element → its nodes as written. + elements: BTreeMap>, + /// The part ids elements are in. + parts: BTreeSet, + node_sets: BTreeMap>, +} + +fn fields(line: &str) -> Vec<&str> { + (0..line.len()).step_by(20).map(|i| line[i..(i + 20).min(line.len())].trim()).collect() +} + +fn read(text: &str) -> Read { + let mut out = Read::default(); + let mut keyword = String::new(); + let mut cards: Vec<&str> = Vec::new(); + let flush = |keyword: &str, cards: &[&str], out: &mut Read| match keyword { + "*NODE" => { + for c in cards { + let f = fields(c); + out.nodes.insert(f[0].parse().unwrap(), [f[1].parse().unwrap(), f[2].parse().unwrap(), f[3].parse().unwrap()]); + } + } + "*ELEMENT_SOLID" | "*ELEMENT_SOLID_H20" => { + let per = if keyword == "*ELEMENT_SOLID" { 2 } else { 3 }; + for e in cards.chunks(per) { + let head = fields(e[0]); + let nodes: Vec = e[1..].iter().flat_map(|c| fields(c)).map(|v| v.parse().unwrap()).collect(); + out.elements.insert(head[0].parse().unwrap(), nodes); + out.parts.insert(head[1].parse().unwrap()); + } + } + "*ELEMENT_SHELL" | "*ELEMENT_BEAM" => { + for c in cards { + let f: Vec = fields(c).iter().map(|v| v.parse().unwrap()).collect(); + out.elements.insert(f[0], f[2..].to_vec()); + out.parts.insert(f[1]); + } + } + "*SET_NODE_LIST_TITLE" => { + let nodes = cards[2..].iter().flat_map(|c| fields(c)).map(|v| v.parse().unwrap()).collect(); + out.node_sets.insert(cards[0].to_string(), nodes); + } + _ => {} + }; + for line in text.lines() { + if line.starts_with('$') { + continue; + } + if line.starts_with('*') { + flush(&keyword, &cards, &mut out); + keyword = line.to_string(); + cards.clear(); + } else { + cards.push(line); + } + } + flush(&keyword, &cards, &mut out); + out +} + +#[test] +fn re_read_gives_back_nodes_elements_and_sets() { + for model in [cantilever(), cantilever_modes(), plate(), solids()] { + let w = lsdyna::write(&model).unwrap(); + let deck = read(&w.text); + for (label, n) in model.nodes() { + assert_eq!(deck.nodes[label], n.xyz, "node {label} round-trips exactly"); + } + for (label, e) in model.elements() { + let written = &deck.elements[label]; + let unique: BTreeSet = written.iter().copied().collect(); + assert_eq!(unique, e.nodes.iter().copied().collect::>(), "element {label}"); + } + let r = model.resolve().unwrap(); + for (name, nodes) in &deck.node_sets { + if let Some(s) = r.sets.get(name) { + assert_eq!(nodes, &s.nodes, "set {name}"); + } + } + } +} + +#[test] +fn awkward_coordinates_round_trip_or_are_counted() { + let mut m = cantilever(); + // Full 17-digit mantissas at every magnitude a model allows. + let values = [0.1 + 0.2, -123_456_789.123_456_78, 1e-300, -2.2250738585072014e-308, 0.012_345_678_901_234_567, 999_999_999.999_999_9]; + for (i, v) in values.iter().enumerate() { + m.set_node(100 + i as u32, [*v, -*v, *v / 3.0]).unwrap(); + } + let w = lsdyna::write(&m).unwrap(); + let deck = read(&w.text); + let mut inexact = 0; + for (i, _) in values.iter().enumerate() { + let label = 100 + i as u32; + let want = m.node(label).unwrap().xyz; + let got = deck.nodes[&label]; + for (a, b) in want.iter().zip(got) { + if *a != b { + inexact += 1; + assert!(((a - b) / a).abs() < 5e-13, "{a:e} {b:e}"); + } + } + } + assert_eq!(inexact, w.rounded_values); +} + +// --------------------------------------------------------------------------------------------- +// Node order: the sign of the Jacobian in LS-DYNA's element conventions. + +/// Hexahedron corners in the reference cube, in LS-DYNA (and model) order. +const CUBE: [[f64; 3]; 8] = + [[-1., -1., -1.], [1., -1., -1.], [1., 1., -1.], [-1., 1., -1.], [-1., -1., 1.], [1., -1., 1.], [1., 1., 1.], [-1., 1., 1.]]; + +/// Hexahedron edges as corner pairs, in mid-side node order. +const HEX_EDGES: [(usize, usize); 12] = [(0, 1), (1, 2), (2, 3), (3, 0), (4, 5), (5, 6), (6, 7), (7, 4), (0, 4), (1, 5), (2, 6), (3, 7)]; + +/// det J of the map with shape-function gradients `dn[i]` (reference axes) at nodes `x[i]`. +fn jacobian_det(x: &[[f64; 3]], dn: impl Iterator) -> f64 { + let mut j = [[0.0; 3]; 3]; + for (p, d) in x.iter().zip(dn) { + for a in 0..3 { + for b in 0..3 { + j[a][b] += d[a] * p[b]; + } + } + } + det(j) +} + +/// Trilinear hexahedron in LS-DYNA order (1-4 one face, 5-8 over them): det J at a point. +fn hex8_det(x: &[[f64; 3]], (r, s, t): (f64, f64, f64)) -> f64 { + jacobian_det( + x, + CUBE.iter().map(|c| { + [ + c[0] * (1.0 + c[1] * s) * (1.0 + c[2] * t) / 8.0, + c[1] * (1.0 + c[0] * r) * (1.0 + c[2] * t) / 8.0, + c[2] * (1.0 + c[0] * r) * (1.0 + c[1] * s) / 8.0, + ] + }), + ) +} + +fn det(j: [[f64; 3]; 3]) -> f64 { + j[0][0] * (j[1][1] * j[2][2] - j[1][2] * j[2][1]) - j[0][1] * (j[1][0] * j[2][2] - j[1][2] * j[2][0]) + + j[0][2] * (j[1][0] * j[2][1] - j[1][1] * j[2][0]) +} + +const GAUSS: f64 = 0.577_350_269_189_625_8; + +/// Write a one-element model of `kind` with these model-order coordinates and read back the +/// written coordinates in LS-DYNA order. +fn written_coordinates(kind: ElementKind, xyz: &[[f64; 3]]) -> Vec<[f64; 3]> { + let mut m = Model::default(); + for (i, p) in xyz.iter().enumerate() { + m.set_node(i as u32 + 1, *p).unwrap(); + } + m.set_element(1, kind, (1..=xyz.len() as u32).collect()).unwrap(); + m.add_set("all", Set { elements: vec![1], ..Set::default() }).unwrap(); + m.add_set("fix", Set { nodes: vec![1], ..Set::default() }).unwrap(); + m.add_material("M", steel()).unwrap(); + let kind_of = if kind.dimension() == 3 { SectionKind::Solid {} } else { SectionKind::Shell { thickness: 1.0, offset: 0.0, points: 3 } }; + m.add_section("s", Section { set: "all".into(), material: "M".into(), kind: kind_of, formulation: Formulation::default() }).unwrap(); + m.add_boundary_condition("b", BoundaryCondition::Fixed { target: Target::Set("fix".into()) }).unwrap(); + m.add_step(step("S", Procedure::Frequency { modes: 1, min_hz: None, max_hz: None }, &["b"], &[]), None).unwrap(); + let deck = read(&lsdyna::write(&m).unwrap().text); + deck.elements[&1].iter().map(|n| deck.nodes[n]).collect() +} + +/// Slightly distorted coordinates, so a wrong mapping cannot pass by symmetry (short decimals, +/// so every coordinate is written exactly). +fn jiggle(p: [f64; 3], i: usize) -> [f64; 3] { + let k = ((i as f64 * 0.37).sin() * 0.03 * 1e4).round() / 1e4; + [p[0] + k, p[1] - k * 0.5, p[2] + k * 0.25].map(|c| (c * 1e6).round() / 1e6) +} + +#[test] +fn degenerate_solids_have_positive_jacobians_in_lsdyna_order() { + let tet = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let wedge = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [0.0, 1.0, 1.0]]; + let hex = + [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0], [1.0, 0.0, 1.0], [1.0, 1.0, 1.0], [0.0, 1.0, 1.0]]; + for (kind, xyz) in [(ElementKind::Tet4, &tet[..]), (ElementKind::Wedge6, &wedge[..]), (ElementKind::Hex8, &hex[..])] { + let xyz: Vec<[f64; 3]> = xyz.iter().enumerate().map(|(i, p)| jiggle(*p, i)).collect(); + let x = written_coordinates(kind, &xyz); + assert_eq!(x.len(), 8, "{}", kind.name()); + for r in [-GAUSS, GAUSS] { + for s in [-GAUSS, GAUSS] { + for t in [-GAUSS, GAUSS] { + assert!(hex8_det(&x, (r, s, t)) > 0.0, "{} at ({r}, {s}, {t})", kind.name()); + } + } + } + // The model's own order mirrored is negative (the test can fail). + let mut mirrored = xyz.clone(); + for p in &mut mirrored { + p[2] = -p[2]; + } + let x = written_coordinates_unchecked(kind, &mirrored); + assert!(hex8_det(&x, (0.1, -0.1, 0.1)) < 0.0, "{}", kind.name()); + } +} + +/// [`written_coordinates`] without writing: the writer's node order applied to coordinates the +/// model would reject as inverted. +fn written_coordinates_unchecked(kind: ElementKind, xyz: &[[f64; 3]]) -> Vec<[f64; 3]> { + lsdyna::node_order(kind).iter().map(|&i| xyz[i]).collect() +} + +/// Quadratic tetrahedron in LS-DYNA order (mid-sides 5, 6, 7 on 1-2, 2-3, 3-1 and 8, 9, 10 on +/// 2-4, 3-4, 1-4): det J at volume coordinates (l1, l2, l3). +fn tet10_det(x: &[[f64; 3]], l: [f64; 3]) -> f64 { + let ls = [1.0 - l[0] - l[1] - l[2], l[0], l[1], l[2]]; + // dL/d(ξ, η, ζ) with L1 = 1 − ξ − η − ζ, L2 = ξ, L3 = η, L4 = ζ. + let dl = [[-1.0, -1.0, -1.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let edges = [(0, 1), (1, 2), (2, 0), (1, 3), (2, 3), (0, 3)]; + let corners = (0..4).map(|i| dl[i].map(|d| (4.0 * ls[i] - 1.0) * d)); + let mids = edges.iter().map(|&(a, b)| [0, 1, 2].map(|c| 4.0 * (dl[a][c] * ls[b] + ls[a] * dl[b][c]))); + jacobian_det(x, corners.chain(mids)) +} + +#[test] +fn tet10_mid_side_nodes_sit_on_lsdyna_edges_with_positive_jacobian() { + let c = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]]; + let mut xyz: Vec<[f64; 3]> = c.iter().enumerate().map(|(i, p)| jiggle(*p, i)).collect(); + for (a, b) in [(0, 1), (1, 2), (2, 0), (0, 3), (1, 3), (2, 3)] { + xyz.push([0, 1, 2].map(|k| (xyz[a][k] + xyz[b][k]) / 2.0)); + } + let x = written_coordinates(ElementKind::Tet10, &xyz); + assert_eq!(x.len(), 10); + for (m, (a, b)) in [(0, 1), (1, 2), (2, 0), (1, 3), (2, 3), (0, 3)].iter().enumerate() { + let want = [0, 1, 2].map(|k| (x[*a][k] + x[*b][k]) / 2.0); + assert_eq!(x[4 + m], want, "node {} is on LS-DYNA edge {}-{}", 5 + m, a + 1, b + 1); + } + for l in [[0.25, 0.25, 0.25], [0.1, 0.1, 0.1], [0.6, 0.1, 0.1], [0.1, 0.6, 0.1], [0.1, 0.1, 0.6]] { + assert!(tet10_det(&x, l) > 0.0, "{l:?}"); + } +} + +/// 20-node serendipity hexahedron in LS-DYNA order: det J at a point. +fn hex20_det(x: &[[f64; 3]], p: [f64; 3]) -> f64 { + let mut nodes: Vec<[f64; 3]> = CUBE.to_vec(); + nodes.extend(HEX_EDGES.map(|(a, b)| [0, 1, 2].map(|k| (CUBE[a][k] + CUBE[b][k]) / 2.0))); + let gradient = |n: &[f64; 3]| -> [f64; 3] { + let q = [0, 1, 2].map(|k| 1.0 + n[k] * p[k]); + match (0..3).find(|k| n[*k] == 0.0) { + // Corner: N = q0 q1 q2 (n·p − 2) / 8. + None => { + let s = n[0] * p[0] + n[1] * p[1] + n[2] * p[2] - 2.0; + [0, 1, 2].map(|k| n[k] * q[(k + 1) % 3] * q[(k + 2) % 3] * (s + q[k]) / 8.0) + } + // Mid-side on an edge along axis z: N = (1 − p_z²) q_a q_b / 4. + Some(z) => [0, 1, 2].map(|k| { + let others = |skip: usize| (0..3).filter(|m| *m != skip && *m != z).map(|m| q[m]).product::(); + if k == z { -2.0 * p[z] * others(z) / 4.0 } else { n[k] * (1.0 - p[z] * p[z]) * others(k) / 4.0 } + }), + } + }; + jacobian_det(x, nodes.iter().map(gradient)) +} + +#[test] +fn hex20_keeps_the_model_order_with_positive_jacobian() { + let xyz: Vec<[f64; 3]> = hex20_nodes([0.0; 3]).into_iter().enumerate().map(|(i, p)| jiggle(p, i)).collect(); + let x = written_coordinates(ElementKind::Hex20, &xyz); + assert_eq!(x, xyz); + for r in [-GAUSS, GAUSS] { + for s in [-GAUSS, GAUSS] { + for t in [-GAUSS, GAUSS] { + assert!(hex20_det(&x, [r, s, t]) > 0.0); + } + } + } + // The unit cube's Jacobian is 1/8 everywhere: the shape functions are right. + assert!((hex20_det(&hex20_nodes([0.0; 3]), [0.3, -0.2, 0.7]) - 0.125).abs() < 1e-12); +} + +#[test] +fn shells_keep_their_normal() { + let normal = |x: &[[f64; 3]]| { + // Diagonals of the (possibly degenerate) quadrilateral 1-2-3-4. + let d1 = [0, 1, 2].map(|k| x[2][k] - x[0][k]); + let d2 = [0, 1, 2].map(|k| x[3][k] - x[1][k]); + [d1[1] * d2[2] - d1[2] * d2[1], d1[2] * d2[0] - d1[0] * d2[2], d1[0] * d2[1] - d1[1] * d2[0]] + }; + let tri = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]; + let quad = [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0], [0.0, 1.0, 0.0]]; + let mid = |p: &[[f64; 3]], a: usize, b: usize| [0, 1, 2].map(|k| (p[a][k] + p[b][k]) / 2.0); + let tri6: Vec<[f64; 3]> = tri.iter().copied().chain([mid(&tri, 0, 1), mid(&tri, 1, 2), mid(&tri, 2, 0)]).collect(); + let quad8: Vec<[f64; 3]> = quad.iter().copied().chain([mid(&quad, 0, 1), mid(&quad, 1, 2), mid(&quad, 2, 3), mid(&quad, 3, 0)]).collect(); + for (kind, xyz) in + [(ElementKind::Tri3, tri.to_vec()), (ElementKind::Quad4, quad.to_vec()), (ElementKind::Tri6, tri6), (ElementKind::Quad8, quad8)] + { + let x = written_coordinates(kind, &xyz); + assert!(normal(&x)[2] > 0.0, "{} keeps +z as its top", kind.name()); + if kind == ElementKind::Tri6 { + assert_eq!(&x[4..7], &xyz[3..6]); + } + if kind == ElementKind::Quad8 { + assert_eq!(&x[4..8], &xyz[4..8]); + } + } +} + +// --------------------------------------------------------------------------------------------- +// What the format cannot take, and hostile models. + +fn unsupported(m: &Model, needle: &str) { + match lsdyna::write(m) { + Err(Error::Unsupported { format, what }) => { + assert_eq!(format, "LS-DYNA"); + assert!(what.contains(needle), "{what}"); + } + other => panic!("expected unsupported `{needle}`, got {other:?}"), + } +} + +#[test] +fn what_lsdyna_cannot_take_is_named() { + let mut m = cantilever(); + m.add_load("heat", Load::BodyForce { set: "all".into(), vector: [0.0, 0.0, 1.0] }).unwrap(); + m.replace_step(step("Bend", Procedure::Static {}, &["clamp"], &["tip_load", "heat"])).unwrap(); + unsupported(&m, "load `heat`"); + + let mut m = cantilever(); + m.replace_section( + "beam", + Section { + set: "all".into(), + material: "Steel".into(), + kind: SectionKind::Solid {}, + formulation: Formulation { hybrid: true, ..Formulation::default() }, + }, + ) + .unwrap(); + unsupported(&m, "hybrid"); + + let mut m = cantilever(); + m.replace_material("Steel", Material { density: None, ..steel() }).unwrap(); + unsupported(&m, "density"); + + let mut m = cantilever_modes(); + m.replace_coupling( + "pad", + Coupling { reference_point: "RP".into(), set: "tip".into(), kind: CouplingKind::Kinematic, dofs: Dofs::try_from(vec![1, 2, 3]).unwrap() }, + ) + .unwrap(); + unsupported(&m, "coupling `pad`"); + + let mut m = cantilever_modes(); + m.add_boundary_condition("hold_rp", BoundaryCondition::Fixed { target: Target::ReferencePoint("RP".into()) }).unwrap(); + m.replace_step(step("Bend", Procedure::Static {}, &["clamp", "hold_rp"], &["push"])).unwrap(); + unsupported(&m, "boundary condition `hold_rp`"); + + let mut m = plate(); + m.replace_section( + "skin", + Section { + set: "plate".into(), + material: "Steel".into(), + kind: SectionKind::Shell { thickness: 0.5, offset: 0.0, points: 11 }, + formulation: Formulation::default(), + }, + ) + .unwrap(); + unsupported(&m, "11 integration points"); + + let mut m = plate(); + m.add_set("half", Set { elements: vec![1], ..Set::default() }).unwrap(); + m.replace_load("weight", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: Some("half".into()) }).unwrap(); + unsupported(&m, "gravity on set `half`"); +} + +#[test] +fn gravity_on_whole_sections_uses_a_part_set() { + let mut m = plate(); + m.replace_load("weight", Load::Gravity { acceleration: [0.0, 0.0, -9810.0], set: Some("plate".into()) }).unwrap(); + let w = lsdyna::write(&m).unwrap(); + assert!(w.text.contains("*SET_PART_LIST") && w.text.contains("*LOAD_BODY_PARTS")); +} + +#[test] +fn models_that_are_not_ready_are_refused_not_written() { + assert!(matches!(lsdyna::write(&Model::default()), Err(Error::Model(_)))); + let mut m = cantilever(); + m.remove_step("Bend").unwrap(); + assert!(matches!(lsdyna::write(&m), Err(Error::Model(_)))); +} + +#[test] +fn hostile_numbers_stay_within_their_fields() { + let mut m = cantilever(); + m.set_node(1, [1e9, -1e9, 1e-300]).unwrap(); + m.set_node(2, [f64::MIN_POSITIVE, -f64::MIN_POSITIVE, 5e-324]).unwrap(); + m.replace_material( + "Steel", + Material { elastic: Some(Elastic::Isotropic { young: 1e300, poisson: 0.499_999_999_999_999_9 }), density: Some(1e-300) }, + ) + .unwrap(); + m.replace_load( + "tip_load", + Load::Force { target: Target::Set("tip".into()), vector: [1e-300, -1e300, 1.0 / 3.0], distribution: Distribution::PerNode }, + ) + .unwrap(); + let w = lsdyna::write(&m).unwrap(); + for line in w.text.lines().filter(|l| !l.starts_with('$') && !l.starts_with('*')) { + for f in fields(line) { + assert!(f.len() <= 20, "{line}"); + } + } + let deck = read(&w.text); + assert_eq!(deck.nodes[&1], [1e9, -1e9, 1e-300]); +} diff --git a/crates/decks/tests/lsdyna/cantilever.k b/crates/decks/tests/lsdyna/cantilever.k new file mode 100644 index 0000000..3265912 --- /dev/null +++ b/crates/decks/tests/lsdyna/cantilever.k @@ -0,0 +1,93 @@ +*KEYWORD long=y +$ Written by ModelCraft: implicit linear analysis, long format (20-column fields). +$ Units: mm-t-s-N-MPa (consistent; nothing is converted). +*NODE +$ nid x y z + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 2.0 0.0 0.0 + 4 3.0 0.0 0.0 + 5 4.0 0.0 0.0 + 6 0.0 1.0 0.0 + 7 1.0 1.0 0.0 + 8 2.0 1.0 0.0 + 9 3.0 1.0 0.0 + 10 4.0 1.0 0.0 + 11 0.0 0.0 1.0 + 12 1.0 0.0 1.0 + 13 2.0 0.0 1.0 + 14 3.0 0.0 1.0 + 15 4.0 0.0 1.0 + 16 0.0 1.0 1.0 + 17 1.0 1.0 1.0 + 18 2.0 1.0 1.0 + 19 3.0 1.0 1.0 + 20 4.0 1.0 1.0 +*MAT_ELASTIC_TITLE +Steel +$ mid ro e pr + 1 7.85e-9 210000.0 0.3 +*SECTION_SOLID +$ secid elform + 1 2 +*PART +beam +$ pid secid mid eosid hgid + 1 1 1 +*ELEMENT_SOLID +$ eid pid +$ n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 + 1 1 + 1 2 7 6 11 12 17 16 + 2 1 + 2 3 8 7 12 13 18 17 + 3 1 + 3 4 9 8 13 14 19 18 + 4 1 + 4 5 10 9 14 15 20 19 +*SET_NODE_LIST_TITLE +root +$ sid + 2 + 1 6 11 16 +*SET_NODE_LIST_TITLE +tip +$ sid + 3 + 5 10 15 20 +*TITLE +Bend +*CONTROL_IMPLICIT_GENERAL +$ imflag dt0 + 1 1.0 +*CONTROL_IMPLICIT_SOLUTION +$ nsolvr + 1 +*CONTROL_TERMINATION +$ endtim + 1.0 +*DEFINE_CURVE_TITLE +Bend ramp +$ lcid + 1 +$ a1 o1 + 0.0 0.0 + 1.0 1.0 +*BOUNDARY_SPC_SET_ID + 1clamp +$ nsid cid dofx dofy dofz dofrx dofry dofrz + 2 1 1 1 1 1 1 +$ load `tip_load` +*LOAD_NODE_SET +$ nsid dof lcid sf + 3 3 1 -25.0 +*DATABASE_BINARY_D3PLOT +$ dt + 1.0 +*DATABASE_SPCFORC +$ dt + 1.0 +*DATABASE_EXTENT_BINARY +$ neiph neips maxint strflg + 1 +*END diff --git a/crates/decks/tests/lsdyna/cantilever_modes.k b/crates/decks/tests/lsdyna/cantilever_modes.k new file mode 100644 index 0000000..fa92d9c --- /dev/null +++ b/crates/decks/tests/lsdyna/cantilever_modes.k @@ -0,0 +1,124 @@ +*KEYWORD long=y +$ Written by ModelCraft: implicit linear analysis, long format (20-column fields). +$ Units: mm-t-s-N-MPa (consistent; nothing is converted). +$ 2 steps are written as *CASE analyses: run LS-DYNA with CASE on its command line. +*NODE +$ nid x y z + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 2.0 0.0 0.0 + 4 3.0 0.0 0.0 + 5 4.0 0.0 0.0 + 6 0.0 1.0 0.0 + 7 1.0 1.0 0.0 + 8 2.0 1.0 0.0 + 9 3.0 1.0 0.0 + 10 4.0 1.0 0.0 + 11 0.0 0.0 1.0 + 12 1.0 0.0 1.0 + 13 2.0 0.0 1.0 + 14 3.0 0.0 1.0 + 15 4.0 0.0 1.0 + 16 0.0 1.0 1.0 + 17 1.0 1.0 1.0 + 18 2.0 1.0 1.0 + 19 3.0 1.0 1.0 + 20 4.0 1.0 1.0 + 99999999 5.0 0.5 0.5 +*MAT_ORTHOTROPIC_ELASTIC_TITLE +Ply +$ mid ro ea eb ec prba prca prcb + 1 1.6e-9 140000.0 10000.0 10000.0 0.02142857142857143 0.02142857142857143 0.45 +$ gab gbc gca aopt + 5000.0 3500.0 5000.0 2.0 +$ xp yp zp a1 a2 a3 + 1.0 0.0 0.0 +$ v1 v2 v3 d1 d2 d3 + 0.0 1.0 0.0 +*SECTION_SOLID +$ secid elform + 1 -18 +*PART +beam +$ pid secid mid eosid hgid + 1 1 1 +*ELEMENT_SOLID +$ eid pid +$ n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 + 1 1 + 1 2 7 6 11 12 17 16 + 2 1 + 2 3 8 7 12 13 18 17 + 3 1 + 3 4 9 8 13 14 19 18 + 4 1 + 4 5 10 9 14 15 20 19 +*SET_NODE_LIST_TITLE +root +$ sid + 2 + 1 6 11 16 +*SET_NODE_LIST_TITLE +pad (rigid body) +$ sid + 4 + 99999999 5 10 15 20 +*CONSTRAINED_NODAL_RIGID_BODY_TITLE +pad +$ pid cid nsid + 2 4 +*CASE_BEGIN_1 +*TITLE +Bend +*CONTROL_IMPLICIT_GENERAL +$ imflag dt0 + 1 1.0 +*CONTROL_IMPLICIT_SOLUTION +$ nsolvr + 1 +*CONTROL_TERMINATION +$ endtim + 1.0 +*DEFINE_CURVE_TITLE +Bend ramp +$ lcid + 1 +$ a1 o1 + 0.0 0.0 + 1.0 1.0 +*BOUNDARY_SPC_SET_ID + 1clamp +$ nsid cid dofx dofy dofz dofrx dofry dofrz + 2 1 1 1 1 1 1 +*LOAD_NODE_POINT +$ nid dof lcid sf + 99999999 2 1 -10.0 + 99999999 5 1 250.0 +*DATABASE_BINARY_D3PLOT +$ dt + 1.0 +*DATABASE_SPCFORC +$ dt + 1.0 +*DATABASE_EXTENT_BINARY +$ neiph neips maxint strflg + 1 +*CASE_END_1 +*CASE_BEGIN_2 +*TITLE +Modes +*CONTROL_IMPLICIT_GENERAL +$ imflag dt0 + 1 1.0 +*CONTROL_IMPLICIT_EIGENVALUE +$ neig center lflag lftend rflag rhtend + 6 1 1.0 1 5000.0 +*CONTROL_TERMINATION +$ endtim + 1.0 +*BOUNDARY_SPC_SET_ID + 2clamp +$ nsid cid dofx dofy dofz dofrx dofry dofrz + 2 1 1 1 1 1 1 +*CASE_END_2 +*END diff --git a/crates/decks/tests/lsdyna/plate.k b/crates/decks/tests/lsdyna/plate.k new file mode 100644 index 0000000..2aea021 --- /dev/null +++ b/crates/decks/tests/lsdyna/plate.k @@ -0,0 +1,94 @@ +*KEYWORD long=y +$ Written by ModelCraft: implicit linear analysis, long format (20-column fields). +$ Units: mm-t-s-N-MPa (consistent; nothing is converted). +*NODE +$ nid x y z + 1 0.0 0.0 0.0 + 2 10.0 0.0 0.0 + 3 20.0 0.0 0.0 + 4 0.0 10.0 0.0 + 5 10.0 10.0 0.0 + 6 20.0 10.0 0.0 + 7 30.0 0.0 0.0 + 8 30.0 10.0 0.0 +*MAT_ELASTIC_TITLE +Steel +$ mid ro e pr + 1 7.85e-9 210000.0 0.3 +*SECTION_SHELL +$ secid elform shrf nip + 1 4 0.8333333333333334 5 +$ t1 t2 t3 t4 nloc + 0.5 0.5 +*SECTION_SHELL +$ secid elform shrf nip + 2 16 0.8333333333333334 5 +$ t1 t2 t3 t4 nloc + 0.5 0.5 +*PART +skin tri3 +$ pid secid mid eosid hgid + 1 1 1 +*PART +skin quad4 +$ pid secid mid eosid hgid + 2 2 1 +*ELEMENT_SHELL +$ eid pid n1 n2 n3 n4 n5 n6 n7 n8 + 1 2 1 2 5 4 + 2 2 2 3 6 5 + 3 1 3 7 8 8 + 4 1 3 8 6 6 +*SET_NODE_LIST_TITLE +edge +$ sid + 1 + 1 4 +*SET_SEGMENT_TITLE +top +$ sid + 3 +$ n1 n2 n3 n4 + 1 2 5 4 + 2 3 6 5 + 3 7 8 8 + 3 8 6 6 +*TITLE +Sag +*CONTROL_IMPLICIT_GENERAL +$ imflag dt0 + 1 1.0 +*CONTROL_IMPLICIT_SOLUTION +$ nsolvr + 1 +*CONTROL_TERMINATION +$ endtim + 1.0 +*DEFINE_CURVE_TITLE +Sag ramp +$ lcid + 1 +$ a1 o1 + 0.0 0.0 + 1.0 1.0 +*BOUNDARY_SPC_SET_ID + 1hold +$ nsid cid dofx dofy dofz dofrx dofry dofrz + 1 1 1 1 1 1 1 +$ load `press` +*LOAD_SEGMENT_SET +$ ssid lcid sf + 3 1 0.01 +*LOAD_BODY_Z +$ lcid sf + 1 9810.0 +*DATABASE_BINARY_D3PLOT +$ dt + 1.0 +*DATABASE_SPCFORC +$ dt + 1.0 +*DATABASE_EXTENT_BINARY +$ neiph neips maxint strflg + 1 +*END diff --git a/crates/decks/tests/lsdyna/solids.k b/crates/decks/tests/lsdyna/solids.k new file mode 100644 index 0000000..bc2cae8 --- /dev/null +++ b/crates/decks/tests/lsdyna/solids.k @@ -0,0 +1,162 @@ +*KEYWORD long=y +$ Written by ModelCraft: implicit linear analysis, long format (20-column fields). +$ Units: m-kg-s-N-Pa (consistent; nothing is converted). +*NODE +$ nid x y z + 1 0.0 0.0 0.0 + 2 1.0 0.0 0.0 + 3 0.0 1.0 0.0 + 4 0.0 0.0 1.0 + 5 2.0 0.0 0.0 + 6 3.0 0.0 0.0 + 7 2.0 1.0 0.0 + 8 2.0 0.0 1.0 + 9 2.5 0.0 0.0 + 10 2.5 0.5 0.0 + 11 2.0 0.5 0.0 + 12 2.0 0.0 0.5 + 13 2.5 0.0 0.5 + 14 2.0 0.5 0.5 + 15 4.0 0.0 0.0 + 16 5.0 0.0 0.0 + 17 4.0 1.0 0.0 + 18 4.0 0.0 1.0 + 19 5.0 0.0 1.0 + 20 4.0 1.0 1.0 + 21 6.0 0.0 0.0 + 22 7.0 0.0 0.0 + 23 7.0 1.0 0.0 + 24 6.0 1.0 0.0 + 25 6.0 0.0 1.0 + 26 7.0 0.0 1.0 + 27 7.0 1.0 1.0 + 28 6.0 1.0 1.0 + 29 6.5 0.0 0.0 + 30 7.0 0.5 0.0 + 31 6.5 1.0 0.0 + 32 6.0 0.5 0.0 + 33 6.5 0.0 1.0 + 34 7.0 0.5 1.0 + 35 6.5 1.0 1.0 + 36 6.0 0.5 1.0 + 37 6.0 0.0 0.5 + 38 7.0 0.0 0.5 + 39 7.0 1.0 0.5 + 40 6.0 1.0 0.5 + 99999999 6.5 0.5 2.0 +*MAT_ELASTIC_TITLE +Aluminium +$ mid ro e pr + 1 2700.0 70000000000.0 0.33 +*SECTION_SOLID +$ secid elform + 1 23 +*SECTION_SOLID +$ secid elform + 2 16 +*SECTION_SOLID +$ secid elform + 3 13 +*SECTION_SOLID +$ secid elform + 4 2 +*PART +hex20 +$ pid secid mid eosid hgid + 1 1 1 +*PART +tet10 +$ pid secid mid eosid hgid + 2 2 1 +*PART +tet4 +$ pid secid mid eosid hgid + 3 3 1 +*PART +wedge +$ pid secid mid eosid hgid + 4 4 1 +*ELEMENT_SOLID +$ eid pid +$ n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 + 1 3 + 1 2 3 4 4 4 4 4 + 2 2 + 5 6 7 8 9 10 11 13 14 12 + 3 4 + 16 15 18 19 17 17 20 20 +*ELEMENT_SOLID_H20 +$ eid pid +$ n1 n2 n3 n4 n5 n6 n7 n8 n9 n10 + 4 1 + 21 22 23 24 25 26 27 28 29 30 + 31 32 33 34 35 36 37 38 39 40 +*SET_NODE_LIST_TITLE +base +$ sid + 1 + 1 2 3 4 +*SET_NODE_LIST_TITLE +lid +$ sid + 3 + 25 26 27 28 33 34 35 36 +*SET_NODE_LIST_TITLE +tet10 +$ sid + 4 + 5 6 7 8 9 10 11 12 + 13 14 +$ coupling `spider` +*CONSTRAINED_INTERPOLATION +$ icid dnid ddof cidd ityp idnsw fgm + 1 99999999 123456 1 1 +$ inid idof + 3 123 +*TITLE +Load +*CONTROL_IMPLICIT_GENERAL +$ imflag dt0 + 1 1.0 +*CONTROL_IMPLICIT_SOLUTION +$ nsolvr + 1 +*CONTROL_TERMINATION +$ endtim + 1.0 +*DEFINE_CURVE_TITLE +Load ramp +$ lcid + 1 +$ a1 o1 + 0.0 0.0 + 1.0 1.0 +*BOUNDARY_SPC_SET_ID + 1hold +$ nsid cid dofx dofy dofz dofrx dofry dofrz + 1 1 1 1 1 1 1 +*BOUNDARY_PRESCRIBED_MOTION_SET_ID + 2lift +$ nsid dof vad lcid sf + 4 3 2 1 0.001 +*LOAD_NODE_POINT +$ nid dof lcid sf + 25 1 1 -83.33333333333337 + 26 1 1 -83.33333333333336 + 27 1 1 -83.33333333333323 + 28 1 1 -83.3333333333333 + 33 1 1 333.3333333333333 + 34 1 1 333.33333333333337 + 35 1 1 333.3333333333334 + 36 1 1 333.3333333333333 + 99999999 3 1 50.0 +*DATABASE_BINARY_D3PLOT +$ dt + 1.0 +*DATABASE_SPCFORC +$ dt + 1.0 +*DATABASE_EXTENT_BINARY +$ neiph neips maxint strflg + 1 +*END diff --git a/crates/engine/src/cmd/deck.rs b/crates/engine/src/cmd/deck.rs index e8aa76f..206becc 100644 --- a/crates/engine/src/cmd/deck.rs +++ b/crates/engine/src/cmd/deck.rs @@ -10,7 +10,7 @@ use crate::{CommandKind, CommandSpec, EngineError, Result, Session}; pub const WRITE: CommandSpec = CommandSpec { id: "deck.write", label: "Write Solver Deck", - params: r#"{"format": "abaqus|nastran", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: modelcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); fails naming every error of the model check and everything the format cannot express"#, + params: r#"{"format": "abaqus|nastran|lsdyna", "path": "part.inp", "steps": ["Load"]} — writes the model as a solver deck (formats: modelcraft_decks::FORMATS); `steps` (optional) writes only those steps, as a Nastran run needs (static or frequency steps, not both); LS-DYNA writes several steps as *CASE analyses (run it with CASE); fails naming every error of the model check and everything the format cannot express"#, kind: CommandKind::Session, run: write, }; @@ -77,4 +77,30 @@ mod tests { } let _ = std::fs::remove_dir_all(&dir); } + + #[test] + fn writes_lsdyna_keyword_decks() { + let dir = std::env::temp_dir().join(format!("modelcraft-deck-lsdyna-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("bar.k"); + let mut s = Session::default(); + crate::cmd::define::tests::defined_bar(&mut s); + let out = s.execute("deck.write", &json!({"format": "lsdyna", "path": path.to_string_lossy()})).unwrap(); + let written = std::fs::read_to_string(&path).unwrap(); + assert_eq!(written, modelcraft_decks::lsdyna::write(&s.model).unwrap().text); + assert_eq!(out["format"], "lsdyna"); + assert!(written.starts_with("*KEYWORD long=y\n") && written.contains("*CASE_BEGIN_2"), "two steps, two cases"); + // One chosen step: a single analysis, no cases. + s.execute("deck.write", &json!({"format": "lsdyna", "path": path.to_string_lossy(), "steps": ["Modes"]})).unwrap(); + let one = std::fs::read_to_string(&path).unwrap(); + assert!(one.contains("*CONTROL_IMPLICIT_EIGENVALUE") && !one.contains("*CASE_BEGIN") && !one.contains("*CONTROL_IMPLICIT_SOLUTION"), "{one}"); + // What LS-DYNA cannot take is named and nothing is written. + s.execute("model.load.create", &json!({"name": "heat", "type": "body_force", "set": "all", "vector": [0, 0, 1]})).unwrap(); + s.execute("model.step.activate", &json!({"step": "Bend", "loads": ["heat"]})).unwrap(); + let other = dir.join("heat.k"); + let e = s.execute("deck.write", &json!({"format": "lsdyna", "path": other.to_string_lossy()})).unwrap_err().to_string(); + assert!(e.contains("not supported by LS-DYNA") && e.contains("load `heat`"), "{e}"); + assert!(!other.exists()); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f90a770..96c919c 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -238,6 +238,9 @@ mod tests { json!({"format": "abaqus", "path": format!("/no/such/{}", "x".repeat(100_000))}), json!({"format": "abaqus", "path": 7}), json!({"format": "x".repeat(100_000), "path": "/no/such/part.inp"}), + json!({"format": "lsdyna", "path": "/no/such/dir/part.k"}), + json!({"format": "lsdyna", "path": "/"}), + json!({"format": "lsdyna", "path": "x\u{0}.k"}), json!({"format": null, "path": "/no/such/part.inp"}), json!({"format": "nastran", "path": "/no/such/dir/part.bdf"}), json!({"format": "nastran", "path": "/no/such/dir/part.bdf", "steps": ["Bend"]}), diff --git a/docs/parity.md b/docs/parity.md index 4bad3a5..aac208f 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -58,7 +58,7 @@ products are run, no documentation text is copied). Status: **done**, **started* | Interaction | Bolt pretension, springs, point masses, connectors | M7 | | planned | | Decks | Abaqus input writer (`deck.write`, flat deck; reports what Abaqus cannot express) | M3 | | done | | Decks | Nastran bulk data writer (`deck.write`, SOL 101 or 103 per deck; reports what Nastran cannot express) | M3 | | done | -| Decks | LS-DYNA keyword writer (implicit; explicit in M8) | M3 | | planned | +| Decks | LS-DYNA keyword writer (implicit; explicit in M8) | M3 | | done (implicit static and eigenvalue; beam sections planned) | | Decks | Readers keeping unknown keywords | M9 | | planned | | Solve | Own linear static and modal solver, verified against analytic solutions | M4 | yes | planned | | Results | Deformed shape, contours, mode shapes, probes, reaction sums | M4 | | planned | @@ -88,3 +88,15 @@ products are run, no documentation text is copied). Status: **done**, **started* linear solids, hybrid solids, reduced-integration or one-point shells, body forces, gravity on part of the model, orthotropic beams and rods, and orthotropic shells off planes of constant Z are reported as unsupported. Static and frequency steps need separate decks (`deck.write` `steps`). +- LS-DYNA (`modelcraft_decks::lsdyna`, `deck.write {"format": "lsdyna"}`): long format (20-column + fields; every value with up to 13 significant digits, and 17-digit values from 0.01 to 1e17, are + written exactly; the rest is rounded to at least 13 digits and counted). The 10-node tetrahedron + follows the manual's figure: mid-side nodes 8, 9, 10 on edges 2-4, 3-4, 1-4 (the model has them + on 1-4, 2-4, 3-4). Incompatible modes map to ELFORM −18 (eight-point enhanced strain). Several + steps are written as `*CASE` analyses. Refused with the item named: beam sections (not yet), + body forces per volume, partial-DOF kinematic couplings, constraints on a coupled reference + point, gravity on different sets or on parts of sections, hybrid other than tet4, reduced + quadratic elements, materials without density, more than 10 shell integration points. +- Open item (LS-DYNA tet10 node order): the writer follows the manual's figure (mid-side nodes 8, + 9, 10 on edges 2-4, 3-4, 1-4), which disagrees with an earlier reading as the Abaqus order. It + needs one confirmation run by a licensed user; agents never run commercial solvers.