Files
OneCAD-Simulation/crates/model/src/loads.rs
T
e900f41ce3 FEM foundation B: heat transfer and thermal stress (solids, 2D, explicit), coupled temperature–displacement, solve.heat (#45)
* 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 <[email protected]>
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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01NmAttHhUZTokgPeEJia8sm

* clippy

Co-Authored-By: Claude Opus 5.5 <[email protected]>
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 <[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]>
2026-10-13 22:15:03 -04:00

252 lines
11 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.
//! Loads. Which steps a load is active in is listed explicitly by each step ([`crate::steps`]).
//!
//! In a 2D model (plane stress, plane strain, axisymmetric sections) loads lie in the XY plane:
//! surface loads act on the element edges of the set, forces are totals over the thickness (or
//! over the full circumference for axisymmetric models), and a vector with a Z component is an
//! error.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use crate::bcs::Target;
use crate::{Error, Model, Result, context, finite, insert_new, not_in_use, positive, remove_existing, replace_existing, require};
/// How a force or moment on a set is shared among the set's nodes.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Distribution {
/// The vector is the total. On a set with element faces it is split by the faces' consistent
/// nodal weights (area-weighted, [`Model::surface_weights`]); on a set of nodes only, equally.
/// The total is the same on every mesh of the set.
#[default]
Total,
/// The vector is applied at every node of the set, so the total grows with the number of
/// nodes: it depends on the mesh.
PerNode,
}
/// A load.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
pub enum Load {
/// A force on the target's nodes, shared as `distribution` says (a reference point takes it
/// whole).
Force {
target: Target,
vector: [f64; 3],
#[serde(default)]
distribution: Distribution,
},
/// A moment on the target's nodes, shared as `distribution` says. Solid elements have no
/// rotations, so a moment on a solid goes through a coupled reference point.
Moment {
target: Target,
vector: [f64; 3],
#[serde(default)]
distribution: Distribution,
},
/// A force per area on the set's element faces, as consistent nodal forces.
Traction { set: String, vector: [f64; 3] },
/// Uniform pressure on the set's element faces, positive pushing into the elements (against
/// the outward normal).
Pressure { set: String, magnitude: f64 },
/// Gravity: an acceleration of the mass of the set's elements, or of the whole model when no
/// set is given.
Gravity {
acceleration: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
set: Option<String>,
},
/// A force per volume on the set's elements.
BodyForce { set: String, vector: [f64; 3] },
/// The centrifugal load of a steady rotation at `angular_velocity` (radians per time unit)
/// about the axis through `point` along `axis`: a force per volume ρ ω² r on the mass of the
/// set's elements (or the whole model), r the distance from the axis, pointing away from it.
Centrifugal {
angular_velocity: f64,
point: [f64; 3],
axis: [f64; 3],
#[serde(default, skip_serializing_if = "Option::is_none")]
set: Option<String>,
},
/// Heat flux per area into the set's element faces (positive heats the body).
SurfaceFlux { set: String, flux: f64 },
/// Convection from the set's element faces to a surrounding medium at `sink` temperature:
/// heat flux h (T_sink − T) into the body, with film coefficient `film` (h).
Convection { set: String, film: f64, sink: f64 },
/// Heat generated per volume in the set's elements.
HeatSource { set: String, power: f64 },
/// A temperature field at the target's nodes in a stress step: it strains the material
/// thermally (ε = α (T − T_ref)); nodes it does not reach stay at their material's reference
/// temperature.
Temperature { target: Target, value: f64 },
}
/// The centrifugal acceleration ω² r at `x` of a rotation at `angular_velocity` about the axis
/// through `point` along `axis` (any length, not zero): r is the part of `x − point` normal to
/// the axis, so the acceleration points away from the axis.
pub fn centrifugal_acceleration(angular_velocity: f64, point: [f64; 3], axis: [f64; 3], x: [f64; 3]) -> [f64; 3] {
let len2 = axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2];
let d = [x[0] - point[0], x[1] - point[1], x[2] - point[2]];
let along = if len2 > 0.0 { (d[0] * axis[0] + d[1] * axis[1] + d[2] * axis[2]) / len2 } else { 0.0 };
let w2 = angular_velocity * angular_velocity;
[0, 1, 2].map(|k| w2 * (d[k] - along * axis[k]))
}
impl Load {
/// `force`, `moment`, `traction`, `pressure`, `gravity` or `body_force`.
pub fn kind(&self) -> &'static str {
match self {
Load::Force { .. } => "force",
Load::Moment { .. } => "moment",
Load::Traction { .. } => "traction",
Load::Pressure { .. } => "pressure",
Load::Gravity { .. } => "gravity",
Load::BodyForce { .. } => "body_force",
Load::Centrifugal { .. } => "centrifugal",
Load::SurfaceFlux { .. } => "surface_flux",
Load::Convection { .. } => "convection",
Load::HeatSource { .. } => "heat_source",
Load::Temperature { .. } => "temperature",
}
}
/// Whether it is a heat transfer load (flux, convection, heat source), active in heat
/// transfer and coupled temperature–displacement steps.
pub fn is_thermal(&self) -> bool {
matches!(self, Load::SurfaceFlux { .. } | Load::Convection { .. } | Load::HeatSource { .. })
}
/// The set the load acts on, if any.
pub fn set(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.set(),
Load::Pressure { set, .. }
| Load::BodyForce { set, .. }
| Load::Traction { set, .. }
| Load::SurfaceFlux { set, .. }
| Load::Convection { set, .. }
| Load::HeatSource { set, .. } => Some(set),
Load::Gravity { set, .. } | Load::Centrifugal { set, .. } => set.as_deref(),
}
}
/// The reference point the load acts on, if any.
pub fn reference_point(&self) -> Option<&str> {
match self {
Load::Force { target, .. } | Load::Moment { target, .. } | Load::Temperature { target, .. } => target.reference_point(),
_ => None,
}
}
pub fn validate(&self) -> Result<()> {
let vector = match self {
Load::Force { vector, .. } | Load::Moment { vector, .. } | Load::BodyForce { vector, .. } | Load::Traction { vector, .. } => vector,
Load::Gravity { acceleration, .. } => acceleration,
Load::Pressure { magnitude, .. } => {
finite("pressure", *magnitude)?;
return Ok(());
}
Load::Centrifugal { angular_velocity, point, axis, .. } => {
let w = finite("angular velocity", *angular_velocity)?;
if w == 0.0 || !(w * w).is_finite() {
return Err(Error::Invalid(format!(
"a centrifugal load's angular velocity must be a non-zero number whose square is finite, got {w}"
)));
}
for c in point.iter().chain(axis) {
finite("centrifugal axis", *c)?;
}
if axis.iter().all(|c| *c == 0.0) {
return Err(Error::Invalid("a centrifugal load's axis direction must not be zero".into()));
}
return Ok(());
}
Load::SurfaceFlux { flux: v, .. } | Load::HeatSource { power: v, .. } => {
if finite(self.kind(), *v)? == 0.0 {
return Err(Error::Invalid(format!("a {} must not be zero", self.kind())));
}
return Ok(());
}
Load::Convection { film, sink, .. } => {
positive("film coefficient", *film)?;
finite("sink temperature", *sink)?;
return Ok(());
}
Load::Temperature { target, value } => {
finite("temperature", *value)?;
if let Target::ReferencePoint(r) = target {
return Err(Error::Invalid(format!("reference points have no temperature (`{r}`); apply it to a set")));
}
return Ok(());
}
};
for c in vector {
finite(self.kind(), *c)?;
}
if vector.iter().all(|c| *c == 0.0) {
return Err(Error::Invalid(format!("a {} vector must not be zero", self.kind())));
}
Ok(())
}
}
impl Model {
pub fn loads(&self) -> &BTreeMap<String, Load> {
&self.loads
}
pub fn load(&self, name: &str) -> Option<&Load> {
self.loads.get(name)
}
fn check_load(&self, name: &str, load: &Load) -> Result<()> {
load.validate().map_err(|e| context("load", name, e))?;
if let Some(s) = load.set() {
require(&self.sets, "set", s).map_err(|e| context("load", name, e))?;
}
if let Some(r) = load.reference_point() {
require(&self.reference_points, "reference point", r).map_err(|e| context("load", name, e))?;
}
Ok(())
}
pub fn add_load(&mut self, name: &str, load: Load) -> Result<()> {
self.check_load(name, &load)?;
insert_new(&mut self.loads, "load", name, load)
}
pub fn replace_load(&mut self, name: &str, load: Load) -> Result<()> {
self.check_load(name, &load)?;
replace_existing(&mut self.loads, "load", name, load)
}
/// Remove a load that no step lists.
pub fn remove_load(&mut self, name: &str) -> Result<()> {
let users = self.steps.iter().filter(|s| s.loads.contains(name)).map(|s| format!("step `{}`", s.name)).collect();
not_in_use("load", name, users)?;
remove_existing(&mut self.loads, "load", name).map(|_| ())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn heat_loads_validate() {
let set = || "s".to_string();
assert!(Load::SurfaceFlux { set: set(), flux: 2.0 }.validate().is_ok());
assert!(Load::SurfaceFlux { set: set(), flux: 0.0 }.validate().is_err());
assert!(Load::HeatSource { set: set(), power: f64::NAN }.validate().is_err());
assert!(Load::Convection { set: set(), film: 0.0, sink: 1.0 }.validate().is_err());
assert!(Load::Convection { set: set(), film: 1.0, sink: f64::INFINITY }.validate().is_err());
assert!(Load::Temperature { target: Target::ReferencePoint("RP".into()), value: 1.0 }.validate().is_err());
assert!(Load::Temperature { target: Target::Set(set()), value: -40.0 }.validate().is_ok());
let l = Load::Convection { set: set(), film: 10.0, sink: 20.0 };
assert!(l.is_thermal() && l.set() == Some("s") && l.kind() == "convection");
assert!(!Load::Temperature { target: Target::Set(set()), value: 1.0 }.is_thermal());
}
}