Skip to main content

pantometry_thermal/
solid.rs

1//! Conduction through a block, in three dimensions.
2//!
3//! [`Bar1D`](crate::Bar1D) resolves a gradient along one axis, and that is the right model for a
4//! rod, a wall or a beam landing on a strip. A heat sink is not any of those. Heat spreading
5//! sideways out of a hot spot is exactly what a fin, a spreader plate and a mounting boss are
6//! *for*, and a one-dimensional model cannot show it: it has nowhere for the heat to go but
7//! along.
8//!
9//! # What the third dimension costs
10//!
11//! Twice, and the second time is the one that hurts.
12//!
13//! A block of `n` cells on a side is `n³` cells rather than `n`. And the explicit stability
14//! limit tightens with each axis, because the limit is on the *sum* of what the three
15//! directions do in one step:
16//!
17//! ```text
18//!   1D      α·dt/dx² ≤ 1/2
19//!   2D      α·dt/dx² ≤ 1/4
20//!   3D      α·dt/dx² ≤ 1/6
21//! ```
22//!
23//! So a 3D block at the same spacing takes three times as many steps as a bar, each of them n²
24//! times more work. `Room` in `pantometry-acoustic` records the same trade for the wave equation and
25//! reaches a factor of √3 rather than 3, because a wave's limit is on the wave speed and a
26//! diffusion limit is on the diffusivity — one is linear in the sum and the other in its square
27//! root.
28//!
29//! That is why [`LumpedMass`](crate::LumpedMass) and [`ThermalNetwork`](crate::ThermalNetwork)
30//! are not going anywhere. In aluminium at a millimetre the step is **2.41 ms**, so a motor
31//! housing over its two-thousand-second thermal time constant is **828,000 steps** — each of
32//! them a sweep over however many cells the housing is, which at that spacing is around a
33//! million. A graph of four nodes answers the same question immediately. Use the cheapest model
34//! whose reduction still holds, and `LumpedMass::biot_number` is how you find out whether it
35//! does.
36//!
37//! What a block is *for* is the case where the reduction does not hold: a hot spot, a spreader,
38//! a gradient across a joint. Those are questions a lumped model cannot answer at any price.
39//!
40//! # What it is checked against
41//!
42//! A separable cosine mode is an **exact eigenvector of the discrete operator**, not merely an
43//! approximate solution of the continuum one. On a cell-centred grid with insulated faces the
44//! mode `cos(aπ(i+½)/nx)·cos(bπ(j+½)/ny)·cos(cπ(k+½)/nz)` decays by exactly the same factor
45//! every step, and that factor is known in closed form. So the test is an equality at machine
46//! precision rather than a tolerance, and it is sensitive to a swapped axis, a dropped term or a
47//! wrong spacing in a way a smooth decaying blob would not be.
48//!
49//! The *continuum* rate is then a second test: the discrete eigenvalue approaches
50//! `−α·π²·(1/Lx² + 1/Ly² + 1/Lz²)` at second order, and refining the grid quarters the error.
51//! Rate rather than value, because a scheme that is first order where it claims to be second is
52//! the defect this workspace has already shipped once.
53//!
54//! # A workaround that is gone
55//!
56//! This domain briefly offered `Domain::as_bodies` alongside `as_field`, so a viewer could get
57//! its cells as a point cloud. That was not a design choice; it was cover for `pantometry-scene`'s
58//! `Extent` being two-dimensional, which would have captured a block as its `z = 0` face.
59//!
60//! `Extent` is three-dimensional now, so the cover is unnecessary — and it was never free. A
61//! domain that is two shapes at once makes the picture depend on whether somebody remembered to
62//! set an extent, which is a mode nothing announces. It is a field, and only a field.
63
64use glam::DVec3;
65use pantometry_core::conserved::quantity;
66use pantometry_core::Reading;
67use pantometry_core::{
68    units::{
69        Area, Conductance, Energy, HeatCapacity, Length, LengthVec, Power, Temperature, Time,
70        Volume,
71    },
72    Domain, Exchange, Ledger, ScalarField, Substance, Violation,
73};
74
75use crate::{Environment, HEAT};
76use pantometry_units::STEFAN_BOLTZMANN;
77use std::collections::BTreeMap;
78
79/// The largest Fourier number an explicit three-dimensional sweep is stable at.
80///
81/// `1/(2d)` for `d` dimensions, from requiring the amplification factor of the worst-resolved
82/// mode to stay inside the unit circle. Public because a caller sizing a grid needs it before
83/// there is anything to ask.
84pub const STABLE_FOURIER_3D: f64 = 1.0 / 6.0;
85
86/// A rectangular block, conducting in three dimensions, of one material or of several.
87///
88/// Cells are **cubes** of a single spacing rather than boxes of three. That is a deliberate
89/// restriction and the same one `Room::of_air` makes: an anisotropic cell makes the stability
90/// limit anisotropic and the truncation error different along each axis, so the grid would be
91/// resolving one direction better than another for a reason that had nothing to do with the
92/// physics. A block that is longer than it is thick is more cells along, not longer cells.
93///
94/// Faces are **insulated**. Every boundary cell exchanges with the neighbours it has and no
95/// others, which is what makes the total exactly conserved rather than conserved to a tolerance.
96/// A block that should lose heat gets that from a domain on the other side of the bus.
97///
98/// [`fill`](Solid3D::fill) gives cells a different substance from the constructor's, which is what
99/// makes a coating, a joint or a layered wall expressible. Read that method for the one number the
100/// scheme turns on — the conductivity **on a face** is the harmonic mean of the two cells', not
101/// A surface conductance put in series with the half cell of solid behind it.
102///
103/// `1/(1/G_film + dx/(2kA))`. The film is charged against the **surface**, and a finite-volume
104/// cell knows only its centre, so the half cell between them is part of the path. Leaving it
105/// out sheds too much heat and — measured — turns a second-order boundary into a first-order
106/// one, which is the defect this workspace has twice found in an acoustic wall.
107///
108/// An infinite conductivity (a substance that does not say) leaves the film alone, which is the
109/// right limit rather than a special case.
110fn series_with_half_cell(film: f64, conductivity: f64, area: f64, dx: f64) -> f64 {
111    if film <= 0.0 {
112        return 0.0;
113    }
114    let half = 2.0 * conductivity * area / dx;
115    if !half.is_finite() {
116        return film;
117    }
118    if half <= 0.0 {
119        return 0.0;
120    }
121    1.0 / (1.0 / film + 1.0 / half)
122}
123
124/// One outer face of a block.
125///
126/// Named rather than indexed because a caller says which side of a part is exposed, and
127/// `faces[3]` is a thing nobody can read back. The axis pairs are low and high along `x`, `y`
128/// and `z` in the block's own coordinates — a [`Pose`](pantometry_core::Pose) is what puts those in
129/// the world.
130#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
131pub enum Face {
132    /// The `x = 0` face.
133    XMin,
134    /// The far face along `x`.
135    XMax,
136    /// The `y = 0` face.
137    YMin,
138    /// The far face along `y`.
139    YMax,
140    /// The `z = 0` face.
141    ZMin,
142    /// The far face along `z`.
143    ZMax,
144}
145
146impl Face {
147    /// All six, in a fixed order — for a block exposed on every side.
148    pub const ALL: [Face; 6] = [
149        Face::XMin,
150        Face::XMax,
151        Face::YMin,
152        Face::YMax,
153        Face::ZMin,
154        Face::ZMax,
155    ];
156
157    /// Whether the cell at `(i, j, k)` of a `counts`-shaped block lies on this face.
158    fn holds(&self, (i, j, k): (usize, usize, usize), counts: (usize, usize, usize)) -> bool {
159        let (nx, ny, nz) = counts;
160        match self {
161            Face::XMin => i == 0,
162            Face::XMax => i + 1 == nx,
163            Face::YMin => j == 0,
164            Face::YMax => j + 1 == ny,
165            Face::ZMin => k == 0,
166            Face::ZMax => k + 1 == nz,
167        }
168    }
169}
170
171/// the arithmetic one, and the difference is not a refinement away.
172#[derive(Clone, Debug)]
173pub struct Solid3D {
174    name: String,
175    /// Every substance in the block. Index 0 is the constructor's, and a block nobody has
176    /// [`fill`](Solid3D::fill)ed holds only that one.
177    materials: Vec<Substance>,
178    /// Which of `materials` each cell is, indexed as `cells` is.
179    which: Vec<u32>,
180    /// Cell-centre temperatures, indexed `x + nx*(y + ny*z)`.
181    cells: Vec<f64>,
182    saved: Vec<f64>,
183    counts: (usize, usize, usize),
184    dx: Length,
185    absorbed: f64,
186    /// What [`stored_heat`](Solid3D::stored_heat) is measured from — see [`Bar1D`](crate::Bar1D)
187    /// for why an enthalpy reference is chosen for precision rather than for physics.
188    reference: f64,
189    /// Face conductivities in W/m/K, rebuilt by [`resolve`](Solid3D::resolve): `kx` is indexed
190    /// `i + (nx+1)*(j + ny*k)` and holds the face between cells `i-1` and `i`, so `kx[0]` and
191    /// `kx[nx]` are the outer faces and are **zero** — which is the insulated boundary, stated in
192    /// the conductance rather than in the stencil's index arithmetic.
193    kx: Vec<f64>,
194    ky: Vec<f64>,
195    kz: Vec<f64>,
196    /// Per-cell `dx / C_i`, which is what multiplies `Σ k_f ΔT` to give a rate of temperature.
197    mobility: Vec<f64>,
198    /// Per-cell heat capacity `ρ_i c_i dx³`, in J/K. `NaN` where a substance does not say.
199    capacity: Vec<f64>,
200    /// Per-cell melting point in kelvin, or `+∞` for a substance that does not melt — so the phase
201    /// branch is a comparison that is simply never true rather than an `Option` to unwrap per cell.
202    melt_point: Vec<f64>,
203    /// Per-cell latent heat in **joules**: `ρ L dx³`. Zero where there is no phase change, which is
204    /// what the sweep tests.
205    ///
206    /// # It was kelvin, and two phases broke that
207    ///
208    /// `L/c_p` — 163 K for ice — kept the whole update in one unit and the numbers `O(1)` rather than
209    /// `O(3e5)`, which is the reference-point argument `runtime/gpu` paid 1660× for. It is correct
210    /// while a cell has **one** specific heat.
211    ///
212    /// With two it is not, and it fails quietly. The kelvin figure is normalised by the *solid's*
213    /// `c_p` and then multiplied by the cell's *current* capacity, which for a liquid cell is the
214    /// liquid's — so freezing a cell of water cost `4182/2050` = **2.04×** what it should, and the
215    /// front came out **27% short** with nothing pointing at the latent heat. Joules do not have a
216    /// normalisation to get wrong.
217    latent: Vec<f64>,
218    /// Per-cell capacity of the **solid** phase, `ρ c_s dx³`, in J/K.
219    ///
220    /// The enthalpy map needs both capacities by name: `C_s` below the melting point and `C_l` above
221    /// it. `capacity` is the *mixed* one and is what the sweep divides a flux by, which is right —
222    /// a fully solid or fully liquid cell has its own, and a mushy cell does not change temperature
223    /// at all, so what it would have divided by never matters.
224    cap_solid: Vec<f64>,
225    /// Per-cell capacity of the **liquid** phase, `ρ c_l dx³`. Equal to `cap_solid` under the
226    /// one-phase model.
227    cap_liquid: Vec<f64>,
228    /// How much of each cell has melted, `0` solid and `1` liquid. Derived from the temperature by
229    /// [`resolve`](Solid3D::resolve) and then carried by the sweep.
230    melted: Vec<f64>,
231    /// The phase at the last [`checkpoint`](Domain::checkpoint), alongside `saved`.
232    saved_melted: Vec<f64>,
233    /// `max_i dx·Σ_f k_f / C_i`, the reciprocal of the largest stable step. See
234    /// [`max_stable_dt`](Domain::max_stable_dt) for why it is a maximum over cells and not a
235    /// function of the fastest material.
236    worst_rate: f64,
237    /// Cells that are **not part of the block** — see [`Solid3D::empty`].
238    void: Vec<bool>,
239    /// Pairs of solid cells that face each other across a run of void, with the radiative
240    /// exchange coefficient between them. Rebuilt whenever the void or the materials change.
241    ///
242    /// `(a, b, coefficient)` where the coefficient is `σA/(1/ε₁ + 1/ε₂ − 1)`, so the exchange is
243    /// `coefficient · (Tₐ⁴ − T_b⁴)` — see [`Solid3D::empty`] for what this models and what it
244    /// does not.
245    gaps: Vec<(usize, usize, f64)>,
246    /// Each cell's conduction sum, `Σ_f k_f`, kept so the stability limit can be rebuilt at
247    /// the block's **current** temperature — see [`Solid3D::worst_rate_now`].
248    face_sum: Vec<f64>,
249    /// Which faces lose heat, and to what. Empty is the adiabatic block every scene had until
250    /// now: six insulated faces and no steady state to reach.
251    exposed: BTreeMap<Face, Environment>,
252    /// Joules given up to those environments over the run, and the reason the books still
253    /// balance: the ledger is `stored + lost`, so what leaves the cells arrives in this
254    /// counter and the total moves only by what crossed the bus. `LumpedMass` keeps the same
255    /// pair for the same reason.
256    lost: f64,
257    /// Watts generated in each cell, held per cell rather than per region so a source and a
258    /// material can be cut by different boxes without either knowing about the other.
259    ///
260    /// **The gap this closes is one the bus cannot**, by design: the plain channel carries an
261    /// amount and no location, so heat arriving there spreads to a uniform rise over everything
262    /// that can hold it. That is the only choice that adds no information, and it is the wrong
263    /// answer for a die, a winding, a brake disc or a laser absorber — every real thing that
264    /// dissipates does it *somewhere*. Symmetric with [`Solid3D::losing_from`], which takes energy
265    /// out at a face; this puts it in at a region.
266    source: Vec<f64>,
267    /// Joules generated over the run, and the reason the books still balance: the ledger is
268    /// `stored + lost − supplied`, so a source that adds a joule to a cell adds one here too and
269    /// the total moves only by what crossed the bus.
270    supplied: f64,
271    /// The saved counterpart of [`Solid3D::supplied`], for the same reason [`Solid3D::lost`] has
272    /// one: an iterative sweep that rewinds the cells and not the counter would credit itself a
273    /// sweep of generated heat per retry.
274    saved_supplied: f64,
275    /// The saved counterpart of [`Solid3D::lost`].
276    ///
277    /// Saved because `LumpedMass` learned this the expensive way: rewinding the cells and not
278    /// the losses makes an iterative sweep grow its books by one sweep of shed heat per
279    /// iteration, and the audit reports energy created from nothing. Measured there at 1567.6 J
280    /// becoming 1600.9 J over forty advances.
281    saved_lost: f64,
282    /// Whether any material names a **liquid** phase whose properties differ from its solid.
283    ///
284    /// The one thing that decides whether the sweep has to rebuild its operator each step. A block of
285    /// ice does; a block of aluminium, or of ice under the one-phase model, does not.
286    two_phase: bool,
287}
288
289/// One patch of a clearance: two facing surfaces and how far apart they are.
290///
291/// A gap is found pair by pair, along one axis, one cell face against the one directly across
292/// from it. A *patch* is what those pairs add up to — a connected sheet of facing area at one
293/// separation, which is the shape a view factor is a statement about.
294#[derive(Debug, Clone, Copy, PartialEq)]
295pub struct GapPatch {
296    /// How many facing cell pairs are in it.
297    pub pairs: usize,
298    /// The patch's extent across the gap, in metres — the bounding box of the facing cells.
299    pub span: (f64, f64),
300    /// How far the two surfaces are apart, in metres.
301    pub distance: f64,
302    /// Whether the facing cells fill their bounding box.
303    ///
304    /// [`view_factor`](GapPatch::view_factor) is exact for a rectangle and an **upper bound** for
305    /// anything else, because a patch with a bite out of it sees less of itself than its bounding
306    /// box does. Reported rather than corrected: an L-shaped clearance is not a shape the closed
307    /// form covers, and a number invented for it would be worth less than knowing it is a bound.
308    pub rectangular: bool,
309}
310
311impl GapPatch {
312    /// The view factor from one of the facing surfaces to the other, `F₁₂`.
313    ///
314    /// The exact closed form for two equal, parallel, directly-opposed rectangles — which is the
315    /// geometry the pairing produces by construction, since a pair is a cell and the cell across
316    /// from it. With `X = a/c` and `Y = b/c`:
317    ///
318    /// ```text
319    /// F = 2/(πXY) · [ ln √((1+X²)(1+Y²)/(1+X²+Y²))
320    ///                 + X√(1+Y²)·atan(X/√(1+Y²)) + Y√(1+X²)·atan(Y/√(1+X²))
321    ///                 − X·atan X − Y·atan Y ]
322    /// ```
323    ///
324    /// **This is not what the exchange is charged**, and the difference is the point. What the
325    /// block charges is `F̄ = 1`, which is exact when the sides of the gap are mirrors — and the
326    /// block's own outer faces are exactly that, because an insulated boundary is implemented as
327    /// a mirror and a mirror extends the two surfaces to infinity. `F₁₂` is what the same pair
328    /// would exchange with **nothing** at the sides, open to space. The two agree when the gap is
329    /// narrow compared with the surfaces and diverge without limit when it is not, so this is the
330    /// number that says how much of the answer is resting on reading the boundary one way.
331    pub fn view_factor(&self) -> f64 {
332        let (x, y) = (self.span.0 / self.distance, self.span.1 / self.distance);
333        // A patch with no extent sees nothing; two surfaces with nothing between them see each
334        // other entirely. Both are limits of the form rather than sentinels, and both are here
335        // because this is public: a caller can build a `GapPatch` this block would never produce.
336        if !self.distance.is_finite() || self.span.0.is_nan() || self.span.1.is_nan() {
337            return 0.0;
338        }
339        if self.distance <= 0.0 {
340            return if self.span.0 > 0.0 && self.span.1 > 0.0 {
341                1.0
342            } else {
343                0.0
344            };
345        }
346        if x <= 0.0 || y <= 0.0 {
347            return 0.0;
348        }
349        let (x2, y2) = (x * x, y * y);
350        // **The closed form cancels itself away for a distant patch.** The bracket below is a
351        // small difference of terms of order `X`, and the answer is of order `X²` — so at `X` of
352        // 1e-3 four digits are already gone and the ratio to the exact `A/πc²` limit *turns
353        // around*: measured 0.999993 at X = 1/300, 1.00009 at 1/1000 and 1.007 at 1/3000, growing
354        // where it should be converging. A function that is quietly worse the further apart two
355        // surfaces are is the shape of defect this repository looks for, so the small-argument
356        // regime gets the series instead, `F = XY/π · (1 − (X²+Y²)/3 + O(X⁴))`, which is exact to
357        // 1e-8 either side of the crossover.
358        if x < 0.01 && y < 0.01 {
359            return (x * y / std::f64::consts::PI * (1.0 - (x2 + y2) / 3.0)).clamp(0.0, 1.0);
360        }
361        let (rx, ry) = ((1.0 + x2).sqrt(), (1.0 + y2).sqrt());
362        let t = ((1.0 + x2) * (1.0 + y2) / (1.0 + x2 + y2)).sqrt().ln()
363            + x * ry * (x / ry).atan()
364            + y * rx * (y / rx).atan()
365            - x * x.atan()
366            - y * y.atan();
367        (2.0 / (std::f64::consts::PI * x * y) * t).clamp(0.0, 1.0)
368    }
369}
370
371impl Solid3D {
372    /// A block of `counts` cubic cells of side `dx`, all starting at `initial`.
373    ///
374    /// Each count is forced to at least one. A block one cell thick in two directions is a
375    /// legitimate thing to ask for and reduces exactly to a bar, which is how the closed-form
376    /// tests check the three axes against each other.
377    pub fn new(
378        name: impl Into<String>,
379        substance: Substance,
380        counts: (usize, usize, usize),
381        dx: Length,
382        initial: Temperature,
383    ) -> Solid3D {
384        let counts = (counts.0.max(1), counts.1.max(1), counts.2.max(1));
385        let cells = vec![initial.to_si(); counts.0 * counts.1 * counts.2];
386        let mut block = Solid3D {
387            name: name.into(),
388            materials: vec![substance],
389            which: vec![0; cells.len()],
390            saved: cells.clone(),
391            cells,
392            counts,
393            dx,
394            absorbed: 0.0,
395            reference: initial.to_si(),
396            kx: Vec::new(),
397            ky: Vec::new(),
398            kz: Vec::new(),
399            mobility: Vec::new(),
400            capacity: Vec::new(),
401            melt_point: Vec::new(),
402            latent: Vec::new(),
403            melted: Vec::new(),
404            saved_melted: Vec::new(),
405            cap_solid: Vec::new(),
406            cap_liquid: Vec::new(),
407            worst_rate: 0.0,
408            void: vec![false; counts.0 * counts.1 * counts.2],
409            source: vec![0.0; counts.0 * counts.1 * counts.2],
410            supplied: 0.0,
411            saved_supplied: 0.0,
412            gaps: Vec::new(),
413            face_sum: Vec::new(),
414            exposed: BTreeMap::new(),
415            lost: 0.0,
416            saved_lost: 0.0,
417            two_phase: false,
418        };
419        block.resolve();
420        block
421    }
422
423    /// How much of one cell has melted: `0` entirely solid, `1` entirely liquid.
424    ///
425    /// Always zero for a substance with no [`FusionProps`](pantometry_core::substance::FusionProps), which is every
426    /// entry in the catalogue but [`Substance::ice`].
427    pub fn melted_fraction_at(&self, i: usize, j: usize, k: usize) -> f64 {
428        let (nx, ny, nz) = self.counts;
429        self.melted[i.min(nx - 1) + nx * (j.min(ny - 1) + ny * k.min(nz - 1))]
430    }
431
432    /// Declare how much of a cell has melted, for an initial condition a temperature cannot express.
433    ///
434    /// The counterpart to [`set_temperature`](Solid3D::set_temperature), and it exists because **a
435    /// temperature is not a state** for a substance that melts: 0 °C is ice, water, or any mixture,
436    /// and Stefan's problem starts from liquid at exactly the melting point. Without this the only
437    /// way to say that is to start a hair above it, which puts sensible heat in the initial condition
438    /// that the closed form does not have.
439    ///
440    /// Ignored for a substance that does not melt, and out of range is ignored, matching
441    /// `set_temperature`.
442    ///
443    /// # Supercooling is not representable, and this keeps it that way
444    ///
445    /// The state is one monotone number, so a fraction and a temperature cannot disagree: asking for
446    /// liquid raises the temperature to at least the melting point and asking for solid lowers it to
447    /// at most. Liquid below freezing and solid above it are real states of real matter and this
448    /// model does not have them — the sharp-interface problem assumes the interface is *at* the
449    /// melting point, which is what makes Neumann's solution its solution.
450    pub fn set_melted_fraction(&mut self, i: usize, j: usize, k: usize, fraction: f64) {
451        let Some(idx) = self.index(i, j, k) else {
452            return;
453        };
454        if self.latent[idx] <= 0.0 {
455            return;
456        }
457        let phi = fraction.clamp(0.0, 1.0);
458        self.melted[idx] = phi;
459        let point = self.melt_point[idx];
460        if phi >= 1.0 {
461            self.cells[idx] = self.cells[idx].max(point);
462        } else if phi <= 0.0 {
463            self.cells[idx] = self.cells[idx].min(point);
464        } else {
465            self.cells[idx] = point;
466        }
467    }
468
469    /// The volume that has melted, summed over every cell.
470    ///
471    /// The integral quantity, and the one worth reading rather than a front *position*: a front is
472    /// only a position if the problem is one-dimensional, and this is the same number in three. For
473    /// a column of cells it gives the position anyway — `melted_volume / area` is how far in the
474    /// front has reached, including the partial cell it is currently inside, which is what makes a
475    /// front measurable to better than one cell.
476    pub fn melted_volume(&self) -> Volume {
477        Volume::from_si(self.melted.iter().sum::<f64>() * self.cell_volume())
478    }
479
480    /// Put a different substance in every cell the predicate accepts.
481    ///
482    /// Composable: call it once per layer, per coating, per inclusion. Cells nobody claims keep the
483    /// constructor's substance, so a block is never partly undefined.
484    ///
485    /// # The harmonic mean, and why it is not a detail
486    ///
487    /// Heat crosses a face, and a face has two materials touching it. The conductance of the half
488    /// cell either side is in **series**, so the conductivity that governs the face is
489    ///
490    /// ```text
491    ///   k_face = 2 k_L k_R / (k_L + k_R)
492    /// ```
493    ///
494    /// the harmonic mean, and the arithmetic mean `(k_L + k_R)/2` is not an alternative convention
495    /// — it is wrong. With aluminium against borosilicate the two differ by **38×** (2.21 against
496    /// 84.1 W/m/K), and the arithmetic one short-circuits the interface: a wall it models has 4.2%
497    /// less resistance than its own layers add up to at 24 cells, and reaching the harmonic answer
498    /// to 0.1% would take about a thousand.
499    ///
500    /// The harmonic mean is not merely better. With the material interface on a cell face it makes
501    /// the discrete series resistance **exactly** `Σ Lᵢ/(kᵢA)` at every resolution, which is why
502    /// `a_layered_wall.rs` is an equality and not a tolerance.
503    ///
504    /// "On a cell face" is the whole condition, and it is one this method cannot break: cells are
505    /// whole, so an interface is always on a face. A scheme that placed a layer boundary partway
506    /// through a cell would be first order there whichever mean it used, because the cell would be a
507    /// mixture and no single conductivity describes one.
508    ///
509    /// # What it costs
510    ///
511    /// Five `f64` and a `u32` per cell beyond the temperatures: three face conductivities, a
512    /// capacity, its reciprocal scaled by `dx`, and which substance the cell is. All rebuilt here
513    /// and none of it in the sweep, which is the trade — the alternative is six harmonic means and
514    /// six divisions per cell per step.
515    ///
516    /// A uniform block pays the same. That is deliberate: a fast path for one material would be a
517    /// second implementation of the same physics, exercised by the tests that came before this
518    /// method and by nothing after it.
519    pub fn fill(
520        &mut self,
521        substance: Substance,
522        which: impl Fn(usize, usize, usize) -> bool,
523    ) -> &mut Solid3D {
524        let id = match self.materials.iter().position(|s| *s == substance) {
525            Some(at) => at as u32,
526            None => {
527                self.materials.push(substance);
528                (self.materials.len() - 1) as u32
529            }
530        };
531        let (nx, ny, nz) = self.counts;
532        for k in 0..nz {
533            for j in 0..ny {
534                for i in 0..nx {
535                    if which(i, j, k) {
536                        self.which[i + nx * (j + ny * k)] = id;
537                    }
538                }
539            }
540        }
541        self.resolve();
542        self
543    }
544
545    /// The substance in one cell. Out of range reads the nearest one in range.
546    pub fn substance_at(&self, i: usize, j: usize, k: usize) -> &Substance {
547        let (nx, ny, nz) = self.counts;
548        let idx = i.min(nx - 1) + nx * (j.min(ny - 1) + ny * k.min(nz - 1));
549        &self.materials[self.which[idx] as usize]
550    }
551
552    /// How many distinct substances are in the block. One until something has [`fill`]ed it.
553    ///
554    /// [`fill`]: Solid3D::fill
555    pub fn substances(&self) -> usize {
556        self.materials.len()
557    }
558
559    /// The conductance of the face between two cells, or `None` if they are not face neighbours.
560    ///
561    /// `k_face · A / dx`, which for cubic cells is `k_face · dx`. This is the number the sweep
562    /// actually uses, so a caller checking a joint against `Σ Lᵢ/(kᵢA)` by hand is checking the
563    /// same arithmetic the march does rather than a restatement of it.
564    pub fn face_conductance(
565        &self,
566        a: (usize, usize, usize),
567        b: (usize, usize, usize),
568    ) -> Option<Conductance> {
569        let (nx, ny, _) = self.counts;
570        self.index(a.0, a.1, a.2)?;
571        self.index(b.0, b.1, b.2)?;
572        let step = |p: usize, q: usize| (p as isize - q as isize).abs();
573        let (di, dj, dk) = (step(a.0, b.0), step(a.1, b.1), step(a.2, b.2));
574        let k = match (di, dj, dk) {
575            (1, 0, 0) => self.kx[a.0.max(b.0) + (nx + 1) * (a.1 + ny * a.2)],
576            (0, 1, 0) => self.ky[a.0 + nx * (a.1.max(b.1) + (ny + 1) * a.2)],
577            (0, 0, 1) => self.kz[a.0 + nx * (a.1 + ny * a.2.max(b.2))],
578            _ => return None,
579        };
580        Some(Conductance::from_si(k * self.dx.to_si()))
581    }
582
583    /// Rebuild everything derived from the materials: face conductivities, mobilities, the limit.
584    ///
585    /// Called by the constructor and by every [`fill`](Solid3D::fill), on the rule this workspace
586    /// learned from `Puck::repack` — a mutator that leaves a cached solve stale reports a number
587    /// that was true about the previous object, and it looks exactly like a number.
588    fn resolve(&mut self) {
589        let (nx, ny, nz) = self.counts;
590        let dx = self.dx.to_si();
591        let volume = Volume::from_si(dx * dx * dx);
592
593        // Per material, the **solid** pair and the **liquid** pair. A material whose `FusionProps`
594        // names no liquid uses the solid pair for both, which is the one-phase model — exact whenever
595        // the liquid sits at the melting point, because a face with no temperature difference across
596        // it carries no heat whatever its conductivity.
597        //
598        // `NaN` for a substance that does not say, which `step` refuses on rather than stepping with
599        // a plausible default.
600        let props: Vec<((f64, f64), (f64, f64))> = self
601            .materials
602            .iter()
603            .map(|s| {
604                let solid = (
605                    s.thermal.map_or(f64::NAN, |t| t.conductivity.to_si()),
606                    s.heat_capacity(volume).map_or(f64::NAN, |c| c.to_si()),
607                );
608                let liquid = match s.fusion.and_then(|f| f.liquid) {
609                    Some(t) => (
610                        t.conductivity.to_si(),
611                        s.mass_of(volume).to_si() * t.specific_heat.to_si(),
612                    ),
613                    None => solid,
614                };
615                (solid, liquid)
616            })
617            .collect();
618
619        // A liquid phase that differs from the solid is what makes the operator move with the front.
620        // A material whose `liquid` is absent, or identical to its solid, needs no per-step rebuild.
621        self.two_phase =
622            self.materials
623                .iter()
624                .any(|s| match (s.fusion.and_then(|f| f.liquid), s.thermal) {
625                    (Some(l), Some(t)) => {
626                        l.conductivity != t.conductivity || l.specific_heat != t.specific_heat
627                    }
628                    _ => false,
629                });
630
631        // The phase state first, because everything below depends on it.
632        //
633        // Rebuilt from temperature only when there is not one yet. Once the sweep is carrying a
634        // fraction, re-deriving it would throw away every partially melted cell — and this runs every
635        // step for a two-phase block, because a cell's conductivity moves with its fraction.
636        let fresh = self.melted.len() != self.cells.len();
637        let carried = std::mem::take(&mut self.melted);
638        self.melt_point = Vec::with_capacity(self.cells.len());
639        self.latent = Vec::with_capacity(self.cells.len());
640        self.melted = Vec::with_capacity(self.cells.len());
641        for (c, prior) in carried
642            .iter()
643            .copied()
644            .chain(std::iter::repeat(0.0))
645            .take(self.cells.len())
646            .enumerate()
647        {
648            let s = &self.materials[self.which[c] as usize];
649            let (point, latent) = match (s.fusion, s.thermal) {
650                (Some(f), Some(_)) => (
651                    f.melting_point.to_si(),
652                    s.latent_energy(volume).map_or(0.0, |e| e.to_si()),
653                ),
654                // A substance with fusion but no thermal properties cannot be stepped at all: its
655                // capacity is `NaN` and the sweep refuses rather than guessing.
656                _ => (f64::INFINITY, 0.0),
657            };
658            self.melt_point.push(point);
659            self.latent.push(latent);
660            self.melted.push(if fresh {
661                if self.cells[c] > point {
662                    1.0
663                } else {
664                    0.0
665                }
666            } else {
667                prior
668            });
669        }
670        // Only on a fresh build. Doing it every time would overwrite a checkpoint, and `resolve` is
671        // called from the sweep now.
672        if fresh {
673            self.saved_melted.clone_from(&self.melted);
674        }
675
676        // **Mixed by melt fraction, arithmetically, and that choice is the one to argue about.**
677        //
678        // A half-melted cell is half of each, and across a cell the two phases sit side by side
679        // rather than in series — heat crossing the mush passes through both at once, so a parallel
680        // mean is what matches. The *face* between two cells stays the harmonic mean of whatever the
681        // two cells came out as, because a face **is** series. Mixing one way and joining the other
682        // is not an inconsistency; they are different geometries.
683        //
684        // It applies only inside the mush, one or two cells wide, and that is where the scheme's
685        // remaining first-order error lives.
686        let mixed: Vec<(f64, f64)> = (0..self.cells.len())
687            .map(|c| {
688                // Nothing conducts nothing and holds nothing. Zeroing here rather than at every
689                // reader is what makes void fall out of the rest of the arithmetic: the face
690                // mean is already the harmonic one and guarded at zero, so a face touching void
691                // carries zero without knowing what void is.
692                if self.void[c] {
693                    return (0.0, 0.0);
694                }
695                let (solid, liquid) = props[self.which[c] as usize];
696                let phi = self.melted[c];
697                (
698                    solid.0 + phi * (liquid.0 - solid.0),
699                    solid.1 + phi * (liquid.1 - solid.1),
700                )
701            })
702            .collect();
703
704        // The series mean of the two half cells. Written as `2ab/(a+b)` and guarded at zero, because
705        // a perfect insulator is a legitimate fill and `0/0` is not a boundary condition.
706        let series = |a: f64, b: f64| {
707            let sum = a + b;
708            if sum > 0.0 {
709                2.0 * a * b / sum
710            } else {
711                0.0
712            }
713        };
714        let k_of = |cell: usize| mixed[cell].0;
715
716        self.kx = vec![0.0; (nx + 1) * ny * nz];
717        self.ky = vec![0.0; nx * (ny + 1) * nz];
718        self.kz = vec![0.0; nx * ny * (nz + 1)];
719        for k in 0..nz {
720            for j in 0..ny {
721                for i in 0..nx {
722                    let c = i + nx * (j + ny * k);
723                    if i > 0 {
724                        self.kx[i + (nx + 1) * (j + ny * k)] = series(k_of(c - 1), k_of(c));
725                    }
726                    if j > 0 {
727                        self.ky[i + nx * (j + (ny + 1) * k)] = series(k_of(c - nx), k_of(c));
728                    }
729                    if k > 0 {
730                        self.kz[i + nx * (j + ny * k)] = series(k_of(c - nx * ny), k_of(c));
731                    }
732                }
733            }
734        }
735
736        self.cap_solid = (0..self.cells.len())
737            .map(|c| props[self.which[c] as usize].0 .1)
738            .collect();
739        self.cap_liquid = (0..self.cells.len())
740            .map(|c| props[self.which[c] as usize].1 .1)
741            .collect();
742        self.capacity = vec![0.0; self.cells.len()];
743        self.mobility = vec![0.0; self.cells.len()];
744        self.face_sum = vec![0.0; self.cells.len()];
745        self.worst_rate = 0.0;
746        let mut nowhere = false;
747        for k in 0..nz {
748            for j in 0..ny {
749                for i in 0..nx {
750                    let c = i + nx * (j + ny * k);
751                    let cap = mixed[c].1;
752                    self.capacity[c] = cap;
753                    // A void cell has no capacity, and `dx/0` is an infinity that would poison
754                    // the limit and the sweep alike. Zero mobility is the honest value: nothing
755                    // moves in a cell that holds nothing.
756                    self.mobility[c] = if self.void[c] { 0.0 } else { dx / cap };
757                    let sum = self.kx[i + (nx + 1) * (j + ny * k)]
758                        + self.kx[i + 1 + (nx + 1) * (j + ny * k)]
759                        + self.ky[i + nx * (j + (ny + 1) * k)]
760                        + self.ky[i + nx * (j + 1 + (ny + 1) * k)]
761                        + self.kz[i + nx * (j + ny * k)]
762                        + self.kz[i + nx * (j + ny * (k + 1))];
763                    // NaN loses every comparison, so `max` would step straight past a substance that
764                    // does not say what it conducts. Tested for, and the sweep is refused.
765                    //
766                    // An exposed cell also loses to air, and that conductance leaves the cell
767                    // exactly as a face conductance does — so it belongs in the same sum. It is
768                    // divided by `dx` because `mobility` is `dx/capacity`: the face terms are
769                    // conductivities and this one is already a conductance.
770                    self.face_sum[c] = sum;
771                    let rate = self.mobility[c] * sum;
772                    if rate.is_nan() {
773                        nowhere = true;
774                    } else {
775                        self.worst_rate = self.worst_rate.max(rate);
776                    }
777                }
778            }
779        }
780        if nowhere {
781            self.worst_rate = f64::NAN;
782        }
783        self.find_gaps();
784    }
785
786    /// Which solid cells face each other across void, and how strongly they radiate.
787    ///
788    /// Along each grid line, a run of void with solid at both ends is a gap, and the two cells
789    /// at its ends see each other. Diagonals are not paired: a face is what radiates, and the
790    /// grid's faces are axis-aligned.
791    fn find_gaps(&mut self) {
792        self.gaps.clear();
793        if !self.void.iter().any(|v| *v) {
794            return;
795        }
796        let (nx, ny, nz) = self.counts;
797        let dx = self.dx.to_si();
798        let area = dx * dx;
799        let emissivity = |c: usize| {
800            self.materials[self.which[c] as usize]
801                .thermal
802                .map_or(0.0, |t| t.emissivity)
803        };
804
805        // One pass per axis. `line` walks the indices of a single row, column or pillar.
806        let mut walk = |line: Vec<usize>| {
807            let mut last_solid: Option<usize> = None;
808            let mut void_between = false;
809            for c in line {
810                if self.void[c] {
811                    void_between = last_solid.is_some();
812                    continue;
813                }
814                if let (Some(a), true) = (last_solid, void_between) {
815                    let (ea, eb) = (emissivity(a), emissivity(c));
816                    // The parallel-plate series: `1/ε₁ + 1/ε₂ − 1`. A surface that does not
817                    // radiate at all makes the pair carry nothing, which is the right limit and
818                    // also keeps the reciprocal finite.
819                    if ea > 0.0 && eb > 0.0 {
820                        let resistance = 1.0 / ea + 1.0 / eb - 1.0;
821                        if resistance > 0.0 {
822                            self.gaps
823                                .push((a, c, STEFAN_BOLTZMANN.to_si() * area / resistance));
824                        }
825                    }
826                }
827                last_solid = Some(c);
828                void_between = false;
829            }
830        };
831
832        for k in 0..nz {
833            for j in 0..ny {
834                walk((0..nx).map(|i| i + nx * (j + ny * k)).collect());
835            }
836        }
837        for k in 0..nz {
838            for i in 0..nx {
839                walk((0..ny).map(|j| i + nx * (j + ny * k)).collect());
840            }
841        }
842        for j in 0..ny {
843            for i in 0..nx {
844                walk((0..nz).map(|k| i + nx * (j + ny * k)).collect());
845            }
846        }
847    }
848
849    /// The clearances in this block, as sheets of facing area rather than as cell pairs.
850    ///
851    /// A pair is what the exchange is computed on; a patch is what a **view factor** is a
852    /// statement about, and the two are not the same object. Grouped by axis and separation, then
853    /// by connectivity across the gap, so two unrelated clearances at the same width do not
854    /// average into one patch that describes neither.
855    ///
856    /// Empty for a block with no void, which is every block that existed before there was one.
857    pub fn gap_patches(&self) -> Vec<GapPatch> {
858        let (nx, ny, _) = self.counts;
859        let dx = self.dx.to_si();
860        // A pair's axis and separation are recoverable from the two indices: the stride between
861        // them says which way it ran, and the multiple of that stride says how far.
862        let strides = [(0usize, 1usize), (1, nx), (2, nx * ny)];
863        let mut sheets: BTreeMap<(usize, usize), Vec<(usize, usize)>> = BTreeMap::new();
864        for (a, b, _) in &self.gaps {
865            let delta = b - a;
866            let Some(&(axis, stride)) = strides.iter().find(|(ax, st)| {
867                delta % st == 0
868                    && delta / st > 1
869                    && match ax {
870                        0 => delta < nx,
871                        1 => delta < nx * ny,
872                        _ => true,
873                    }
874            }) else {
875                continue;
876            };
877            // The **void** between them, not the distance between their centres. A view factor is
878            // a statement about two surfaces, and the surfaces are the faces bounding the gap: a
879            // one-cell clearance puts them one cell apart while their centres are two.
880            let cells = delta / stride - 1;
881            let (i, j, k) = (a % nx, (a / nx) % ny, a / (nx * ny));
882            // The two coordinates *across* the gap, which is what a patch is measured in.
883            let across = match axis {
884                0 => (j, k),
885                1 => (i, k),
886                _ => (i, j),
887            };
888            sheets.entry((axis, cells)).or_default().push(across);
889        }
890
891        let mut out = Vec::new();
892        for ((_, cells), mut face) in sheets {
893            face.sort_unstable();
894            let mut seen = vec![false; face.len()];
895            let index: BTreeMap<(usize, usize), usize> =
896                face.iter().enumerate().map(|(n, p)| (*p, n)).collect();
897            for start in 0..face.len() {
898                if seen[start] {
899                    continue;
900                }
901                // Four-connected flood fill over the facing cells, so a patch is a sheet somebody
902                // could point at rather than every pair that happens to share a width.
903                let mut stack = vec![start];
904                seen[start] = true;
905                let mut members = Vec::new();
906                while let Some(n) = stack.pop() {
907                    let (u, v) = face[n];
908                    members.push((u, v));
909                    for (du, dv) in [(1i64, 0i64), (-1, 0), (0, 1), (0, -1)] {
910                        let (nu, nv) = (u as i64 + du, v as i64 + dv);
911                        if nu < 0 || nv < 0 {
912                            continue;
913                        }
914                        if let Some(&m) = index.get(&(nu as usize, nv as usize)) {
915                            if !seen[m] {
916                                seen[m] = true;
917                                stack.push(m);
918                            }
919                        }
920                    }
921                }
922                let (u0, u1) = (
923                    members.iter().map(|m| m.0).min().unwrap_or(0),
924                    members.iter().map(|m| m.0).max().unwrap_or(0),
925                );
926                let (v0, v1) = (
927                    members.iter().map(|m| m.1).min().unwrap_or(0),
928                    members.iter().map(|m| m.1).max().unwrap_or(0),
929                );
930                let (wu, wv) = (u1 + 1 - u0, v1 + 1 - v0);
931                out.push(GapPatch {
932                    pairs: members.len(),
933                    span: (wu as f64 * dx, wv as f64 * dx),
934                    distance: cells as f64 * dx,
935                    rectangular: members.len() == wu * wv,
936                });
937            }
938        }
939        out
940    }
941
942    /// Mark cells as **nothing** — not a substance, not part of the block.
943    ///
944    /// A grid had no void until now, so the cells a part did not occupy were some other material,
945    /// and an assembly of two parts in air was two parts buried in whatever the block was made
946    /// of. `ARCHITECTURE.md` names it: "insulating it is a substance with a low conductivity,
947    /// which is not the same thing". A low conductivity still conducts, still stores heat, and
948    /// still sets a stability limit; nothing does none of those.
949    ///
950    /// A void cell holds no heat, conducts to nothing — every face it touches carries zero, which
951    /// the harmonic mean already gives for a zero conductivity — takes no share of what arrives
952    /// on the bus, and is left out of every average. Its temperature is **not a number**, because
953    /// there is nothing there to have one, and `temperature_at` says so rather than returning a
954    /// zero somebody would plot.
955    ///
956    /// # What crosses a gap, and what does not
957    ///
958    /// **Radiation does.** Two solid cells facing each other along a grid line across a run of
959    /// void exchange `σA(T₁⁴ − T₂⁴)/(1/ε₁ + 1/ε₂ − 1)`, the parallel-plate series, with an
960    /// exchange factor of **one**.
961    ///
962    /// That factor was written down here as a known approximation — a wide gap has a view factor
963    /// well under one, so charging it as one was said to couple a wide gap too hard. Measuring it
964    /// says otherwise, and the correction is worth more than the caveat was. The sides of a gap in
965    /// this model are the **block's own outer faces**, and an insulated boundary is implemented
966    /// as a mirror; a mirror puts an image of each surface beyond it and the images tile the
967    /// plane, so the pair *is* two infinite parallel plates and one is exact at every width. What
968    /// the old note described was a different geometry from the one the model has.
969    ///
970    /// The geometry it described is a real one, though — two parts floating in vacuum, open to
971    /// space, where most of what leaves one surface does miss the other. Which of the two a scene
972    /// means is a statement about its boundary that a grid cannot infer, so it is not chosen here.
973    /// It is *reported*: [`gap_patches`](Solid3D::gap_patches) groups a clearance into the sheets
974    /// a view factor is a statement about and [`GapPatch::view_factor`] gives the open-gap number,
975    /// so the difference between the two readings is a factor somebody can see. For a 32 mm square
976    /// part 16 mm under a lid it is **2.4x**.
977    ///
978    /// Still not here: a radiative-exchange solver. Side walls that are real material at a real
979    /// temperature do not radiate into the gap at all, and that is a domain rather than a boundary
980    /// condition.
981    ///
982    /// **Convection does not.** A gap full of air carries heat by moving that air, which needs a
983    /// Rayleigh number and a correlation, and a correlation is not a closed form. So a gap in
984    /// air is coupled *less* here than it really is, by however much the convection would have
985    /// carried — and for a millimetre-scale gap at modest temperatures that is the same order as
986    /// the radiation, so the answer is a lower bound rather than an estimate.
987    ///
988    /// **Conduction does not**, and that is right: there is nothing there to conduct through.
989    ///
990    /// It is still not a fluid. A part in air also loses heat to the room, and that is
991    /// [`Solid3D::losing_from`], which is about the block's **outer** faces.
992    pub fn empty(mut self, which: impl Fn(usize, usize, usize) -> bool) -> Solid3D {
993        let (nx, ny, nz) = self.counts;
994        for k in 0..nz {
995            for j in 0..ny {
996                for i in 0..nx {
997                    if which(i, j, k) {
998                        self.void[i + nx * (j + ny * k)] = true;
999                    }
1000                }
1001            }
1002        }
1003        self.resolve();
1004        self
1005    }
1006
1007    /// Whether this cell is void — nothing rather than a substance.
1008    pub fn is_void(&self, i: usize, j: usize, k: usize) -> bool {
1009        self.index(i, j, k).map(|c| self.void[c]).unwrap_or(false)
1010    }
1011
1012    /// How many cells hold nothing.
1013    pub fn void_cells(&self) -> usize {
1014        self.void.iter().filter(|v| **v).count()
1015    }
1016
1017    /// Expose a face to an environment, so the block can lose heat through it.
1018    ///
1019    /// **Until this existed a `Solid3D` was adiabatic on all six faces**, which means no
1020    /// three-dimensional thermal scene could reach a steady state: every one of them warmed
1021    /// for as long as it ran. That is honest for a pulse and useless for the question a
1022    /// designer actually asks — *what temperature does this run at* — which is the question a
1023    /// chip package, a magnet busbar, a motor and a factory cell are all asking.
1024    ///
1025    /// The loss is [`Environment::loss_from`]'s, convective **and** radiative, applied per
1026    /// boundary cell at that cell's own temperature and its own emissivity. Per cell rather
1027    /// than to a mean, because that is what makes a gradient: the middle of a face runs hotter
1028    /// than its edge, and a lumped loss says it does not.
1029    ///
1030    /// The environment's `area` is the **whole face's** area; each boundary cell is charged its
1031    /// share, `area / cells_on_the_face`. Stating the face's area rather than a cell's keeps
1032    /// the number a caller writes independent of the grid they chose, which is what lets the
1033    /// same scene be refined without becoming a different problem — the property
1034    /// `pantometry-world`'s `verify` sweep depends on.
1035    ///
1036    /// Exposing a face **tightens the stability limit**, because a cell that can also lose to
1037    /// air has more conductance leaving it. [`Solid3D::max_stable_dt`] accounts for it; a
1038    /// caller stepping by hand past the returned limit is refused as ever.
1039    pub fn losing_from(mut self, face: Face, environment: Environment) -> Solid3D {
1040        self.exposed.insert(face, environment);
1041        self.resolve();
1042        self
1043    }
1044
1045    /// Generate `watts` **spread evenly over the cells `where_` selects**, replacing whatever
1046    /// those cells generated before.
1047    ///
1048    /// The total is the watts given, not watts per cell: a die dissipating 50 W dissipates 50 W
1049    /// whether the grid gives it eight cells or eight thousand, so a scene's answer does not move
1050    /// when its grid refines. That is what makes a source stated this way survive `verify`'s
1051    /// resolution sweep, and it is the opposite of the choice a per-cell figure would force.
1052    ///
1053    /// Void cells are skipped and do not count toward the spread — nothing generates nothing — so
1054    /// a box drawn around a part and its clearance heats the part at the full rate rather than
1055    /// losing a share of it to the gap.
1056    ///
1057    /// Selecting no solid cell is not an error here, because a caller building an assembly cell by
1058    /// cell passes through that state; the watts simply have nowhere to go and none are generated.
1059    /// A *scene* refuses it, because a scene saying 50 W and meaning none is a different mistake.
1060    pub fn dissipating(
1061        mut self,
1062        watts: f64,
1063        where_: impl Fn(usize, usize, usize) -> bool,
1064    ) -> Solid3D {
1065        let (nx, ny, nz) = self.counts;
1066        let mut chosen = Vec::new();
1067        for k in 0..nz {
1068            for j in 0..ny {
1069                for i in 0..nx {
1070                    let c = i + nx * (j + ny * k);
1071                    if !self.void[c] && where_(i, j, k) {
1072                        chosen.push(c);
1073                    }
1074                }
1075            }
1076        }
1077        if chosen.is_empty() {
1078            return self;
1079        }
1080        let each = watts / chosen.len() as f64;
1081        for c in chosen {
1082            self.source[c] = each;
1083        }
1084        self.resolve();
1085        self
1086    }
1087
1088    /// How many **solid** cells a predicate selects.
1089    ///
1090    /// What a caller needs in order to refuse a source that would be generated nowhere: the block
1091    /// itself allows that state, because assembling cell by cell passes through it, and a scene
1092    /// does not.
1093    pub fn cells_on_where(&self, where_: &dyn Fn(usize, usize, usize) -> bool) -> usize {
1094        let (nx, ny, nz) = self.counts;
1095        let mut n = 0;
1096        for k in 0..nz {
1097            for j in 0..ny {
1098                for i in 0..nx {
1099                    if !self.void[i + nx * (j + ny * k)] && where_(i, j, k) {
1100                        n += 1;
1101                    }
1102                }
1103            }
1104        }
1105        n
1106    }
1107
1108    /// Whether anything in this block generates at all, so a block with no source pays nothing for
1109    /// the feature — which every scene written before it existed relies on.
1110    fn supplies(&self) -> bool {
1111        self.source.iter().any(|w| *w != 0.0)
1112    }
1113
1114    /// Total watts generated, summed over the cells.
1115    ///
1116    /// What a caller gets back is what it asked for, which is worth being able to check: a source
1117    /// spread over cells and then read back through a different route is exactly where a factor of
1118    /// the cell count hides.
1119    pub fn generated_power(&self) -> Power {
1120        Power::from_si(self.source.iter().sum())
1121    }
1122
1123    /// Joules generated over the run.
1124    pub fn generated_energy(&self) -> Energy {
1125        Energy::from_si(self.supplied)
1126    }
1127
1128    /// Heat given up to the exposed faces' environments over the run.
1129    pub fn lost_energy(&self) -> Energy {
1130        Energy::from_si(self.lost)
1131    }
1132
1133    /// Which faces are exposed, and to what.
1134    pub fn exposed_faces(&self) -> impl Iterator<Item = (Face, &Environment)> + '_ {
1135        self.exposed.iter().map(|(f, e)| (*f, e))
1136    }
1137
1138    /// The stability rate at the block's **present** state: conduction, plus each exposed
1139    /// cell's loss conductance at that cell's own temperature.
1140    ///
1141    /// State-dependent on purpose, which is what [`Domain::max_stable_dt`] means by "the
1142    /// largest step this domain can take **from `now`**". A block cooling from 1000 °C is
1143    /// stiffer at the start than at the end, and a limit that ignored that would be correct
1144    /// only at the end.
1145    ///
1146    /// Costs a pass over the boundary cells and nothing at all for an unexposed block, which is
1147    /// every scene that existed before this.
1148    fn worst_rate_now(&self) -> f64 {
1149        // Gaps as well as films: a pair of plates facing each other across void may have no
1150        // conducting face at all, so the radiative exchange is the *only* thing setting their
1151        // step. Returning early on `exposed` alone handed such a pair an infinite limit, and a
1152        // march at infinity is a NaN block reported as a substance with no diffusivity.
1153        if (self.exposed.is_empty() && self.gaps.is_empty()) || self.worst_rate.is_nan() {
1154            return self.worst_rate;
1155        }
1156        let (nx, ny, nz) = self.counts;
1157        let dx = self.dx.to_si();
1158        let mut worst = self.worst_rate;
1159        // A gap's radiative conductance, linearised at the **hotter** of the pair, which is the
1160        // conservative end: `dq/dT = 4σA T³/(1/ε₁+1/ε₂−1)` grows with temperature, so the hotter
1161        // cell's tangent bounds the pair's. Charged to both cells, because either could be the
1162        // one the step is too long for.
1163        for (a, b, coefficient) in &self.gaps {
1164            let hotter = self.cells[*a].max(self.cells[*b]);
1165            let g = 4.0 * coefficient * hotter.powi(3);
1166            for c in [*a, *b] {
1167                if self.capacity[c] > 0.0 {
1168                    worst = worst.max(g / self.capacity[c]);
1169                }
1170            }
1171        }
1172        for k in 0..nz {
1173            for j in 0..ny {
1174                for i in 0..nx {
1175                    let c = i + nx * (j + ny * k);
1176                    let loss = self.loss_conductance_at(self.temperature_at(i, j, k), (i, j, k));
1177                    if loss == 0.0 {
1178                        continue;
1179                    }
1180                    worst = worst.max(self.mobility[c] * (self.face_sum[c] + loss / dx));
1181                }
1182            }
1183        }
1184        worst
1185    }
1186
1187    /// The film's flux out of a cell, in the sweep's own units — a conductivity times a
1188    /// temperature difference, so that `mobility · this` is a rate in kelvin per second.
1189    ///
1190    /// The **secant** conductance of the bare surface, which is the exact one for this flux,
1191    /// put in series with the half cell of solid behind it. Positive means heat leaving.
1192    fn film_flux(&self, old: &[f64], cell: (usize, usize, usize), c: usize) -> f64 {
1193        let thermal = self.substance_at(cell.0, cell.1, cell.2).thermal;
1194        let emissivity = thermal.map_or(0.0, |t| t.emissivity);
1195        let conductivity = thermal.map_or(f64::INFINITY, |t| t.conductivity.to_si());
1196        let dx = self.dx.to_si();
1197        let here = Temperature::from_si(old[c]);
1198        let mut out = 0.0;
1199        for (face, env) in &self.exposed {
1200            if !face.holds(cell, self.counts) {
1201                continue;
1202            }
1203            let share = env.area.to_si() / self.cells_on(*face).max(1) as f64;
1204            let gap = here.to_si() - env.ambient.to_si();
1205            if gap == 0.0 {
1206                continue;
1207            }
1208            let bare = Environment {
1209                ambient: env.ambient,
1210                convection_w_per_m2_k: env.convection_w_per_m2_k,
1211                area: Area::from_si(share),
1212            }
1213            .loss_from(here, emissivity)
1214            .to_si();
1215            let g = series_with_half_cell(bare / gap, conductivity, share, dx);
1216            out += g * gap / dx;
1217        }
1218        out
1219    }
1220
1221    /// How many cells lie on a face.
1222    fn cells_on(&self, face: Face) -> usize {
1223        let (nx, ny, nz) = self.counts;
1224        match face {
1225            Face::XMin | Face::XMax => ny * nz,
1226            Face::YMin | Face::YMax => nx * nz,
1227            Face::ZMin | Face::ZMax => nx * ny,
1228        }
1229    }
1230
1231    /// The loss conductance an exposed cell carries, in W/K, summed over the faces it lies on.
1232    ///
1233    /// Evaluated at the temperature handed in, and the callers hand in **the cell's own current
1234    /// temperature** rather than the ambient. That is not a refinement, it is the difference
1235    /// between a limit that holds and one that does not: the radiative conductance is `4εσT³`
1236    /// and a part at 1000 °C carries **26 times** what the same surface carries at room
1237    /// temperature — measured, `ε = 0.9`, 136 against 5.14 W/m²·K. A limit linearised about
1238    /// ambient would hand a hot block a step an order too long, and an explicit boundary past
1239    /// its limit does not diverge loudly: it oscillates about the air it is losing to while the
1240    /// conservation audit stays perfectly happy, because the heat really did leave.
1241    ///
1242    /// Two things make it conservative rather than merely plausible, and each was measured
1243    /// wrong first.
1244    ///
1245    /// The **derivative** `4εσT³` rather than the secant `q/(T−T∞)`, because `T⁴` is convex and
1246    /// the derivative is the larger of the two — four times it in the hot limit.
1247    ///
1248    /// And at `max(T_cell, T_ambient)`, because the derivative only dominates the secant for
1249    /// `T ≥ T∞`. Below ambient it reverses without bound, and the first version of this used
1250    /// the cell alone: a 20 mm cell at 20 °C inside a 700 °C radiant enclosure was handed a
1251    /// step whose true ratio was **13**, and one accepted step took it to 8847 °C. `4T∞³`
1252    /// bounds `(T+T∞)(T²+T∞²)` for every `T ≤ T∞`, so the larger of the two points is safe on
1253    /// both sides.
1254    ///
1255    /// The film is put **in series with the half cell of solid between the cell centre and the
1256    /// surface**, `2k·A/dx`. A finite-volume cell knows only its centre and the film acts at the
1257    /// surface, so the half cell between them is part of the path; charging the whole film
1258    /// against the centre sheds too much, by the cell Biot number `h·dx/2k`.
1259    ///
1260    /// **And it took two corrections to reach second order, not one.** The series form fixes
1261    /// the *space*: without it a review measured 2.08, 2.04, 2.02 per grid doubling against the
1262    /// interior's 4.0 — the signature this workspace has twice caught in an acoustic wall. With
1263    /// it the boundary was still first order, at 1.27, 1.71, 1.87, because the film was applied
1264    /// as a pass *after* the conduction sweep. That split's error carries a coefficient growing
1265    /// as `1/dx` while the step falls as `dx²`, so their product falls as `dx`. Applying the
1266    /// film inside the same explicit update — see `film_flux`, called from the sweep — makes
1267    /// them one operator, and `tests/the_cooled_boundary_order.rs` measures four per doubling at
1268    /// `Bi = 1.7` and at `Bi = 17`.
1269    fn loss_conductance_at(&self, at: Temperature, cell: (usize, usize, usize)) -> f64 {
1270        self.exposed
1271            .iter()
1272            .filter(|(face, _)| face.holds(cell, self.counts))
1273            .map(|(face, env)| {
1274                let share = env.area.to_si() / self.cells_on(*face).max(1) as f64;
1275                let thermal = self.substance_at(cell.0, cell.1, cell.2).thermal;
1276                let emissivity = thermal.map_or(0.0, |t| t.emissivity);
1277                let hot = at.to_si().max(env.ambient.to_si());
1278                let h = env.convection_w_per_m2_k
1279                    + 4.0 * emissivity * STEFAN_BOLTZMANN.to_si() * hot.powi(3);
1280                series_with_half_cell(
1281                    h * share,
1282                    thermal.map_or(f64::INFINITY, |t| t.conductivity.to_si()),
1283                    share,
1284                    self.dx.to_si(),
1285                )
1286            })
1287            .sum()
1288    }
1289
1290    /// How many cells along each axis.
1291    pub fn counts(&self) -> (usize, usize, usize) {
1292        self.counts
1293    }
1294
1295    /// The cell side.
1296    pub fn spacing(&self) -> Length {
1297        self.dx
1298    }
1299
1300    /// The block's extent, which is `counts × dx` — the outer faces, not the cell centres.
1301    pub fn size(&self) -> LengthVec {
1302        let (nx, ny, nz) = self.counts;
1303        LengthVec::from_si(DVec3::new(nx as f64, ny as f64, nz as f64) * self.dx.to_si())
1304    }
1305
1306    /// The flat index of a cell, or `None` if any component is out of range.
1307    ///
1308    /// Returned rather than panicking because the natural way to write a stencil is to ask for a
1309    /// neighbour that may not exist, and a boundary is exactly where that happens.
1310    pub fn index(&self, i: usize, j: usize, k: usize) -> Option<usize> {
1311        let (nx, ny, nz) = self.counts;
1312        (i < nx && j < ny && k < nz).then(|| i + nx * (j + ny * k))
1313    }
1314
1315    /// Where the centre of cell `(i, j, k)` is, in the block's own coordinates.
1316    pub fn centre_of(&self, i: usize, j: usize, k: usize) -> LengthVec {
1317        LengthVec::from_si(
1318            DVec3::new(i as f64 + 0.5, j as f64 + 0.5, k as f64 + 0.5) * self.dx.to_si(),
1319        )
1320    }
1321
1322    /// The temperature of one cell. Out of range reads the nearest one in range.
1323    pub fn temperature_at(&self, i: usize, j: usize, k: usize) -> Temperature {
1324        let (nx, ny, nz) = self.counts;
1325        let idx = self
1326            .index(i.min(nx - 1), j.min(ny - 1), k.min(nz - 1))
1327            .expect("clamped indices are in range");
1328        // **Not a number where there is nothing.** A void cell has no temperature, and a zero
1329        // or an ambient here is a value somebody would plot, average or believe. Every reader in
1330        // this workspace that draws a field already skips a non-finite sample.
1331        if self.void[idx] {
1332            return Temperature::from_si(f64::NAN);
1333        }
1334        Temperature::from_si(self.cells[idx])
1335    }
1336
1337    /// Set one cell, for an initial condition a constructor cannot express.
1338    ///
1339    /// **This does not change what the block has absorbed.** It is a statement about the initial
1340    /// state, not a delivery of heat, so `stored_heat` moves and `absorbed_energy` does not — and
1341    /// a simulation started this way and then audited will show the difference as its opening
1342    /// balance rather than as a leak. Use [`deposit`](Solid3D::deposit) for heat that arrived.
1343    ///
1344    /// Out of range is ignored rather than a panic: a caller writing a hot spot in a loop over a
1345    /// radius is the expected use, and clipping at the edge is what they mean.
1346    pub fn set_temperature(&mut self, i: usize, j: usize, k: usize, t: Temperature) {
1347        if let Some(idx) = self.index(i, j, k) {
1348            self.cells[idx] = t.to_si();
1349            // A temperature is not a state for a substance that melts — 0 °C is ice, water, or any
1350            // mixture of the two. Naming a temperature therefore names a phase as well, and the
1351            // choice is the unmelted one at the point itself: a caller clamping a face below
1352            // freezing means ice, and a caller clamping it above means liquid.
1353            if self.latent[idx] > 0.0 {
1354                self.melted[idx] = if t.to_si() > self.melt_point[idx] {
1355                    1.0
1356                } else {
1357                    0.0
1358                };
1359            }
1360        }
1361    }
1362
1363    /// Put joules into one cell, as heat that arrived there.
1364    ///
1365    /// Counts toward [`absorbed_energy`](Solid3D::absorbed_energy), so the books balance. Out of
1366    /// range is ignored, which would silently lose energy — so it does not: the joules are
1367    /// refused, and nothing is added to either total.
1368    pub fn deposit(&mut self, i: usize, j: usize, k: usize, joules: Energy) {
1369        let Some(idx) = self.index(i, j, k) else {
1370            return;
1371        };
1372        let rise = joules.to_si() / self.capacity[idx];
1373        self.add_kelvin(idx, rise);
1374        self.absorbed += joules.to_si();
1375    }
1376
1377    /// Mean over every cell. Every cell has the same volume, so this is the volume average.
1378    pub fn mean_temperature(&self) -> Temperature {
1379        // Over what is there. Averaging in the void's placeholder would drag the number toward
1380        // a value nothing holds, and the more of the box is empty the further it would drag.
1381        let (sum, n) = (0..self.cells.len())
1382            .filter(|c| !self.void[*c])
1383            .fold((0.0, 0usize), |(s, n), c| (s + self.cells[c], n + 1));
1384        Temperature::from_si(if n == 0 { f64::NAN } else { sum / n as f64 })
1385    }
1386
1387    /// The hottest cell — the number a hot spot exists to produce, and the one a lumped model
1388    /// reports as the mean.
1389    pub fn peak_temperature(&self) -> Temperature {
1390        Temperature::from_si(
1391            (0..self.cells.len())
1392                .filter(|c| !self.void[*c])
1393                .map(|c| self.cells[c])
1394                .fold(f64::MIN, f64::max),
1395        )
1396    }
1397
1398    /// The coldest cell.
1399    pub fn coldest_temperature(&self) -> Temperature {
1400        Temperature::from_si(
1401            (0..self.cells.len())
1402                .filter(|c| !self.void[*c])
1403                .map(|c| self.cells[c])
1404                .fold(f64::MAX, f64::min),
1405        )
1406    }
1407
1408    /// Heat taken from the bus over the run.
1409    pub fn absorbed_energy(&self) -> Energy {
1410        Energy::from_si(self.absorbed)
1411    }
1412
1413    /// `α·dt/dx²` for the constructor's substance — the classic Fourier number.
1414    ///
1415    /// This is the quantity [`mode_amplification`](Solid3D::mode_amplification) is written in terms
1416    /// of, so it stays the textbook one and does not become shape- or fill-aware. For a block of
1417    /// one material with at least two cells on every axis it *is* the stability number, and
1418    /// `fourier_number(max_stable_dt) == 1/6` exactly.
1419    ///
1420    /// It is **not** the stability number for a thin block or a filled one, and those are the two
1421    /// cases where a caller sizing a step by hand needs the other one — see
1422    /// [`stability_ratio`](Solid3D::stability_ratio), which is what `step` actually enforces.
1423    pub fn fourier_number(&self, dt: Time) -> f64 {
1424        let Some(alpha) = self.materials[0].diffusivity() else {
1425            return f64::INFINITY;
1426        };
1427        alpha.to_si() * dt.to_si() / (self.dx.to_si() * self.dx.to_si())
1428    }
1429
1430    /// `dt` as a fraction of the largest step this block is stable at — exactly one at the limit.
1431    ///
1432    /// The number `step` refuses on, and the honest one for a block that is thin or filled. It is
1433    /// `dt · maxᵢ (Σ_f k_f / Cᵢ) · dx`: a maximum over **cells**, because stability is a statement
1434    /// about a row of the update matrix and each cell has its own.
1435    pub fn stability_ratio(&self, dt: Time) -> f64 {
1436        dt.to_si() * self.worst_rate_now()
1437    }
1438
1439    /// The heat capacity of the whole block, which for a filled one is a sum and not a product.
1440    pub fn heat_capacity(&self) -> HeatCapacity {
1441        HeatCapacity::from_si(self.capacity.iter().sum())
1442    }
1443
1444    /// The exact per-step amplification of one separable cosine mode.
1445    ///
1446    /// `(a, b, c)` are half-wave counts along the three axes: `(1, 0, 0)` is the longest mode
1447    /// along x with the other two flat. Returns the factor the mode's amplitude is multiplied by
1448    /// in one step of `dt`, which is `1 + F·Σ(−4 sin²(mπ/2n))` — **exact**, not a linearisation,
1449    /// because that mode is an eigenvector of the discrete operator this domain steps with.
1450    ///
1451    /// Public because it is what makes this domain checkable without a reference implementation:
1452    /// a caller can predict an amplitude arbitrarily far ahead and compare. It is also what a
1453    /// grid designer wants, since a factor outside `(−1, 1]` is the instability itself.
1454    pub fn mode_amplification(&self, mode: (usize, usize, usize), dt: Time) -> f64 {
1455        let f = self.fourier_number(dt);
1456        1.0 + f * self.mode_eigenvalue_dx2(mode)
1457    }
1458
1459    /// The discrete Laplacian eigenvalue of a mode, times `dx²` — dimensionless, in `[-12, 0]`.
1460    fn mode_eigenvalue_dx2(&self, mode: (usize, usize, usize)) -> f64 {
1461        let (nx, ny, nz) = self.counts;
1462        let term = |m: usize, n: usize| {
1463            if n <= 1 {
1464                // One cell across an axis is a mirror against itself: no gradient is
1465                // representable, so that direction contributes nothing at all.
1466                return 0.0;
1467            }
1468            let s = (m as f64 * std::f64::consts::PI / (2.0 * n as f64)).sin();
1469            -4.0 * s * s
1470        };
1471        term(mode.0, nx) + term(mode.1, ny) + term(mode.2, nz)
1472    }
1473
1474    /// Fill the block with one separable cosine mode about a mean.
1475    ///
1476    /// The initial condition the closed-form tests use, and a genuinely useful one for anybody
1477    /// checking a grid: it is the only shape whose future this domain can state exactly.
1478    pub fn release_mode(&mut self, mode: (usize, usize, usize), mean: Temperature, amplitude: f64) {
1479        let (nx, ny, nz) = self.counts;
1480        let phase = |m: usize, n: usize, i: usize| {
1481            if n <= 1 {
1482                1.0
1483            } else {
1484                (m as f64 * std::f64::consts::PI * (i as f64 + 0.5) / n as f64).cos()
1485            }
1486        };
1487        for k in 0..nz {
1488            for j in 0..ny {
1489                for i in 0..nx {
1490                    let idx = i + nx * (j + ny * k);
1491                    self.cells[idx] = mean.to_si()
1492                        + amplitude
1493                            * phase(mode.0, nx, i)
1494                            * phase(mode.1, ny, j)
1495                            * phase(mode.2, nz, k);
1496                }
1497            }
1498        }
1499        self.saved.clone_from(&self.cells);
1500    }
1501
1502    /// The amplitude of one mode currently present, by projection.
1503    ///
1504    /// The counterpart to [`release_mode`](Solid3D::release_mode), and what makes a decay
1505    /// measurable rather than merely visible. Cosine modes on this grid are orthogonal, so this
1506    /// is exact for a block holding one of them and is the correct coefficient for a block
1507    /// holding several.
1508    pub fn mode_amplitude(&self, mode: (usize, usize, usize)) -> f64 {
1509        let (nx, ny, nz) = self.counts;
1510        let phase = |m: usize, n: usize, i: usize| {
1511            if n <= 1 {
1512                1.0
1513            } else {
1514                (m as f64 * std::f64::consts::PI * (i as f64 + 0.5) / n as f64).cos()
1515            }
1516        };
1517        // ⟨cos²⟩ is 1/2 per axis that actually varies, and 1 for an axis that cannot.
1518        let norm = [(mode.0, nx), (mode.1, ny), (mode.2, nz)]
1519            .iter()
1520            .map(|&(m, n)| if n <= 1 || m == 0 { 1.0 } else { 0.5 })
1521            .product::<f64>();
1522        let mut sum = 0.0;
1523        for k in 0..nz {
1524            for j in 0..ny {
1525                for i in 0..nx {
1526                    let idx = i + nx * (j + ny * k);
1527                    sum += self.cells[idx]
1528                        * phase(mode.0, nx, i)
1529                        * phase(mode.1, ny, j)
1530                        * phase(mode.2, nz, k);
1531                }
1532            }
1533        }
1534        sum / (self.cells.len() as f64 * norm)
1535    }
1536
1537    /// The volume of the whole block.
1538    pub fn volume(&self) -> Volume {
1539        let (nx, ny, nz) = self.counts;
1540        Volume::from_si((nx * ny * nz) as f64 * self.cell_volume())
1541    }
1542
1543    fn cell_volume(&self) -> f64 {
1544        let dx = self.dx.to_si();
1545        dx * dx * dx
1546    }
1547
1548    /// Heat held, measured from the state the block started in.
1549    ///
1550    /// Weighted cell by cell, so a filled block's books balance for the same reason a uniform
1551    /// one's do: the sweep moves `G_f·ΔT` across a face and takes it off one side and puts it on
1552    /// the other, and this is the sum that is therefore constant.
1553    ///
1554    /// **In enthalpy, not in temperature**, so a melting front is on the books. A cell holding at its
1555    /// melting point while it absorbs 306 mJ per cubic millimetre has taken that heat in and its
1556    /// temperature says nothing about it; an audit reading temperature alone would call it a leak.
1557    fn stored_heat(&self) -> f64 {
1558        (0..self.cells.len())
1559            .map(|c| self.enthalpy_joules(c) - self.reference_enthalpy_joules(c))
1560            .sum()
1561    }
1562
1563    /// The cell's state as one number, in kelvin above its melting point.
1564    ///
1565    /// Monotone and invertible, which is the whole reason the phase change needs no iteration and
1566    /// conserves exactly:
1567    ///
1568    /// ```text
1569    ///   solid    e = T − T_m ≤ 0
1570    ///   mush     e = φ·ℓ ∈ [0, ℓ]      at T = T_m
1571    ///   liquid   e = ℓ + T − T_m ≥ ℓ
1572    /// ```
1573    ///
1574    /// This is the enthalpy method, bookkept as a temperature and a fraction rather than as a single
1575    /// enthalpy field — identical arithmetic, and it keeps `cells` holding kelvin so everything that
1576    /// reads a temperature still can.
1577    ///
1578    /// It is **not** the apparent-heat-capacity method, and the difference is the failure that method
1579    /// has: smearing `L` over a narrow temperature interval lets a cell cross the whole interval in
1580    /// one step and skip the latent heat, which runs the front fast and conserves nothing. Here a
1581    /// step that overshoots the mush deposits the remainder as sensible heat on the far side, because
1582    /// the inverse map says where the energy goes rather than a branch guessing.
1583    fn enthalpy_joules(&self, c: usize) -> f64 {
1584        let (t, point, latent) = (self.cells[c], self.melt_point[c], self.latent[c]);
1585        if latent <= 0.0 {
1586            // No phase change. Measured from the block's own reference rather than from a melting
1587            // point at infinity, and that choice is precision: subtracting a nearby number keeps
1588            // digits that subtracting 273.15 from 293.15 does not.
1589            return self.capacity[c] * (t - self.reference);
1590        }
1591        if self.melted[c] >= 1.0 {
1592            latent + self.cap_liquid[c] * (t - point)
1593        } else if self.melted[c] <= 0.0 {
1594            self.cap_solid[c] * (t - point)
1595        } else {
1596            self.melted[c] * latent
1597        }
1598    }
1599
1600    /// What [`enthalpy_kelvin`](Solid3D::enthalpy_kelvin) was when the block started, so the ledger
1601    /// reports what arrived rather than what is there.
1602    fn reference_enthalpy_joules(&self, c: usize) -> f64 {
1603        let (point, latent) = (self.melt_point[c], self.latent[c]);
1604        if latent <= 0.0 {
1605            return 0.0;
1606        }
1607        if self.reference > point {
1608            latent + self.cap_liquid[c] * (self.reference - point)
1609        } else {
1610            self.cap_solid[c] * (self.reference - point)
1611        }
1612    }
1613
1614    /// Add `rise` kelvin of *enthalpy* to a cell, which is not the same as adding kelvin to it.
1615    ///
1616    /// The one place heat becomes state, so that a phase change cannot be forgotten at one of the
1617    /// several doors heat comes in through — the sweep, [`deposit`](Solid3D::deposit), and the plain
1618    /// channel all pass through here. A cell at its melting point takes the whole of it as melting
1619    /// and does not warm at all.
1620    fn add_kelvin(&mut self, c: usize, rise: f64) {
1621        if self.latent[c] > 0.0 {
1622            // `rise` is what the cell *would* have warmed by; the joules it stands for are that times
1623            // whichever capacity the cell has now, which is the mixed one and is exactly right for a
1624            // cell that is wholly one phase. A mushy cell does not change temperature, so the flux
1625            // that reaches it has to be converted through the capacity it had when the flux was
1626            // computed — the same one.
1627            let joules = rise * self.capacity[c];
1628            let e = self.enthalpy_joules(c) + joules;
1629            let (t, phi) = self.state_from_enthalpy(c, e);
1630            self.cells[c] = t;
1631            self.melted[c] = phi;
1632        } else {
1633            self.cells[c] += rise;
1634        }
1635    }
1636
1637    /// The inverse: a state in kelvin back to a temperature and a melted fraction.
1638    fn state_from_enthalpy(&self, c: usize, e: f64) -> (f64, f64) {
1639        let (point, latent) = (self.melt_point[c], self.latent[c]);
1640        if e <= 0.0 {
1641            (point + e / self.cap_solid[c], 0.0)
1642        } else if e >= latent {
1643            (point + (e - latent) / self.cap_liquid[c], 1.0)
1644        } else {
1645            (point, e / latent)
1646        }
1647    }
1648}
1649
1650impl Domain for Solid3D {
1651    fn books_balance(&self) -> bool {
1652        true
1653    }
1654
1655    fn name(&self) -> &str {
1656        &self.name
1657    }
1658
1659    /// `minᵢ Cᵢ / (dx · Σ_f k_f)` — the tightest row of the update matrix, and `dx²/(6α)` when the
1660    /// block is one material with at least two cells on every axis.
1661    ///
1662    /// # Why a maximum over cells, and what summing the faces buys
1663    ///
1664    /// Stability is Gershgorin's condition on one row: the update is `I + dt·D⁻¹L`, row `i` has
1665    /// diagonal `−θᵢ` and off-diagonals summing to `+θᵢ` for `θᵢ = dt Σ_f G_f / Cᵢ`, so every
1666    /// eigenvalue is in `[1 − 2θ_max, 1]` and `θ_max ≤ 1` is the limit.
1667    ///
1668    /// On a uniform grid that disc is **tight** rather than cautious — the sharpest representable
1669    /// mode saturates it — and this is where `1/2`, `1/4` and `1/6` come from. They are not three
1670    /// dimensions; they are however many axes have more than one cell, and the block is stepped at
1671    /// whichever applies. This used to report `dx²/(6α)` for every shape, which cost a bar-shaped
1672    /// block three times the steps for nothing.
1673    ///
1674    /// On a filled grid the value is the opposite one: the limit is usually far **looser** than
1675    /// `dx²/(6·α_max)`, because `k_f ≤ 2·min(k_L, k_R)` means a cell cannot be heated through a
1676    /// face faster than its worse side allows. Heat does not reach a fast material at that
1677    /// material's own rate; it reaches it at the rate the neighbour delivers. Measured on one
1678    /// aluminium cell embedded in borosilicate, the honest limit is **75×** the one aluminium's
1679    /// diffusivity would name, and that factor is wall-clock.
1680    ///
1681    /// It can in principle go the other way — up to a factor of two, when a cell's neighbours
1682    /// conduct better *and* store more — but that needs volumetric heat capacity to vary as widely
1683    /// as conductivity, and across solids it varies by one order of magnitude where conductivity
1684    /// varies by four. So the tightening is a bound this catalogue cannot reach, and the loosening
1685    /// is a saving any coating or inclusion gets.
1686    ///
1687    /// # A third of the bar's, for a cube
1688    ///
1689    /// A third of what [`Bar1D`](crate::Bar1D) reports for the same spacing and material. That
1690    /// factor is the reason `Schedule::Multirate` exists: a block and a lumped mass in one scene
1691    /// differ by five orders of magnitude in the step they can take, and a single global step
1692    /// would make the cheap domain pay the expensive one's bill.
1693    ///
1694    /// # Stable is not accurate, and this is only the first
1695    ///
1696    /// At **exactly** this step the sharpest mode the grid can hold has an amplification factor of
1697    /// `−1`. It flips sign every step and never decays. That is what marginal stability means and
1698    /// it is not a defect — the scheme does not diverge there, which is the whole of what a
1699    /// stability limit claims.
1700    ///
1701    /// It does mean sharp initial data is carried badly. A point source excites that mode as hard
1702    /// as anything can, and the peak comes out **1.96×** the exact answer while the conservation
1703    /// audit stays exact to the last bit. At half this step it is 1.005×.
1704    ///
1705    /// So take a fraction of it when the initial condition is sharp. `Schedule::Multirate` divides
1706    /// by `ceil(dt / limit)` and so usually lands comfortably inside, but a caller stepping by
1707    /// hand can sit exactly on it. `cargo run --example heat_in_three_dimensions` is the
1708    /// measurement.
1709    fn max_stable_dt(&self, _now: Time) -> Time {
1710        let rate = self.worst_rate_now();
1711        if rate.is_nan() {
1712            return Time::from_si(f64::INFINITY);
1713        }
1714        Time::from_si(1.0 / rate)
1715    }
1716
1717    fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
1718        let ratio = self.stability_ratio(dt);
1719        if ratio.is_nan() {
1720            return Err(Violation::at(
1721                &self.name,
1722                "substance has no diffusivity",
1723                f64::INFINITY,
1724            ));
1725        }
1726        // Reported in Fourier-number units, which is `dt/(6·max_stable_dt)` — the classic
1727        // `α·dt/dx²` for a block of one material with two cells on every axis, and the number the
1728        // limit is expressed in for every other block.
1729        if ratio > 1.0 + 6e-12 {
1730            return Err(Violation {
1731                quantity: "Fourier number".to_string(),
1732                site: format!("{} (explicit 3D conduction)", self.name),
1733                before: STABLE_FOURIER_3D,
1734                after: ratio * STABLE_FOURIER_3D,
1735                scale: STABLE_FOURIER_3D,
1736                tolerance: 1e-12,
1737            });
1738        }
1739
1740        // Heat off the plain channel, which carries an amount and no location.
1741        //
1742        // **Spread evenly**, and this is the one place where the 3D domain must not copy the 1D
1743        // one. `Bar1D` puts placeless heat in its first cell, and that is defensible for a bar,
1744        // which has an end that a surface absorbing light would plausibly be. A block has six
1745        // faces and no distinguished cell, so choosing one would invent a location the bus never
1746        // carried — and a hot spot that came from a tie-break is worse than no hot spot, because
1747        // it looks like physics. Even spreading is the unique choice that adds no information.
1748        // Heat that *does* have a place arrives through `deposit` or over an `Interface`.
1749        // Spread to a uniform **rise**, which for a filled block means in proportion to each cell's
1750        // capacity rather than in equal joules. Equal joules would warm the low-capacity material
1751        // more and so would say where the heat landed, which the bus never carried.
1752        let gained = bus.take_share(HEAT, dt);
1753        if gained != 0.0 {
1754            self.absorbed += gained;
1755            let rise = gained / self.capacity.iter().sum::<f64>();
1756            for c in 0..self.cells.len() {
1757                // Void takes no share: it has no capacity to contribute to the sum above and no
1758                // temperature to raise. Warming it would put joules where there is nothing to
1759                // hold them, and the ledger would then disagree with the block.
1760                if self.void[c] {
1761                    continue;
1762                }
1763                self.add_kelvin(c, rise);
1764            }
1765        }
1766
1767        // The seven-point stencil in conductance form: `Cᵢ ΔTᵢ = dt Σ_f G_f (T_f − Tᵢ)`.
1768        //
1769        // It is the **face flux** that is computed, and each face is read once from each side with
1770        // opposite sign, so `Σ Cᵢ Tᵢ` is conserved to the last bit rather than to a tolerance —
1771        // and it stays that way when the capacities differ, which a stencil written as
1772        // `f·(Σ T_n − 6T)` cannot do because there is no per-cell `f` in it.
1773        let (nx, ny, nz) = self.counts;
1774        let old = self.cells.clone();
1775        let dts = dt.to_si();
1776        for k in 0..nz {
1777            for j in 0..ny {
1778                for i in 0..nx {
1779                    let c = i + nx * (j + ny * k);
1780                    let t = old[c];
1781                    let (xr, yr) = ((nx + 1) * (j + ny * k), nx * (j + (ny + 1) * k));
1782                    let mut flux = 0.0;
1783                    if i > 0 {
1784                        flux += self.kx[i + xr] * (old[c - 1] - t);
1785                    }
1786                    if i + 1 < nx {
1787                        flux += self.kx[i + 1 + xr] * (old[c + 1] - t);
1788                    }
1789                    if j > 0 {
1790                        flux += self.ky[i + yr] * (old[c - nx] - t);
1791                    }
1792                    if j + 1 < ny {
1793                        flux += self.ky[i + nx + yr] * (old[c + nx] - t);
1794                    }
1795                    if k > 0 {
1796                        flux += self.kz[c] * (old[c - nx * ny] - t);
1797                    }
1798                    if k + 1 < nz {
1799                        flux += self.kz[c + nx * ny] * (old[c + nx * ny] - t);
1800                    }
1801                    debug_assert_eq!(t, self.cells[c], "the sweep reads `old` and writes `cells`");
1802
1803                    // **The film is part of this flux, not a pass after it.** Applying it
1804                    // separately is Lie splitting, and the split's error carries a coefficient
1805                    // that grows as `1/dx` while the step falls as `dx²` — so the product falls
1806                    // as `dx`, and the boundary came out **first order** while the interior was
1807                    // second. Measured before this line moved here: ratios 1.27, 1.71, 1.87 per
1808                    // grid doubling, approaching two rather than four. In the same update they
1809                    // are one operator and the order is the interior's.
1810                    //
1811                    // Read off `old` like every other term, so the sweep stays a function of the
1812                    // state it began with.
1813                    let shed = if self.exposed.is_empty() {
1814                        0.0
1815                    } else {
1816                        self.film_flux(&old, (i, j, k), c)
1817                    };
1818                    self.add_kelvin(c, dts * self.mobility[c] * (flux - shed));
1819                    // What that removed, in joules, counted in the same statement that removes
1820                    // it so `stored + lost` stays exact.
1821                    if shed != 0.0 {
1822                        self.lost += dts * self.mobility[c] * shed * self.capacity[c];
1823                    }
1824                }
1825            }
1826        }
1827
1828        // **What the gaps carry.** A pair of solid cells facing each other across void exchange
1829        // radiation, and it is applied here — in the same explicit pass as the conduction sweep,
1830        // read off the same `old` state — for the reason the surface film moved into the sweep:
1831        // a separate pass is Lie splitting, whose error carries a coefficient growing as `1/dx`
1832        // against a step falling as `dx²`, and the boundary comes out an order worse than the
1833        // interior.
1834        //
1835        // **Antisymmetric**, so the pair conserves exactly: what leaves one arrives in the
1836        // other, in the same statement, and `Σ Cᵢ Tᵢ` is unchanged to the last bit as it is for
1837        // a conduction face.
1838        // **Heat that does have a place**, added after the sweep rather than before it.
1839        //
1840        // The distinction is not cosmetic and this learned it by measuring. A forward step is
1841        // `T′ = T + (dt/C)(P + F(T))`: the source and the conduction are both evaluated at the
1842        // state the step *began* with. The first version applied the source to `cells` before
1843        // `old` was taken, so the stencil saw the generated joules and conducted a share of them
1844        // away inside the same step — a share equal to `G·dt/C`, which for an explicit sweep at
1845        // its own stability limit is about a half. **The steady state then depended on the
1846        // timestep**, which is the failure that matters: a bar generating at one end and cooled at
1847        // the other dropped `P·dx/(kA)` across every gap except the first, and exactly half of it
1848        // across that one.
1849        //
1850        // Splitting it costs nothing in order, unlike the film and the gap exchange above. Those
1851        // are fluxes proportional to `T`, so applying them separately is Lie splitting with a real
1852        // error; a constant source commutes with everything and `dT/dt = S` is solved exactly by
1853        // adding `S·dt`.
1854        if self.supplies() {
1855            let dts = dt.to_si();
1856            for c in 0..self.cells.len() {
1857                if self.source[c] == 0.0 || self.void[c] {
1858                    continue;
1859                }
1860                let joules = self.source[c] * dts;
1861                self.add_kelvin(c, joules / self.capacity[c]);
1862                self.supplied += joules;
1863            }
1864        }
1865
1866        if !self.gaps.is_empty() {
1867            let dts = dt.to_si();
1868            for (a, b, coefficient) in self.gaps.clone() {
1869                let (ta, tb) = (old[a], old[b]);
1870                let watts = coefficient * (ta.powi(4) - tb.powi(4));
1871                if watts == 0.0 {
1872                    continue;
1873                }
1874                let joules = watts * dts;
1875                self.add_kelvin(a, -joules / self.capacity[a]);
1876                self.add_kelvin(b, joules / self.capacity[b]);
1877            }
1878        }
1879
1880        // **A two-phase block re-derives its conductivities, because they moved.**
1881        //
1882        // A cell's `k` and `c` depend on how much of it has melted, so a front that advanced changed
1883        // the operator. `resolve` carries the fractions the sweep just produced rather than rebuilding
1884        // them from temperature, and recomputes the faces, the capacities and the limit from them.
1885        //
1886        // Only when there is a liquid phase to mix toward: `latent > 0` alone is the one-phase model,
1887        // whose properties do not depend on the fraction at all, and paying for a rebuild there would
1888        // slow every non-melting block for nothing.
1889        //
1890        // Measured, so the cost is on the record rather than assumed: a `resolve` is 1.6x a `step` at
1891        // 40 cells and 4.2x at 4096, so a two-phase sweep runs 2.6x to 5.2x a one-phase one. That is
1892        // the price of the simple version. An incremental update touching only the mush — one or two
1893        // cells wide — is the optimisation available if a problem ever needs it, and none does yet.
1894        // The film is applied inside the sweep above, in the same explicit update as
1895        // conduction — see . It used to run here, as a pass afterwards, and that
1896        // split cost the boundary an order.
1897        if self.two_phase {
1898            self.resolve();
1899        }
1900        Ok(())
1901    }
1902
1903    /// Heat gained since the start. The faces are insulated, so this is exactly what came in.
1904    fn ledger(&self) -> Ledger {
1905        // `stored + lost`, so the total moves only by what crossed the bus and the claim in
1906        // `books_balance` survives a block that sheds heat to air. `LumpedMass` carries the
1907        // same pair for the same reason; what differs is that this one keeps the claim,
1908        // because every joule it sheds is counted here in the same step it leaves a cell.
1909        // `stored + lost − supplied`. A source is energy entering from outside the domain, so it
1910        // is subtracted here for the same reason `lost` is added: the ledger is what the *bus*
1911        // moved, and a joule this block generated for itself never crossed it. Without the term a
1912        // dissipating block's books grow by its own output every step and the audit stops the run.
1913        // Three contributions rather than their sum, and the difference is the whole reason
1914        // `Ledger` records a scale. `add` raises that scale to the largest single entry, and the
1915        // audit judges a change against it — which is what makes a relative tolerance mean
1916        // anything when the net is near zero.
1917        //
1918        // Adding them here first threw that away. A block that starts at its own reference
1919        // temperature stores nothing, so a scene stating a source opened its books at **exactly
1920        // zero**, and the first 3.7e-11 J of rounding was judged a 100% change: the audit stopped
1921        // a correct run on its first step. The sum is the same; the scale is now the size of the
1922        // numbers the rounding actually lives on.
1923        Ledger::new()
1924            .with(quantity::ENERGY, self.stored_heat())
1925            .with(quantity::ENERGY, self.lost)
1926            .with(quantity::ENERGY, -self.supplied)
1927    }
1928
1929    /// The temperatures **and** the phase, because a temperature alone is not a state.
1930    ///
1931    /// A cell at 0 °C is ice, water or any mixture, so saving `cells` and not `melted` would restore
1932    /// a block that was half melted as one that was entirely solid at the same temperature — losing
1933    /// 306 mJ per cubic millimetre with nothing to say it had gone. `Schedule::Iterative` and the
1934    /// audit's retry both restore, so this is a live path and not a precaution.
1935    fn checkpoint(&mut self) {
1936        self.saved.clone_from(&self.cells);
1937        self.saved_melted.clone_from(&self.melted);
1938        // The losses too, and `LumpedMass` records what it costs to forget: an iterative sweep
1939        // that rewinds the cells and not the counter grows its books by one sweep of shed heat
1940        // per iteration, and the audit reports energy created from nothing.
1941        self.saved_lost = self.lost;
1942        self.saved_supplied = self.supplied;
1943    }
1944
1945    fn restore(&mut self) {
1946        self.cells.clone_from(&self.saved);
1947        self.melted.clone_from(&self.saved_melted);
1948        self.lost = self.saved_lost;
1949        self.supplied = self.saved_supplied;
1950    }
1951
1952    fn supports_restore(&self) -> bool {
1953        true
1954    }
1955
1956    /// Peak, mean and coldest, in celsius, and what it has absorbed.
1957    ///
1958    /// All three ends of the distribution, because the whole reason to pay for a 3D grid is that
1959    /// they differ. A block reported by its mean alone is a `LumpedMass` that cost `n³` times as
1960    /// much, and the gap between peak and mean is the number that says whether the reduction
1961    /// would have been honest.
1962    fn readings(&self) -> Vec<Reading> {
1963        let mut out = vec![
1964            Reading::new(
1965                &self.name,
1966                "peak",
1967                self.peak_temperature().to_si() - 273.15,
1968                "C",
1969            ),
1970            Reading::new(
1971                &self.name,
1972                "mean",
1973                self.mean_temperature().to_si() - 273.15,
1974                "C",
1975            ),
1976            Reading::new(
1977                &self.name,
1978                "coldest",
1979                self.coldest_temperature().to_si() - 273.15,
1980                "C",
1981            ),
1982            Reading::new(&self.name, "absorbed", self.absorbed_energy().to_si(), "J"),
1983        ];
1984        // What the block made for itself, and **only** for a block that makes any. A source
1985        // nobody can see in the report is the shape this workspace calls a silent failure: the
1986        // run would be right and the reader would have no way to tell 45 W from 45 mW. Conditional
1987        // for the same reason `melted` is — a column of zeros in every other report costs a line
1988        // in all of them and tells nobody anything — and the condition is fixed at construction,
1989        // so it is not a mode that can surprise somebody mid-run.
1990        if self.supplies() {
1991            out.push(Reading::new(
1992                &self.name,
1993                "generated",
1994                self.generated_energy().to_si(),
1995                "J",
1996            ));
1997        }
1998        // The melted volume, and **only** for a block that can melt. A column of zeros in every
1999        // report tells a reader nothing and costs a line in all of them; the condition is a property
2000        // of the block's materials fixed at construction, so it is not a mode that can surprise
2001        // anybody mid-run. Without it a phase change is the one thing this domain does that a report
2002        // could not see, and the temperature would say a cell was holding still.
2003        if self.latent.iter().any(|l| *l > 0.0) {
2004            out.push(Reading::new(
2005                &self.name,
2006                "melted",
2007                self.melted_volume().to_si() * 1e9,
2008                "mm3",
2009            ));
2010        }
2011        out
2012    }
2013
2014    fn as_any(&self) -> Option<&dyn std::any::Any> {
2015        Some(self)
2016    }
2017
2018    fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
2019        Some(self)
2020    }
2021
2022    /// A temperature field, so nothing above has to know this is a block.
2023    fn as_field(&self) -> Option<&dyn ScalarField> {
2024        Some(self)
2025    }
2026}
2027
2028impl ScalarField for Solid3D {
2029    /// **Kelvin**, because that is what the cells hold. See [`Bar1D`](crate::Bar1D).
2030    fn unit(&self) -> &'static str {
2031        "K"
2032    }
2033
2034    /// Trilinear between cell centres, clamped at the faces, and **masked over void**.
2035    ///
2036    /// Clamped rather than extrapolated: outside an insulated face the temperature is not
2037    /// defined, and continuing the gradient would draw a block hotter than any cell in it.
2038    ///
2039    /// Masked because `self.cells` still holds whatever an emptied cell held when it was emptied,
2040    /// and reading it raw is how a gap came out of every exporter as a piece of the block sitting
2041    /// at ambient forever — a plausible, unchanging number, which is worse than no number. The
2042    /// weights are renormalised over the solid corners, so a sample at a cell centre is that cell
2043    /// exactly, a sample inside a clearance is `NaN`, and a sample straddling the two is the
2044    /// material's own value rather than a blend with something that is not there.
2045    fn at(&self, p: LengthVec, _t: Time) -> f64 {
2046        let (nx, ny, nz) = self.counts;
2047        let q = p.to_si() / self.dx.to_si() - DVec3::splat(0.5);
2048        // NaN spelled out rather than folded into a comparison: a visualiser can hand one over,
2049        // and it must not reach the cast below. Answered with a `NaN` rather than with cell zero —
2050        // a question about nowhere has no answer, and cell zero may itself be empty, in which case
2051        // the old fallback returned a frozen number for a point that was never asked about.
2052        if q.is_nan() {
2053            return f64::NAN;
2054        }
2055        let axis = |v: f64, n: usize| -> (usize, f64) {
2056            let last = n.saturating_sub(1);
2057            if v <= 0.0 {
2058                return (0, 0.0);
2059            }
2060            if v >= last as f64 {
2061                return (last, 0.0);
2062            }
2063            let i = v.floor();
2064            (i as usize, v - i)
2065        };
2066        let (i, fx) = axis(q.x, nx);
2067        let (j, fy) = axis(q.y, ny);
2068        let (k, fz) = axis(q.z, nz);
2069
2070        // **A sample belongs to the cell it is in**, and if that cell is empty there is nothing
2071        // there to sample. Without this the masked weights below still answer — with the value of
2072        // whichever solid neighbour the sample leans towards — and a panel that samples across the
2073        // extent rather than on cell centres lands most of a clearance's first layer inside the
2074        // material's half. Measured on a two-layer gap: 36 of the 72 empty cells came out solid.
2075        let nearest = |v: f64, n: usize| (v.round().max(0.0) as usize).min(n.saturating_sub(1));
2076        if self.void[nearest(q.x, nx) + nx * (nearest(q.y, ny) + ny * nearest(q.z, nz))] {
2077            return f64::NAN;
2078        }
2079        let (i1, j1, k1) = (
2080            (i + 1).min(nx - 1),
2081            (j + 1).min(ny - 1),
2082            (k + 1).min(nz - 1),
2083        );
2084
2085        let mut sum = 0.0;
2086        let mut weight = 0.0;
2087        for (a, wa) in [(i, 1.0 - fx), (i1, fx)] {
2088            for (b, wb) in [(j, 1.0 - fy), (j1, fy)] {
2089                for (c, wc) in [(k, 1.0 - fz), (k1, fz)] {
2090                    let w = wa * wb * wc;
2091                    let at = a + nx * (b + ny * c);
2092                    // A corner with no weight is not a corner, so a sample sitting on a cell
2093                    // centre never consults the neighbour it does not use — which is what makes
2094                    // an exactly-sampled grid exact rather than nearly so.
2095                    if w > 0.0 && !self.void[at] {
2096                        sum += w * self.cells[at];
2097                        weight += w;
2098                    }
2099                }
2100            }
2101        }
2102        if weight > 0.0 {
2103            sum / weight
2104        } else {
2105            f64::NAN
2106        }
2107    }
2108
2109    /// Central differences on the cell grid, mirrored at the faces **and at void**.
2110    ///
2111    /// A clearance is a boundary, and the block already knows what to do at one: mirror. Walking
2112    /// off the outer edge and walking into nothing are the same situation — there is no sample
2113    /// that way — so both return the asking cell and the difference becomes one-sided, which is
2114    /// the insulated condition rather than a slope towards a number that is not there.
2115    ///
2116    /// `NaN` inside a clearance, for the same reason [`temperature_at`](Solid3D::temperature_at)
2117    /// gives one: a gradient there is a value somebody would plot.
2118    fn gradient(&self, p: LengthVec, _t: Time, _h: Length) -> DVec3 {
2119        let (i, j, k) = self.nearest_cell(p);
2120        if self.absent(i, j, k) {
2121            return DVec3::splat(f64::NAN);
2122        }
2123        let d = 2.0 * self.dx.to_si();
2124        let m = |a: isize, b: isize, c: isize| self.mirrored_into(i, j, k, a, b, c);
2125        DVec3::new(
2126            (m(i + 1, j, k) - m(i - 1, j, k)) / d,
2127            (m(i, j + 1, k) - m(i, j - 1, k)) / d,
2128            (m(i, j, k + 1) - m(i, j, k - 1)) / d,
2129        )
2130    }
2131
2132    /// `∇²T` on the seven-point stencil.
2133    ///
2134    /// The Laplacian of the *temperature*, which is what the trait asks for and is a statement
2135    /// about the field rather than about the material. It is the operator the sweep uses only when
2136    /// the block is one material; for a filled one the sweep uses `∇·(k∇T)`, and
2137    /// [`rate`](ScalarField::rate) is where that appears.
2138    fn laplacian(&self, p: LengthVec, _t: Time, _h: Length) -> f64 {
2139        let (i, j, k) = self.nearest_cell(p);
2140        if self.absent(i, j, k) {
2141            return f64::NAN;
2142        }
2143        let dx = self.dx.to_si();
2144        let centre = self.mirrored(i, j, k);
2145        // Each neighbour that is not there mirrors back to the centre and contributes nothing to
2146        // `sum - 6·centre`, which is exactly what an insulated face does — and exactly what a face
2147        // touching a clearance carries, since the harmonic face mean is already zero there.
2148        let m = |a: isize, b: isize, c: isize| self.mirrored_into(i, j, k, a, b, c);
2149        let sum = m(i - 1, j, k)
2150            + m(i + 1, j, k)
2151            + m(i, j - 1, k)
2152            + m(i, j + 1, k)
2153            + m(i, j, k - 1)
2154            + m(i, j, k + 1);
2155        (sum - 6.0 * centre) / (dx * dx)
2156    }
2157
2158    /// `∂T/∂t = (1/Cᵢ)·Σ_f G_f (T_f − Tᵢ)`, which is `α∇²T` where the block is one material.
2159    ///
2160    /// The conductance form rather than `α·laplacian`, so that it is the sweep's own operator at a
2161    /// point in a filled block too — read off the same face conductivities, not reconstructed from
2162    /// a diffusivity the block may not have a single value of.
2163    ///
2164    /// Conduction only: heat arriving over the bus is a source this cannot see.
2165    fn rate(&self, p: LengthVec, _t: Time, _dt: Time) -> f64 {
2166        let (nx, ny, nz) = self.counts;
2167        let (i, j, k) = self.nearest_cell(p);
2168        let clamp = |v: isize, n: usize| v.clamp(0, n as isize - 1) as usize;
2169        let (i, j, k) = (clamp(i, nx), clamp(j, ny), clamp(k, nz));
2170        let c = i + nx * (j + ny * k);
2171        // Nothing does not warm at a rate, and the `is_finite` guard below would otherwise report
2172        // that it warms at **zero** — `mobility` is `1/C`, a cell with no capacity has none, and
2173        // `inf * 0.0` is the `NaN` that guard was written to catch coming from somewhere else.
2174        // A confident zero here is indistinguishable from a solid cell in equilibrium.
2175        if self.void[c] {
2176            return f64::NAN;
2177        }
2178        let t = self.cells[c];
2179        let (xr, yr) = ((nx + 1) * (j + ny * k), nx * (j + (ny + 1) * k));
2180        let mut flux = 0.0;
2181        if i > 0 {
2182            flux += self.kx[i + xr] * (self.cells[c - 1] - t);
2183        }
2184        if i + 1 < nx {
2185            flux += self.kx[i + 1 + xr] * (self.cells[c + 1] - t);
2186        }
2187        if j > 0 {
2188            flux += self.ky[i + yr] * (self.cells[c - nx] - t);
2189        }
2190        if j + 1 < ny {
2191            flux += self.ky[i + nx + yr] * (self.cells[c + nx] - t);
2192        }
2193        if k > 0 {
2194            flux += self.kz[c] * (self.cells[c - nx * ny] - t);
2195        }
2196        if k + 1 < nz {
2197            flux += self.kz[c + nx * ny] * (self.cells[c + nx * ny] - t);
2198        }
2199        let rate = self.mobility[c] * flux;
2200        if rate.is_finite() {
2201            rate
2202        } else {
2203            0.0
2204        }
2205    }
2206}
2207
2208impl Solid3D {
2209    /// Whether the cell a stencil is asking about is not there — off the grid, or empty.
2210    ///
2211    /// The two cases are one case for a derivative: neither is a sample. Signed, so a stencil can
2212    /// ask about a neighbour it has already walked off the edge to reach.
2213    fn absent(&self, i: isize, j: isize, k: isize) -> bool {
2214        let (nx, ny, nz) = self.counts;
2215        let outside = |v: isize, n: usize| v < 0 || v >= n as isize;
2216        if outside(i, nx) || outside(j, ny) || outside(k, nz) {
2217            return true;
2218        }
2219        self.void[i as usize + nx * (j as usize + ny * k as usize)]
2220    }
2221
2222    /// [`mirrored`](Solid3D::mirrored), but a neighbour that is **not there** mirrors back to the
2223    /// cell that asked rather than to the edge of the grid.
2224    ///
2225    /// The distinction only shows up inside a block: `mirrored` clamps, so a stencil at cell 3
2226    /// reaching into a clearance at cell 4 would be handed cell 4's frozen value. This hands back
2227    /// cell 3, which is the one-sided difference a boundary deserves.
2228    fn mirrored_into(&self, ci: isize, cj: isize, ck: isize, i: isize, j: isize, k: isize) -> f64 {
2229        if self.absent(i, j, k) {
2230            self.mirrored(ci, cj, ck)
2231        } else {
2232            self.mirrored(i, j, k)
2233        }
2234    }
2235
2236    /// The value at a cell, with out-of-range indices mirrored back — an insulated face.
2237    ///
2238    /// A mirror rather than a zero, and the distinction is the whole boundary condition: a zero
2239    /// neighbour is a face held at absolute zero and would drain the block, where a mirror is a
2240    /// face with no gradient across it and so no flow through it.
2241    fn mirrored(&self, i: isize, j: isize, k: isize) -> f64 {
2242        let (nx, ny, nz) = self.counts;
2243        let clamp = |v: isize, n: usize| v.clamp(0, n as isize - 1) as usize;
2244        let idx = clamp(i, nx) + nx * (clamp(j, ny) + ny * clamp(k, nz));
2245        self.cells[idx]
2246    }
2247
2248    /// The cell a point falls in, as signed indices so a stencil can walk off the edge.
2249    fn nearest_cell(&self, p: LengthVec) -> (isize, isize, isize) {
2250        let q = p.to_si() / self.dx.to_si();
2251        let one = |v: f64| {
2252            if v.is_nan() {
2253                0
2254            } else {
2255                v.floor().clamp(-1.0, 1e9) as isize
2256            }
2257        };
2258        (one(q.x), one(q.y), one(q.z))
2259    }
2260}