use pantometry_core::conserved::quantity;
use pantometry_core::units::{Energy, Length, Temperature, Time};
use pantometry_core::{Domain, Exchange, Schedule, Simulation, Substance};
use pantometry_thermal::{Solid3D, STABLE_FOURIER_3D};
fn block(counts: (usize, usize, usize), dx_mm: f64) -> Solid3D {
Solid3D::new(
"block",
Substance::aluminium_6061(),
counts,
Length::mm(dx_mm),
Temperature::celsius(20.0),
)
}
fn run(block: &mut Solid3D, dt: Time, steps: usize) {
let mut bus = Exchange::new();
let mut t = Time::from_si(0.0);
for _ in 0..steps {
block.step(t, dt, &mut bus).expect("stable and isolated");
t += dt;
}
}
#[test]
fn a_separable_mode_decays_at_its_exact_discrete_rate() {
for mode in [(1, 0, 0), (0, 1, 0), (0, 0, 1), (2, 1, 1)] {
let mut b = block((7, 5, 3), 2.0);
b.release_mode(mode, Temperature::celsius(20.0), 4.0);
let dt = Time::from_si(b.max_stable_dt(Time::from_si(0.0)).to_si() * 0.8);
let per_step = b.mode_amplification(mode, dt);
assert!(
per_step.abs() <= 1.0,
"mode {mode:?} amplifies by {per_step}, which is the instability itself"
);
let steps = 25;
run(&mut b, dt, steps);
let want = 4.0 * per_step.powi(steps as i32);
let got = b.mode_amplitude(mode);
assert!(
(got - want).abs() < 1e-12 * 4.0,
"mode {mode:?}: amplitude {got:.15e} against the exact {want:.15e}"
);
assert!(got.abs() < 0.9 * 4.0, "mode {mode:?} barely moved: {got}");
}
}
#[test]
fn the_axes_are_the_same_physics_in_a_different_order() {
let amplitude_after = |counts, mode| {
let mut b = block(counts, 2.0);
b.release_mode(mode, Temperature::celsius(20.0), 4.0);
let dt = Time::from_si(5e-3);
run(&mut b, dt, 200);
b.mode_amplitude(mode)
};
let along_x = amplitude_after((7, 5, 3), (1, 0, 0));
let along_z = amplitude_after((5, 3, 7), (0, 0, 1));
let along_y = amplitude_after((3, 7, 5), (0, 1, 0));
assert!(
(along_x - along_z).abs() < 1e-12 && (along_x - along_y).abs() < 1e-12,
"the long axis should not matter: x {along_x:.12e}, y {along_y:.12e}, z {along_z:.12e}"
);
assert!(
along_x.abs() < 0.2 * 4.0,
"the mode should be mostly gone, or three orientations agree about nothing: {along_x}"
);
}
#[test]
fn the_rate_converges_to_the_continuum_at_second_order() {
let length = 20e-3;
let alpha = Substance::aluminium_6061()
.diffusivity()
.expect("aluminium conducts")
.to_si();
let exact = alpha * std::f64::consts::PI.powi(2) / (length * length);
let error_at = |n: usize| {
let dx = length / n as f64;
let b = block((n, 1, 1), dx * 1e3);
let dt = Time::from_si(1e-6);
let rate = -b.mode_amplification((1, 0, 0), dt).ln() / dt.to_si();
(rate / exact - 1.0).abs()
};
let (coarse, fine) = (error_at(8), error_at(16));
let ratio = coarse / fine;
assert!(
(ratio - 4.0).abs() < 0.15,
"second order means the error quarters on refinement: {coarse:.3e} -> {fine:.3e}, \
ratio {ratio:.4} against 4"
);
assert!(fine < 0.01, "16 cells should be within 1%, was {fine:.4e}");
}
#[test]
fn an_insulated_block_conserves_exactly() {
let mut b = block((9, 7, 5), 1.5);
b.release_mode((2, 1, 1), Temperature::celsius(20.0), 30.0);
let capacity = Substance::aluminium_6061()
.heat_capacity(b.volume())
.expect("aluminium has a specific heat")
.to_si();
let cells = b.counts().0 * b.counts().1 * b.counts().2;
let mean_at = |b: &Solid3D| b.mean_temperature().to_si();
let before = mean_at(&b);
let spread = b.peak_temperature().to_si() - b.coldest_temperature().to_si();
assert!(
spread > 50.0,
"the mode should be a real gradient: {spread} K"
);
run(&mut b, Time::from_si(1e-4), 600);
let after = mean_at(&b);
let moved = capacity * spread;
let lost = capacity * (after - before).abs();
assert!(
lost < 1e-9 * moved,
"insulated: {lost:.3e} J lost against {moved:.3e} J of gradient over {cells} cells"
);
let left = b.peak_temperature().to_si() - b.coldest_temperature().to_si();
assert!(
left < 0.2 * spread,
"the gradient should be mostly gone, or nothing was asked of the sweep: {spread:.3} K -> {left:.3} K"
);
}
#[test]
fn the_third_dimension_costs_a_factor_of_three_and_the_limit_is_enforced() {
let b = block((5, 5, 5), 2.0);
let dx = 2e-3;
let alpha = Substance::aluminium_6061().diffusivity().unwrap().to_si();
let limit = b.max_stable_dt(Time::from_si(0.0)).to_si();
let exact = dx * dx / (6.0 * alpha);
assert!(
(limit / exact - 1.0).abs() < 1e-12,
"dx²/6α: {limit:.9e} against {exact:.9e}"
);
assert!((b.fourier_number(Time::from_si(limit)) - STABLE_FOURIER_3D).abs() < 1e-12);
let bar_limit = dx * dx / (2.0 * alpha);
assert!(
(bar_limit / limit - 3.0).abs() < 1e-9,
"three dimensions should cost exactly 3× in steps, got {:.6}",
bar_limit / limit
);
let mut b = block((5, 5, 5), 2.0);
let mut bus = Exchange::new();
let over = Time::from_si(limit * 1.05);
let err = b
.step(Time::from_si(0.0), over, &mut bus)
.expect_err("5% past the limit must be refused");
assert_eq!(err.quantity, "Fourier number");
assert!(
(err.after / STABLE_FOURIER_3D - 1.05).abs() < 1e-9,
"the violation should say by how much: {}",
err.after
);
let mut b = block((5, 5, 5), 2.0);
b.step(
Time::from_si(0.0),
Time::from_si(limit),
&mut Exchange::new(),
)
.expect("exactly at the limit is stable");
}
#[test]
fn placeless_heat_leaves_the_block_uniform() {
let mut b = block((4, 3, 2), 3.0);
let mut bus = Exchange::new();
let joules = 12.0;
bus.publish(quantity::ENERGY, joules);
b.step(Time::from_si(0.0), Time::from_si(1e-5), &mut bus)
.expect("stable");
let (nx, ny, nz) = b.counts();
let first = b.temperature_at(0, 0, 0).to_si();
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let t = b.temperature_at(i, j, k).to_si();
assert!(
(t - first).abs() < 1e-12,
"cell ({i},{j},{k}) is at {t} against {first} — placeless heat found a place"
);
}
}
}
let capacity = Substance::aluminium_6061()
.heat_capacity(b.volume())
.unwrap()
.to_si();
let rise = first - Temperature::celsius(20.0).to_si();
assert!(
(rise - joules / capacity).abs() < 1e-12,
"{joules} J into {capacity:.4} J/K should be {:.9} K, was {rise:.9}",
joules / capacity
);
assert!((b.absorbed_energy().to_si() - joules).abs() < 1e-12);
}
#[test]
fn a_hot_spot_spreads_the_same_way_along_every_axis() {
let mut b = block((9, 9, 9), 1.0);
b.deposit(4, 4, 4, Energy::from_si(2.0));
let hot = b.temperature_at(4, 4, 4).to_si();
assert!(hot > 20.0 + 273.15, "the deposit should have warmed it");
run(&mut b, Time::from_si(2e-6), 5);
let arms = [
b.temperature_at(3, 4, 4).to_si(),
b.temperature_at(5, 4, 4).to_si(),
b.temperature_at(4, 3, 4).to_si(),
b.temperature_at(4, 5, 4).to_si(),
b.temperature_at(4, 4, 3).to_si(),
b.temperature_at(4, 4, 5).to_si(),
];
let ambient = Temperature::celsius(20.0).to_si();
for (n, arm) in arms.iter().enumerate() {
assert!(*arm > ambient + 1e-6, "arm {n} never warmed: {arm}");
assert!(
(arm - arms[0]).abs() < 1e-12 * (arms[0] - ambient),
"arm {n} at {arm} against arm 0 at {}: the stencil is not isotropic",
arms[0]
);
}
let corner = b.temperature_at(3, 3, 4).to_si();
assert!(
corner > ambient && corner < arms[0],
"diagonal {corner} should sit between ambient {ambient} and the face {}",
arms[0]
);
}
#[test]
fn one_cell_thick_reduces_to_a_bar() {
let n = 12;
let mut b = block((n, 1, 1), 2.0);
b.release_mode((1, 0, 0), Temperature::celsius(20.0), 5.0);
let dt = Time::from_si(2e-5);
let f = b.fourier_number(dt);
let lambda = -4.0 * (std::f64::consts::PI / (2.0 * n as f64)).sin().powi(2);
let want_per_step = 1.0 + f * lambda;
assert!(
(b.mode_amplification((1, 0, 0), dt) - want_per_step).abs() < 1e-15,
"the flat axes should contribute nothing to the eigenvalue"
);
run(&mut b, dt, 30);
let want = 5.0 * want_per_step.powi(30);
assert!(
(b.mode_amplitude((1, 0, 0)) - want).abs() < 1e-12 * 5.0,
"1D limit: {:.15e} against {want:.15e}",
b.mode_amplitude((1, 0, 0))
);
}
#[test]
fn the_field_reads_back_the_cells_and_clamps_at_the_faces() {
use pantometry_core::ScalarField;
let mut b = block((5, 4, 3), 2.0);
b.release_mode((1, 1, 1), Temperature::celsius(20.0), 10.0);
let t = Time::from_si(0.0);
let (nx, ny, nz) = b.counts();
for k in 0..nz {
for j in 0..ny {
for i in 0..nx {
let at = b.at(b.centre_of(i, j, k), t);
let cell = b.temperature_at(i, j, k).to_si();
assert!(
(at - cell).abs() < 1e-9,
"centre ({i},{j},{k}): field {at} against cell {cell}"
);
}
}
}
let hottest = b.peak_temperature().to_si();
let coldest = b.coldest_temperature().to_si();
for p in [
pantometry_core::units::LengthVec::m(-1.0, 0.0, 0.0),
pantometry_core::units::LengthVec::m(0.0, -1.0, 0.0),
pantometry_core::units::LengthVec::m(0.0, 0.0, -1.0),
pantometry_core::units::LengthVec::m(1.0, 1.0, 1.0),
] {
let v = b.at(p, t);
assert!(
v <= hottest + 1e-9 && v >= coldest - 1e-9,
"sampling outside gave {v}, past the block's own range [{coldest}, {hottest}]"
);
}
assert_eq!(b.unit(), "K", "the cells hold kelvin");
}
#[test]
fn it_runs_and_audits_inside_a_simulation() {
struct Source {
watts: f64,
reserve: f64,
}
impl Domain for Source {
fn name(&self) -> &str {
"lamp"
}
fn kind(&self) -> pantometry_core::Kind {
pantometry_core::Kind::QuasiStatic
}
fn step(
&mut self,
_t: Time,
dt: Time,
bus: &mut Exchange,
) -> Result<(), pantometry_core::Violation> {
let joules = (self.watts * dt.to_si()).min(self.reserve);
self.reserve -= joules;
bus.publish(quantity::ENERGY, joules);
Ok(())
}
fn ledger(&self) -> pantometry_core::Ledger {
pantometry_core::Ledger::new().with(quantity::ENERGY, self.reserve)
}
}
let seconds = 0.5;
let watts = 8.0;
let mut sim = Simulation::new(Schedule::Multirate)
.with(Source {
watts,
reserve: watts * seconds * 10.0,
})
.with(block((6, 6, 6), 2.0));
sim.advance(Time::from_si(seconds))
.expect("an insulated block taking every joule cannot leak");
let b = sim
.domain_as::<Solid3D>("block")
.expect("the block is still there");
let paid = watts * seconds;
assert!(
(b.absorbed_energy().to_si() - paid).abs() < 1e-9 * paid,
"{paid} J published, {} J absorbed",
b.absorbed_energy().to_si()
);
let spread = b.peak_temperature().to_si() - b.coldest_temperature().to_si();
assert!(
spread < 1e-9,
"placeless heat made a gradient of {spread} K"
);
let capacity = Substance::aluminium_6061()
.heat_capacity(b.volume())
.unwrap()
.to_si();
let rise = b.mean_temperature().to_si() - Temperature::celsius(20.0).to_si();
assert!(
(rise - paid / capacity).abs() < 1e-9 * (paid / capacity),
"{rise:.6} K against {:.6} K",
paid / capacity
);
}
#[test]
fn the_limit_is_where_the_sharpest_mode_stops_growing() {
let n = 5;
let sharpest = (n - 1, n - 1, n - 1);
let mut b = block((n, n, n), 2.0);
b.release_mode(sharpest, Temperature::celsius(20.0), 1.0);
let limit = b.max_stable_dt(Time::from_si(0.0));
run(&mut b, limit, 60);
let after = b.mode_amplitude(sharpest);
assert!(
after.abs() <= 1.0 + 1e-9,
"sixty steps at the reported limit turned amplitude 1 into {after:.6e}"
);
let per_step = block((n, n, n), 2.0).mode_amplification(sharpest, limit);
assert!(
(-1.0..-0.5).contains(&per_step),
"the limit should be marginal and oscillatory: amplification {per_step:.6}"
);
let over = Time::from_si(limit.to_si() * 3.0);
let runaway = block((n, n, n), 2.0).mode_amplification(sharpest, over);
assert!(
runaway.abs() > 1.0 && runaway.abs().powi(60) > 1e6,
"three times the limit should diverge: amplification {runaway:.6}"
);
let mut b = block((n, n, n), 2.0);
b.step(Time::from_si(0.0), over, &mut Exchange::new())
.expect_err("and it is refused rather than run");
}