#![cfg(feature = "std")]
#![allow(clippy::disallowed_methods)]
use alice_physics::fatigue::{miner_damage, SnCurve};
use alice_physics::math::Fix128;
use alice_physics::transient_thermal::{
crank_nicolson_step_1d, transient_step_1d, TemperatureDependence, ThermalMaterial,
};
fn constant_material() -> ThermalMaterial {
ThermalMaterial {
name: "oracle_constant",
conductivity: TemperatureDependence::Constant(40.0), specific_heat: TemperatureDependence::Constant(500.0), density: TemperatureDependence::Constant(8000.0), reference_temperature: 293.15,
}
}
fn cosine_mode(n: usize, dx: f64, t0: f64, amp: f64) -> Vec<f32> {
let l = n as f64 * dx;
(0..n)
.map(|i| {
let x = (i as f64 + 0.5) * dx;
(t0 + amp * (core::f64::consts::PI * x / l).cos()) as f32
})
.collect()
}
fn max_abs_error(grid: &[f32], dx: f64, t0: f64, amp: f64, alpha: f64, t: f64) -> f64 {
let n = grid.len();
let l = n as f64 * dx;
let decay = (-alpha * core::f64::consts::PI * core::f64::consts::PI * t / (l * l)).exp();
grid.iter()
.enumerate()
.map(|(i, &v)| {
let x = (i as f64 + 0.5) * dx;
let want = t0 + amp * (core::f64::consts::PI * x / l).cos() * decay;
(f64::from(v) - want).abs()
})
.fold(0.0, f64::max)
}
fn interior_mean(grid: &[f32]) -> f64 {
grid.iter().map(|&v| f64::from(v)).sum::<f64>() / grid.len() as f64
}
#[test]
fn transient_thermal_explicit_euler_matches_cosine_eigenmode_decay() {
let material = constant_material();
let alpha = f64::from(material.diffusivity_at(300.0));
assert!((alpha - 1e-5).abs() < 1e-9, "α = {alpha}");
let (n, dx, t0, amp) = (64usize, 1e-3f64, 300.0f64, 50.0f64);
let mut grid = cosine_mode(n, dx, t0, amp);
let dt = 0.25 * dx * dx / alpha;
let steps = 400; for _ in 0..steps {
transient_step_1d(&mut grid, &material, dx as f32, dt as f32);
}
let t = steps as f64 * dt;
let err = max_abs_error(&grid, dx, t0, amp, alpha, t);
assert!(err < 0.2, "explicit Euler max error {err} K after {t} s");
let mean = interior_mean(&grid);
assert!(
(mean - t0).abs() < 0.05,
"mean temperature drifted to {mean}"
);
}
#[test]
fn transient_thermal_crank_nicolson_matches_cosine_eigenmode_decay() {
let material = constant_material();
let alpha = f64::from(material.diffusivity_at(300.0));
let (n, dx, t0, amp) = (64usize, 1e-3f64, 300.0f64, 50.0f64);
let mut grid = cosine_mode(n, dx, t0, amp);
let dt = 2.0 * dx * dx / alpha;
let steps = 50; for _ in 0..steps {
crank_nicolson_step_1d(&mut grid, &material, dx as f32, dt as f32);
}
let t = steps as f64 * dt;
let err = max_abs_error(&grid, dx, t0, amp, alpha, t);
assert!(err < 0.5, "Crank-Nicolson max error {err} K after {t} s");
let mean = interior_mean(&grid);
assert!(
(mean - t0).abs() < 0.05,
"mean temperature drifted to {mean}"
);
}
#[test]
fn transient_thermal_explicit_and_crank_nicolson_agree_on_the_same_rod() {
let material = constant_material();
let alpha = f64::from(material.diffusivity_at(300.0));
let (n, dx, t0, amp) = (32usize, 1e-3f64, 300.0f64, 50.0f64);
let mut explicit = cosine_mode(n, dx, t0, amp);
let mut cn = cosine_mode(n, dx, t0, amp);
let dt = 0.1 * dx * dx / alpha;
for _ in 0..500 {
transient_step_1d(&mut explicit, &material, dx as f32, dt as f32);
crank_nicolson_step_1d(&mut cn, &material, dx as f32, dt as f32);
}
let worst = explicit
.iter()
.zip(&cn)
.map(|(a, b)| (f64::from(*a) - f64::from(*b)).abs())
.fold(0.0, f64::max);
assert!(
worst < 0.05,
"explicit vs Crank-Nicolson differ by {worst} K on the same rod"
);
}
#[test]
fn transient_thermal_uniform_field_is_a_fixed_point() {
let material = constant_material();
let mut grid = vec![350.0f32; 32];
for _ in 0..100 {
transient_step_1d(&mut grid, &material, 1e-3, 1e-2);
}
assert!(
grid.iter().all(|&v| v == 350.0),
"uniform field changed: {grid:?}"
);
}
#[test]
fn fatigue_miner_damage_matches_basquin_closed_form() {
let curve = SnCurve {
ultimate_tensile_mpa: Fix128::from_int(100),
endurance_stress_mpa: Fix128::from_int(30),
endurance_cycles: 1_000_000,
fatigue_exponent_m: 5,
};
let d_full = miner_damage(&[(Fix128::from_int(60), 31_250)], &curve).to_f64();
assert!((d_full - 1.0).abs() < 1e-6, "D at N(S) = {d_full}");
let d_half = miner_damage(&[(Fix128::from_int(60), 15_625)], &curve).to_f64();
assert!((d_half - 0.5).abs() < 1e-6, "D at N/2 = {d_half}");
let d_90 = miner_damage(&[(Fix128::from_int(90), 4_115)], &curve).to_f64();
let n_90 = 1e6 * (30.0f64 / 90.0).powi(5);
assert!(
(d_90 - 4_115.0 / n_90).abs() < 1e-3,
"D(90 MPa, 4115) = {d_90} vs {}",
4_115.0 / n_90
);
let d_block = miner_damage(
&[
(Fix128::from_int(60), 15_625),
(Fix128::from_int(90), 2_057),
],
&curve,
)
.to_f64();
assert!(
(d_block - (0.5 + 2_057.0 / n_90)).abs() < 1e-3,
"block D = {d_block}"
);
assert_eq!(
miner_damage(&[(Fix128::from_int(30), u64::MAX / 2)], &curve),
Fix128::ZERO
);
assert_eq!(
miner_damage(&[(Fix128::from_int(10), 1_000_000_000)], &curve),
Fix128::ZERO
);
}