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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* docs: LS-DYNA writer status and deck notes

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[email protected]>
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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

---------

Co-authored-by: jyviko <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-13 17:39:45 -04:00
1 parent f4a0333d28
commit 81fa8d7220
13 files changed
+2353 -3

No files matched your search

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