pantometry_thermal/lib.rs
1//! pantometry-thermal: heat, as a domain built on the `pantometry-core` kernel.
2//!
3//! Four domains, spanning three dimensionalities and one graph. The first two sit either side of
4//! the line that matters to a scheduler:
5//!
6//! - [`LumpedMass`] has one temperature and no internal structure. Its stability
7//! limit is its own time constant, which is seconds for a piece of glass in still
8//! air — so it takes one step per frame and costs nothing.
9//! - [`Bar1D`] resolves a gradient on a grid, and pays the explicit diffusion limit
10//! `dt < dx²/2α` for it. On a millimetre grid that is about a second in N-BK7 and
11//! seven milliseconds in aluminium, and *that* two-orders-of-magnitude gap between
12//! two parts of the same instrument is what
13//! [`Schedule::Multirate`](pantometry_core::Schedule::Multirate) exists for.
14//!
15//! The third answers a different question. Both of the above report *one* body:
16//!
17//! - [`ThermalNetwork`] is n lumped bodies joined by conductances — winding, stator,
18//! housing — and it carries the **drop across each joint**, which is the number a
19//! designer actually needs and the one a single lumped mass cannot give: it reports
20//! the temperature of the skin and the winding together. It also expresses a *contact*
21//! resistance between different materials, which [`Bar1D`]'s uniform grid cannot.
22//! A network of one node reduces to a [`LumpedMass`] bit for bit, so it inherits every
23//! check that domain already passes.
24//!
25//! And the fourth resolves what a bar cannot:
26//!
27//! - [`Solid3D`] is conduction in three dimensions on a cubic grid, which is what a hot spot
28//! needs: heat spreading *sideways* out of a spot is the whole job of a spreader plate and a
29//! fin, and a one-dimensional model has nowhere for it to go but along. It pays `dx²/6α`,
30//! a third of [`Bar1D`]'s limit, because the explicit limit tightens with every axis.
31//!
32//! # Where the heat comes from
33//!
34//! Neither domain generates any. They consume [`HEAT`] from the kernel's
35//! [`Exchange`], and the thing that publishes it is optics:
36//! `SurfaceOptics::absorptance` against a `SpectralPower` is a definite number of
37//! watts, and those watts have to go somewhere. That is the whole coupling, and it
38//! is auditable because it goes over the bus.
39//!
40//! # What is deliberately simple
41//!
42//! Both domains are explicit and both are linear in temperature except for the
43//! radiative term. There is no implicit solver, no mesh, no natural convection
44//! model — a convective loss is a coefficient the caller supplies, because
45//! computing one honestly means solving a fluid problem and that is a different
46//! crate. The point here is a domain that couples correctly and reports its own
47//! stability limit, not a competitive thermal solver.
48
49// Every public item carries a doc comment. Denied rather than warned: a public physics API
50// whose `Length::mm` shows a blank summary in rustdoc is documented in the sense that a
51// paragraph exists somewhere, and not in the sense a reader needs.
52#![deny(missing_docs)]
53
54pub mod network;
55pub mod solid;
56
57use glam::DVec3;
58pub use network::{Node, SteadyState, ThermalNetwork};
59use pantometry_core::conserved::quantity;
60use pantometry_core::{
61 Domain, Exchange, Interface, Kind, Ledger, Reading, ScalarField, Substance, Violation,
62};
63use pantometry_units::{
64 Area, Energy, HeatCapacity, Length, LengthVec, Power, Temperature, Time, Volume,
65 STEFAN_BOLTZMANN,
66};
67pub use solid::{Face, GapPatch, Solid3D, STABLE_FOURIER_3D};
68
69/// The bus channel heat arrives on, in joules.
70///
71/// Joules rather than watts, because a domain steps over an interval and what
72/// crossed the interface is an amount, not a rate. The publisher multiplies by its
73/// own `dt`, which is what makes the audit an equality rather than an approximation.
74pub const HEAT: &str = quantity::ENERGY;
75
76/// How a body loses heat to its surroundings.
77#[derive(Clone, Copy, Debug, PartialEq)]
78pub struct Environment {
79 /// The temperature the surroundings sit at, and what a body relaxes towards.
80 pub ambient: Temperature,
81 /// Convective coefficient `h`, W·m⁻²·K⁻¹. Still air is about 5 to 10; forced air
82 /// 25 to 100; water hundreds. There is no right default, so there is no default.
83 pub convection_w_per_m2_k: f64,
84 /// Surface area available to lose heat through.
85 pub area: Area,
86}
87
88impl Environment {
89 /// A part sitting in still room air.
90 pub fn still_air(ambient: Temperature, area: Area) -> Environment {
91 Environment {
92 ambient,
93 convection_w_per_m2_k: 7.0,
94 area,
95 }
96 }
97
98 /// Power lost from a surface at `temperature`, convective plus radiative.
99 ///
100 /// The radiative term is `εσA(T⁴ - T_a⁴)`, and it is the only non-linearity in
101 /// this crate. It is also not negligible: at room temperature a black surface
102 /// radiates about 6 W·m⁻²·K⁻¹, which is the same order as still-air convection,
103 /// so leaving it out halves the loss.
104 pub fn loss_from(&self, temperature: Temperature, emissivity: f64) -> Power {
105 let (t, ta) = (temperature.to_si(), self.ambient.to_si());
106 let convective = self.convection_w_per_m2_k * self.area.to_si() * (t - ta);
107 let radiative =
108 emissivity * STEFAN_BOLTZMANN.to_si() * self.area.to_si() * (t.powi(4) - ta.powi(4));
109 Power::from_si(convective + radiative)
110 }
111}
112
113/// A body at one temperature.
114///
115/// The lumped approximation: valid when heat spreads through the body faster than it
116/// escapes, which is the small-Biot-number condition `hL/k << 1`. For a 10 mm piece
117/// of N-BK7 in still air that number is about 0.03, so it holds comfortably; for the
118/// same piece in flowing water it does not, and [`Bar1D`] is the honest choice.
119/// [`LumpedMass::biot_number`] says which situation you are in.
120pub struct LumpedMass {
121 name: String,
122 substance: Substance,
123 volume: Volume,
124 /// Characteristic length for the Biot number — volume over surface area.
125 thickness: Length,
126 temperature: Temperature,
127 environment: Environment,
128 saved: Option<(Temperature, f64, f64)>,
129 /// Joules taken from the bus over the run, for the books.
130 absorbed: f64,
131 /// Joules given up to the environment over the run.
132 lost: f64,
133}
134
135impl LumpedMass {
136 /// A body of one substance at one temperature, losing heat to its surroundings.
137 ///
138 /// `thickness` is the characteristic length for the Biot number, usually volume over
139 /// surface area. It does not enter the dynamics — only
140 /// [`LumpedMass::biot_number`], which is how you find out whether the lumped
141 /// approximation was honest here.
142 pub fn new(
143 name: impl Into<String>,
144 substance: Substance,
145 volume: Volume,
146 thickness: Length,
147 initial: Temperature,
148 environment: Environment,
149 ) -> LumpedMass {
150 LumpedMass {
151 name: name.into(),
152 substance,
153 volume,
154 thickness,
155 temperature: initial,
156 environment,
157 saved: None,
158 absorbed: 0.0,
159 lost: 0.0,
160 }
161 }
162
163 /// Absolute temperature of the whole body.
164 pub fn temperature(&self) -> Temperature {
165 self.temperature
166 }
167
168 /// Rise above ambient.
169 pub fn rise(&self) -> Temperature {
170 self.temperature - self.environment.ambient
171 }
172
173 /// `mc_p` for this body. Infinite if the substance has no specific heat recorded,
174 /// which makes it refuse to warm rather than warm by a made-up amount.
175 pub fn heat_capacity(&self) -> HeatCapacity {
176 self.substance
177 .heat_capacity(self.volume)
178 .unwrap_or(HeatCapacity::from_si(f64::INFINITY))
179 }
180
181 /// Heat taken from the bus over the run.
182 pub fn absorbed_energy(&self) -> Energy {
183 Energy::from_si(self.absorbed)
184 }
185
186 /// Heat given up to the environment over the run.
187 pub fn lost_energy(&self) -> Energy {
188 Energy::from_si(self.lost)
189 }
190
191 /// `hL/k` — whether the lumped approximation is honest here. Under about 0.1 it
192 /// is; well past that, the body has an internal gradient this domain cannot see.
193 pub fn biot_number(&self) -> f64 {
194 let Some(thermal) = self.substance.thermal else {
195 return f64::INFINITY;
196 };
197 self.environment.convection_w_per_m2_k * self.thickness.to_si()
198 / thermal.conductivity.to_si()
199 }
200
201 /// Time constant `C/(hA)` — how long it takes to get most of the way to
202 /// equilibrium, and the step this domain must not much exceed.
203 /// How long it takes to settle, linearised **at the temperature it is at now**.
204 ///
205 /// `C / (hA + 4εσA·T³)`. Both loss paths, because the crate's own
206 /// [`Environment::loss_from`] says why: at room temperature a black surface radiates about
207 /// 6 W·m⁻²·K⁻¹, the same order as still-air convection, so leaving it out roughly halves
208 /// the conductance and doubles this number.
209 ///
210 /// It used to leave it out, and reported the same time constant for a polished surface and
211 /// a blackbody one. Measured on a 1.12 kg box in still air under 21 W, against the time to
212 /// reach 63% of its settled rise:
213 ///
214 /// ```text
215 /// emissivity was at rest once hot measured
216 /// 0.05 53.0 50.8 48.3 49.2 min
217 /// 0.09 53.0 49.2 45.4 46.6
218 /// 0.50 53.0 37.1 30.1 32.2
219 /// 0.90 53.0 29.9 23.8 25.6
220 /// ```
221 ///
222 /// **Why the temperature it is at now, and not ambient.** The radiative conductance grows
223 /// as `T³`, so a body running hot settles faster than the same body at rest. This is not a
224 /// constant of the body; it is a property of its current state, and a function returning
225 /// one number has to say which one. The two right-hand columns above are the same call on
226 /// the same body cold and settled — and the measured figure lies *between* them, because a
227 /// large-signal time constant is an average over the trajectory and the small-signal one
228 /// bounds it at each end.
229 ///
230 /// [`LumpedMass::max_stable_dt`] re-reads this every step, so a scheduler tightens as the
231 /// body warms: 179 s to 143 s over that last row, against 318 s before radiation was
232 /// counted at all.
233 ///
234 /// Infinite when nothing carries heat away — no convection and no emissivity — which is a
235 /// body that never settles rather than one that settles instantly.
236 pub fn time_constant(&self) -> Time {
237 let capacity = self.heat_capacity().to_si();
238 let conductance = self.loss_conductance(self.temperature);
239 if conductance <= 0.0 || !capacity.is_finite() {
240 return Time::from_si(f64::INFINITY);
241 }
242 Time::from_si(capacity / conductance)
243 }
244
245 /// `d(loss)/dT` at a temperature: convection plus the linearised radiative term.
246 fn loss_conductance(&self, at: Temperature) -> f64 {
247 linearised_loss_conductance(&self.environment, at, self.emissivity())
248 }
249
250 /// Steady-state rise for a constant absorbed power, **with radiation**.
251 ///
252 /// Solves `P = hA·ΔT + εσA((Tₐ+ΔT)⁴ − Tₐ⁴)` rather than `P/(hA)`. The answer a designer
253 /// actually wants: not how it gets there, but where it ends up — and the radiative term is
254 /// not a correction to it. On a 1.12 kg box in still air under 21 W, `P/(hA)` says 99.2 K
255 /// whatever the surface is, against a measured 92.9 K at ε = 0.05 and **47.5 K at ε = 1.0**.
256 /// It was over by 2.09× at the top of that range, and the error is always in the
257 /// comfortable direction.
258 ///
259 /// One positive root, because the right-hand side is strictly increasing in `ΔT` above
260 /// `−Tₐ`. Newton from the convective guess, which is an overestimate and therefore
261 /// approaches from the side where the derivative is largest — three or four steps.
262 ///
263 /// Infinite only when nothing carries heat away at all.
264 pub fn equilibrium_rise(&self, absorbed: Power) -> Temperature {
265 let p = absorbed.to_si();
266 let area = self.environment.area.to_si();
267 let ha = self.environment.convection_w_per_m2_k * area;
268 let er = self.emissivity() * STEFAN_BOLTZMANN.to_si() * area;
269 let ta = self.environment.ambient.to_si();
270
271 if !p.is_finite() || (ha <= 0.0 && er <= 0.0) {
272 return Temperature::from_si(if p == 0.0 { 0.0 } else { f64::INFINITY });
273 }
274 if p == 0.0 {
275 return Temperature::from_si(0.0);
276 }
277 if er <= 0.0 {
278 return Temperature::from_si(p / ha);
279 }
280
281 // Start from whichever single-path answer exists; both are above the true root when
282 // the other path is also carrying heat, so Newton descends onto it.
283 let mut x = if ha > 0.0 {
284 p / ha
285 } else {
286 (p / er + ta.powi(4)).max(0.0).powf(0.25) - ta
287 };
288 for _ in 0..64 {
289 let t = (ta + x).max(0.0);
290 let f = ha * x + er * (t.powi(4) - ta.powi(4)) - p;
291 let df = ha + 4.0 * er * t * t * t;
292 if df <= 0.0 || !f.is_finite() {
293 break;
294 }
295 let step = f / df;
296 x -= step;
297 if step.abs() <= 1e-12 * (1.0 + x.abs()) {
298 break;
299 }
300 }
301 Temperature::from_si(x)
302 }
303
304 fn emissivity(&self) -> f64 {
305 self.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0)
306 }
307}
308
309impl Domain for LumpedMass {
310 fn name(&self) -> &str {
311 &self.name
312 }
313
314 fn kind(&self) -> Kind {
315 Kind::Evolving
316 }
317
318 /// A tenth of the time constant. Explicit Euler on `dT/dt = -(T-Ta)/τ` is stable
319 /// up to `2τ` and *accurate* nowhere near it, so the limit reported is the
320 /// accuracy one — a scheduler that honours it gets a curve rather than a
321 /// staircase.
322 fn max_stable_dt(&self, _now: Time) -> Time {
323 self.time_constant() / 10.0
324 }
325
326 fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
327 let capacity = self.heat_capacity();
328 if !capacity.to_si().is_finite() || capacity.to_si() <= 0.0 {
329 return Err(Violation::at(
330 &self.name,
331 "substance has no heat capacity",
332 capacity.to_si(),
333 ));
334 }
335
336 // This step's share of the channel, not all of it. A lumped mass is subcycled under
337 // `Schedule::Multirate`, and taking the whole outer step's joules on the first substep
338 // deposits them all at its beginning — which stops the substep count from improving
339 // anything. See `Exchange::take_share`.
340 let gained = bus.take_share(HEAT, dt);
341 self.absorbed += gained;
342
343 let lost = self
344 .environment
345 .loss_from(self.temperature, self.emissivity());
346 let lost_joules = lost.to_si() * dt.to_si();
347 self.lost += lost_joules;
348
349 let net = gained - lost_joules;
350 self.temperature += Temperature::from_si(net / capacity.to_si());
351 Ok(())
352 }
353
354 /// Energy this body accounts for: what is stored above ambient, plus what has
355 /// already left to the environment.
356 ///
357 /// Not minus what it absorbed. `stored + lost` *is* what it absorbed, so
358 /// subtracting that as well would cancel the entry against itself and leave the
359 /// publisher's debt unmatched — the audit catches that immediately, which is how
360 /// this convention got settled.
361 fn ledger(&self) -> Ledger {
362 let stored = self.heat_capacity().to_si() * self.rise().to_si();
363 Ledger::new().with(quantity::ENERGY, stored + self.lost)
364 }
365
366 /// Everything the ledger reads, not only the temperature.
367 ///
368 /// `ledger()` is `stored + lost`, and `stored` follows the temperature while `lost` is a
369 /// running total. Saving one and not the other means a sweep that gets rewound leaves its
370 /// losses behind: under `Schedule::Iterative` the books then grow by one sweep of shed heat
371 /// per iteration and the audit sees energy created out of nothing. Measured at 1567.6 J
372 /// becoming 1600.9 J over forty advances of three sweeps each.
373 ///
374 /// It went unnoticed because nothing in this workspace has a residual, so `iterate` always
375 /// converged on its first sweep and the restore branch never ran —
376 /// `crates/pantometry/tests/iterative_restore.rs` supplies the domain that makes it run.
377 fn checkpoint(&mut self) {
378 self.saved = Some((self.temperature, self.absorbed, self.lost));
379 }
380
381 fn restore(&mut self) {
382 if let Some((t, absorbed, lost)) = self.saved {
383 self.temperature = t;
384 self.absorbed = absorbed;
385 self.lost = lost;
386 }
387 }
388
389 fn supports_restore(&self) -> bool {
390 true
391 }
392
393 /// One temperature, which is the whole of what a lumped model claims to know.
394 fn readings(&self) -> Vec<Reading> {
395 vec![
396 Reading::new(
397 &self.name,
398 "temperature",
399 self.temperature.to_si() - 273.15,
400 "C",
401 ),
402 Reading::new(&self.name, "absorbed", self.absorbed_energy().to_si(), "J"),
403 ]
404 }
405
406 fn as_any(&self) -> Option<&dyn std::any::Any> {
407 Some(self)
408 }
409
410 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
411 Some(self)
412 }
413}
414
415/// One-dimensional explicit heat conduction on a uniform grid.
416///
417/// Exists to exercise the thing [`LumpedMass`] cannot: a real stability limit that a
418/// scheduler has to subcycle around. The explicit update
419/// `T'ᵢ = Tᵢ + α dt/dx² (Tᵢ₊₁ - 2Tᵢ + Tᵢ₋₁)` is stable only for
420/// `α dt/dx² ≤ 1/2`, and exceeding it does not degrade the answer gracefully — it
421/// oscillates and diverges within a few steps.
422///
423/// Ends are insulated, so the total heat is conserved exactly and the audit has
424/// something sharp to check.
425pub struct Bar1D {
426 name: String,
427 substance: Substance,
428 /// Temperatures at the cell centres.
429 cells: Vec<f64>,
430 saved: Vec<f64>,
431 dx: Length,
432 /// Cross-sectional area, for turning joules into a temperature.
433 area: Area,
434 /// The boundary this bar offers to other domains, one face per cell, if it has one.
435 boundary: Option<Interface>,
436 absorbed: f64,
437 /// The temperature the stored heat is measured from — see [`Bar1D::stored_heat`].
438 reference: f64,
439}
440
441impl Bar1D {
442 /// A bar of `cells` cells, each `dx` long, all starting at `initial`.
443 pub fn new(
444 name: impl Into<String>,
445 substance: Substance,
446 cells: usize,
447 dx: Length,
448 area: Area,
449 initial: Temperature,
450 ) -> Bar1D {
451 let cells = cells.max(2);
452 let temps = vec![initial.to_si(); cells];
453 Bar1D {
454 name: name.into(),
455 substance,
456 cells: temps.clone(),
457 saved: temps,
458 dx,
459 area,
460 boundary: None,
461 absorbed: 0.0,
462 reference: initial.to_si(),
463 }
464 }
465
466 /// Expose the bar's long side as an [`Interface`], one face per cell.
467 ///
468 /// Without this the bar can only be heated lumpedly, and the heat lands in cell 0
469 /// because that is where a surface absorbing light *would* put it if the bar knew where
470 /// the surface was. It does not: `Exchange::publish` carries an amount and no place, so
471 /// "the light hit the middle" is unsayable.
472 ///
473 /// With it, whoever illuminates the bar publishes a [`Flux`](pantometry_core::Flux) over these faces and the
474 /// heat appears where it landed. One face per cell deliberately: the two sides then
475 /// share a discretisation, so nothing has to interpolate, and interpolation is where a
476 /// coupling loses energy. A publisher on a different grid resamples explicitly with
477 /// [`Flux::resample`](pantometry_core::Flux::resample), which the bus insists on rather than doing quietly.
478 ///
479 /// `face_area` is the area of one cell's exposed side, which is not the bar's
480 /// cross-section — a bar conducts along its length and is illuminated across it. It is
481 /// only used to turn a lumped total into a distribution, so it does not enter the
482 /// conduction at all.
483 pub fn exposing(mut self, boundary: impl Into<String>, face_area: Area) -> Bar1D {
484 self.boundary = Some(Interface::uniform(boundary, self.cells.len(), face_area));
485 self
486 }
487
488 /// The boundary other domains publish onto, if [`Bar1D::exposing`] gave it one.
489 pub fn boundary(&self) -> Option<&Interface> {
490 self.boundary.as_ref()
491 }
492
493 /// Temperature of one cell, clamped to the ends of the bar.
494 pub fn temperature_at(&self, index: usize) -> Temperature {
495 Temperature::from_si(self.cells[index.min(self.cells.len() - 1)])
496 }
497
498 /// How many cells the bar is cut into.
499 pub fn cell_count(&self) -> usize {
500 self.cells.len()
501 }
502
503 /// Mean temperature along the bar.
504 pub fn mean_temperature(&self) -> Temperature {
505 Temperature::from_si(self.cells.iter().sum::<f64>() / self.cells.len() as f64)
506 }
507
508 /// Temperature difference between the ends — what a gradient looks like from
509 /// outside, and what a lumped model reports as zero.
510 pub fn end_to_end(&self) -> Temperature {
511 Temperature::from_si(self.cells[self.cells.len() - 1] - self.cells[0])
512 }
513
514 fn cell_capacity(&self) -> f64 {
515 let volume = Volume::from_si(self.area.to_si() * self.dx.to_si());
516 self.substance
517 .heat_capacity(volume)
518 .map(|c| c.to_si())
519 .unwrap_or(f64::INFINITY)
520 }
521
522 /// Heat held, measured from the temperature the bar started at.
523 ///
524 /// The reference point of an enthalpy is arbitrary, so it should be chosen for
525 /// precision, and the natural-looking choice is the bad one. Against absolute zero a
526 /// 20 mm aluminium bar of 1 cm² section holds 1.42 kJ, and a millijoule arriving is a
527 /// change in the seventh significant figure — so differencing two such numbers leaves a
528 /// rounding floor of a few times 10⁻¹² J whatever the transfer was, and the audit's
529 /// *relative* check on a 1 mJ step is then asking for precision the arithmetic threw
530 /// away. Refining the grid makes it worse rather than better: 1.6×10⁻¹² J at 41 cells
531 /// against 7.3×10⁻¹² J at 161, because there are more absolute temperatures to add up.
532 ///
533 /// Measured from the initial temperature, the number being summed *is* the change, and
534 /// the audit's precision tracks the heat that moved rather than the enthalpy it moved
535 /// within.
536 fn stored_heat(&self) -> f64 {
537 self.cell_capacity() * self.cells.iter().map(|t| t - self.reference).sum::<f64>()
538 }
539
540 /// Heat taken from the bus over the run.
541 pub fn absorbed_energy(&self) -> Energy {
542 Energy::from_si(self.absorbed)
543 }
544
545 /// `α dt/dx²`, the number that must stay at or under 1/2.
546 pub fn fourier_number(&self, dt: Time) -> f64 {
547 let Some(alpha) = self.substance.diffusivity() else {
548 return f64::INFINITY;
549 };
550 alpha.to_si() * dt.to_si() / (self.dx.to_si() * self.dx.to_si())
551 }
552}
553
554impl Domain for Bar1D {
555 fn books_balance(&self) -> bool {
556 true
557 }
558
559 fn name(&self) -> &str {
560 &self.name
561 }
562
563 /// `dx²/(2α)` — the explicit diffusion limit, exactly.
564 fn max_stable_dt(&self, _now: Time) -> Time {
565 let Some(alpha) = self.substance.diffusivity() else {
566 return Time::from_si(f64::INFINITY);
567 };
568 Time::from_si(self.dx.to_si() * self.dx.to_si() / (2.0 * alpha.to_si()))
569 }
570
571 fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
572 let f = self.fourier_number(dt);
573 if !f.is_finite() {
574 return Err(Violation::at(&self.name, "substance has no diffusivity", f));
575 }
576 // Refuse rather than diverge. A scheduler honouring `max_stable_dt` never
577 // sees this; one that ignores it gets told which limit it broke and by how
578 // much, instead of a bar full of oscillating nonsense.
579 if f > 0.5 + 1e-12 {
580 return Err(Violation {
581 quantity: "Fourier number".to_string(),
582 site: format!("{} (explicit conduction)", self.name),
583 before: 0.5,
584 after: f,
585 scale: 0.5,
586 tolerance: 1e-12,
587 });
588 }
589
590 let capacity = self.cell_capacity();
591
592 // Lumped heat has no place, so it goes into the first cell — where a surface
593 // absorbing light would put it, if the bar knew where the surface was. This step's
594 // share of it: the bar subcycles hard under `Schedule::Multirate`, and taking the whole
595 // interval at once would put every joule of it in the first substep.
596 let gained = bus.take_share(HEAT, dt);
597 self.absorbed += gained;
598 self.cells[0] += gained / capacity;
599
600 // Heat that does know where it landed. Taken before the conduction sweep so it
601 // spreads on the same step it arrives, and taken out of the borrow of `boundary`
602 // before the cells are written to.
603 let arriving = match self.boundary.as_ref() {
604 Some(boundary) => Some(bus.take_on(boundary, HEAT)?),
605 None => None,
606 };
607 if let Some(flux) = arriving {
608 for (cell, joules) in self.cells.iter_mut().zip(flux.per_face()) {
609 *cell += joules / capacity;
610 }
611 self.absorbed += flux.total();
612 }
613
614 // Insulated ends: the boundary cell exchanges with its one neighbour only,
615 // which is what makes the total conserved to the last bit.
616 let previous = self.cells.clone();
617 let last = previous.len() - 1;
618 for i in 0..=last {
619 let left = previous[i.saturating_sub(1)];
620 let right = previous[(i + 1).min(last)];
621 self.cells[i] = previous[i] + f * (left - 2.0 * previous[i] + right);
622 }
623 Ok(())
624 }
625
626 /// Heat gained since the start. The ends are insulated, so this is exactly what
627 /// came in over the bus — see the note on [`LumpedMass::ledger`] for why the
628 /// absorbed total is not subtracted here as well.
629 fn ledger(&self) -> Ledger {
630 Ledger::new().with(quantity::ENERGY, self.stored_heat())
631 }
632
633 fn checkpoint(&mut self) {
634 self.saved = self.cells.clone();
635 }
636
637 fn restore(&mut self) {
638 self.cells = self.saved.clone();
639 }
640
641 fn supports_restore(&self) -> bool {
642 true
643 }
644
645 /// Mean and peak in celsius, and what it has absorbed.
646 ///
647 /// Both ends of the profile, because a bar's whole reason to exist rather than a lumped mass
648 /// is that those two differ — reporting the mean alone would describe it as the thing it is
649 /// not.
650 fn readings(&self) -> Vec<Reading> {
651 let peak = (0..self.cells.len())
652 .map(|i| self.temperature_at(i).to_si())
653 .fold(f64::MIN, f64::max);
654 vec![
655 Reading::new(
656 &self.name,
657 "mean",
658 self.mean_temperature().to_si() - 273.15,
659 "C",
660 ),
661 Reading::new(&self.name, "peak", peak - 273.15, "C"),
662 Reading::new(&self.name, "absorbed", self.absorbed_energy().to_si(), "J"),
663 ]
664 }
665
666 fn as_any(&self) -> Option<&dyn std::any::Any> {
667 Some(self)
668 }
669
670 /// **And mutably**, because a domain that can be read and not written is one a coupling can
671 /// only fail at silently — `Simulation::domain_as_mut` returns `None` when this is missing.
672 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
673 Some(self)
674 }
675
676 /// The bar reads as a temperature field, so a renderer never has to know it is a bar.
677 fn as_field(&self) -> Option<&dyn pantometry_core::ScalarField> {
678 Some(self)
679 }
680}
681
682/// The bar as a temperature field, in kelvin.
683///
684/// The first implementation of [`ScalarField`] in the workspace, and it exists to answer a
685/// question rather than to be used internally: the trait was written as the interface a
686/// visualiser would read a simulation through, and until something implemented it, whether
687/// it was the *right* interface was a guess. Two things came out of implementing it.
688///
689/// # The bar lies along x, and is uniform in y and z
690///
691/// Cell `i` is centred at `(i + ½)·dx`, so the bar occupies `0` to `n·dx`. Off the ends the
692/// value is held constant, which is not a fudge: the ends are insulated, so the temperature
693/// really does stop changing there. Off-axis it is uniform, which is not an approximation
694/// being hidden either — a one-dimensional model *is* the claim that nothing varies across
695/// the bar, and [`LumpedMass::biot_number`] is where you check whether that claim holds.
696///
697/// # `at` ignores the time it is given, and that is the interface's one rough edge
698///
699/// [`ScalarField::at`] takes a [`Time`], because a closed-form field like
700/// [`Motion`](pantometry_core::Motion) can answer for any instant. A marched domain cannot: it
701/// holds *now* and nothing else. So the argument is ignored here, and a caller wanting a
702/// different instant has to have recorded one.
703///
704/// The default [`ScalarField::rate`] would then read zero — it differences `at` across two
705/// times — which would be wrong rather than merely unavailable, since the bar is visibly
706/// heating. It is overridden below, and the fix is not a workaround: a diffusive field's
707/// time derivative *is* `α∇²T`, so the governing equation supplies from the present state
708/// exactly what the finite difference wanted history for.
709///
710/// # The derivatives use the domain's own stencil
711///
712/// [`ScalarField`] offers central differences over a step you choose, and its documentation
713/// says a field that knows better should override them. This one does: `gradient` and
714/// `laplacian` use the same mirrored three-point stencil that [`Domain::step`] integrates,
715/// evaluated on the cells rather than by re-sampling the interpolated field. So the field
716/// reports what the domain actually believes, and the `h` argument is ignored — asking for
717/// a derivative on a scale finer than `dx` is asking for information the bar does not have.
718///
719/// They have to come from the cells rather than from `at`, and the reason is worth stating:
720/// `at` interpolates linearly, so its exact second derivative is zero between nodes and
721/// infinite at them. A Laplacian read off the interpolant would be useless. The cost is that
722/// `gradient` is not quite the derivative of `at` — it is the derivative the *scheme* uses —
723/// and that is an unavoidable property of sampling a discrete field, not a rough edge that
724/// could be polished out.
725impl ScalarField for Bar1D {
726 /// **Kelvin**, because that is what the cells hold.
727 ///
728 /// Not celsius. `readings` reports celsius and a picture of a bar usually wants celsius, but
729 /// both of those are *conversions a view chooses*; the field returns what it stores. Labelling
730 /// this "C" would have put 293.15 under a degrees-celsius header, which is the failure a unit
731 /// on a legend exists to prevent.
732 fn unit(&self) -> &'static str {
733 "K"
734 }
735
736 fn at(&self, p: LengthVec, _t: Time) -> f64 {
737 let last = self.cells.len() - 1;
738 // Position in cell-index space: cell centres land on the integers.
739 let u = p.to_si().x / self.dx.to_si() - 0.5;
740 // The NaN case is spelled out rather than folded into a negated comparison: it is a
741 // real input a visualiser can hand over, and it must not reach the cast below.
742 if u.is_nan() || u <= 0.0 {
743 return self.cells[0];
744 }
745 if u >= last as f64 {
746 return self.cells[last];
747 }
748 let i = u.floor() as usize;
749 let f = u - i as f64;
750 self.cells[i] * (1.0 - f) + self.cells[i + 1] * f
751 }
752
753 fn gradient(&self, p: LengthVec, _t: Time, _h: Length) -> DVec3 {
754 let (left, _, right) = self.stencil_at(p);
755 DVec3::new((right - left) / (2.0 * self.dx.to_si()), 0.0, 0.0)
756 }
757
758 fn laplacian(&self, p: LengthVec, _t: Time, _h: Length) -> f64 {
759 let (left, centre, right) = self.stencil_at(p);
760 let dx = self.dx.to_si();
761 (left - 2.0 * centre + right) / (dx * dx)
762 }
763
764 /// `∂T/∂t = α∇²T` — the heat equation, evaluated rather than differenced.
765 ///
766 /// Conduction only. Heat arriving over the bus is a source term the field cannot see,
767 /// so during illumination this reports how fast the bar is *spreading* what it has, not
768 /// how fast it is warming. Away from the beam those are the same number.
769 fn rate(&self, p: LengthVec, t: Time, _dt: Time) -> f64 {
770 let Some(alpha) = self.substance.diffusivity() else {
771 return 0.0;
772 };
773 alpha.to_si() * self.laplacian(p, t, self.dx)
774 }
775}
776
777impl Bar1D {
778 /// The three cell values the domain's own update uses at this point, with the ends
779 /// mirrored exactly as [`Domain::step`] mirrors them.
780 ///
781 /// Past either end the stencil goes flat, so the derivatives agree with [`ScalarField::at`]
782 /// holding its value out there. Inside, mirroring is what makes the ends insulated —
783 /// but note what that does *not* mean. On a cell-centred grid the first sample sits half
784 /// a cell inside the wall, so the gradient reported at `x = 0` is the mirrored estimate
785 /// `(T₁ − T₀)/2dx` and not zero. Insulation shows up as no heat crossing the boundary,
786 /// which the conservation audit checks; it does not show up as a zero slope at a
787 /// position the grid cannot sample.
788 fn stencil_at(&self, p: LengthVec) -> (f64, f64, f64) {
789 let last = self.cells.len() - 1;
790 let x = p.to_si().x / self.dx.to_si();
791 // The bar occupies [0, L], so the walls are inside it and only points beyond them go
792 // flat. NaN spelled out for the same reason as in `at`.
793 if x.is_nan() || x < 0.0 {
794 let v = self.cells[0];
795 return (v, v, v);
796 }
797 if x >= self.cells.len() as f64 {
798 let v = self.cells[last];
799 return (v, v, v);
800 }
801 let i = (x as usize).min(last);
802 (
803 self.cells[i.saturating_sub(1)],
804 self.cells[i],
805 self.cells[(i + 1).min(last)],
806 )
807 }
808}
809
810/// `d(loss)/dT` for an environment at a temperature: convection plus the linearised radiative
811/// term `4εσAT³`.
812///
813/// Shared by [`LumpedMass::time_constant`] and [`ThermalNetwork`] rather than written out twice,
814/// so the two cannot drift apart. A network of one node has to reduce to a lumped mass *exactly*
815/// — [`one_node_is_a_lumped_mass_bit_for_bit`] compares the two bit for bit over a whole
816/// trajectory including the step limit — and a second copy of this expression is the obvious way
817/// for that to quietly stop being true.
818///
819/// [`one_node_is_a_lumped_mass_bit_for_bit`]: https://github.com/YounghyeonPark/pantometry-core/blob/main/crates/pantometry-thermal/tests/network_closed_forms.rs
820pub(crate) fn linearised_loss_conductance(
821 environment: &Environment,
822 at: Temperature,
823 emissivity: f64,
824) -> f64 {
825 let area = environment.area.to_si();
826 let t = at.to_si().max(0.0);
827 environment.convection_w_per_m2_k * area
828 + 4.0 * emissivity * STEFAN_BOLTZMANN.to_si() * area * t * t * t
829}
830
831#[cfg(test)]
832mod tests {
833 use super::*;
834
835 /// The box from the report that opened this: 1.122 kg, 96x60x60 mm, still air, 21 W.
836 ///
837 /// A real part from a downstream project rather than an invented one, and the emissivity is
838 /// the parameter because that is exactly what the old functions were blind to.
839 fn radiating_box(emissivity: f64) -> LumpedMass {
840 use pantometry_units::SpecificHeat;
841 let (area, vol) = (0.030_24, 3.456e-4);
842 let mut substance = Substance::aluminium_6061();
843 substance.density = Density::kg_per_m3(1.122 / vol);
844 if let Some(t) = substance.thermal.as_mut() {
845 t.specific_heat = SpecificHeat::j_per_kg_k(600.0);
846 t.emissivity = emissivity;
847 }
848 LumpedMass::new(
849 "box",
850 substance,
851 Volume::from_si(vol),
852 Length::from_si(vol / area),
853 Temperature::celsius(25.0),
854 Environment {
855 ambient: Temperature::celsius(25.0),
856 convection_w_per_m2_k: 7.0,
857 area: Area::from_si(area),
858 },
859 )
860 }
861
862 /// Step to steady state under 21 W and report the settled rise.
863 fn settle(body: &mut LumpedMass) -> f64 {
864 let mut bus = Exchange::new();
865 for k in 0..400_000 {
866 bus.publish(HEAT, 21.0);
867 body.step(Time::s(k as f64), Time::s(1.0), &mut bus)
868 .unwrap();
869 }
870 body.rise().to_si()
871 }
872
873 use pantometry_core::{Flux, Schedule, Simulation};
874 use pantometry_units::Density;
875
876 fn lens_volume() -> Volume {
877 // A 25 mm disc, 5 mm thick.
878 Volume::from_si(std::f64::consts::PI * 0.0125f64.powi(2) * 0.005)
879 }
880
881 fn lens_area() -> Area {
882 // Two faces plus the rim.
883 let r = 0.0125f64;
884 Area::from_si(2.0 * std::f64::consts::PI * r * r + std::f64::consts::TAU * r * 0.005)
885 }
886
887 fn lens(initial_c: f64) -> LumpedMass {
888 LumpedMass::new(
889 "lens",
890 Substance::borosilicate_crown(),
891 lens_volume(),
892 Length::mm(5.0),
893 Temperature::celsius(initial_c),
894 Environment::still_air(Temperature::celsius(20.0), lens_area()),
895 )
896 }
897
898 /// The lumped approximation is only honest when the Biot number is small, and the
899 /// domain says which situation it is in rather than assuming.
900 #[test]
901 fn the_lumped_approximation_declares_when_it_applies() {
902 let glass_in_air = lens(20.0);
903 assert!(
904 glass_in_air.biot_number() < 0.1,
905 "still air over 5 mm of glass should be lumpable, Bi = {}",
906 glass_in_air.biot_number()
907 );
908
909 // The same glass in flowing water is not lumpable: h is two orders larger.
910 let mut wet = lens(20.0);
911 wet.environment.convection_w_per_m2_k = 500.0;
912 assert!(wet.biot_number() > 1.0, "Bi = {}", wet.biot_number());
913
914 // A substance with no thermal data cannot answer at all, and says so.
915 let unknown = LumpedMass::new(
916 "unknown",
917 Substance::bulk("x", Density::g_per_cm3(2.0)),
918 lens_volume(),
919 Length::mm(5.0),
920 Temperature::celsius(20.0),
921 Environment::still_air(Temperature::celsius(20.0), lens_area()),
922 );
923 assert!(!unknown.biot_number().is_finite());
924 }
925
926 /// Newton's law of cooling has a closed form: `ΔT(t) = ΔT₀ exp(-t/τ)`. Integrated
927 /// with steps under the reported limit, the domain reproduces it — which is what
928 /// says both the physics and the stability limit are right.
929 #[test]
930 fn cooling_follows_the_exponential_it_should() {
931 // Radiation would add a second loss path and spoil the closed form, so this
932 // one case turns it off to test the convective term alone.
933 let mut body = lens(30.0);
934 body.substance.thermal.as_mut().unwrap().emissivity = 0.0;
935 let tau = body.time_constant();
936 // 5.3 J/K of heat capacity against 9.6 mW/K of still-air conductance is
937 // 549 s — nine minutes. A glass lens is a poor conductor with real heat
938 // capacity, so it settles slowly, and that slowness is why its thermal domain
939 // takes one step per video frame while a metal mount's does not.
940 assert!(
941 (tau.to_si() - 549.0).abs() < 5.0,
942 "a lens in still air settles over about nine minutes, tau = {} s",
943 tau.to_si()
944 );
945
946 let initial_rise = body.rise().to_si();
947
948 // Run for one whole time constant, in `steps` equal pieces.
949 let run = |steps: u32| {
950 let mut body = lens(30.0);
951 body.substance.thermal.as_mut().unwrap().emissivity = 0.0;
952 let dt = tau / steps as f64;
953 let mut bus = Exchange::new();
954 for _ in 0..steps {
955 body.step(Time::ZERO, dt, &mut bus).unwrap();
956 }
957 body.rise().to_si()
958 };
959
960 // Against the *discrete* closed form first: explicit Euler on
961 // `dT/dt = -(T-Ta)/tau` is exactly `(1 - h/tau)^n`, and the domain reproduces
962 // it to machine precision. This is what says the update is the integrator it
963 // claims to be, with no stray factor hiding in the heat capacity.
964 for steps in [10u32, 100, 1000] {
965 let discrete = initial_rise * (1.0 - 1.0 / steps as f64).powi(steps as i32);
966 let got = run(steps);
967 assert!(
968 (got / discrete - 1.0).abs() < 1e-9,
969 "{steps} steps: got {got:.6} K, discrete solution {discrete:.6} K"
970 );
971 }
972
973 // Then against the *continuous* one, which is the physics. Euler undershoots
974 // it, and the shortfall is first order in the step: 5.2% at tau/10, 0.51% at
975 // tau/100, 0.05% at tau/1000 — a clean factor of ten each time, which is what
976 // first order means and why `max_stable_dt` reports tau/10 rather than the
977 // 2*tau that mere stability would allow.
978 let exact = initial_rise * (-1.0f64).exp();
979 let shortfall = |steps: u32| (exact - run(steps)) / exact;
980 assert!((shortfall(10) - 0.0522).abs() < 1e-3, "{}", shortfall(10));
981 assert!((shortfall(100) - 0.0051).abs() < 1e-3, "{}", shortfall(100));
982 assert!(
983 (shortfall(10) / shortfall(100) - 10.0).abs() < 1.0,
984 "first order: ratio {}",
985 shortfall(10) / shortfall(100)
986 );
987 assert!(run(10) < initial_rise, "it should have cooled");
988 }
989
990 /// Radiation is not a small correction at room temperature: for a black surface
991 /// it is the same order as still-air convection, and leaving it out halves the
992 /// loss. That is the mistake this test exists to make impossible.
993 #[test]
994 fn radiation_matters_as_much_as_convection() {
995 let env = Environment::still_air(Temperature::celsius(20.0), lens_area());
996 let hot = Temperature::celsius(30.0);
997 let with_radiation = env.loss_from(hot, 0.90).to_si();
998 let without = env.loss_from(hot, 0.0).to_si();
999 let radiative = with_radiation - without;
1000 assert!(
1001 radiative / without > 0.6 && radiative / without < 1.0,
1002 "radiation is {:.2} of convection, not negligible",
1003 radiative / without
1004 );
1005 // At ambient nothing is lost either way.
1006 assert!(env.loss_from(Temperature::celsius(20.0), 0.9).to_si().abs() < 1e-12);
1007 // And a colder body gains, with the sign to prove it.
1008 assert!(env.loss_from(Temperature::celsius(10.0), 0.9).to_si() < 0.0);
1009 }
1010
1011 /// **`equilibrium_rise` agrees with stepping the domain**, whatever the surface is.
1012 ///
1013 /// It used to be `P/(hA)`, which reports the same rise for a polished surface and a
1014 /// blackbody one, and was over by 2.09x at the top of that range — always in the
1015 /// comfortable direction. It solves the full balance now.
1016 ///
1017 /// Asserted against `step`, and that is the point: the crate had two public functions
1018 /// disagreeing with its own physics while `Environment::loss_from`'s documentation
1019 /// explained exactly why that would be wrong. Reported by a downstream project that built
1020 /// a conclusion on the old number and had to retract the mechanism.
1021 ///
1022 /// `quoted / settled` across the emissivity range was 1.07, 1.12, 1.37, 1.58, 1.99, 2.09.
1023 #[test]
1024 fn the_equilibrium_agrees_with_stepping_there_at_every_emissivity() {
1025 for e in [0.0, 0.05, 0.3, 0.9, 1.0] {
1026 let mut body = radiating_box(e);
1027 let quoted = body.equilibrium_rise(Power::w(21.0)).to_si();
1028 let settled = settle(&mut body);
1029 assert!(
1030 (quoted / settled - 1.0).abs() < 1e-6,
1031 "emissivity {e}: quoted {quoted:.4} K, settled {settled:.4} K"
1032 );
1033 }
1034 // And the surface matters, which is the whole complaint: a black box settles at about
1035 // half the rise of a polished one under the same load, where `P/(hA)` said 99.2 K for
1036 // both.
1037 let (mut black, mut shiny) = (radiating_box(1.0), radiating_box(0.05));
1038 let (b, s) = (settle(&mut black), settle(&mut shiny));
1039 assert!(b < 0.55 * s, "black {b:.1} K against polished {s:.1} K");
1040 }
1041
1042 /// **The time constant tightens as the body warms**, and brackets the large-signal value.
1043 ///
1044 /// `4εσA·T³` grows with temperature, so this is a property of the state and not of the
1045 /// body. The measured figure — time to 63% of the settled rise — must lie between the value
1046 /// at rest and the value once hot, because a large-signal time constant is an average over
1047 /// the trajectory that the small-signal one bounds at each end.
1048 ///
1049 /// At ε = 0.9: 29.9 min at rest, 23.8 once settled, 25.6 large-signal. Before radiation was
1050 /// counted it was 53.0 whatever the surface was.
1051 #[test]
1052 fn the_time_constant_brackets_the_measured_one_and_tightens_when_hot() {
1053 let cold = radiating_box(0.9);
1054 let tau_cold = cold.time_constant().to_si();
1055
1056 let mut body = radiating_box(0.9);
1057 let settled = settle(&mut body);
1058 let tau_hot = body.time_constant().to_si();
1059 assert!(
1060 tau_hot < tau_cold,
1061 "hot {tau_hot:.1} s against cold {tau_cold:.1} s"
1062 );
1063
1064 let mut probe = radiating_box(0.9);
1065 let mut bus = Exchange::new();
1066 let mut t63 = f64::NAN;
1067 for k in 0..400_000 {
1068 bus.publish(HEAT, 21.0);
1069 probe
1070 .step(Time::s(k as f64), Time::s(1.0), &mut bus)
1071 .unwrap();
1072 let reached = probe.rise().to_si() >= settled * (1.0 - 1.0 / std::f64::consts::E);
1073 if t63.is_nan() && reached {
1074 t63 = k as f64;
1075 }
1076 }
1077 assert!(
1078 tau_hot < t63 && t63 < tau_cold,
1079 "the measured {:.1} min should lie between {:.1} and {:.1}",
1080 t63 / 60.0,
1081 tau_hot / 60.0,
1082 tau_cold / 60.0
1083 );
1084 // The scheduler follows it: a tenth of a time constant that is now shorter.
1085 assert!(body.max_stable_dt(Time::ZERO) < cold.max_stable_dt(Time::ZERO));
1086 }
1087
1088 /// A body with no way to lose heat never settles, rather than settling instantly.
1089 #[test]
1090 fn a_body_that_cannot_lose_heat_has_no_equilibrium() {
1091 let sealed = LumpedMass::new(
1092 "sealed",
1093 Substance::aluminium_6061(),
1094 Volume::from_si(1e-4),
1095 Length::mm(10.0),
1096 Temperature::celsius(20.0),
1097 Environment {
1098 ambient: Temperature::celsius(20.0),
1099 convection_w_per_m2_k: 0.0,
1100 area: Area::from_si(0.0),
1101 },
1102 );
1103 assert!(sealed.equilibrium_rise(Power::w(1.0)).to_si().is_infinite());
1104 assert_eq!(sealed.equilibrium_rise(Power::w(0.0)).to_si(), 0.0);
1105 assert!(sealed.time_constant().to_si().is_infinite());
1106 }
1107
1108 /// With no convection at all the root is the Stefan-Boltzmann one, exactly.
1109 ///
1110 /// The case the old `P/(hA)` divided by zero on and returned infinity for — a body in
1111 /// vacuum has an equilibrium, and it is a closed form.
1112 #[test]
1113 fn a_body_in_vacuum_settles_where_stefan_boltzmann_says() {
1114 let (area, vol) = (0.030_24, 3.456e-4);
1115 let emissivity = 0.8;
1116 let mut substance = Substance::aluminium_6061();
1117 if let Some(t) = substance.thermal.as_mut() {
1118 t.emissivity = emissivity;
1119 }
1120 let vacuum = LumpedMass::new(
1121 "vac",
1122 substance,
1123 Volume::from_si(vol),
1124 Length::from_si(vol / area),
1125 Temperature::celsius(25.0),
1126 Environment {
1127 ambient: Temperature::celsius(25.0),
1128 convection_w_per_m2_k: 0.0,
1129 area: Area::from_si(area),
1130 },
1131 );
1132 let rise = vacuum.equilibrium_rise(Power::w(21.0)).to_si();
1133 // Closed form, computed here: T = (P/(eps A sigma) + Ta^4)^(1/4).
1134 let ta = Temperature::celsius(25.0).to_si();
1135 let want =
1136 (21.0 / (emissivity * STEFAN_BOLTZMANN.to_si() * area) + ta.powi(4)).powf(0.25) - ta;
1137 assert!(
1138 (rise / want - 1.0).abs() < 1e-9,
1139 "vacuum: got {rise:.4} K, closed form {want:.4} K"
1140 );
1141 }
1142
1143 /// The answer a designer wants: where does it end up.
1144 ///
1145 /// Asserted against stepping there rather than against a band, and the band is why. This
1146 /// test used to require 1 K to 20 K, which the convective-only formula satisfied and the
1147 /// real answer does not: 10 mW into this lens settles **0.60 K** up, because borosilicate
1148 /// radiates and the old formula pretended it did not. The band was wide enough to look
1149 /// generous and narrow enough to encode the bug.
1150 #[test]
1151 fn equilibrium_rise_is_the_number_that_matters() {
1152 let mut body = lens(20.0);
1153 let quoted = body.equilibrium_rise(Power::mw(10.0)).to_si();
1154
1155 let mut bus = Exchange::new();
1156 for k in 0..200_000 {
1157 bus.publish(HEAT, 0.010);
1158 body.step(Time::s(k as f64), Time::s(1.0), &mut bus)
1159 .unwrap();
1160 }
1161 let settled = body.rise().to_si();
1162 assert!(
1163 (quoted / settled - 1.0).abs() < 1e-6,
1164 "quoted {quoted:.6} K, settled {settled:.6} K"
1165 );
1166 // Small enough to matter for a focus and not for the glass, which is the point of
1167 // the number — and that check lives on Substance.
1168 assert!(quoted > 0.1 && quoted < 2.0, "got {quoted} K");
1169 assert_eq!(
1170 Substance::borosilicate_crown().survives(Temperature::from_si(quoted)),
1171 Some(true)
1172 );
1173 }
1174
1175 /// Explicit conduction refuses to run past its stability limit rather than
1176 /// diverging. This is the failure mode the whole `max_stable_dt` mechanism
1177 /// exists to prevent, and here it is caught even when a caller ignores it.
1178 #[test]
1179 fn explicit_conduction_refuses_an_unstable_step() {
1180 let mut bar = Bar1D::new(
1181 "bar",
1182 Substance::aluminium_6061(),
1183 20,
1184 Length::mm(1.0),
1185 Area::from_si(1e-4),
1186 Temperature::celsius(20.0),
1187 );
1188 let limit = bar.max_stable_dt(Time::ZERO);
1189 // Aluminium on a millimetre grid: about 7 ms.
1190 assert!(
1191 (limit.in_ms() - 7.2).abs() < 0.2,
1192 "limit {} ms",
1193 limit.in_ms()
1194 );
1195 assert!((bar.fourier_number(limit) - 0.5).abs() < 1e-12);
1196
1197 let mut bus = Exchange::new();
1198 assert!(bar.step(Time::ZERO, limit, &mut bus).is_ok());
1199 let err = bar
1200 .step(Time::ZERO, limit * 1.5, &mut bus)
1201 .expect_err("past the limit must not be attempted");
1202 assert_eq!(err.quantity, "Fourier number");
1203 assert!(err.after > 0.5, "{err}");
1204 }
1205
1206 /// Glass and aluminium differ by two orders of magnitude in how big a step they
1207 /// can take on the same grid — which is the concrete reason a multirate schedule
1208 /// is not a premature optimisation.
1209 #[test]
1210 fn two_materials_on_one_grid_need_different_steps() {
1211 let bar = |s: Substance| {
1212 Bar1D::new(
1213 "bar",
1214 s,
1215 10,
1216 Length::mm(1.0),
1217 Area::from_si(1e-4),
1218 Temperature::celsius(20.0),
1219 )
1220 .max_stable_dt(Time::ZERO)
1221 .to_si()
1222 };
1223 let glass = bar(Substance::borosilicate_crown());
1224 let metal = bar(Substance::aluminium_6061());
1225 assert!((glass - 0.967).abs() < 0.02, "glass {glass} s");
1226 assert!((metal - 0.0072).abs() < 0.001, "metal {metal} s");
1227 assert!(glass / metal > 100.0, "ratio {}", glass / metal);
1228 }
1229
1230 /// Insulated conduction conserves heat exactly and flattens a gradient
1231 /// monotonically — the two things the heat equation is supposed to do.
1232 #[test]
1233 fn conduction_conserves_heat_and_flattens_a_gradient() {
1234 let mut bar = Bar1D::new(
1235 "bar",
1236 Substance::aluminium_6061(),
1237 21,
1238 Length::mm(1.0),
1239 Area::from_si(1e-4),
1240 Temperature::celsius(20.0),
1241 );
1242 // A hot spot in the middle.
1243 bar.cells[10] = Temperature::celsius(60.0).to_si();
1244 let total_before: f64 = bar.cells.iter().sum();
1245 let spread_before = bar.cells.iter().cloned().fold(0.0f64, f64::max)
1246 - bar.cells.iter().cloned().fold(f64::MAX, f64::min);
1247
1248 let dt = bar.max_stable_dt(Time::ZERO);
1249 let mut bus = Exchange::new();
1250 for _ in 0..500 {
1251 bar.step(Time::ZERO, dt * 0.9, &mut bus).unwrap();
1252 }
1253
1254 let total_after: f64 = bar.cells.iter().sum();
1255 assert!(
1256 (total_after / total_before - 1.0).abs() < 1e-12,
1257 "insulated ends must conserve heat exactly: {total_before} -> {total_after}"
1258 );
1259 let spread_after = bar.cells.iter().cloned().fold(0.0f64, f64::max)
1260 - bar.cells.iter().cloned().fold(f64::MAX, f64::min);
1261 assert!(
1262 spread_after < spread_before / 10.0,
1263 "the gradient should have flattened: {spread_before} -> {spread_after}"
1264 );
1265 // A lumped model would have reported zero gradient from the start; this one
1266 // still sees a little.
1267 assert!(bar.mean_temperature().in_celsius() > 21.0);
1268 }
1269
1270 /// **What a place-aware flux buys, stated as the difference it makes.** The same
1271 /// joules, delivered to the same bar, once as a lumped total and once resolved over the
1272 /// boundary, land somewhere different — and the resolved one lands where the light
1273 /// actually was.
1274 ///
1275 /// This is the check that could not be written before: the lumped bus had no way to say
1276 /// "the middle", so both runs would have been the same run.
1277 #[test]
1278 fn heat_arrives_where_the_flux_says_it_did() {
1279 let build = || {
1280 Bar1D::new(
1281 "bar",
1282 Substance::aluminium_6061(),
1283 21,
1284 Length::mm(1.0),
1285 Area::from_si(1e-4),
1286 Temperature::celsius(20.0),
1287 )
1288 };
1289 let joules = 2.0;
1290
1291 // Lumped: everything into cell 0, because there is nothing else it could mean.
1292 let mut lumped = build();
1293 let mut bus = Exchange::new();
1294 bus.publish(HEAT, joules);
1295 lumped
1296 .step(Time::ZERO, Time::from_si(1e-4), &mut bus)
1297 .unwrap();
1298
1299 // Resolved: all of it onto face 10, the middle of the bar.
1300 let mut resolved = build().exposing("bar face", Area::from_si(1e-4));
1301 let boundary = resolved.boundary().expect("it was just given one").clone();
1302 assert_eq!(
1303 boundary.faces(),
1304 21,
1305 "one face per cell, so nothing interpolates"
1306 );
1307 let mut spot = vec![0.0; 21];
1308 spot[10] = joules;
1309 bus.publish_on(&boundary, HEAT, &Flux::from_faces(spot))
1310 .unwrap();
1311 resolved
1312 .step(Time::ZERO, Time::from_si(1e-4), &mut bus)
1313 .unwrap();
1314
1315 // Same energy in, by the domain's own accounting.
1316 assert!(
1317 (resolved.absorbed_energy().to_si() - lumped.absorbed_energy().to_si()).abs() < 1e-15,
1318 "the two runs must differ in place, not in amount"
1319 );
1320 assert!(
1321 bus.unclaimed().next().is_none(),
1322 "and nothing was left on the bus"
1323 );
1324
1325 // But a different bar. The lumped run is hot at the end it was told nothing about;
1326 // the resolved one is hot in the middle, where the beam was.
1327 let lumped_end = lumped.temperature_at(0).in_celsius();
1328 let lumped_middle = lumped.temperature_at(10).in_celsius();
1329 assert!(
1330 lumped_end > lumped_middle + 1.0,
1331 "lumped heat piled up at cell 0"
1332 );
1333 assert!(
1334 (lumped_middle - 20.0).abs() < 1e-9,
1335 "and never reached the middle"
1336 );
1337
1338 let resolved_end = resolved.temperature_at(0).in_celsius();
1339 let resolved_middle = resolved.temperature_at(10).in_celsius();
1340 assert!(
1341 resolved_middle > resolved_end + 1.0,
1342 "resolved heat is in the middle"
1343 );
1344 assert!(
1345 (resolved_end - 20.0).abs() < 1e-9,
1346 "and the end is untouched"
1347 );
1348 // Symmetric about the spot, which a one-sided injection can never be.
1349 assert!(
1350 (resolved.temperature_at(9).in_celsius() - resolved.temperature_at(11).in_celsius())
1351 .abs()
1352 < 1e-12
1353 );
1354 }
1355
1356 /// A distribution the bar cannot read is refused, and refusing it means the step fails
1357 /// rather than heating the wrong cells. The bar's grid is the discretisation; a
1358 /// publisher on a different one has to say so.
1359 #[test]
1360 fn a_flux_on_the_wrong_grid_stops_the_step() {
1361 let mut bar = Bar1D::new(
1362 "bar",
1363 Substance::aluminium_6061(),
1364 21,
1365 Length::mm(1.0),
1366 Area::from_si(1e-4),
1367 Temperature::celsius(20.0),
1368 )
1369 .exposing("bar face", Area::from_si(1e-4));
1370 let boundary = bar.boundary().unwrap().clone();
1371
1372 // An illuminator on a 64-pixel grid, which is a perfectly reasonable thing to be.
1373 let camera = Interface::uniform("bar face", 64, Area::from_si(1e-4) * (21.0 / 64.0));
1374 let mut bus = Exchange::new();
1375 bus.publish_on(&camera, HEAT, &Flux::spread_over(2.0, &camera))
1376 .unwrap();
1377
1378 let err = bar
1379 .step(Time::ZERO, Time::from_si(1e-4), &mut bus)
1380 .expect_err("a 64-face flux is not a 21-cell bar");
1381 assert!(err.quantity.contains("expected 21"), "{err}");
1382 assert!(
1383 (bar.temperature_at(10).in_celsius() - 20.0).abs() < 1e-9,
1384 "and nothing was heated"
1385 );
1386
1387 // Resampling first is what works, and the bar then gets all of it.
1388 let crossed = bus
1389 .take_on(&camera, HEAT)
1390 .unwrap()
1391 .resample(&camera, &boundary)
1392 .unwrap();
1393 bus.publish_on(&boundary, HEAT, &crossed).unwrap();
1394 bar.step(Time::ZERO, Time::from_si(1e-4), &mut bus).unwrap();
1395 assert!((bar.absorbed_energy().to_si() - 2.0).abs() < 1e-12);
1396 assert!(bus.unclaimed().next().is_none());
1397 }
1398
1399 /// The field reads the bar where the bar is, and holds its value past the insulated
1400 /// ends rather than running off the array.
1401 #[test]
1402 fn the_field_samples_the_bar_and_stops_at_its_ends() {
1403 let mut bar = Bar1D::new(
1404 "bar",
1405 Substance::aluminium_6061(),
1406 5,
1407 Length::mm(1.0),
1408 Area::from_si(1e-4),
1409 Temperature::celsius(20.0),
1410 );
1411 // A ramp, so every cell is distinguishable.
1412 for (i, cell) in bar.cells.iter_mut().enumerate() {
1413 *cell = 300.0 + i as f64;
1414 }
1415 let at = |mm: f64| bar.at(LengthVec::mm(mm, 0.0, 0.0), Time::ZERO);
1416
1417 // Cell centres are at 0.5, 1.5, ... mm and read exactly.
1418 for i in 0..5 {
1419 assert!(
1420 (at(i as f64 + 0.5) - (300.0 + i as f64)).abs() < 1e-12,
1421 "cell {i}"
1422 );
1423 }
1424 // Halfway between two centres is halfway between their values.
1425 assert!((at(1.0) - 300.5).abs() < 1e-12);
1426 assert!((at(3.75) - 303.25).abs() < 1e-12);
1427
1428 // Past either end the value is held. The ends are insulated, so the temperature
1429 // really does stop changing there — this is the physics, not a clamp for safety.
1430 assert!((at(-50.0) - 300.0).abs() < 1e-12);
1431 assert!((at(0.0) - 300.0).abs() < 1e-12);
1432 assert!((at(5.0) - 304.0).abs() < 1e-12);
1433 assert!((at(1e6) - 304.0).abs() < 1e-12);
1434 assert!(
1435 (at(f64::NAN) - 300.0).abs() < 1e-12,
1436 "a NaN must not index the array"
1437 );
1438
1439 // Uniform across the bar, which is what a one-dimensional model asserts.
1440 assert_eq!(
1441 bar.at(LengthVec::mm(2.5, 0.0, 0.0), Time::ZERO),
1442 bar.at(LengthVec::mm(2.5, 40.0, -70.0), Time::ZERO)
1443 );
1444 }
1445
1446 /// Gradient and Laplacian against closed forms. A linear ramp has a constant gradient
1447 /// and no curvature; a quadratic has a curvature the three-point stencil gets exactly,
1448 /// because a second difference of a quadratic is not an approximation.
1449 #[test]
1450 fn the_fields_derivatives_match_their_closed_forms() {
1451 let dx = 1e-3;
1452 let build = |f: &dyn Fn(f64) -> f64| {
1453 let mut bar = Bar1D::new(
1454 "bar",
1455 Substance::aluminium_6061(),
1456 21,
1457 Length::from_si(dx),
1458 Area::from_si(1e-4),
1459 Temperature::celsius(20.0),
1460 );
1461 for (i, cell) in bar.cells.iter_mut().enumerate() {
1462 *cell = f((i as f64 + 0.5) * dx);
1463 }
1464 bar
1465 };
1466 let probe = LengthVec::from_si(DVec3::new(10.5 * dx, 0.0, 0.0));
1467 let h = Length::from_si(dx);
1468
1469 // T = 300 + 40x, so dT/dx = 40 K/m everywhere and the curvature is zero.
1470 let ramp = build(&|x| 300.0 + 40.0 * x);
1471 let g = ramp.gradient(probe, Time::ZERO, h);
1472 assert!((g.x - 40.0).abs() < 1e-9, "got {g}");
1473 assert!(
1474 g.y == 0.0 && g.z == 0.0,
1475 "a 1D bar has no transverse gradient"
1476 );
1477 assert!(
1478 ramp.laplacian(probe, Time::ZERO, h).abs() < 1e-6,
1479 "a ramp has no curvature"
1480 );
1481
1482 // T = 300 + 5000x², so d²T/dx² = 10000 K/m² exactly, and dT/dx = 10000x.
1483 let curved = build(&|x| 300.0 + 5000.0 * x * x);
1484 let lap = curved.laplacian(probe, Time::ZERO, h);
1485 assert!((lap / 10_000.0 - 1.0).abs() < 1e-9, "got {lap}");
1486 let g = curved.gradient(probe, Time::ZERO, h);
1487 assert!((g.x / (10_000.0 * 10.5 * dx) - 1.0).abs() < 1e-9, "got {g}");
1488
1489 // Past the ends the derivatives go to zero, because that is where `at` goes flat.
1490 // A field whose gradient disagreed with its own values would be worse than useless
1491 // to a visualiser, which reads both.
1492 for outside in [-5.0 * dx, 30.0 * dx] {
1493 let p = LengthVec::from_si(DVec3::new(outside, 0.0, 0.0));
1494 assert_eq!(
1495 curved.gradient(p, Time::ZERO, h),
1496 DVec3::ZERO,
1497 "at {outside}"
1498 );
1499 assert_eq!(curved.laplacian(p, Time::ZERO, h), 0.0, "at {outside}");
1500 }
1501
1502 // But *at* the insulated wall the gradient is not zero, and pretending otherwise
1503 // would be a lie about what a cell-centred grid knows: its first sample is half a
1504 // cell inside, where the temperature really is still changing.
1505 let wall = LengthVec::ZERO;
1506 let expected = (curved.cells[1] - curved.cells[0]) / (2.0 * dx);
1507 assert!((curved.gradient(wall, Time::ZERO, h).x - expected).abs() < 1e-9);
1508 assert!(expected > 0.0, "the mirrored estimate is not zero");
1509 }
1510
1511 /// **The check that makes the field worth having.** The rate it reports is exactly what
1512 /// the domain does on its next step — not approximately, to the last bit.
1513 ///
1514 /// That is not a coincidence and it is the reason `rate` is overridden. The explicit
1515 /// update *is* `T += α·dt·∇²T` on this stencil, so evaluating the governing equation and
1516 /// taking the step are the same arithmetic. A visualiser drawing `rate` is drawing what
1517 /// is about to happen, and the default finite difference in time — which would have
1518 /// needed history the domain does not keep — would have read zero.
1519 #[test]
1520 fn the_reported_rate_is_exactly_the_step_the_domain_takes() {
1521 let dx = 1e-3;
1522 let mut bar = Bar1D::new(
1523 "bar",
1524 Substance::aluminium_6061(),
1525 21,
1526 Length::from_si(dx),
1527 Area::from_si(1e-4),
1528 Temperature::celsius(20.0),
1529 );
1530 bar.cells[10] = Temperature::celsius(60.0).to_si();
1531 bar.cells[14] = Temperature::celsius(35.0).to_si();
1532
1533 let dt = bar.max_stable_dt(Time::ZERO) * 0.5;
1534 let probes: Vec<LengthVec> = (0..21)
1535 .map(|i| LengthVec::from_si(DVec3::new((i as f64 + 0.5) * dx, 0.0, 0.0)))
1536 .collect();
1537 let predicted: Vec<f64> = probes
1538 .iter()
1539 .map(|p| bar.rate(*p, Time::ZERO, dt))
1540 .collect();
1541 let before: Vec<f64> = bar.cells.clone();
1542
1543 bar.step(Time::ZERO, dt, &mut Exchange::new()).unwrap();
1544
1545 for (i, p) in probes.iter().enumerate() {
1546 let observed = (bar.cells[i] - before[i]) / dt.to_si();
1547 let _ = p;
1548 if predicted[i].abs() < 1e-12 {
1549 assert!(
1550 observed.abs() < 1e-9,
1551 "cell {i}: {observed} against nothing"
1552 );
1553 } else {
1554 assert!(
1555 (observed / predicted[i] - 1.0).abs() < 1e-12,
1556 "cell {i}: predicted {} but the step did {observed}",
1557 predicted[i]
1558 );
1559 }
1560 }
1561 // And the hot cell really was cooling while its neighbours warmed, so the test is
1562 // not passing on a bar where nothing happened.
1563 assert!(
1564 predicted[10] < -1.0,
1565 "the peak should be cooling: {}",
1566 predicted[10]
1567 );
1568 assert!(
1569 predicted[9] > 1.0,
1570 "and its neighbour warming: {}",
1571 predicted[9]
1572 );
1573 }
1574
1575 /// A substance with no diffusivity has no conduction to report, rather than an
1576 /// infinity or a panic.
1577 #[test]
1578 fn a_field_with_no_diffusivity_reports_no_rate() {
1579 let bar = Bar1D::new(
1580 "bar",
1581 Substance::bulk("mystery", Density::from_si(1000.0)),
1582 5,
1583 Length::mm(1.0),
1584 Area::from_si(1e-4),
1585 Temperature::celsius(20.0),
1586 );
1587 assert_eq!(bar.rate(LengthVec::ZERO, Time::ZERO, Time::s(1.0)), 0.0);
1588 }
1589
1590 /// The domain plugs into the kernel's scheduler and its books balance: heat taken
1591 /// from the bus is stored plus lost, to within the audit's tolerance, over a run
1592 /// long enough for both paths to matter.
1593 #[test]
1594 fn the_domain_balances_its_books_under_the_scheduler() {
1595 struct Heater {
1596 watts: f64,
1597 paid: f64,
1598 }
1599 impl Domain for Heater {
1600 fn name(&self) -> &str {
1601 "heater"
1602 }
1603 fn kind(&self) -> Kind {
1604 Kind::QuasiStatic
1605 }
1606 fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
1607 let joules = self.watts * dt.to_si();
1608 bus.publish(HEAT, joules);
1609 self.paid += joules;
1610 Ok(())
1611 }
1612 fn ledger(&self) -> Ledger {
1613 Ledger::new().with(quantity::ENERGY, -self.paid)
1614 }
1615 fn checkpoint(&mut self) {}
1616 fn restore(&mut self) {}
1617 fn supports_restore(&self) -> bool {
1618 true
1619 }
1620 }
1621
1622 let mut sim = Simulation::new(Schedule::Multirate)
1623 .with(Heater {
1624 watts: 0.01,
1625 paid: 0.0,
1626 })
1627 .with(lens(20.0));
1628
1629 for _ in 0..40 {
1630 sim.advance(Time::s(5.0)).expect("the books must balance");
1631 }
1632 // 40 windows of 5 s is 200 s, and the lens has warmed measurably.
1633 assert!((sim.time().to_si() - 200.0).abs() < 1e-9);
1634 // The audit already proved conservation; this is what it looks like.
1635 let total = sim.ledger().get(quantity::ENERGY).unwrap();
1636 assert!(total.abs() < 1e-9, "residual {total}");
1637 }
1638
1639 /// A domain whose substance has no thermal data fails by name rather than
1640 /// silently doing nothing.
1641 #[test]
1642 fn a_substance_without_heat_capacity_is_refused() {
1643 let mut body = LumpedMass::new(
1644 "mystery",
1645 Substance::bulk("mystery", Density::g_per_cm3(3.0)),
1646 lens_volume(),
1647 Length::mm(5.0),
1648 Temperature::celsius(20.0),
1649 Environment::still_air(Temperature::celsius(20.0), lens_area()),
1650 );
1651 let mut bus = Exchange::new();
1652 let err = body.step(Time::ZERO, Time::s(1.0), &mut bus).unwrap_err();
1653 assert_eq!(err.site, "mystery");
1654 assert!(err.quantity.contains("heat capacity"), "{err}");
1655 }
1656}