Files
OneCAD-Simulation/crates/model/src/steps.rs
T
jyvikoandClaude Opus 5.5 0966dfa2a6 engine: result units, per-step staleness, step symbols, true-scale nonlinear results
- Quantity names the unit of every number the results show from the model's
  unit system (mm, MPa, N, N·mm, mJ, mW, °C or K); Field::title puts it in the
  legend title, and calls a mode shape normalised (#321).
- A solved step's result stays current through edits that leave what it reads
  alone (Model::step_inputs: other steps and what only they use), and comes
  back current on undo (#318).
- Session::step_glyphs: the symbols of what a solved step used, for drawing
  over its results; no load arrows on a frequency step's modes (#327).
- Nonlinear static steps open deformed at true scale like explicit ones, and
  their automatic scale is 1 (auto_scale, shared by the viewport and the
  headless pictures) (#326).
- results.reactions with an empty sets list sums the step's boundary-condition
  sets, as its form says (#328).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
2026-10-15 09:36:54 -04:00

1186 lines
53 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Analysis steps and the step × load/boundary-condition table.
//!
//! Steps run in order. Each step lists the boundary conditions and loads active in it, by name;
//! nothing carries over from one step to the next unless it is listed again, so the table a user
//! sees is the whole truth (no propagation, no hidden modified or inactive states). The state
//! before the first step is the unloaded, unconstrained model; a boundary condition active in
//! every step can be written at model level by formats that have one.
use std::collections::{BTreeMap, BTreeSet};
use serde::{Deserialize, Serialize};
use crate::{Error, Friction, Model, Result, check_name, context, finite, positive, require};
/// Most steps in a model.
pub const MAX_STEPS: usize = 1000;
/// Most modes a frequency step extracts.
pub const MAX_MODES: u32 = 100_000;
/// Most time increments an explicit dynamic step may ask for.
pub const MAX_INCREMENTS: u64 = 1_000_000_000;
/// Most field frames an explicit dynamic step writes (end time over output interval).
pub const MAX_FRAMES: f64 = 10_000.0;
/// Most history samples an explicit dynamic step records (end time over history interval).
pub const MAX_HISTORY_SAMPLES: f64 = 1_000_000.0;
/// Most initial velocities or rigid walls an explicit dynamic step lists.
pub const MAX_STEP_ITEMS: usize = 10_000;
/// Most time steps a fluid step asks for (the solver caps a run lower by default).
pub const MAX_FLUID_STEPS: u64 = 1_000_000_000;
/// Most output frames a fluid step writes.
pub const MAX_FLUID_FRAMES: u32 = 1000;
fn default_frames() -> u32 {
10
}
/// What a step solves.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Procedure {
/// Linear static equilibrium under the active loads and boundary conditions.
Static {},
/// Natural frequencies and mode shapes: the lowest `modes` modes, optionally only those
/// between `min_hz` and `max_hz` (cycles per time unit of the unit system).
Frequency {
modes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
min_hz: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
max_hz: Option<f64>,
},
/// Linear buckling: the load factors λ at which the step's loads (and prescribed motions),
/// applied as a linear preload, make K + λ K_σ singular, and their mode shapes; the lowest
/// `modes` positive ones, or those nearest above `shift` when one is given.
Buckle {
modes: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
shift: Option<f64>,
},
/// Transient dynamics by explicit time integration up to `end_time`.
ExplicitDynamic(ExplicitDynamic),
/// Time-dependent incompressible flow in a fluid domain, from rest (or the domain's initial
/// velocity) to `end_time`, or for `steps` time steps of `time_step` (exactly one of the two
/// lengths). The solver picks the time step when `time_step` is left out. `frames` velocity
/// and pressure fields are written, evenly spaced, after the initial one; forces on every
/// wall are recorded throughout. `magic` is the lattice Boltzmann two-relaxation-time
/// parameter Λ (default 3/16, which puts bounce-back walls exactly halfway between cells).
Fluid {
domain: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
end_time: Option<f64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
steps: Option<u64>,
#[serde(default, skip_serializing_if = "Option::is_none")]
time_step: Option<f64>,
#[serde(default = "default_frames")]
frames: u32,
#[serde(default, skip_serializing_if = "Option::is_none")]
magic: Option<f64>,
},
/// Heat conduction: the steady temperature field, or its history over time when `transient`
/// is given. Temperature boundary conditions and heat loads (flux, convection, sources)
/// act; mechanical ones are ignored.
HeatTransfer {
#[serde(default, skip_serializing_if = "Option::is_none")]
transient: Option<Transient>,
},
/// Steady heat conduction and the static equilibrium of the thermal strains it causes,
/// together: every boundary condition and load of the step acts.
CoupledTemperatureDisplacement {},
/// Static equilibrium followed in load increments with Newton–Raphson iterations: large
/// displacements, rotations and strains (`nonlinear_geometry`), hyperelastic and plastic
/// materials and follower pressure. Loads and prescribed displacements are the step's load
/// factor times their values: a ramp from 0 to 1, or the step's load path.
NonlinearStatic(NonlinearStatic),
}
/// Most load increments a nonlinear static step may take.
pub const MAX_NONLINEAR_INCREMENTS: u32 = 100_000;
/// Most Newton iterations a nonlinear static step may take in one increment.
pub const MAX_NONLINEAR_ITERATIONS: u32 = 1000;
/// The settings of a nonlinear static step.
///
/// The step's load factor λ runs from 0 to 1: every load is λ times its value and every
/// prescribed displacement λ times its value (a linear ramp). Increments are fractions of the
/// step: the first is `initial_increment`; an increment that converges quickly lets the next
/// grow (up to `max_increment`), one that diverges, fails to converge in `max_iterations` or
/// inverts an element is retried with a quarter of its size, and the step fails, naming the
/// load factor and the reason, when an increment would fall below `min_increment` or the step
/// has taken `max_increments` increments.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct NonlinearStatic {
/// Large displacements, rotations and strains (total Lagrangian elements, follower
/// pressure); `false` keeps the kinematics of small displacements (only the materials are
/// nonlinear).
pub nonlinear_geometry: bool,
/// The first increment, as a fraction of the step.
pub initial_increment: f64,
/// The smallest increment a cutback may reach.
pub min_increment: f64,
/// The largest increment.
pub max_increment: f64,
/// Cap on the increments (cutbacks included).
pub max_increments: u32,
/// Cap on the Newton iterations of one increment.
pub max_iterations: u32,
/// Convergence tolerances.
pub tolerances: Tolerances,
/// A line search along each Newton correction (off by default: full Newton steps converge
/// best for elastic large-rotation problems; a search helps where the material softens).
pub line_search: bool,
/// The load factor against the step fraction: piecewise linear through these
/// `[fraction, factor]` points, from `[0, 0]` to fraction 1 (`None`: the linear ramp, the
/// factor equal to the fraction). Loads and prescribed displacements are the factor times
/// their values, so a path that comes down unloads and one that turns negative reverses
/// them (cyclic loading). Increments end at every point of the path.
#[serde(skip_serializing_if = "Option::is_none")]
pub load_path: Option<Vec<[f64; 2]>>,
/// A geometric imperfection: the mode shape of a buckling step, scaled, added to the node
/// coordinates before this step is solved (the step and its results are on that geometry).
#[serde(skip_serializing_if = "Option::is_none")]
pub imperfection: Option<Imperfection>,
/// Arc-length (Riks–Crisfield) control: the load factor becomes an unknown, so the step can
/// follow its equilibrium path through limit points (snap-through, snap-back). The first
/// increment's load factor change is `initial_increment`; the increment sizes become arc
/// lengths in proportion. `None`: load control.
#[serde(skip_serializing_if = "Option::is_none")]
pub arc_length: Option<ArcLength>,
}
/// The constraint of an arc-length step.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ArcConstraint {
/// ‖Δu‖ = Δl (Crisfield's cylindrical arc).
#[default]
Cylindrical,
/// ‖Δu‖² + Δλ² ‖u_f‖² = Δl², u_f the first increment's tangent displacement of the unit
/// load (the spherical arc, scaled to displacement units).
Spherical,
}
/// When an arc-length step ends: at `max_load_factor` (default 1), when the displacement of
/// `node` along `direction` (1, 2, 3) reaches `displacement` in magnitude, or at the step's
/// increment cap, whichever comes first.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ArcLength {
pub constraint: ArcConstraint,
pub max_load_factor: f64,
#[serde(skip_serializing_if = "Option::is_none")]
pub displacement_limit: Option<DisplacementLimit>,
}
impl Default for ArcLength {
fn default() -> ArcLength {
ArcLength { constraint: ArcConstraint::Cylindrical, max_load_factor: 1.0, displacement_limit: None }
}
}
/// A displacement that ends an arc-length step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DisplacementLimit {
pub node: u32,
pub direction: u8,
pub displacement: f64,
}
/// Mode `mode` (from 1) of buckling step `step`, scaled so its largest component is `scale`
/// (a length), as an initial imperfection.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Imperfection {
pub step: String,
pub mode: u32,
pub scale: f64,
}
/// Most points of a nonlinear step's load path.
pub const MAX_LOAD_PATH_POINTS: usize = 1000;
/// When an increment's Newton iterations have converged: all three hold.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct Tolerances {
/// The largest residual force component over the largest force (applied, internal or
/// reaction) the step has seen.
pub force: f64,
/// The largest displacement correction over the largest displacement of the increment.
pub displacement: f64,
/// The work of the last correction against the residual over that of the first.
pub energy: f64,
}
impl Default for Tolerances {
fn default() -> Tolerances {
Tolerances { force: 1e-6, displacement: 1e-5, energy: 1e-10 }
}
}
impl Default for NonlinearStatic {
fn default() -> NonlinearStatic {
NonlinearStatic {
nonlinear_geometry: true,
initial_increment: 0.1,
min_increment: 1e-5,
max_increment: 1.0,
max_increments: 100,
max_iterations: 16,
tolerances: Tolerances::default(),
line_search: false,
load_path: None,
imperfection: None,
arc_length: None,
}
}
}
impl NonlinearStatic {
pub fn validate(&self) -> Result<()> {
let fraction = |what: &str, v: f64| {
if v > 0.0 && v <= 1.0 { Ok(v) } else { Err(Error::Invalid(format!("the {what} is a fraction of the step in (0, 1], got {v}"))) }
};
if let Some(arc) = &self.arc_length {
if !(arc.max_load_factor.is_finite() && arc.max_load_factor > 0.0) {
return Err(Error::Invalid(format!("an arc-length step's maximum load factor is a positive number, got {}", arc.max_load_factor)));
}
if let Some(l) = &arc.displacement_limit {
if !(1..=3).contains(&l.direction) {
return Err(Error::Invalid(format!("a displacement limit's direction is 1, 2 or 3, got {}", l.direction)));
}
if !(l.displacement.is_finite() && l.displacement != 0.0) {
return Err(Error::Invalid(format!("a displacement limit is a nonzero number, got {}", l.displacement)));
}
}
if self.load_path.is_some() {
return Err(Error::Invalid("an arc-length step finds its own load factor; it takes no load path".into()));
}
}
if let Some(imp) = &self.imperfection {
if !(1..=MAX_MODES).contains(&imp.mode) {
return Err(Error::Invalid(format!("an imperfection takes mode 1 to {MAX_MODES}, got {}", imp.mode)));
}
finite("imperfection scale", imp.scale)?;
}
let initial = fraction("initial increment", self.initial_increment)?;
let min = fraction("minimum increment", self.min_increment)?;
let max = fraction("maximum increment", self.max_increment)?;
if !(min <= initial && initial <= max) {
return Err(Error::Invalid(format!("the increments must satisfy minimum ≤ initial ≤ maximum, got {min}, {initial} and {max}")));
}
if !(1..=MAX_NONLINEAR_INCREMENTS).contains(&self.max_increments) {
return Err(Error::Invalid(format!("a nonlinear step takes 1 to {MAX_NONLINEAR_INCREMENTS} increments, got {}", self.max_increments)));
}
if !(1..=MAX_NONLINEAR_ITERATIONS).contains(&self.max_iterations) {
return Err(Error::Invalid(format!(
"a nonlinear step takes 1 to {MAX_NONLINEAR_ITERATIONS} iterations per increment, got {}",
self.max_iterations
)));
}
let t = self.tolerances;
for (what, v) in [("force", t.force), ("displacement", t.displacement), ("energy", t.energy)] {
if !(v > 0.0 && v < 1.0) {
return Err(Error::Invalid(format!("the {what} tolerance is in (0, 1), got {v}")));
}
}
if let Some(path) = &self.load_path {
if !(2..=MAX_LOAD_PATH_POINTS).contains(&path.len()) {
return Err(Error::Invalid(format!("a load path has 2 to {MAX_LOAD_PATH_POINTS} points, got {}", path.len())));
}
if path.first() != Some(&[0.0, 0.0]) || path.last().map(|p| p[0]) != Some(1.0) {
return Err(Error::Invalid("a load path starts at [0, 0] (the unloaded start of the step) and ends at fraction 1".into()));
}
if path.iter().flatten().any(|v| !v.is_finite()) {
return Err(Error::Invalid("the points of a load path must be finite".into()));
}
if path.windows(2).any(|w| w.get(1).map(|b| b[0]) <= w.first().map(|a| a[0])) {
return Err(Error::Invalid("the fractions of a load path must increase".into()));
}
}
Ok(())
}
/// The load factor at step fraction `t` (in [0, 1]).
pub fn load_factor(&self, t: f64) -> f64 {
let Some(path) = &self.load_path else { return t };
for w in path.windows(2) {
if let [[t0, f0], [t1, f1]] = *w
&& t <= t1
{
return if t1 > t0 { f0 + (f1 - f0) * ((t - t0) / (t1 - t0)).clamp(0.0, 1.0) } else { f1 };
}
}
path.last().map_or(t, |p| p[1])
}
/// The first point of the load path after fraction `t` (1 without a path), where an
/// increment from `t` must end.
pub fn next_turn(&self, t: f64) -> f64 {
self.load_path.as_ref().and_then(|path| path.iter().map(|p| p[0]).find(|f| *f > t * (1.0 + 1e-12) + 1e-12)).unwrap_or(1.0)
}
}
/// Most time increments of a transient heat transfer step.
pub const MAX_HEAT_INCREMENTS: f64 = 100_000.0;
/// Time integration of a transient heat transfer step: the generalized trapezoidal (θ) method
/// with fixed increments, from a uniform initial temperature.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Transient {
pub end_time: f64,
pub increment: f64,
/// 1 is backward Euler (the default), ½ Crank–Nicolson; θ ≥ ½ is unconditionally stable.
#[serde(default = "default_theta")]
pub theta: f64,
#[serde(default)]
pub initial_temperature: f64,
}
fn default_theta() -> f64 {
1.0
}
impl Transient {
/// Backward Euler from 0.
pub fn new(end_time: f64, increment: f64) -> Transient {
Transient { end_time, increment, theta: default_theta(), initial_temperature: 0.0 }
}
/// The number of increments (the last one may be shorter).
pub fn increments(&self) -> usize {
(self.end_time / self.increment - 1e-9).ceil().max(1.0) as usize
}
pub fn validate(&self) -> Result<()> {
let end = positive("end time", self.end_time)?;
let dt = positive("time increment", self.increment)?;
if end / dt > MAX_HEAT_INCREMENTS {
return Err(Error::Invalid(format!("a transient heat step takes at most {MAX_HEAT_INCREMENTS} increments, got {}", end / dt)));
}
if !(0.5..=1.0).contains(&self.theta) {
return Err(Error::Invalid(format!("θ is in [0.5, 1] (unconditionally stable), got {}", self.theta)));
}
finite("initial temperature", self.initial_temperature)?;
Ok(())
}
}
/// The settings of an explicit dynamic step.
///
/// Loads act in full from the start of the step; a displacement boundary condition with a value
/// is reached at constant velocity over the step (value / `end_time`), a zero value holds the
/// node. The step starts from the undeformed model at rest, apart from `initial_velocities`.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExplicitDynamic {
/// Duration of the step (time units of the unit system).
pub end_time: f64,
/// Fraction of the stable time increment taken (0 < scale ≤ 1).
#[serde(default = "default_dt_scale")]
pub dt_scale: f64,
/// Cap on the time increments; the run stops early (and says so) when it is reached.
#[serde(default = "default_max_increments")]
pub max_increments: u64,
/// Time between field frames (displacement, velocity, stress); by default a twentieth of the
/// step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub output_interval: Option<f64>,
/// Time between history samples (energies, momentum, constraint forces and the histories of
/// `history_nodes`); by default a thousandth of the step.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_interval: Option<f64>,
/// A set whose nodes' displacement and velocity are recorded as histories.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub history_nodes: Option<String>,
/// Velocities of the nodes of sets at the start of the step (a node in several sets takes
/// the last one listed).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub initial_velocities: Vec<InitialVelocity>,
/// Fixed rigid planes the nodes of sets cannot pass through, with Coulomb friction.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub rigid_walls: Vec<RigidWall>,
/// Hourglass control of one-point hexahedra.
#[serde(default)]
pub hourglass: Hourglass,
/// Artificial bulk viscosity.
#[serde(default)]
pub bulk_viscosity: BulkViscosity,
/// Large deformation (updated Lagrangian: gradients of the current configuration, stress
/// updated in rate form with an objective stress rate); `false` keeps the gradients of the
/// undeformed mesh (small displacements and rotations).
#[serde(default = "yes")]
pub nonlinear_geometry: bool,
/// The objective stress rate of large deformation.
#[serde(default)]
pub stress_rate: StressRate,
/// Opt-in mass scaling: the elements whose stable increment is below a target get the
/// density that brings them to it (the added mass is reported).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mass_scaling: Option<MassScaling>,
}
/// Mass scaling to a target time increment: every element (of `set`, or of the whole model) whose
/// stable increment, times the step's scale, is below `target_increment` has its density raised
/// by (target / increment)², which brings it to the target. It changes the dynamics: the added
/// mass is reported, per section, with its share of the kinetic energy.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct MassScaling {
pub target_increment: f64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub set: Option<String>,
}
fn yes() -> bool {
true
}
/// The objective stress rate a large-deformation stress update uses.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum StressRate {
/// Jaumann (co-rotational with the spin), integrated with the Hughes–Winget rotation.
#[default]
Jaumann,
/// Green–Naghdi (co-rotational with the rotation of the polar decomposition), integrated as
/// Flanagan and Taylor do.
GreenNaghdi,
}
fn default_dt_scale() -> f64 {
0.9
}
fn default_max_increments() -> u64 {
10_000_000
}
impl ExplicitDynamic {
/// A step of `end_time` with every other setting at its default.
pub fn new(end_time: f64) -> ExplicitDynamic {
ExplicitDynamic {
end_time,
dt_scale: default_dt_scale(),
max_increments: default_max_increments(),
output_interval: None,
history_interval: None,
history_nodes: None,
initial_velocities: Vec::new(),
rigid_walls: Vec::new(),
hourglass: Hourglass::default(),
bulk_viscosity: BulkViscosity::default(),
nonlinear_geometry: true,
stress_rate: StressRate::default(),
mass_scaling: None,
}
}
/// The time between field frames.
pub fn output_interval(&self) -> f64 {
self.output_interval.unwrap_or(self.end_time / 20.0)
}
/// The time between history samples.
pub fn history_interval(&self) -> f64 {
self.history_interval.unwrap_or(self.end_time / 1000.0)
}
/// The sets the step refers to.
pub fn sets(&self) -> impl Iterator<Item = &str> {
self.history_nodes
.iter()
.map(String::as_str)
.chain(self.initial_velocities.iter().map(|v| v.set.as_str()))
.chain(self.rigid_walls.iter().map(|w| w.set.as_str()))
.chain(self.mass_scaling.iter().filter_map(|m| m.set.as_deref()))
}
pub fn validate(&self) -> Result<()> {
let end = positive("end time", self.end_time)?;
if !(self.dt_scale > 0.0 && self.dt_scale <= 1.0) {
return Err(Error::Invalid(format!("the time increment scale is in (0, 1], got {}", self.dt_scale)));
}
if !(1..=MAX_INCREMENTS).contains(&self.max_increments) {
return Err(Error::Invalid(format!("an explicit step takes 1 to {MAX_INCREMENTS} increments, got {}", self.max_increments)));
}
for (what, interval, most) in
[("output interval", self.output_interval, MAX_FRAMES), ("history interval", self.history_interval, MAX_HISTORY_SAMPLES)]
{
if let Some(i) = interval {
positive(what, i)?;
if end / i > most {
return Err(Error::Invalid(format!("the {what} {i} gives more than {most} samples over the step")));
}
}
}
if self.initial_velocities.len() > MAX_STEP_ITEMS || self.rigid_walls.len() > MAX_STEP_ITEMS {
return Err(Error::Limit(format!("a step lists at most {MAX_STEP_ITEMS} initial velocities and rigid walls")));
}
for v in &self.initial_velocities {
for c in v.velocity {
finite("initial velocity", c)?;
}
}
for w in &self.rigid_walls {
for c in w.point.iter().chain(&w.normal) {
finite("rigid wall", *c)?;
}
if !(w.normal.iter().map(|c| c * c).sum::<f64>() > 0.0) {
return Err(Error::Invalid("a rigid wall's normal must not be zero".into()));
}
w.friction.validate().map_err(|e| Error::Invalid(format!("rigid wall on set `{}`: {e}", w.set)))?;
}
if let Some(m) = &self.mass_scaling {
positive("mass scaling target increment", m.target_increment)?;
if m.target_increment > end {
return Err(Error::Invalid(format!("the mass scaling target increment {} is longer than the step", m.target_increment)));
}
}
self.hourglass.validate()?;
self.bulk_viscosity.validate()
}
}
/// A velocity given to the nodes of a set at the start of an explicit step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct InitialVelocity {
pub set: String,
pub velocity: [f64; 3],
}
/// A fixed rigid plane through `point`. Its `normal` points to the side the nodes of `set` stay
/// on; nodes sliding on it feel `friction` (frictionless by default).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RigidWall {
pub set: String,
pub point: [f64; 3],
pub normal: [f64; 3],
#[serde(default, skip_serializing_if = "Friction::is_frictionless")]
pub friction: Friction,
}
/// How one-point hexahedra resist their hourglass (zero-energy) modes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HourglassKind {
/// Forces proportional to the hourglass velocities.
#[default]
Viscous,
/// Forces proportional to the hourglass displacements.
Stiffness,
}
/// Hourglass control: the kind and a dimensionless coefficient.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Hourglass {
#[serde(rename = "type", default)]
pub kind: HourglassKind,
#[serde(default = "default_hourglass_coefficient")]
pub coefficient: f64,
}
/// Largest hourglass coefficient.
pub const MAX_HOURGLASS_COEFFICIENT: f64 = 0.25;
fn default_hourglass_coefficient() -> f64 {
0.1
}
impl Default for Hourglass {
fn default() -> Hourglass {
Hourglass { kind: HourglassKind::Viscous, coefficient: default_hourglass_coefficient() }
}
}
impl Hourglass {
pub fn validate(&self) -> Result<()> {
if !(0.0..=MAX_HOURGLASS_COEFFICIENT).contains(&self.coefficient) {
return Err(Error::Invalid(format!("the hourglass coefficient is in 0..={MAX_HOURGLASS_COEFFICIENT}, got {}", self.coefficient)));
}
Ok(())
}
}
/// Artificial bulk viscosity in compression: a pressure `ρ L (quadratic L ε̇ᵥ² − linear c ε̇ᵥ)`
/// for a volumetric strain rate ε̇ᵥ < 0, element size L and wave speed c.
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BulkViscosity {
#[serde(default = "default_linear_viscosity")]
pub linear: f64,
#[serde(default = "default_quadratic_viscosity")]
pub quadratic: f64,
}
/// Largest bulk viscosity coefficient.
pub const MAX_BULK_VISCOSITY: f64 = 10.0;
fn default_linear_viscosity() -> f64 {
0.06
}
fn default_quadratic_viscosity() -> f64 {
1.5
}
impl Default for BulkViscosity {
fn default() -> BulkViscosity {
BulkViscosity { linear: default_linear_viscosity(), quadratic: default_quadratic_viscosity() }
}
}
impl BulkViscosity {
pub fn validate(&self) -> Result<()> {
for (what, v) in [("linear", self.linear), ("quadratic", self.quadratic)] {
if !(0.0..=MAX_BULK_VISCOSITY).contains(&v) {
return Err(Error::Invalid(format!("the {what} bulk viscosity coefficient is in 0..={MAX_BULK_VISCOSITY}, got {v}")));
}
}
Ok(())
}
}
impl Procedure {
/// The procedure's type, as the model file names it.
pub fn name(&self) -> &'static str {
match self {
Procedure::Static {} => "static",
Procedure::Frequency { .. } => "frequency",
Procedure::Buckle { .. } => "buckle",
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
Procedure::HeatTransfer { .. } => "heat_transfer",
Procedure::CoupledTemperatureDisplacement {} => "coupled_temperature_displacement",
Procedure::Fluid { .. } => "fluid",
Procedure::NonlinearStatic(_) => "nonlinear_static",
}
}
/// The fluid domain a fluid step solves in.
pub fn fluid_domain(&self) -> Option<&str> {
match self {
Procedure::Fluid { domain, .. } => Some(domain),
_ => None,
}
}
/// Whether the procedure solves for the motion of the solids (not pure heat transfer or
/// fluid flow).
pub fn is_structural(&self) -> bool {
!matches!(self, Procedure::HeatTransfer { .. } | Procedure::Fluid { .. })
}
/// Whether the procedure solves for temperatures.
pub fn is_thermal(&self) -> bool {
matches!(self, Procedure::HeatTransfer { .. } | Procedure::CoupledTemperatureDisplacement {})
}
/// Whether the procedure is transient dynamics (it needs mass; a free body is legitimate).
pub fn is_dynamic(&self) -> bool {
matches!(self, Procedure::ExplicitDynamic(_))
}
/// The sets the procedure's own settings refer to.
pub fn sets(&self) -> impl Iterator<Item = &str> {
let explicit = match self {
Procedure::ExplicitDynamic(e) => Some(e),
_ => None,
};
explicit.into_iter().flat_map(ExplicitDynamic::sets)
}
pub fn validate(&self) -> Result<()> {
if let Procedure::ExplicitDynamic(e) = self {
return e.validate();
}
if let Procedure::NonlinearStatic(n) = self {
return n.validate();
}
if let Procedure::Fluid { end_time, steps, time_step, frames, magic, .. } = *self {
match (end_time, steps) {
(Some(t), None) => {
crate::positive("end time", t)?;
}
(None, Some(n)) if (1..=MAX_FLUID_STEPS).contains(&n) => {}
(None, Some(n)) => return Err(Error::Invalid(format!("a fluid step runs 1 to {MAX_FLUID_STEPS} time steps, got {n}"))),
_ => return Err(Error::Invalid("a fluid step needs exactly one of `end_time` and `steps`".into())),
}
if let Some(dt) = time_step {
crate::positive("time step", dt)?;
}
if !(1..=MAX_FLUID_FRAMES).contains(&frames) {
return Err(Error::Invalid(format!("a fluid step writes 1 to {MAX_FLUID_FRAMES} frames, got {frames}")));
}
if let Some(m) = magic
&& !(m > 0.0 && m <= 1.0)
{
return Err(Error::Invalid(format!("the two-relaxation-time parameter Λ must be above 0 and at most 1, got {m}")));
}
}
if let Procedure::HeatTransfer { transient: Some(t) } = self {
return t.validate();
}
if let Procedure::Buckle { modes, shift } = *self {
if !(1..=MAX_MODES).contains(&modes) {
return Err(Error::Invalid(format!("a buckling step extracts 1 to {MAX_MODES} modes, got {modes}")));
}
if let Some(s) = shift {
finite("buckling shift", s)?;
}
}
if let Procedure::Frequency { modes, min_hz, max_hz } = *self {
if !(1..=MAX_MODES).contains(&modes) {
return Err(Error::Invalid(format!("a frequency step extracts 1 to {MAX_MODES} modes, got {modes}")));
}
for f in [min_hz, max_hz].into_iter().flatten() {
if finite("frequency", f)? < 0.0 {
return Err(Error::Invalid(format!("frequencies are not negative, got {f}")));
}
}
if let (Some(lo), Some(hi)) = (min_hz, max_hz)
&& lo >= hi
{
return Err(Error::Invalid(format!("the frequency range {lo}..{hi} is empty")));
}
}
Ok(())
}
/// The output a step of this procedure requests unless told otherwise.
pub fn default_output(&self) -> BTreeSet<OutputVariable> {
match self {
Procedure::Static {} | Procedure::NonlinearStatic(_) => OutputVariable::ALL.into_iter().collect(),
Procedure::Frequency { .. } | Procedure::Buckle { .. } => [OutputVariable::Displacement].into_iter().collect(),
Procedure::ExplicitDynamic(_) => [OutputVariable::Displacement, OutputVariable::Stress].into_iter().collect(),
// Velocity, pressure and wall forces, always.
Procedure::Fluid { .. } => BTreeSet::new(),
Procedure::HeatTransfer { .. } => [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect(),
Procedure::CoupledTemperatureDisplacement {} => {
OutputVariable::ALL.into_iter().chain([OutputVariable::Temperature, OutputVariable::HeatFlux]).collect()
}
}
}
}
/// A result a step writes.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum OutputVariable {
/// Nodal displacements (mode shapes in a frequency step).
Displacement,
/// Reaction forces at constrained nodes.
ReactionForce,
Stress,
Strain,
/// Nodal temperatures (heat transfer).
Temperature,
/// Heat flux at the integration points (heat transfer).
HeatFlux,
}
impl OutputVariable {
/// The structural variables.
pub const ALL: [OutputVariable; 4] =
[OutputVariable::Displacement, OutputVariable::ReactionForce, OutputVariable::Stress, OutputVariable::Strain];
}
/// An analysis step.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Step {
pub name: String,
pub procedure: Procedure,
pub output: BTreeSet<OutputVariable>,
/// Boundary conditions active in this step.
#[serde(default)]
pub boundary_conditions: BTreeSet<String>,
/// Loads active in this step.
#[serde(default)]
pub loads: BTreeSet<String>,
}
impl Step {
/// A step with the procedure's default output and nothing active.
pub fn new(name: impl Into<String>, procedure: Procedure) -> Step {
Step { name: name.into(), output: procedure.default_output(), procedure, boundary_conditions: BTreeSet::new(), loads: BTreeSet::new() }
}
/// Change the procedure. Moving between a structural and a fluid procedure starts from the
/// new procedure's default output and, for a fluid step, nothing active (a fluid step takes
/// its conditions from its domain); between structural procedures the output and table stay.
pub fn set_procedure(&mut self, procedure: Procedure) {
if procedure.fluid_domain().is_some() != self.procedure.fluid_domain().is_some() {
self.output = procedure.default_output();
if procedure.fluid_domain().is_some() {
self.boundary_conditions.clear();
self.loads.clear();
}
}
self.procedure = procedure;
}
}
/// What solving one step reads from a [`Model`] ([`Model::step_inputs`]), borrowed: two models
/// whose step inputs serialize the same give the step the same result.
#[derive(Debug, Serialize)]
pub struct StepInputs<'a> {
nodes: &'a BTreeMap<u32, crate::Node>,
elements: &'a BTreeMap<u32, crate::Element>,
classification: &'a crate::Classification,
units: crate::UnitSystem,
sets: BTreeMap<&'a str, &'a crate::Set>,
materials: &'a BTreeMap<String, crate::Material>,
sections: &'a BTreeMap<String, crate::Section>,
reference_points: &'a BTreeMap<String, crate::ReferencePoint>,
couplings: &'a BTreeMap<String, crate::Coupling>,
contacts: &'a BTreeMap<String, crate::Contact>,
ties: &'a BTreeMap<String, crate::Tie>,
masses: &'a BTreeMap<String, crate::PointMass>,
springs: &'a BTreeMap<String, crate::Spring>,
boundary_conditions: BTreeMap<&'a str, &'a crate::BoundaryCondition>,
loads: BTreeMap<&'a str, &'a crate::Load>,
fluid_domains: &'a BTreeMap<String, crate::FluidDomain>,
step: &'a Step,
}
impl Model {
/// What solving step `name` reads: the mesh, the materials, sections, interactions and every
/// definition that acts in all steps, the step itself and the boundary conditions, loads and
/// sets it uses. Other steps and what only they use are left out (steps are solved each on
/// its own), and so are the meshing inputs (datums, partitions, seeds: the mesh they made is
/// in). `None` when there is no such step.
pub fn step_inputs(&self, name: &str) -> Option<StepInputs<'_>> {
let step = self.step(name)?;
Some(StepInputs {
nodes: &self.nodes,
elements: &self.elements,
classification: &self.classification,
units: self.units,
sets: self.sets.iter().filter(|(n, _)| self.step_uses_set(step, n)).map(|(n, s)| (n.as_str(), s)).collect(),
materials: &self.materials,
sections: &self.sections,
reference_points: &self.reference_points,
couplings: &self.couplings,
contacts: &self.contacts,
ties: &self.ties,
masses: &self.masses,
springs: &self.springs,
boundary_conditions: self
.boundary_conditions
.iter()
.filter(|(n, _)| step.boundary_conditions.contains(*n))
.map(|(n, b)| (n.as_str(), b))
.collect(),
loads: self.loads.iter().filter(|(n, _)| step.loads.contains(*n)).map(|(n, l)| (n.as_str(), l)).collect(),
fluid_domains: &self.fluid_domains,
step,
})
}
/// The steps in analysis order.
pub fn steps(&self) -> &[Step] {
&self.steps
}
pub fn step(&self, name: &str) -> Option<&Step> {
self.steps.iter().find(|s| s.name == name)
}
/// The step named `name`, or the only step whose procedure is `wanted` when no name is
/// given; it must be such a step (`what` names the kind in messages, e.g. "static").
pub fn select_step(&self, name: Option<&str>, wanted: fn(&Procedure) -> bool, what: &str) -> Result<&Step> {
let step = match name {
Some(n) => self.step(n).ok_or_else(|| Error::Invalid(format!("step `{n}` does not exist")))?,
None => {
let matching: Vec<&Step> = self.steps.iter().filter(|s| wanted(&s.procedure)).collect();
match matching.as_slice() {
[one] => *one,
[] => return Err(Error::Invalid(format!("the model has no {what} step"))),
many => {
let names: Vec<&str> = many.iter().map(|s| s.name.as_str()).collect();
return Err(Error::Invalid(format!("name the step: the model has {what} steps {}", names.join(", "))));
}
}
}
};
if !wanted(&step.procedure) {
return Err(Error::Invalid(format!("step `{}` is a {} step, not a {what} one", step.name, step.procedure.name())));
}
Ok(step)
}
fn step_index(&self, name: &str) -> Result<usize> {
self.steps.iter().position(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("step `{name}`")))
}
fn step_mut(&mut self, name: &str) -> Result<&mut Step> {
self.steps.iter_mut().find(|s| s.name == name).ok_or_else(|| Error::NotFound(format!("step `{name}`")))
}
fn check_step(&self, step: &Step) -> Result<()> {
step.procedure.validate().map_err(|e| context("step", &step.name, e))?;
for set in step.procedure.sets() {
require(&self.sets, "set", set).map_err(|e| context("step", &step.name, e))?;
}
if let Some(domain) = step.procedure.fluid_domain() {
require(&self.fluid_domains, "fluid domain", domain).map_err(|e| context("step", &step.name, e))?;
if !(step.boundary_conditions.is_empty() && step.loads.is_empty() && step.output.is_empty()) {
return Err(context(
"step",
&step.name,
Error::Invalid("a fluid step takes its conditions from its fluid domain and always writes velocity, pressure and wall forces: list no boundary conditions, loads or output".into()),
));
}
}
for b in &step.boundary_conditions {
require(&self.boundary_conditions, "boundary condition", b).map_err(|e| context("step", &step.name, e))?;
}
for l in &step.loads {
require(&self.loads, "load", l).map_err(|e| context("step", &step.name, e))?;
}
Ok(())
}
/// Add a step at the end, or just before the step named `before`.
pub fn add_step(&mut self, step: Step, before: Option<&str>) -> Result<()> {
check_name("step", &step.name)?;
if self.step(&step.name).is_some() {
return Err(Error::Invalid(format!("step `{}` already exists", step.name)));
}
if self.steps.len() >= MAX_STEPS {
return Err(Error::Limit(format!("a model holds at most {MAX_STEPS} steps")));
}
self.check_step(&step)?;
let at = match before {
Some(b) => self.step_index(b)?,
None => self.steps.len(),
};
self.steps.insert(at, step);
Ok(())
}
/// Replace a step's definition (procedure, output and table entries), keeping its place.
pub fn replace_step(&mut self, step: Step) -> Result<()> {
self.check_step(&step)?;
let name = step.name.clone();
*self.step_mut(&name)? = step;
Ok(())
}
pub fn remove_step(&mut self, name: &str) -> Result<()> {
let i = self.step_index(name)?;
self.steps.remove(i);
Ok(())
}
/// Make a boundary condition active or inactive in a step.
pub fn set_boundary_condition_active(&mut self, step: &str, name: &str, active: bool) -> Result<()> {
require(&self.boundary_conditions, "boundary condition", name)?;
switch(&mut self.step_mut(step)?.boundary_conditions, name, active);
Ok(())
}
/// Make a load active or inactive in a step.
pub fn set_load_active(&mut self, step: &str, name: &str, active: bool) -> Result<()> {
require(&self.loads, "load", name)?;
switch(&mut self.step_mut(step)?.loads, name, active);
Ok(())
}
}
fn switch(active: &mut BTreeSet<String>, name: &str, on: bool) {
if on {
active.insert(name.to_string());
} else {
active.remove(name);
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{BoundaryCondition, Set, Target};
#[test]
fn steps_keep_order_and_an_explicit_table() {
let mut m = Model::default();
m.add_set("base", Set { nodes: vec![1], ..Set::default() }).unwrap();
m.add_boundary_condition("fix", BoundaryCondition::Fixed { target: Target::Set("base".into()) }).unwrap();
m.add_step(Step::new("Load", Procedure::Static {}), None).unwrap();
m.add_step(Step::new("Modes", Procedure::Frequency { modes: 10, min_hz: None, max_hz: None }), Some("Load")).unwrap();
assert_eq!(m.steps().iter().map(|s| s.name.as_str()).collect::<Vec<_>>(), ["Modes", "Load"]);
assert!(m.add_step(Step::new("Load", Procedure::Static {}), None).is_err());
assert!(m.add_step(Step::new("X", Procedure::Static {}), Some("Nope")).is_err());
assert!(m.add_step(Step::new("Y", Procedure::Frequency { modes: 0, min_hz: None, max_hz: None }), None).is_err());
assert!(m.add_step(Step::new("Y", Procedure::Frequency { modes: 5, min_hz: Some(10.0), max_hz: Some(1.0) }), None).is_err());
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "static", "modes": 3})).is_err(), "typos are errors");
m.set_boundary_condition_active("Load", "fix", true).unwrap();
assert!(m.step("Load").unwrap().boundary_conditions.contains("fix"));
assert!(m.step("Modes").unwrap().boundary_conditions.is_empty(), "no propagation");
assert!(m.set_boundary_condition_active("Load", "nope", true).is_err());
assert!(m.set_load_active("Load", "fix", true).is_err(), "loads and boundary conditions are separate");
// Nothing still referred to can be deleted: the set by the boundary condition, the
// boundary condition by the step that lists it.
assert!(matches!(m.remove_set("base"), Err(Error::InUse(_))));
assert!(m.remove_boundary_condition("fix").unwrap_err().to_string().contains("step `Load`"));
m.set_boundary_condition_active("Load", "fix", false).unwrap();
m.remove_boundary_condition("fix").unwrap();
m.remove_set("base").unwrap();
m.remove_step("Modes").unwrap();
assert_eq!(m.steps().len(), 1);
}
#[test]
fn explicit_steps_validate_and_hold_their_sets() {
let mut m = Model::default();
m.add_set("front", Set { nodes: vec![1], ..Set::default() }).unwrap();
let json = serde_json::json!({
"type": "explicit_dynamic", "end_time": 1e-3, "history_nodes": "front",
"initial_velocities": [{"set": "front", "velocity": [-10, 0, 0]}],
"rigid_walls": [{"set": "front", "point": [0, 0, 0], "normal": [1, 0, 0], "friction": {"static": 0.2}}],
"hourglass": {"type": "stiffness"}
});
let p: Procedure = serde_json::from_value(json).unwrap();
let Procedure::ExplicitDynamic(e) = &p else { panic!("{p:?}") };
assert_eq!((e.dt_scale, e.hourglass.kind, e.hourglass.coefficient), (0.9, HourglassKind::Stiffness, 0.1));
assert_eq!(e.bulk_viscosity, BulkViscosity { linear: 0.06, quadratic: 1.5 });
assert!((e.output_interval() - 5e-5).abs() < 1e-18 && (e.history_interval() - 1e-6).abs() < 1e-18);
let back: Procedure = serde_json::from_value(serde_json::to_value(&p).unwrap()).unwrap();
assert_eq!(back, p);
m.add_step(Step::new("Impact", p.clone()), None).unwrap();
assert!(matches!(m.remove_set("front"), Err(Error::InUse(_))), "the step refers to the set");
assert!(m.add_step(Step::new("Impact", p), None).is_err(), "names stay unique");
let missing = serde_json::json!({"type": "explicit_dynamic", "end_time": 1.0, "history_nodes": "nope"});
assert!(m.add_step(Step::new("Bad", serde_json::from_value(missing).unwrap()), None).is_err());
let base = ExplicitDynamic::new(1.0);
let bad = [
ExplicitDynamic { end_time: 0.0, ..base.clone() },
ExplicitDynamic { end_time: f64::NAN, ..base.clone() },
ExplicitDynamic { dt_scale: 1.5, ..base.clone() },
ExplicitDynamic { dt_scale: 0.0, ..base.clone() },
ExplicitDynamic { max_increments: 0, ..base.clone() },
ExplicitDynamic { output_interval: Some(1e-9), ..base.clone() },
ExplicitDynamic { history_interval: Some(-1.0), ..base.clone() },
ExplicitDynamic {
rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [0.0; 3], friction: Friction::NONE }],
..base.clone()
},
ExplicitDynamic {
rigid_walls: vec![RigidWall { set: "front".into(), point: [0.0; 3], normal: [1.0, 0.0, 0.0], friction: Friction::coulomb(f64::NAN) }],
..base.clone()
},
ExplicitDynamic {
initial_velocities: vec![InitialVelocity { set: "front".into(), velocity: [f64::INFINITY, 0.0, 0.0] }],
..base.clone()
},
ExplicitDynamic { hourglass: Hourglass { kind: HourglassKind::Viscous, coefficient: 2.0 }, ..base.clone() },
ExplicitDynamic { bulk_viscosity: BulkViscosity { linear: -1.0, quadratic: 1.0 }, ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: 0.0, set: None }), ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: f64::NAN, set: None }), ..base.clone() },
ExplicitDynamic { mass_scaling: Some(MassScaling { target_increment: 2.0, set: None }), ..base.clone() },
];
for b in bad {
assert!(Procedure::ExplicitDynamic(b.clone()).validate().is_err(), "{b:?}");
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "explicit_dynamic", "end_time": 1, "dt": 2})).is_err());
}
#[test]
fn nonlinear_static_steps_validate_their_controls() {
let p: Procedure = serde_json::from_value(serde_json::json!({"type": "nonlinear_static"})).unwrap();
assert_eq!(p, Procedure::NonlinearStatic(NonlinearStatic::default()));
assert_eq!(p.name(), "nonlinear_static");
assert!(p.validate().is_ok() && p.is_structural() && !p.is_dynamic());
assert_eq!(p.default_output(), Procedure::Static {}.default_output());
let p: Procedure = serde_json::from_value(serde_json::json!({
"type": "nonlinear_static", "nonlinear_geometry": false, "initial_increment": 0.5, "min_increment": 0.01,
"max_increments": 20, "max_iterations": 8, "tolerances": {"force": 1e-4}, "line_search": true
}))
.unwrap();
let Procedure::NonlinearStatic(n) = &p else { panic!("{p:?}") };
assert_eq!((n.nonlinear_geometry, n.max_increment, n.tolerances.force, n.tolerances.energy), (false, 1.0, 1e-4, 1e-10));
let back: Procedure = serde_json::from_value(serde_json::to_value(&p).unwrap()).unwrap();
assert_eq!(back, p);
let base = NonlinearStatic::default();
let bad = [
NonlinearStatic { initial_increment: 0.0, ..base.clone() },
NonlinearStatic { initial_increment: f64::NAN, ..base.clone() },
NonlinearStatic { initial_increment: 2.0, max_increment: 2.0, ..base.clone() },
NonlinearStatic { min_increment: 0.5, initial_increment: 0.1, ..base.clone() },
NonlinearStatic { max_increment: 0.05, ..base.clone() },
NonlinearStatic { max_increments: 0, ..base.clone() },
NonlinearStatic { max_increments: MAX_NONLINEAR_INCREMENTS + 1, ..base.clone() },
NonlinearStatic { max_iterations: 0, ..base.clone() },
NonlinearStatic { max_iterations: u32::MAX, ..base.clone() },
NonlinearStatic { tolerances: Tolerances { force: 0.0, ..Tolerances::default() }, ..base.clone() },
NonlinearStatic { tolerances: Tolerances { energy: f64::INFINITY, ..Tolerances::default() }, ..base.clone() },
NonlinearStatic { tolerances: Tolerances { displacement: 1.0, ..Tolerances::default() }, ..base.clone() },
];
for b in bad {
assert!(Procedure::NonlinearStatic(b.clone()).validate().is_err(), "{b:?}");
}
assert!(serde_json::from_value::<Procedure>(serde_json::json!({"type": "nonlinear_static", "nlgeom": true})).is_err());
// A load path: up, down through zero, back to zero.
let cyclic = NonlinearStatic { load_path: Some(vec![[0.0, 0.0], [0.25, 1.0], [0.75, -1.0], [1.0, 0.0]]), ..base.clone() };
assert!(Procedure::NonlinearStatic(cyclic.clone()).validate().is_ok());
for (t, f) in [(0.0, 0.0), (0.125, 0.5), (0.25, 1.0), (0.5, 0.0), (0.75, -1.0), (0.875, -0.5), (1.0, 0.0)] {
assert!((cyclic.load_factor(t) - f).abs() < 1e-15, "{t}: {}", cyclic.load_factor(t));
}
assert_eq!((cyclic.next_turn(0.0), cyclic.next_turn(0.25), cyclic.next_turn(0.3), cyclic.next_turn(0.75)), (0.25, 0.75, 0.75, 1.0));
assert_eq!((base.load_factor(0.3), base.next_turn(0.3)), (0.3, 1.0));
for path in [
vec![[0.0, 0.0]],
vec![[0.0, 0.5], [1.0, 1.0]],
vec![[0.0, 0.0], [0.9, 1.0]],
vec![[0.0, 0.0], [0.5, 1.0], [0.5, 2.0], [1.0, 0.0]],
vec![[0.0, 0.0], [0.5, f64::NAN], [1.0, 0.0]],
vec![[0.0, 0.0]; MAX_LOAD_PATH_POINTS + 1],
] {
assert!(Procedure::NonlinearStatic(NonlinearStatic { load_path: Some(path.clone()), ..base.clone() }).validate().is_err(), "{path:?}");
}
}
#[test]
fn heat_transfer_procedures() {
let steady: Procedure = serde_json::from_value(serde_json::json!({"type": "heat_transfer"})).unwrap();
assert_eq!(steady, Procedure::HeatTransfer { transient: None });
assert!(steady.is_thermal() && steady.validate().is_ok());
assert_eq!(steady.default_output(), [OutputVariable::Temperature, OutputVariable::HeatFlux].into_iter().collect());
let t: Procedure =
serde_json::from_value(serde_json::json!({"type": "heat_transfer", "transient": {"end_time": 10, "increment": 0.3}})).unwrap();
let Procedure::HeatTransfer { transient: Some(tr) } = t else { panic!() };
assert_eq!((tr.theta, tr.initial_temperature, tr.increments()), (1.0, 0.0, 34));
assert_eq!(Transient::new(1.0, 0.25).increments(), 4);
for bad in [
Transient { theta: 0.4, ..Transient::new(1.0, 0.1) },
Transient { theta: f64::NAN, ..Transient::new(1.0, 0.1) },
Transient::new(1.0, 0.0),
Transient::new(-1.0, 0.1),
Transient::new(1e300, 1e-300),
Transient { initial_temperature: f64::INFINITY, ..Transient::new(1.0, 0.1) },
] {
assert!(Procedure::HeatTransfer { transient: Some(bad) }.validate().is_err(), "{bad:?}");
}
let coupled: Procedure = serde_json::from_value(serde_json::json!({"type": "coupled_temperature_displacement"})).unwrap();
assert!(coupled.is_thermal() && coupled.default_output().contains(&OutputVariable::Stress));
assert_eq!(coupled.name(), "coupled_temperature_displacement");
}
}