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 <[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]>
252 lines
11 KiB
Rust
252 lines
11 KiB
Rust
//! 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());
|
||
}
|
||
}
|