forked from 1b-agent/fieldcraft-upstream
* Heat transfer and thermal stress: thermal materials, temperature BCs, heat loads, heat transfer and coupled temperature–displacement steps, solve.heat
Model: Material.thermal (conductivity, specific heat, expansion, isotropic or
per axis; reference temperature), BoundaryCondition::Temperature (dof 11),
loads SurfaceFlux, Convection, HeatSource and Temperature (a field in static
steps), procedures HeatTransfer { transient (θ-method) } and
CoupledTemperatureDisplacement; checks for missing conductivity/specific
heat/expansion. fem: heat conduction kernels (conductivity, capacity,
source, face flux/film, heat flux) on the DofLayout (one T per node), thermal
strain load and stress D(ε − ε_th) for every solid formulation incl.
incompatible modes. Solver: steady and transient heat transfer, thermal loads
in static steps, coupled steps (exact one-way coupling). Engine: solve.heat,
temperature/heat-flux result fields, probes. Decks refuse heat transfer by
name. Analytic verification: slab, source+convection/flux, thick cylinder,
erfc semi-infinite solid, free/constrained expansion, bimetal strip, coupled
step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
* thermal: strains in 2D elements and explicit dynamics
Plane::thermal_load and stresses_with for plane stress (εzz free), plane
strain (σzz = −D₃·ε_th) and axisymmetric elements, incompatible modes
included; the solver's 2D kernels take them. Explicit dynamics turns a
step's temperature loads into a per-element thermal strain rate (reached
linearly over the step) subtracted from each strain increment, so it feeds
J2 and Johnson–Cook updates. Verified: free expansion in every 2D theory
and element, a biaxially held plate, an explicit free bar (ΔL = αΔT L,
stress-free) and a clamped bar at −EαΔT, capped at the yield stress with
perfect plasticity.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
* clippy
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
* thermal: trusses take the axial thermal strain; keep the roadmap's M7 line
Truss::thermal_load and stresses_with project the thermal strain on the
member's tangent (Σ αₖ tₖ² ΔT); the static solver applies them to rod2/rod3
(#38). Explicit trusses take none yet and a warning says so.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm
---------
Co-authored-by: jyviko <2501648+jyviko@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
782 lines
33 KiB
Rust
782 lines
33 KiB
Rust
//! 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::BTreeSet;
|
||
|
||
use serde::{Deserialize, Serialize};
|
||
|
||
use crate::{Error, 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>,
|
||
},
|
||
/// 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 {},
|
||
}
|
||
|
||
/// 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, frictionless rigid planes the nodes of sets cannot pass through.
|
||
#[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,
|
||
}
|
||
|
||
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(),
|
||
}
|
||
}
|
||
|
||
/// 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()))
|
||
}
|
||
|
||
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()));
|
||
}
|
||
}
|
||
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, frictionless rigid plane through `point`. Its `normal` points to the side the nodes
|
||
/// of `set` stay on.
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
pub struct RigidWall {
|
||
pub set: String,
|
||
pub point: [f64; 3],
|
||
pub normal: [f64; 3],
|
||
}
|
||
|
||
/// 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 {
|
||
/// `static` or `frequency`.
|
||
pub fn name(&self) -> &'static str {
|
||
match self {
|
||
Procedure::Static {} => "static",
|
||
Procedure::Frequency { .. } => "frequency",
|
||
Procedure::ExplicitDynamic(_) => "explicit_dynamic",
|
||
Procedure::HeatTransfer { .. } => "heat_transfer",
|
||
Procedure::CoupledTemperatureDisplacement {} => "coupled_temperature_displacement",
|
||
Procedure::Fluid { .. } => "fluid",
|
||
}
|
||
}
|
||
|
||
/// 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::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::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 {} => OutputVariable::ALL.into_iter().collect(),
|
||
Procedure::Frequency { .. } => [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;
|
||
}
|
||
}
|
||
|
||
impl Model {
|
||
/// 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]}],
|
||
"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] }], ..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() },
|
||
];
|
||
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 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");
|
||
}
|
||
}
|