Skip to main content

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}