use crate::material::Material;
use crate::strain::StrainTensor;
use crate::stress::StressTensor;
#[must_use]
pub fn to_thermal_material(
material: &Material,
conductivity: f64,
specific_heat: f64,
) -> ushma::material::ThermalMaterial {
ushma::material::ThermalMaterial {
name: material.name.clone(),
conductivity,
specific_heat,
density: material.density,
melting_point: 0.0,
boiling_point: 0.0,
}
}
#[must_use]
#[inline]
pub fn thermal_strain_tensor(material: &Material, delta_t: f64) -> StrainTensor {
let e = material.thermal_expansion * delta_t;
StrainTensor::new(e, e, e, 0.0, 0.0, 0.0)
}
#[must_use]
pub fn constrained_thermal_stress(material: &Material, delta_t: f64) -> StressTensor {
let v = material.poisson_ratio;
let denom = 1.0 - 2.0 * v;
let sigma = if denom.abs() < hisab::EPSILON_F64 {
0.0
} else {
-material.youngs_modulus * material.thermal_expansion * delta_t / denom
};
StressTensor::new(sigma, sigma, sigma, 0.0, 0.0, 0.0)
}
#[must_use]
#[inline]
pub fn mechanical_strain(
total_strain: &StrainTensor,
material: &Material,
delta_t: f64,
) -> StrainTensor {
let eth = thermal_strain_tensor(material, delta_t);
*total_strain - eth
}
#[must_use]
pub fn stress_from_thermal_grid_1d(
material: &Material,
grid: &ushma::numerical::ThermalGrid1D,
reference_temp: f64,
) -> Vec<StressTensor> {
grid.nodes
.iter()
.map(|&t| constrained_thermal_stress(material, t - reference_temp))
.collect()
}
#[must_use]
pub fn stress_from_thermal_grid_2d(
material: &Material,
grid: &ushma::numerical::ThermalGrid2D,
reference_temp: f64,
) -> Vec<Vec<StressTensor>> {
grid.nodes
.iter()
.map(|row: &Vec<f64>| {
row.iter()
.map(|&t| constrained_thermal_stress(material, t - reference_temp))
.collect()
})
.collect()
}
#[must_use]
pub fn find_thermal_yield_1d(
material: &Material,
grid: &ushma::numerical::ThermalGrid1D,
reference_temp: f64,
) -> Option<usize> {
for (i, &t) in grid.nodes.iter().enumerate() {
let stress = constrained_thermal_stress(material, t - reference_temp);
if stress.von_mises() >= material.yield_strength {
return Some(i);
}
}
None
}
#[must_use]
#[inline]
pub fn max_thermal_delta_t(material: &Material) -> f64 {
let denom = material.youngs_modulus * material.thermal_expansion;
if denom.abs() < hisab::EPSILON_F64 {
return f64::INFINITY;
}
let v = material.poisson_ratio;
material.yield_strength * (1.0 - 2.0 * v) / denom
}
#[derive(Debug, Clone)]
pub struct ThermalStepResult {
pub temperatures: Vec<f64>,
pub von_mises: Vec<f64>,
pub any_yielded: bool,
pub first_yield_node: Option<usize>,
pub time: f64,
}
pub fn transient_thermal_stress_1d(
material: &Material,
grid: &mut ushma::numerical::ThermalGrid1D,
reference_temp: f64,
dt: f64,
n_steps: usize,
record_interval: usize,
) -> Vec<ThermalStepResult> {
let interval = record_interval.max(1);
let mut history = Vec::with_capacity(n_steps / interval + 1);
let mut time = 0.0;
for step in 0..n_steps {
let _ = grid.step_crank_nicolson(dt);
time += dt;
if (step + 1) % interval == 0 || step == n_steps - 1 {
let mut von_mises = Vec::with_capacity(grid.nodes.len());
let mut any_yielded = false;
let mut first_yield = None;
for (i, &t) in grid.nodes.iter().enumerate() {
let stress = constrained_thermal_stress(material, t - reference_temp);
let vm = stress.von_mises();
if vm >= material.yield_strength && first_yield.is_none() {
any_yielded = true;
first_yield = Some(i);
}
von_mises.push(vm);
}
history.push(ThermalStepResult {
temperatures: grid.nodes.clone(),
von_mises,
any_yielded,
first_yield_node: first_yield,
time,
});
}
}
history
}
#[must_use]
pub fn node_stress_history(results: &[ThermalStepResult], node_index: usize) -> Vec<f64> {
results
.iter()
.filter_map(|r| r.von_mises.get(node_index).copied())
.collect()
}
#[must_use]
pub fn thermal_fatigue_damage(
results: &[ThermalStepResult],
node_index: usize,
fatigue_strength_coeff: f64,
fatigue_exponent: f64,
) -> f64 {
let history = node_stress_history(results, node_index);
let turning = crate::fatigue::extract_turning_points(&history);
let cycles = crate::fatigue::rainflow_count(&turning);
cycles
.iter()
.map(|&(range, _mean, count)| {
let amplitude = range / 2.0;
let n_f =
crate::fatigue::basquin_cycles(amplitude, fatigue_strength_coeff, fatigue_exponent);
if n_f > 0.0 { count / n_f } else { 0.0 }
})
.sum()
}
#[must_use]
pub fn time_to_yield(results: &[ThermalStepResult]) -> Option<f64> {
results.iter().find(|r| r.any_yielded).map(|r| r.time)
}
#[must_use]
pub fn peak_thermal_stress(results: &[ThermalStepResult]) -> f64 {
results
.iter()
.flat_map(|r| r.von_mises.iter())
.copied()
.fold(0.0_f64, f64::max)
}
pub fn stress_from_thermal_network(
material: &Material,
network: &ushma::numerical::ThermalNetwork,
reference_temp: f64,
) -> crate::Result<Vec<StressTensor>> {
let temps = network.solve().map_err(|e| {
crate::DravyaError::InvalidMaterial(format!("thermal network solve failed: {e}"))
})?;
Ok(temps
.iter()
.map(|&t| constrained_thermal_stress(material, t - reference_temp))
.collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn thermal_strain_tensor_isotropic() {
let steel = Material::steel();
let ts = thermal_strain_tensor(&steel, 100.0);
let expected = steel.thermal_expansion * 100.0; assert!((ts.components[0] - expected).abs() < hisab::EPSILON_F64);
assert!((ts.components[1] - expected).abs() < hisab::EPSILON_F64);
assert!((ts.components[2] - expected).abs() < hisab::EPSILON_F64);
assert!(ts.components[3].abs() < hisab::EPSILON_F64);
assert!(ts.components[4].abs() < hisab::EPSILON_F64);
assert!(ts.components[5].abs() < hisab::EPSILON_F64);
}
#[test]
fn constrained_thermal_stress_hydrostatic() {
let steel = Material::steel();
let stress = constrained_thermal_stress(&steel, 100.0);
assert!((stress.components[0] - stress.components[1]).abs() < 1.0);
assert!((stress.components[1] - stress.components[2]).abs() < 1.0);
assert!(stress.components[3].abs() < 1.0);
assert!(
(stress.components[0] - (-600e6)).abs() < 1e3,
"constrained thermal stress should be -600 MPa, got {}",
stress.components[0] / 1e6
);
}
#[test]
fn constrained_thermal_stress_heating_is_compressive() {
let steel = Material::steel();
let stress = constrained_thermal_stress(&steel, 50.0);
assert!(
stress.components[0] < 0.0,
"heating under constraint should produce compressive stress"
);
}
#[test]
fn constrained_thermal_stress_cooling_is_tensile() {
let steel = Material::steel();
let stress = constrained_thermal_stress(&steel, -50.0);
assert!(
stress.components[0] > 0.0,
"cooling under constraint should produce tensile stress"
);
}
#[test]
fn mechanical_strain_subtracts_thermal() {
let steel = Material::steel();
let total = StrainTensor::new(0.002, 0.002, 0.002, 0.0, 0.0, 0.0);
let mech = mechanical_strain(&total, &steel, 100.0);
let expected = 0.002 - steel.thermal_expansion * 100.0;
assert!((mech.components[0] - expected).abs() < hisab::EPSILON_F64);
}
#[test]
fn to_thermal_material_preserves_density() {
let steel = Material::steel();
let tm = to_thermal_material(&steel, 43.0, 490.0);
assert!((tm.density - steel.density).abs() < hisab::EPSILON_F64);
assert!((tm.conductivity - 43.0).abs() < hisab::EPSILON_F64);
assert!((tm.specific_heat - 490.0).abs() < hisab::EPSILON_F64);
}
#[test]
fn max_thermal_delta_t_steel() {
let steel = Material::steel();
let dt_max = max_thermal_delta_t(&steel);
assert!(
(dt_max - 41.67).abs() < 0.1,
"max ΔT for steel should be ~41.7 K, got {dt_max}"
);
}
#[test]
fn max_thermal_delta_t_zero_expansion() {
let mut m = Material::steel();
m.thermal_expansion = 0.0;
assert!(max_thermal_delta_t(&m).is_infinite());
}
#[test]
fn stress_from_grid_1d_basic() {
let steel = Material::steel();
let grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
steel.thermal_expansion,
350.0, ushma::numerical::BoundaryCondition::Fixed(350.0),
ushma::numerical::BoundaryCondition::Fixed(350.0),
)
.expect("grid creation should succeed");
let stresses = stress_from_thermal_grid_1d(&steel, &grid, 293.0);
assert_eq!(stresses.len(), 5);
for s in &stresses {
assert!(s.components[0] < 0.0, "heating should be compressive");
}
}
#[test]
fn find_thermal_yield_safe() {
let steel = Material::steel();
let grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
steel.thermal_expansion,
300.0, ushma::numerical::BoundaryCondition::Fixed(300.0),
ushma::numerical::BoundaryCondition::Fixed(300.0),
)
.expect("grid creation should succeed");
assert!(
find_thermal_yield_1d(&steel, &grid, 293.0).is_none(),
"small ΔT should not yield"
);
}
#[test]
fn transient_thermal_stress_runs() {
let steel = Material::steel();
let mut grid = ushma::numerical::ThermalGrid1D::new(
5,
0.01,
1.12e-5,
400.0,
ushma::numerical::BoundaryCondition::Fixed(400.0),
ushma::numerical::BoundaryCondition::Fixed(400.0),
)
.expect("grid creation");
let history = transient_thermal_stress_1d(&steel, &mut grid, 293.0, 0.001, 20, 5);
assert_eq!(history.len(), 4, "20 steps / 5 interval = 4 records");
for (i, record) in history.iter().enumerate() {
assert_eq!(record.temperatures.len(), 5);
assert_eq!(record.von_mises.len(), 5);
if i > 0 {
assert!(record.time > history[i - 1].time, "time should advance");
}
}
}
#[test]
fn transient_time_progresses() {
let steel = Material::steel();
let mut grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
1e-5,
293.0,
ushma::numerical::BoundaryCondition::Fixed(293.0),
ushma::numerical::BoundaryCondition::Fixed(293.0),
)
.expect("grid creation");
let history = transient_thermal_stress_1d(&steel, &mut grid, 293.0, 0.1, 50, 25);
assert_eq!(history.len(), 2);
assert!((history[0].time - 2.5).abs() < 0.01);
assert!((history[1].time - 5.0).abs() < 0.01);
}
#[test]
fn node_stress_history_extraction() {
let steel = Material::steel();
let mut grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
1e-5,
350.0,
ushma::numerical::BoundaryCondition::Fixed(350.0),
ushma::numerical::BoundaryCondition::Fixed(350.0),
)
.expect("grid creation");
let history = transient_thermal_stress_1d(&steel, &mut grid, 293.0, 0.01, 20, 5);
let node_hist = node_stress_history(&history, 2);
assert_eq!(node_hist.len(), history.len());
}
#[test]
fn thermal_fatigue_damage_is_non_negative() {
let steel = Material::steel();
let mut grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
1e-5,
350.0,
ushma::numerical::BoundaryCondition::Fixed(350.0),
ushma::numerical::BoundaryCondition::Fixed(293.0),
)
.expect("grid creation");
let history = transient_thermal_stress_1d(&steel, &mut grid, 293.0, 0.01, 50, 5);
let damage = thermal_fatigue_damage(&history, 2, 1000e6, -0.1);
assert!(damage >= 0.0, "damage should be non-negative");
}
#[test]
fn peak_thermal_stress_from_snapshot() {
let results = vec![
ThermalStepResult {
temperatures: vec![300.0, 350.0, 400.0],
von_mises: vec![10e6, 50e6, 100e6],
any_yielded: false,
first_yield_node: None,
time: 1.0,
},
ThermalStepResult {
temperatures: vec![310.0, 360.0, 410.0],
von_mises: vec![20e6, 80e6, 150e6],
any_yielded: false,
first_yield_node: None,
time: 2.0,
},
];
let peak = peak_thermal_stress(&results);
assert!((peak - 150e6).abs() < 1.0, "peak should be 150 MPa");
}
#[test]
fn time_to_yield_none_for_small_gradient() {
let steel = Material::steel();
let mut grid = ushma::numerical::ThermalGrid1D::new(
5,
0.1,
1e-5,
295.0, ushma::numerical::BoundaryCondition::Fixed(295.0),
ushma::numerical::BoundaryCondition::Fixed(293.0),
)
.expect("grid creation");
let history = transient_thermal_stress_1d(&steel, &mut grid, 293.0, 0.01, 20, 5);
assert!(
time_to_yield(&history).is_none(),
"tiny gradient should not yield"
);
}
#[test]
fn thermal_network_stress() {
let steel = Material::steel();
let mut network = ushma::numerical::ThermalNetwork::new(3);
network.add_resistance(0, 1, 0.1).unwrap();
network.add_resistance(1, 2, 0.1).unwrap();
network.set_fixed_temperature(0, 400.0).unwrap();
network.set_fixed_temperature(2, 293.0).unwrap();
let stresses = stress_from_thermal_network(&steel, &network, 293.0);
assert!(stresses.is_ok());
let stresses = stresses.unwrap();
assert_eq!(stresses.len(), 3, "should have stress for each node");
}
}