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}