pub fn compute_thermal_strain(delta_t: f64, alpha: f64) -> f64 {
alpha * delta_t
}
pub fn plane_stress_thermal(e: f64, nu: f64, alpha: f64, d_t: f64) -> [f64; 3] {
let s = -e * alpha * d_t / (1.0 - nu);
[s, s, 0.0]
}
pub fn plane_strain_thermal(e: f64, nu: f64, alpha: f64, d_t: f64) -> [f64; 3] {
let s = -e * alpha * d_t / (1.0 - 2.0 * nu);
[s, s, 0.0]
}
pub fn thermal_stress_3d(e: f64, nu: f64, alpha: f64, d_t: f64) -> f64 {
-e * alpha * d_t / (1.0 - 2.0 * nu)
}
pub fn von_mises_2d(sxx: f64, syy: f64, txy: f64) -> f64 {
(sxx * sxx - sxx * syy + syy * syy + 3.0 * txy * txy).sqrt()
}
pub fn principal_stresses_2d(sxx: f64, syy: f64, txy: f64) -> [f64; 3] {
let avg = 0.5 * (sxx + syy);
let r = ((0.5 * (sxx - syy)).powi(2) + txy * txy).sqrt();
[avg + r, avg - r, 0.0]
}
#[derive(Debug, Clone)]
pub struct ThermalLoad {
pub temperature_field: Vec<f64>,
pub reference_temp: f64,
pub alpha: f64,
}
impl ThermalLoad {
pub fn new(temperature_field: Vec<f64>, reference_temp: f64, alpha: f64) -> Self {
Self {
temperature_field,
reference_temp,
alpha,
}
}
pub fn delta_t(&self, i: usize) -> f64 {
self.temperature_field[i] - self.reference_temp
}
pub fn thermal_strain_at(&self, i: usize) -> f64 {
compute_thermal_strain(self.delta_t(i), self.alpha)
}
pub fn mean_delta_t(&self) -> f64 {
if self.temperature_field.is_empty() {
return 0.0;
}
let sum: f64 = self
.temperature_field
.iter()
.map(|&t| t - self.reference_temp)
.sum();
sum / self.temperature_field.len() as f64
}
pub fn max_delta_t(&self) -> f64 {
self.temperature_field
.iter()
.map(|&t| (t - self.reference_temp).abs())
.fold(0.0_f64, f64::max)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CouplingMode {
OneWay,
TwoWay,
}
#[derive(Debug, Clone)]
pub struct ThermoelasticElement {
pub node_ids: Vec<usize>,
pub temperature_dofs: Vec<usize>,
pub mechanical_dofs: Vec<usize>,
pub e_modulus: f64,
pub nu: f64,
pub alpha: f64,
pub conductivity: f64,
pub length: f64,
pub area: f64,
}
impl ThermoelasticElement {
pub fn new_1d_rod(
n0: usize,
n1: usize,
e: f64,
nu: f64,
alpha: f64,
k: f64,
length: f64,
area: f64,
) -> Self {
Self {
node_ids: vec![n0, n1],
temperature_dofs: vec![n0, n1],
mechanical_dofs: vec![2 * n0, 2 * n0 + 1, 2 * n1, 2 * n1 + 1],
e_modulus: e,
nu,
alpha,
conductivity: k,
length,
area,
}
}
pub fn k_thermal(&self) -> [f64; 4] {
let g = self.conductivity * self.area / self.length;
[g, -g, -g, g]
}
pub fn k_mechanical(&self) -> [f64; 4] {
let g = self.e_modulus * self.area / self.length;
[g, -g, -g, g]
}
pub fn f_thermal(&self, delta_t_mean: f64) -> [f64; 2] {
let v = self.e_modulus * self.area * self.alpha * delta_t_mean;
[-v, v]
}
pub fn axial_thermal_stress(&self, delta_t: f64) -> f64 {
-self.e_modulus * self.alpha * delta_t
}
}
#[derive(Debug, Clone)]
pub struct ThermalStress {
pub von_mises: f64,
pub principal: [f64; 3],
pub temperature: f64,
pub direct: [f64; 3],
pub shear: [f64; 3],
}
impl ThermalStress {
pub fn from_plane_stress(sxx: f64, syy: f64, txy: f64, temperature: f64) -> Self {
let vm = von_mises_2d(sxx, syy, txy);
let pr = principal_stresses_2d(sxx, syy, txy);
Self {
von_mises: vm,
principal: pr,
temperature,
direct: [sxx, syy, 0.0],
shear: [txy, 0.0, 0.0],
}
}
pub fn is_compressive(&self) -> bool {
self.direct[0] < 0.0 && self.direct[1] < 0.0
}
}
#[derive(Debug, Clone)]
pub struct BoundaryCondition {
pub dof: usize,
pub value: f64,
}
impl BoundaryCondition {
pub fn new(dof: usize, value: f64) -> Self {
Self { dof, value }
}
}
#[derive(Debug, Clone)]
pub struct ThermoelasticMesh {
pub node_positions: Vec<f64>,
pub connectivity: Vec<[usize; 2]>,
pub area: f64,
}
impl ThermoelasticMesh {
pub fn uniform_1d(n_elems: usize, length: f64, area: f64) -> Self {
let n_nodes = n_elems + 1;
let dx = length / n_elems as f64;
let node_positions: Vec<f64> = (0..n_nodes).map(|i| i as f64 * dx).collect();
let connectivity: Vec<[usize; 2]> = (0..n_elems).map(|i| [i, i + 1]).collect();
Self {
node_positions,
connectivity,
area,
}
}
pub fn n_nodes(&self) -> usize {
self.node_positions.len()
}
pub fn n_elements(&self) -> usize {
self.connectivity.len()
}
pub fn element_length(&self, i: usize) -> f64 {
let [n0, n1] = self.connectivity[i];
(self.node_positions[n1] - self.node_positions[n0]).abs()
}
}
#[derive(Debug, Clone)]
pub struct ThermoelasticResult {
pub temperatures: Vec<f64>,
pub displacements: Vec<f64>,
pub stresses: Vec<ThermalStress>,
}
#[derive(Debug, Clone)]
pub struct ThermalStressAnalysis {
pub mesh: ThermoelasticMesh,
pub thermal_bc: Vec<BoundaryCondition>,
pub mechanical_bc: Vec<BoundaryCondition>,
pub e_modulus: f64,
pub nu: f64,
pub alpha: f64,
pub conductivity: f64,
pub heat_source: f64,
pub coupling_mode: CouplingMode,
}
impl ThermalStressAnalysis {
pub fn new(
mesh: ThermoelasticMesh,
thermal_bc: Vec<BoundaryCondition>,
mechanical_bc: Vec<BoundaryCondition>,
e_modulus: f64,
nu: f64,
alpha: f64,
conductivity: f64,
heat_source: f64,
) -> Self {
Self {
mesh,
thermal_bc,
mechanical_bc,
e_modulus,
nu,
alpha,
conductivity,
heat_source,
coupling_mode: CouplingMode::OneWay,
}
}
pub fn solve(&self) -> ThermoelasticResult {
let temperatures = self.solve_thermal();
let displacements = self.solve_mechanical(&temperatures);
let stresses = self.compute_stresses(&temperatures, &displacements);
ThermoelasticResult {
temperatures,
displacements,
stresses,
}
}
fn solve_thermal(&self) -> Vec<f64> {
let n = self.mesh.n_nodes();
let mut k_global = vec![vec![0.0_f64; n]; n];
let mut f_global = vec![0.0_f64; n];
for i in 0..self.mesh.n_elements() {
let [n0, n1] = self.mesh.connectivity[i];
let len = self.mesh.element_length(i);
let g = self.conductivity * self.mesh.area / len;
k_global[n0][n0] += g;
k_global[n0][n1] -= g;
k_global[n1][n0] -= g;
k_global[n1][n1] += g;
let q = self.heat_source * self.mesh.area * len * 0.5;
f_global[n0] += q;
f_global[n1] += q;
}
let penalty = 1e20_f64;
for bc in &self.thermal_bc {
let d = bc.dof;
if d < n {
k_global[d][d] += penalty;
f_global[d] += penalty * bc.value;
}
}
Self::gauss_solve(&k_global, &f_global)
}
fn solve_mechanical(&self, temperatures: &[f64]) -> Vec<f64> {
let n = self.mesh.n_nodes();
let mut k_global = vec![vec![0.0_f64; n]; n];
let mut f_global = vec![0.0_f64; n];
for i in 0..self.mesh.n_elements() {
let [n0, n1] = self.mesh.connectivity[i];
let len = self.mesh.element_length(i);
let g = self.e_modulus * self.mesh.area / len;
k_global[n0][n0] += g;
k_global[n0][n1] -= g;
k_global[n1][n0] -= g;
k_global[n1][n1] += g;
let t_mean = 0.5 * (temperatures[n0] + temperatures[n1]);
let dt_mean = t_mean; let fth = self.e_modulus * self.mesh.area * self.alpha * dt_mean;
f_global[n0] -= fth;
f_global[n1] += fth;
}
let penalty = 1e20_f64;
for bc in &self.mechanical_bc {
let d = bc.dof;
if d < n {
k_global[d][d] += penalty;
f_global[d] += penalty * bc.value;
}
}
Self::gauss_solve(&k_global, &f_global)
}
fn compute_stresses(&self, temperatures: &[f64], _displacements: &[f64]) -> Vec<ThermalStress> {
let mut stresses = Vec::with_capacity(self.mesh.n_elements());
for i in 0..self.mesh.n_elements() {
let [n0, n1] = self.mesh.connectivity[i];
let t_mean = 0.5 * (temperatures[n0] + temperatures[n1]);
let sxx = -self.e_modulus * self.alpha * t_mean;
let ts = ThermalStress::from_plane_stress(sxx, 0.0, 0.0, t_mean);
stresses.push(ts);
}
stresses
}
fn gauss_solve(a: &[Vec<f64>], b: &[f64]) -> Vec<f64> {
let n = b.len();
let mut mat: Vec<Vec<f64>> = a.to_vec();
let mut rhs: Vec<f64> = b.to_vec();
for col in 0..n {
let mut pivot_row = col;
let mut max_val = mat[col][col].abs();
for (row, mat_row) in mat.iter().enumerate().skip(col + 1) {
if mat_row[col].abs() > max_val {
max_val = mat_row[col].abs();
pivot_row = row;
}
}
mat.swap(col, pivot_row);
rhs.swap(col, pivot_row);
let diag = mat[col][col];
if diag.abs() < 1e-30 {
continue;
}
let col_slice: Vec<f64> = mat[col][col..].to_vec();
for row in (col + 1)..n {
let factor = mat[row][col] / diag;
rhs[row] -= factor * rhs[col];
for (off, &cv) in col_slice.iter().enumerate() {
mat[row][col + off] -= factor * cv;
}
}
}
let mut x = vec![0.0_f64; n];
for row in (0..n).rev() {
let mut sum = rhs[row];
for c in (row + 1)..n {
sum -= mat[row][c] * x[c];
}
let d = mat[row][row];
x[row] = if d.abs() > 1e-30 { sum / d } else { 0.0 };
}
x
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thermal_strain_zero_at_zero_dt() {
assert_eq!(compute_thermal_strain(0.0, 12e-6), 0.0);
}
#[test]
fn thermal_strain_proportional_to_alpha() {
let eps1 = compute_thermal_strain(100.0, 12e-6);
let eps2 = compute_thermal_strain(100.0, 24e-6);
assert!((eps2 - 2.0 * eps1).abs() < 1e-15);
}
#[test]
fn thermal_strain_proportional_to_delta_t() {
let eps1 = compute_thermal_strain(50.0, 12e-6);
let eps2 = compute_thermal_strain(100.0, 12e-6);
assert!((eps2 - 2.0 * eps1).abs() < 1e-15);
}
#[test]
fn thermal_strain_steel_50k() {
let eps = compute_thermal_strain(50.0, 12e-6);
assert!((eps - 6e-4).abs() < 1e-12);
}
#[test]
fn plane_stress_thermal_compressive_on_heating() {
let sig = plane_stress_thermal(200e9, 0.3, 12e-6, 100.0);
assert!(sig[0] < 0.0, "σ_xx should be compressive");
assert!(sig[1] < 0.0, "σ_yy should be compressive");
assert_eq!(sig[2], 0.0, "τ_xy should be zero");
}
#[test]
fn plane_stress_thermal_tensile_on_cooling() {
let sig = plane_stress_thermal(200e9, 0.3, 12e-6, -50.0);
assert!(sig[0] > 0.0, "σ_xx should be tensile on cooling");
}
#[test]
fn plane_stress_thermal_zero_dt() {
let sig = plane_stress_thermal(200e9, 0.3, 12e-6, 0.0);
assert_eq!(sig, [0.0, 0.0, 0.0]);
}
#[test]
fn plane_stress_thermal_symmetry() {
let sig = plane_stress_thermal(200e9, 0.3, 12e-6, 100.0);
assert!(
(sig[0] - sig[1]).abs() < 1e-6,
"σ_xx == σ_yy in biaxial case"
);
}
#[test]
fn plane_strain_thermal_magnitude_greater_than_plane_stress() {
let sig_ps = plane_stress_thermal(200e9, 0.3, 12e-6, 100.0);
let sig_pe = plane_strain_thermal(200e9, 0.3, 12e-6, 100.0);
assert!(sig_pe[0].abs() > sig_ps[0].abs());
}
#[test]
fn plane_strain_thermal_zero_shear() {
let sig = plane_strain_thermal(200e9, 0.3, 12e-6, 100.0);
assert_eq!(sig[2], 0.0);
}
#[test]
fn thermal_stress_3d_sign_positive_on_cooling() {
let s = thermal_stress_3d(200e9, 0.3, 12e-6, -100.0);
assert!(s > 0.0);
}
#[test]
fn von_mises_biaxial_equal_principal() {
let vm = von_mises_2d(100.0, 100.0, 0.0);
assert!((vm - 100.0).abs() < 1e-8);
}
#[test]
fn von_mises_uniaxial() {
let vm = von_mises_2d(200.0, 0.0, 0.0);
assert!((vm - 200.0).abs() < 1e-8);
}
#[test]
fn von_mises_pure_shear() {
let tau = 100.0_f64;
let vm = von_mises_2d(0.0, 0.0, tau);
assert!((vm - 3.0_f64.sqrt() * tau).abs() < 1e-8);
}
#[test]
fn principal_stresses_uniaxial() {
let [s1, s2, _] = principal_stresses_2d(100.0, 0.0, 0.0);
assert!((s1 - 100.0).abs() < 1e-8);
assert!((s2).abs() < 1e-8);
}
#[test]
fn principal_stresses_biaxial_equal() {
let [s1, s2, _] = principal_stresses_2d(100.0, 100.0, 0.0);
assert!((s1 - 100.0).abs() < 1e-8);
assert!((s2 - 100.0).abs() < 1e-8);
}
#[test]
fn principal_stresses_pure_shear() {
let tau = 50.0_f64;
let [s1, s2, _] = principal_stresses_2d(0.0, 0.0, tau);
assert!((s1 - tau).abs() < 1e-8);
assert!((s2 + tau).abs() < 1e-8);
}
#[test]
fn thermal_load_delta_t_at_node() {
let load = ThermalLoad::new(vec![350.0, 400.0, 450.0], 300.0, 12e-6);
assert!((load.delta_t(0) - 50.0).abs() < 1e-12);
assert!((load.delta_t(1) - 100.0).abs() < 1e-12);
assert!((load.delta_t(2) - 150.0).abs() < 1e-12);
}
#[test]
fn thermal_load_strain_at_node() {
let load = ThermalLoad::new(vec![350.0], 300.0, 12e-6);
let eps = load.thermal_strain_at(0);
assert!((eps - 6e-4).abs() < 1e-12);
}
#[test]
fn thermal_load_mean_delta_t() {
let load = ThermalLoad::new(vec![310.0, 320.0, 330.0], 300.0, 12e-6);
assert!((load.mean_delta_t() - 20.0).abs() < 1e-10);
}
#[test]
fn thermal_load_mean_delta_t_empty() {
let load = ThermalLoad::new(vec![], 300.0, 12e-6);
assert_eq!(load.mean_delta_t(), 0.0);
}
#[test]
fn thermal_load_max_delta_t() {
let load = ThermalLoad::new(vec![350.0, 280.0, 400.0], 300.0, 12e-6);
assert!((load.max_delta_t() - 100.0).abs() < 1e-10);
}
#[test]
fn thermoelastic_element_k_thermal_symmetry() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let k = elem.k_thermal();
assert!((k[0] - k[3]).abs() < 1e-10);
assert!((k[1] - k[2]).abs() < 1e-10);
}
#[test]
fn thermoelastic_element_k_thermal_row_sum_zero() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let k = elem.k_thermal();
assert!((k[0] + k[1]).abs() < 1e-10);
assert!((k[2] + k[3]).abs() < 1e-10);
}
#[test]
fn thermoelastic_element_k_mechanical_symmetry() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let k = elem.k_mechanical();
assert!((k[0] - k[3]).abs() < 1e-10);
assert!((k[1] - k[2]).abs() < 1e-10);
}
#[test]
fn thermoelastic_element_k_mechanical_row_sum_zero() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let k = elem.k_mechanical();
assert!((k[0] + k[1]).abs() < 1e-10);
assert!((k[2] + k[3]).abs() < 1e-10);
}
#[test]
fn thermoelastic_element_f_thermal_sign() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let f = elem.f_thermal(50.0); assert!(f[0] < 0.0);
assert!(f[1] > 0.0);
}
#[test]
fn thermoelastic_element_axial_stress_compressive_heating() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
let s = elem.axial_thermal_stress(100.0);
assert!(s < 0.0);
}
#[test]
fn thermoelastic_element_axial_stress_zero_dt() {
let elem = ThermoelasticElement::new_1d_rod(0, 1, 200e9, 0.3, 12e-6, 50.0, 1.0, 0.01);
assert_eq!(elem.axial_thermal_stress(0.0), 0.0);
}
#[test]
fn thermal_stress_from_plane_stress_compressive() {
let ts = ThermalStress::from_plane_stress(-100.0e6, -100.0e6, 0.0, 400.0);
assert!(ts.is_compressive());
assert!((ts.von_mises - 100.0e6).abs() < 1.0);
}
#[test]
fn thermal_stress_principal_order() {
let ts = ThermalStress::from_plane_stress(150.0, -50.0, 0.0, 300.0);
assert!(ts.principal[0] >= ts.principal[1]);
}
#[test]
fn thermal_stress_temperature_stored() {
let ts = ThermalStress::from_plane_stress(0.0, 0.0, 0.0, 500.0);
assert_eq!(ts.temperature, 500.0);
}
#[test]
fn mesh_uniform_1d_node_count() {
let mesh = ThermoelasticMesh::uniform_1d(4, 1.0, 0.01);
assert_eq!(mesh.n_nodes(), 5);
}
#[test]
fn mesh_uniform_1d_element_count() {
let mesh = ThermoelasticMesh::uniform_1d(4, 1.0, 0.01);
assert_eq!(mesh.n_elements(), 4);
}
#[test]
fn mesh_uniform_1d_element_length() {
let mesh = ThermoelasticMesh::uniform_1d(4, 1.0, 0.01);
for i in 0..4 {
assert!((mesh.element_length(i) - 0.25).abs() < 1e-12);
}
}
#[test]
fn mesh_node_positions_start_end() {
let mesh = ThermoelasticMesh::uniform_1d(5, 2.0, 0.01);
assert!((mesh.node_positions[0]).abs() < 1e-12);
assert!((mesh.node_positions[5] - 2.0).abs() < 1e-12);
}
#[test]
fn analysis_linear_temperature_no_panic() {
let mesh = ThermoelasticMesh::uniform_1d(5, 1.0, 1e-4);
let thermal_bc = vec![
BoundaryCondition::new(0, 0.0), BoundaryCondition::new(5, 100.0), ];
let mech_bc = vec![BoundaryCondition::new(0, 0.0)]; let analysis =
ThermalStressAnalysis::new(mesh, thermal_bc, mech_bc, 200e9, 0.3, 12e-6, 50.0, 0.0);
let result = analysis.solve();
assert_eq!(result.temperatures.len(), 6);
assert_eq!(result.displacements.len(), 6);
assert_eq!(result.stresses.len(), 5);
}
#[test]
fn analysis_uniform_temperature_stresses_nonzero() {
let mesh = ThermoelasticMesh::uniform_1d(3, 1.0, 1e-4);
let thermal_bc = vec![
BoundaryCondition::new(0, 50.0),
BoundaryCondition::new(3, 50.0),
];
let mech_bc = vec![BoundaryCondition::new(0, 0.0)];
let analysis =
ThermalStressAnalysis::new(mesh, thermal_bc, mech_bc, 200e9, 0.3, 12e-6, 50.0, 0.0);
let result = analysis.solve();
for &t in &result.temperatures {
assert!((t - 50.0).abs() < 1.0);
}
}
#[test]
fn analysis_coupling_mode_default_one_way() {
let mesh = ThermoelasticMesh::uniform_1d(2, 1.0, 1e-4);
let analysis = ThermalStressAnalysis::new(
mesh,
vec![
BoundaryCondition::new(0, 0.0),
BoundaryCondition::new(2, 100.0),
],
vec![BoundaryCondition::new(0, 0.0)],
200e9,
0.3,
12e-6,
50.0,
0.0,
);
assert_eq!(analysis.coupling_mode, CouplingMode::OneWay);
}
#[test]
fn coupling_mode_equality() {
assert_eq!(CouplingMode::OneWay, CouplingMode::OneWay);
assert_ne!(CouplingMode::OneWay, CouplingMode::TwoWay);
}
}