#![deny(missing_docs)]
pub mod network;
pub mod solid;
use glam::DVec3;
pub use network::{Node, SteadyState, ThermalNetwork};
use pantometry_core::conserved::quantity;
use pantometry_core::{
Domain, Exchange, Interface, Kind, Ledger, Reading, ScalarField, Substance, Violation,
};
use pantometry_units::{
Area, Energy, HeatCapacity, Length, LengthVec, Power, Temperature, Time, Volume,
STEFAN_BOLTZMANN,
};
pub use solid::{Face, GapPatch, Solid3D, STABLE_FOURIER_3D};
pub const HEAT: &str = quantity::ENERGY;
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Environment {
pub ambient: Temperature,
pub convection_w_per_m2_k: f64,
pub area: Area,
}
impl Environment {
pub fn still_air(ambient: Temperature, area: Area) -> Environment {
Environment {
ambient,
convection_w_per_m2_k: 7.0,
area,
}
}
pub fn loss_from(&self, temperature: Temperature, emissivity: f64) -> Power {
let (t, ta) = (temperature.to_si(), self.ambient.to_si());
let convective = self.convection_w_per_m2_k * self.area.to_si() * (t - ta);
let radiative =
emissivity * STEFAN_BOLTZMANN.to_si() * self.area.to_si() * (t.powi(4) - ta.powi(4));
Power::from_si(convective + radiative)
}
}
pub struct LumpedMass {
name: String,
substance: Substance,
volume: Volume,
thickness: Length,
temperature: Temperature,
environment: Environment,
saved: Option<(Temperature, f64, f64)>,
absorbed: f64,
lost: f64,
}
impl LumpedMass {
pub fn new(
name: impl Into<String>,
substance: Substance,
volume: Volume,
thickness: Length,
initial: Temperature,
environment: Environment,
) -> LumpedMass {
LumpedMass {
name: name.into(),
substance,
volume,
thickness,
temperature: initial,
environment,
saved: None,
absorbed: 0.0,
lost: 0.0,
}
}
pub fn temperature(&self) -> Temperature {
self.temperature
}
pub fn rise(&self) -> Temperature {
self.temperature - self.environment.ambient
}
pub fn heat_capacity(&self) -> HeatCapacity {
self.substance
.heat_capacity(self.volume)
.unwrap_or(HeatCapacity::from_si(f64::INFINITY))
}
pub fn absorbed_energy(&self) -> Energy {
Energy::from_si(self.absorbed)
}
pub fn lost_energy(&self) -> Energy {
Energy::from_si(self.lost)
}
pub fn biot_number(&self) -> f64 {
let Some(thermal) = self.substance.thermal else {
return f64::INFINITY;
};
self.environment.convection_w_per_m2_k * self.thickness.to_si()
/ thermal.conductivity.to_si()
}
pub fn time_constant(&self) -> Time {
let capacity = self.heat_capacity().to_si();
let conductance = self.loss_conductance(self.temperature);
if conductance <= 0.0 || !capacity.is_finite() {
return Time::from_si(f64::INFINITY);
}
Time::from_si(capacity / conductance)
}
fn loss_conductance(&self, at: Temperature) -> f64 {
linearised_loss_conductance(&self.environment, at, self.emissivity())
}
pub fn equilibrium_rise(&self, absorbed: Power) -> Temperature {
let p = absorbed.to_si();
let area = self.environment.area.to_si();
let ha = self.environment.convection_w_per_m2_k * area;
let er = self.emissivity() * STEFAN_BOLTZMANN.to_si() * area;
let ta = self.environment.ambient.to_si();
if !p.is_finite() || (ha <= 0.0 && er <= 0.0) {
return Temperature::from_si(if p == 0.0 { 0.0 } else { f64::INFINITY });
}
if p == 0.0 {
return Temperature::from_si(0.0);
}
if er <= 0.0 {
return Temperature::from_si(p / ha);
}
let mut x = if ha > 0.0 {
p / ha
} else {
(p / er + ta.powi(4)).max(0.0).powf(0.25) - ta
};
for _ in 0..64 {
let t = (ta + x).max(0.0);
let f = ha * x + er * (t.powi(4) - ta.powi(4)) - p;
let df = ha + 4.0 * er * t * t * t;
if df <= 0.0 || !f.is_finite() {
break;
}
let step = f / df;
x -= step;
if step.abs() <= 1e-12 * (1.0 + x.abs()) {
break;
}
}
Temperature::from_si(x)
}
fn emissivity(&self) -> f64 {
self.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0)
}
}
impl Domain for LumpedMass {
fn name(&self) -> &str {
&self.name
}
fn kind(&self) -> Kind {
Kind::Evolving
}
fn max_stable_dt(&self, _now: Time) -> Time {
self.time_constant() / 10.0
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let capacity = self.heat_capacity();
if !capacity.to_si().is_finite() || capacity.to_si() <= 0.0 {
return Err(Violation::at(
&self.name,
"substance has no heat capacity",
capacity.to_si(),
));
}
let gained = bus.take_share(HEAT, dt);
self.absorbed += gained;
let lost = self
.environment
.loss_from(self.temperature, self.emissivity());
let lost_joules = lost.to_si() * dt.to_si();
self.lost += lost_joules;
let net = gained - lost_joules;
self.temperature += Temperature::from_si(net / capacity.to_si());
Ok(())
}
fn ledger(&self) -> Ledger {
let stored = self.heat_capacity().to_si() * self.rise().to_si();
Ledger::new().with(quantity::ENERGY, stored + self.lost)
}
fn checkpoint(&mut self) {
self.saved = Some((self.temperature, self.absorbed, self.lost));
}
fn restore(&mut self) {
if let Some((t, absorbed, lost)) = self.saved {
self.temperature = t;
self.absorbed = absorbed;
self.lost = lost;
}
}
fn supports_restore(&self) -> bool {
true
}
fn readings(&self) -> Vec<Reading> {
vec![
Reading::new(
&self.name,
"temperature",
self.temperature.to_si() - 273.15,
"C",
),
Reading::new(&self.name, "absorbed", self.absorbed_energy().to_si(), "J"),
]
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
}
pub struct Bar1D {
name: String,
substance: Substance,
cells: Vec<f64>,
saved: Vec<f64>,
dx: Length,
area: Area,
boundary: Option<Interface>,
absorbed: f64,
reference: f64,
}
impl Bar1D {
pub fn new(
name: impl Into<String>,
substance: Substance,
cells: usize,
dx: Length,
area: Area,
initial: Temperature,
) -> Bar1D {
let cells = cells.max(2);
let temps = vec![initial.to_si(); cells];
Bar1D {
name: name.into(),
substance,
cells: temps.clone(),
saved: temps,
dx,
area,
boundary: None,
absorbed: 0.0,
reference: initial.to_si(),
}
}
pub fn exposing(mut self, boundary: impl Into<String>, face_area: Area) -> Bar1D {
self.boundary = Some(Interface::uniform(boundary, self.cells.len(), face_area));
self
}
pub fn boundary(&self) -> Option<&Interface> {
self.boundary.as_ref()
}
pub fn temperature_at(&self, index: usize) -> Temperature {
Temperature::from_si(self.cells[index.min(self.cells.len() - 1)])
}
pub fn cell_count(&self) -> usize {
self.cells.len()
}
pub fn mean_temperature(&self) -> Temperature {
Temperature::from_si(self.cells.iter().sum::<f64>() / self.cells.len() as f64)
}
pub fn end_to_end(&self) -> Temperature {
Temperature::from_si(self.cells[self.cells.len() - 1] - self.cells[0])
}
fn cell_capacity(&self) -> f64 {
let volume = Volume::from_si(self.area.to_si() * self.dx.to_si());
self.substance
.heat_capacity(volume)
.map(|c| c.to_si())
.unwrap_or(f64::INFINITY)
}
fn stored_heat(&self) -> f64 {
self.cell_capacity() * self.cells.iter().map(|t| t - self.reference).sum::<f64>()
}
pub fn absorbed_energy(&self) -> Energy {
Energy::from_si(self.absorbed)
}
pub fn fourier_number(&self, dt: Time) -> f64 {
let Some(alpha) = self.substance.diffusivity() else {
return f64::INFINITY;
};
alpha.to_si() * dt.to_si() / (self.dx.to_si() * self.dx.to_si())
}
}
impl Domain for Bar1D {
fn books_balance(&self) -> bool {
true
}
fn name(&self) -> &str {
&self.name
}
fn max_stable_dt(&self, _now: Time) -> Time {
let Some(alpha) = self.substance.diffusivity() else {
return Time::from_si(f64::INFINITY);
};
Time::from_si(self.dx.to_si() * self.dx.to_si() / (2.0 * alpha.to_si()))
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let f = self.fourier_number(dt);
if !f.is_finite() {
return Err(Violation::at(&self.name, "substance has no diffusivity", f));
}
if f > 0.5 + 1e-12 {
return Err(Violation {
quantity: "Fourier number".to_string(),
site: format!("{} (explicit conduction)", self.name),
before: 0.5,
after: f,
scale: 0.5,
tolerance: 1e-12,
});
}
let capacity = self.cell_capacity();
let gained = bus.take_share(HEAT, dt);
self.absorbed += gained;
self.cells[0] += gained / capacity;
let arriving = match self.boundary.as_ref() {
Some(boundary) => Some(bus.take_on(boundary, HEAT)?),
None => None,
};
if let Some(flux) = arriving {
for (cell, joules) in self.cells.iter_mut().zip(flux.per_face()) {
*cell += joules / capacity;
}
self.absorbed += flux.total();
}
let previous = self.cells.clone();
let last = previous.len() - 1;
for i in 0..=last {
let left = previous[i.saturating_sub(1)];
let right = previous[(i + 1).min(last)];
self.cells[i] = previous[i] + f * (left - 2.0 * previous[i] + right);
}
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, self.stored_heat())
}
fn checkpoint(&mut self) {
self.saved = self.cells.clone();
}
fn restore(&mut self) {
self.cells = self.saved.clone();
}
fn supports_restore(&self) -> bool {
true
}
fn readings(&self) -> Vec<Reading> {
let peak = (0..self.cells.len())
.map(|i| self.temperature_at(i).to_si())
.fold(f64::MIN, f64::max);
vec![
Reading::new(
&self.name,
"mean",
self.mean_temperature().to_si() - 273.15,
"C",
),
Reading::new(&self.name, "peak", peak - 273.15, "C"),
Reading::new(&self.name, "absorbed", self.absorbed_energy().to_si(), "J"),
]
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn as_field(&self) -> Option<&dyn pantometry_core::ScalarField> {
Some(self)
}
}
impl ScalarField for Bar1D {
fn unit(&self) -> &'static str {
"K"
}
fn at(&self, p: LengthVec, _t: Time) -> f64 {
let last = self.cells.len() - 1;
let u = p.to_si().x / self.dx.to_si() - 0.5;
if u.is_nan() || u <= 0.0 {
return self.cells[0];
}
if u >= last as f64 {
return self.cells[last];
}
let i = u.floor() as usize;
let f = u - i as f64;
self.cells[i] * (1.0 - f) + self.cells[i + 1] * f
}
fn gradient(&self, p: LengthVec, _t: Time, _h: Length) -> DVec3 {
let (left, _, right) = self.stencil_at(p);
DVec3::new((right - left) / (2.0 * self.dx.to_si()), 0.0, 0.0)
}
fn laplacian(&self, p: LengthVec, _t: Time, _h: Length) -> f64 {
let (left, centre, right) = self.stencil_at(p);
let dx = self.dx.to_si();
(left - 2.0 * centre + right) / (dx * dx)
}
fn rate(&self, p: LengthVec, t: Time, _dt: Time) -> f64 {
let Some(alpha) = self.substance.diffusivity() else {
return 0.0;
};
alpha.to_si() * self.laplacian(p, t, self.dx)
}
}
impl Bar1D {
fn stencil_at(&self, p: LengthVec) -> (f64, f64, f64) {
let last = self.cells.len() - 1;
let x = p.to_si().x / self.dx.to_si();
if x.is_nan() || x < 0.0 {
let v = self.cells[0];
return (v, v, v);
}
if x >= self.cells.len() as f64 {
let v = self.cells[last];
return (v, v, v);
}
let i = (x as usize).min(last);
(
self.cells[i.saturating_sub(1)],
self.cells[i],
self.cells[(i + 1).min(last)],
)
}
}
pub(crate) fn linearised_loss_conductance(
environment: &Environment,
at: Temperature,
emissivity: f64,
) -> f64 {
let area = environment.area.to_si();
let t = at.to_si().max(0.0);
environment.convection_w_per_m2_k * area
+ 4.0 * emissivity * STEFAN_BOLTZMANN.to_si() * area * t * t * t
}
#[cfg(test)]
mod tests {
use super::*;
fn radiating_box(emissivity: f64) -> LumpedMass {
use pantometry_units::SpecificHeat;
let (area, vol) = (0.030_24, 3.456e-4);
let mut substance = Substance::aluminium_6061();
substance.density = Density::kg_per_m3(1.122 / vol);
if let Some(t) = substance.thermal.as_mut() {
t.specific_heat = SpecificHeat::j_per_kg_k(600.0);
t.emissivity = emissivity;
}
LumpedMass::new(
"box",
substance,
Volume::from_si(vol),
Length::from_si(vol / area),
Temperature::celsius(25.0),
Environment {
ambient: Temperature::celsius(25.0),
convection_w_per_m2_k: 7.0,
area: Area::from_si(area),
},
)
}
fn settle(body: &mut LumpedMass) -> f64 {
let mut bus = Exchange::new();
for k in 0..400_000 {
bus.publish(HEAT, 21.0);
body.step(Time::s(k as f64), Time::s(1.0), &mut bus)
.unwrap();
}
body.rise().to_si()
}
use pantometry_core::{Flux, Schedule, Simulation};
use pantometry_units::Density;
fn lens_volume() -> Volume {
Volume::from_si(std::f64::consts::PI * 0.0125f64.powi(2) * 0.005)
}
fn lens_area() -> Area {
let r = 0.0125f64;
Area::from_si(2.0 * std::f64::consts::PI * r * r + std::f64::consts::TAU * r * 0.005)
}
fn lens(initial_c: f64) -> LumpedMass {
LumpedMass::new(
"lens",
Substance::borosilicate_crown(),
lens_volume(),
Length::mm(5.0),
Temperature::celsius(initial_c),
Environment::still_air(Temperature::celsius(20.0), lens_area()),
)
}
#[test]
fn the_lumped_approximation_declares_when_it_applies() {
let glass_in_air = lens(20.0);
assert!(
glass_in_air.biot_number() < 0.1,
"still air over 5 mm of glass should be lumpable, Bi = {}",
glass_in_air.biot_number()
);
let mut wet = lens(20.0);
wet.environment.convection_w_per_m2_k = 500.0;
assert!(wet.biot_number() > 1.0, "Bi = {}", wet.biot_number());
let unknown = LumpedMass::new(
"unknown",
Substance::bulk("x", Density::g_per_cm3(2.0)),
lens_volume(),
Length::mm(5.0),
Temperature::celsius(20.0),
Environment::still_air(Temperature::celsius(20.0), lens_area()),
);
assert!(!unknown.biot_number().is_finite());
}
#[test]
fn cooling_follows_the_exponential_it_should() {
let mut body = lens(30.0);
body.substance.thermal.as_mut().unwrap().emissivity = 0.0;
let tau = body.time_constant();
assert!(
(tau.to_si() - 549.0).abs() < 5.0,
"a lens in still air settles over about nine minutes, tau = {} s",
tau.to_si()
);
let initial_rise = body.rise().to_si();
let run = |steps: u32| {
let mut body = lens(30.0);
body.substance.thermal.as_mut().unwrap().emissivity = 0.0;
let dt = tau / steps as f64;
let mut bus = Exchange::new();
for _ in 0..steps {
body.step(Time::ZERO, dt, &mut bus).unwrap();
}
body.rise().to_si()
};
for steps in [10u32, 100, 1000] {
let discrete = initial_rise * (1.0 - 1.0 / steps as f64).powi(steps as i32);
let got = run(steps);
assert!(
(got / discrete - 1.0).abs() < 1e-9,
"{steps} steps: got {got:.6} K, discrete solution {discrete:.6} K"
);
}
let exact = initial_rise * (-1.0f64).exp();
let shortfall = |steps: u32| (exact - run(steps)) / exact;
assert!((shortfall(10) - 0.0522).abs() < 1e-3, "{}", shortfall(10));
assert!((shortfall(100) - 0.0051).abs() < 1e-3, "{}", shortfall(100));
assert!(
(shortfall(10) / shortfall(100) - 10.0).abs() < 1.0,
"first order: ratio {}",
shortfall(10) / shortfall(100)
);
assert!(run(10) < initial_rise, "it should have cooled");
}
#[test]
fn radiation_matters_as_much_as_convection() {
let env = Environment::still_air(Temperature::celsius(20.0), lens_area());
let hot = Temperature::celsius(30.0);
let with_radiation = env.loss_from(hot, 0.90).to_si();
let without = env.loss_from(hot, 0.0).to_si();
let radiative = with_radiation - without;
assert!(
radiative / without > 0.6 && radiative / without < 1.0,
"radiation is {:.2} of convection, not negligible",
radiative / without
);
assert!(env.loss_from(Temperature::celsius(20.0), 0.9).to_si().abs() < 1e-12);
assert!(env.loss_from(Temperature::celsius(10.0), 0.9).to_si() < 0.0);
}
#[test]
fn the_equilibrium_agrees_with_stepping_there_at_every_emissivity() {
for e in [0.0, 0.05, 0.3, 0.9, 1.0] {
let mut body = radiating_box(e);
let quoted = body.equilibrium_rise(Power::w(21.0)).to_si();
let settled = settle(&mut body);
assert!(
(quoted / settled - 1.0).abs() < 1e-6,
"emissivity {e}: quoted {quoted:.4} K, settled {settled:.4} K"
);
}
let (mut black, mut shiny) = (radiating_box(1.0), radiating_box(0.05));
let (b, s) = (settle(&mut black), settle(&mut shiny));
assert!(b < 0.55 * s, "black {b:.1} K against polished {s:.1} K");
}
#[test]
fn the_time_constant_brackets_the_measured_one_and_tightens_when_hot() {
let cold = radiating_box(0.9);
let tau_cold = cold.time_constant().to_si();
let mut body = radiating_box(0.9);
let settled = settle(&mut body);
let tau_hot = body.time_constant().to_si();
assert!(
tau_hot < tau_cold,
"hot {tau_hot:.1} s against cold {tau_cold:.1} s"
);
let mut probe = radiating_box(0.9);
let mut bus = Exchange::new();
let mut t63 = f64::NAN;
for k in 0..400_000 {
bus.publish(HEAT, 21.0);
probe
.step(Time::s(k as f64), Time::s(1.0), &mut bus)
.unwrap();
let reached = probe.rise().to_si() >= settled * (1.0 - 1.0 / std::f64::consts::E);
if t63.is_nan() && reached {
t63 = k as f64;
}
}
assert!(
tau_hot < t63 && t63 < tau_cold,
"the measured {:.1} min should lie between {:.1} and {:.1}",
t63 / 60.0,
tau_hot / 60.0,
tau_cold / 60.0
);
assert!(body.max_stable_dt(Time::ZERO) < cold.max_stable_dt(Time::ZERO));
}
#[test]
fn a_body_that_cannot_lose_heat_has_no_equilibrium() {
let sealed = LumpedMass::new(
"sealed",
Substance::aluminium_6061(),
Volume::from_si(1e-4),
Length::mm(10.0),
Temperature::celsius(20.0),
Environment {
ambient: Temperature::celsius(20.0),
convection_w_per_m2_k: 0.0,
area: Area::from_si(0.0),
},
);
assert!(sealed.equilibrium_rise(Power::w(1.0)).to_si().is_infinite());
assert_eq!(sealed.equilibrium_rise(Power::w(0.0)).to_si(), 0.0);
assert!(sealed.time_constant().to_si().is_infinite());
}
#[test]
fn a_body_in_vacuum_settles_where_stefan_boltzmann_says() {
let (area, vol) = (0.030_24, 3.456e-4);
let emissivity = 0.8;
let mut substance = Substance::aluminium_6061();
if let Some(t) = substance.thermal.as_mut() {
t.emissivity = emissivity;
}
let vacuum = LumpedMass::new(
"vac",
substance,
Volume::from_si(vol),
Length::from_si(vol / area),
Temperature::celsius(25.0),
Environment {
ambient: Temperature::celsius(25.0),
convection_w_per_m2_k: 0.0,
area: Area::from_si(area),
},
);
let rise = vacuum.equilibrium_rise(Power::w(21.0)).to_si();
let ta = Temperature::celsius(25.0).to_si();
let want =
(21.0 / (emissivity * STEFAN_BOLTZMANN.to_si() * area) + ta.powi(4)).powf(0.25) - ta;
assert!(
(rise / want - 1.0).abs() < 1e-9,
"vacuum: got {rise:.4} K, closed form {want:.4} K"
);
}
#[test]
fn equilibrium_rise_is_the_number_that_matters() {
let mut body = lens(20.0);
let quoted = body.equilibrium_rise(Power::mw(10.0)).to_si();
let mut bus = Exchange::new();
for k in 0..200_000 {
bus.publish(HEAT, 0.010);
body.step(Time::s(k as f64), Time::s(1.0), &mut bus)
.unwrap();
}
let settled = body.rise().to_si();
assert!(
(quoted / settled - 1.0).abs() < 1e-6,
"quoted {quoted:.6} K, settled {settled:.6} K"
);
assert!(quoted > 0.1 && quoted < 2.0, "got {quoted} K");
assert_eq!(
Substance::borosilicate_crown().survives(Temperature::from_si(quoted)),
Some(true)
);
}
#[test]
fn explicit_conduction_refuses_an_unstable_step() {
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
20,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
let limit = bar.max_stable_dt(Time::ZERO);
assert!(
(limit.in_ms() - 7.2).abs() < 0.2,
"limit {} ms",
limit.in_ms()
);
assert!((bar.fourier_number(limit) - 0.5).abs() < 1e-12);
let mut bus = Exchange::new();
assert!(bar.step(Time::ZERO, limit, &mut bus).is_ok());
let err = bar
.step(Time::ZERO, limit * 1.5, &mut bus)
.expect_err("past the limit must not be attempted");
assert_eq!(err.quantity, "Fourier number");
assert!(err.after > 0.5, "{err}");
}
#[test]
fn two_materials_on_one_grid_need_different_steps() {
let bar = |s: Substance| {
Bar1D::new(
"bar",
s,
10,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
)
.max_stable_dt(Time::ZERO)
.to_si()
};
let glass = bar(Substance::borosilicate_crown());
let metal = bar(Substance::aluminium_6061());
assert!((glass - 0.967).abs() < 0.02, "glass {glass} s");
assert!((metal - 0.0072).abs() < 0.001, "metal {metal} s");
assert!(glass / metal > 100.0, "ratio {}", glass / metal);
}
#[test]
fn conduction_conserves_heat_and_flattens_a_gradient() {
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
21,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
bar.cells[10] = Temperature::celsius(60.0).to_si();
let total_before: f64 = bar.cells.iter().sum();
let spread_before = bar.cells.iter().cloned().fold(0.0f64, f64::max)
- bar.cells.iter().cloned().fold(f64::MAX, f64::min);
let dt = bar.max_stable_dt(Time::ZERO);
let mut bus = Exchange::new();
for _ in 0..500 {
bar.step(Time::ZERO, dt * 0.9, &mut bus).unwrap();
}
let total_after: f64 = bar.cells.iter().sum();
assert!(
(total_after / total_before - 1.0).abs() < 1e-12,
"insulated ends must conserve heat exactly: {total_before} -> {total_after}"
);
let spread_after = bar.cells.iter().cloned().fold(0.0f64, f64::max)
- bar.cells.iter().cloned().fold(f64::MAX, f64::min);
assert!(
spread_after < spread_before / 10.0,
"the gradient should have flattened: {spread_before} -> {spread_after}"
);
assert!(bar.mean_temperature().in_celsius() > 21.0);
}
#[test]
fn heat_arrives_where_the_flux_says_it_did() {
let build = || {
Bar1D::new(
"bar",
Substance::aluminium_6061(),
21,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
)
};
let joules = 2.0;
let mut lumped = build();
let mut bus = Exchange::new();
bus.publish(HEAT, joules);
lumped
.step(Time::ZERO, Time::from_si(1e-4), &mut bus)
.unwrap();
let mut resolved = build().exposing("bar face", Area::from_si(1e-4));
let boundary = resolved.boundary().expect("it was just given one").clone();
assert_eq!(
boundary.faces(),
21,
"one face per cell, so nothing interpolates"
);
let mut spot = vec![0.0; 21];
spot[10] = joules;
bus.publish_on(&boundary, HEAT, &Flux::from_faces(spot))
.unwrap();
resolved
.step(Time::ZERO, Time::from_si(1e-4), &mut bus)
.unwrap();
assert!(
(resolved.absorbed_energy().to_si() - lumped.absorbed_energy().to_si()).abs() < 1e-15,
"the two runs must differ in place, not in amount"
);
assert!(
bus.unclaimed().next().is_none(),
"and nothing was left on the bus"
);
let lumped_end = lumped.temperature_at(0).in_celsius();
let lumped_middle = lumped.temperature_at(10).in_celsius();
assert!(
lumped_end > lumped_middle + 1.0,
"lumped heat piled up at cell 0"
);
assert!(
(lumped_middle - 20.0).abs() < 1e-9,
"and never reached the middle"
);
let resolved_end = resolved.temperature_at(0).in_celsius();
let resolved_middle = resolved.temperature_at(10).in_celsius();
assert!(
resolved_middle > resolved_end + 1.0,
"resolved heat is in the middle"
);
assert!(
(resolved_end - 20.0).abs() < 1e-9,
"and the end is untouched"
);
assert!(
(resolved.temperature_at(9).in_celsius() - resolved.temperature_at(11).in_celsius())
.abs()
< 1e-12
);
}
#[test]
fn a_flux_on_the_wrong_grid_stops_the_step() {
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
21,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
)
.exposing("bar face", Area::from_si(1e-4));
let boundary = bar.boundary().unwrap().clone();
let camera = Interface::uniform("bar face", 64, Area::from_si(1e-4) * (21.0 / 64.0));
let mut bus = Exchange::new();
bus.publish_on(&camera, HEAT, &Flux::spread_over(2.0, &camera))
.unwrap();
let err = bar
.step(Time::ZERO, Time::from_si(1e-4), &mut bus)
.expect_err("a 64-face flux is not a 21-cell bar");
assert!(err.quantity.contains("expected 21"), "{err}");
assert!(
(bar.temperature_at(10).in_celsius() - 20.0).abs() < 1e-9,
"and nothing was heated"
);
let crossed = bus
.take_on(&camera, HEAT)
.unwrap()
.resample(&camera, &boundary)
.unwrap();
bus.publish_on(&boundary, HEAT, &crossed).unwrap();
bar.step(Time::ZERO, Time::from_si(1e-4), &mut bus).unwrap();
assert!((bar.absorbed_energy().to_si() - 2.0).abs() < 1e-12);
assert!(bus.unclaimed().next().is_none());
}
#[test]
fn the_field_samples_the_bar_and_stops_at_its_ends() {
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
5,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
for (i, cell) in bar.cells.iter_mut().enumerate() {
*cell = 300.0 + i as f64;
}
let at = |mm: f64| bar.at(LengthVec::mm(mm, 0.0, 0.0), Time::ZERO);
for i in 0..5 {
assert!(
(at(i as f64 + 0.5) - (300.0 + i as f64)).abs() < 1e-12,
"cell {i}"
);
}
assert!((at(1.0) - 300.5).abs() < 1e-12);
assert!((at(3.75) - 303.25).abs() < 1e-12);
assert!((at(-50.0) - 300.0).abs() < 1e-12);
assert!((at(0.0) - 300.0).abs() < 1e-12);
assert!((at(5.0) - 304.0).abs() < 1e-12);
assert!((at(1e6) - 304.0).abs() < 1e-12);
assert!(
(at(f64::NAN) - 300.0).abs() < 1e-12,
"a NaN must not index the array"
);
assert_eq!(
bar.at(LengthVec::mm(2.5, 0.0, 0.0), Time::ZERO),
bar.at(LengthVec::mm(2.5, 40.0, -70.0), Time::ZERO)
);
}
#[test]
fn the_fields_derivatives_match_their_closed_forms() {
let dx = 1e-3;
let build = |f: &dyn Fn(f64) -> f64| {
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
21,
Length::from_si(dx),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
for (i, cell) in bar.cells.iter_mut().enumerate() {
*cell = f((i as f64 + 0.5) * dx);
}
bar
};
let probe = LengthVec::from_si(DVec3::new(10.5 * dx, 0.0, 0.0));
let h = Length::from_si(dx);
let ramp = build(&|x| 300.0 + 40.0 * x);
let g = ramp.gradient(probe, Time::ZERO, h);
assert!((g.x - 40.0).abs() < 1e-9, "got {g}");
assert!(
g.y == 0.0 && g.z == 0.0,
"a 1D bar has no transverse gradient"
);
assert!(
ramp.laplacian(probe, Time::ZERO, h).abs() < 1e-6,
"a ramp has no curvature"
);
let curved = build(&|x| 300.0 + 5000.0 * x * x);
let lap = curved.laplacian(probe, Time::ZERO, h);
assert!((lap / 10_000.0 - 1.0).abs() < 1e-9, "got {lap}");
let g = curved.gradient(probe, Time::ZERO, h);
assert!((g.x / (10_000.0 * 10.5 * dx) - 1.0).abs() < 1e-9, "got {g}");
for outside in [-5.0 * dx, 30.0 * dx] {
let p = LengthVec::from_si(DVec3::new(outside, 0.0, 0.0));
assert_eq!(
curved.gradient(p, Time::ZERO, h),
DVec3::ZERO,
"at {outside}"
);
assert_eq!(curved.laplacian(p, Time::ZERO, h), 0.0, "at {outside}");
}
let wall = LengthVec::ZERO;
let expected = (curved.cells[1] - curved.cells[0]) / (2.0 * dx);
assert!((curved.gradient(wall, Time::ZERO, h).x - expected).abs() < 1e-9);
assert!(expected > 0.0, "the mirrored estimate is not zero");
}
#[test]
fn the_reported_rate_is_exactly_the_step_the_domain_takes() {
let dx = 1e-3;
let mut bar = Bar1D::new(
"bar",
Substance::aluminium_6061(),
21,
Length::from_si(dx),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
bar.cells[10] = Temperature::celsius(60.0).to_si();
bar.cells[14] = Temperature::celsius(35.0).to_si();
let dt = bar.max_stable_dt(Time::ZERO) * 0.5;
let probes: Vec<LengthVec> = (0..21)
.map(|i| LengthVec::from_si(DVec3::new((i as f64 + 0.5) * dx, 0.0, 0.0)))
.collect();
let predicted: Vec<f64> = probes
.iter()
.map(|p| bar.rate(*p, Time::ZERO, dt))
.collect();
let before: Vec<f64> = bar.cells.clone();
bar.step(Time::ZERO, dt, &mut Exchange::new()).unwrap();
for (i, p) in probes.iter().enumerate() {
let observed = (bar.cells[i] - before[i]) / dt.to_si();
let _ = p;
if predicted[i].abs() < 1e-12 {
assert!(
observed.abs() < 1e-9,
"cell {i}: {observed} against nothing"
);
} else {
assert!(
(observed / predicted[i] - 1.0).abs() < 1e-12,
"cell {i}: predicted {} but the step did {observed}",
predicted[i]
);
}
}
assert!(
predicted[10] < -1.0,
"the peak should be cooling: {}",
predicted[10]
);
assert!(
predicted[9] > 1.0,
"and its neighbour warming: {}",
predicted[9]
);
}
#[test]
fn a_field_with_no_diffusivity_reports_no_rate() {
let bar = Bar1D::new(
"bar",
Substance::bulk("mystery", Density::from_si(1000.0)),
5,
Length::mm(1.0),
Area::from_si(1e-4),
Temperature::celsius(20.0),
);
assert_eq!(bar.rate(LengthVec::ZERO, Time::ZERO, Time::s(1.0)), 0.0);
}
#[test]
fn the_domain_balances_its_books_under_the_scheduler() {
struct Heater {
watts: f64,
paid: f64,
}
impl Domain for Heater {
fn name(&self) -> &str {
"heater"
}
fn kind(&self) -> Kind {
Kind::QuasiStatic
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let joules = self.watts * dt.to_si();
bus.publish(HEAT, joules);
self.paid += joules;
Ok(())
}
fn ledger(&self) -> Ledger {
Ledger::new().with(quantity::ENERGY, -self.paid)
}
fn checkpoint(&mut self) {}
fn restore(&mut self) {}
fn supports_restore(&self) -> bool {
true
}
}
let mut sim = Simulation::new(Schedule::Multirate)
.with(Heater {
watts: 0.01,
paid: 0.0,
})
.with(lens(20.0));
for _ in 0..40 {
sim.advance(Time::s(5.0)).expect("the books must balance");
}
assert!((sim.time().to_si() - 200.0).abs() < 1e-9);
let total = sim.ledger().get(quantity::ENERGY).unwrap();
assert!(total.abs() < 1e-9, "residual {total}");
}
#[test]
fn a_substance_without_heat_capacity_is_refused() {
let mut body = LumpedMass::new(
"mystery",
Substance::bulk("mystery", Density::g_per_cm3(3.0)),
lens_volume(),
Length::mm(5.0),
Temperature::celsius(20.0),
Environment::still_air(Temperature::celsius(20.0), lens_area()),
);
let mut bus = Exchange::new();
let err = body.step(Time::ZERO, Time::s(1.0), &mut bus).unwrap_err();
assert_eq!(err.site, "mystery");
assert!(err.quantity.contains("heat capacity"), "{err}");
}
}