pantometry_thermal/network.rs
1//! A lumped thermal network: n nodes, conductances between them, one domain.
2//!
3//! # Why this and not two coupled `LumpedMass` domains
4//!
5//! The standard electromechanical model is **junction → case → ambient**: a winding that gets
6//! hot, a housing it conducts into, and a room the housing loses to. That is the model that
7//! answers "will the winding survive", which is usually the only thermal question that matters.
8//!
9//! It cannot be built out of two [`LumpedMass`](crate::LumpedMass) domains, and the reason is
10//! structural rather than an omission. A conductance carries `UA·(T₁ − T₂)`: it needs **both**
11//! temperatures. Domains in this workspace never read each other — they meet on an
12//! [`Exchange`], which carries *amounts* and not state, so neither side
13//! can publish a temperature and neither can compute the flux alone. Any `conducting_to(peer)`
14//! would have to break the property the rest of the design rests on.
15//!
16//! So the network is **one domain holding many nodes**, which is also what a thermal network
17//! physically is: a single coupled system of ODEs, not independent bodies posting parcels to
18//! each other. One `ledger()`, one stability limit, and the conservation audit unchanged.
19//!
20//! It buys something [`Bar1D`](crate::Bar1D) cannot express either — a **contact** resistance
21//! between different materials. A bolted joint or a winding pressed into a stator is not bulk
22//! conduction through one substance, and modelling it on a uniform grid needs a fictitious
23//! conductivity standing in for the real interface.
24//!
25//! # What the audit cannot see here, and what covers it instead
26//!
27//! A link contributes `+q` to one node and `−q` to another **in the same sum**. They cancel
28//! identically, so the ledger is blind to links by construction: a sign error, a transposed
29//! index or a link dropped altogether passes the conservation audit at machine precision.
30//!
31//! That is not a gap in the kernel — [`audit_transfers`](pantometry_core::Exchange::audit_transfers)
32//! covers transfers *between* domains and this one is inside a domain — but it decides how the
33//! tests are written. Every link check is per node or on a closed form, never on the total. It
34//! is the same lesson as the per-face audit in space and the substep share in time, arriving a
35//! third time.
36//!
37//! It also decided the API. A node is addressed by a [`Node`] handle rather than by name,
38//! because a link naming a node that does not exist would be exactly the invisible case above:
39//! the books balance, the winding runs hot forever, and the number is plausible. A handle can
40//! only come from [`ThermalNetwork::node`], so a dangling reference is not representable.
41//! Names still exist — every node carries a label, and [`ThermalNetwork::node_named`] is the
42//! bridge for a caller building from a file, where the resolver is a fifteen-line loop that can
43//! name the file's own vocabulary in its error.
44
45use pantometry_core::conserved::quantity;
46use pantometry_core::{Domain, Exchange, Kind, Ledger, Reading, Substance, Violation};
47use pantometry_units::{Conductance, Energy, Length, Power, Temperature, Time, Volume};
48
49use crate::{Environment, HEAT};
50
51/// A node in one particular network.
52///
53/// Carries the network's identity as well as the index, so a handle from one network used on
54/// another is refused rather than silently addressing whatever sits at that index. The identity
55/// is a hash of the network's name, which is deterministic: no counter, no clock, no global
56/// state, and [`Simulation`](pantometry_core::Simulation) already refuses two domains with one name.
57#[derive(Clone, Copy, Debug, PartialEq, Eq)]
58pub struct Node {
59 index: u32,
60 network: u64,
61}
62
63/// One body in a network: a capacity, a temperature, and possibly somewhere to lose heat to.
64struct NodeState {
65 label: String,
66 /// J/K. Held rather than recomputed, so a node is a capacity and not a substance — which is
67 /// what lets a network mix copper, steel and air without the caller assembling a fiction.
68 capacity: f64,
69 /// For the Biot number, which is the only thing that still needs a length.
70 thickness: f64,
71 substance: Substance,
72 temperature: f64,
73 /// What `ledger` measures against. The *initial* temperature and not ambient, following
74 /// `Bar1D`: differencing absolute enthalpies leaves a rounding floor that gets worse on
75 /// refinement, and an interior node has no ambient to measure from at all.
76 reference: f64,
77 environment: Option<Environment>,
78}
79
80/// A conductance between two nodes, in W/K.
81struct Link {
82 a: usize,
83 b: usize,
84 ua: f64,
85}
86
87/// A network of lumped bodies joined by conductances.
88pub struct ThermalNetwork {
89 name: String,
90 id: u64,
91 nodes: Vec<NodeState>,
92 links: Vec<Link>,
93 /// Which node heat off the bus arrives at. `None` until set, and a network that is never
94 /// told will leave anything published unclaimed, which the bus refuses by itself.
95 absorbing: Option<usize>,
96 absorbed: f64,
97 lost: f64,
98 saved: Option<Saved>,
99}
100
101/// Everything `ledger` reads. All of it, because saving the state and not the running totals is
102/// how a rewound sweep comes to report heat it never shed — see `LumpedMass::checkpoint`, which
103/// did exactly that until an iterative coupling was finally built that could reach the branch.
104type Saved = (Vec<f64>, f64, f64);
105
106/// A deterministic identity for a network, from its name.
107fn identity(name: &str) -> u64 {
108 // FNV-1a. Not for security; for telling two networks apart without a counter or a clock,
109 // which the determinism rule forbids.
110 let mut h: u64 = 0xcbf2_9ce4_8422_2325;
111 for b in name.as_bytes() {
112 h ^= *b as u64;
113 h = h.wrapping_mul(0x0000_0100_0000_01b3);
114 }
115 h
116}
117
118impl ThermalNetwork {
119 /// An empty network. Add nodes, then link them.
120 pub fn new(name: impl Into<String>) -> ThermalNetwork {
121 let name = name.into();
122 let id = identity(&name);
123 ThermalNetwork {
124 name,
125 id,
126 nodes: Vec::new(),
127 links: Vec::new(),
128 absorbing: None,
129 absorbed: 0.0,
130 lost: 0.0,
131 saved: None,
132 }
133 }
134
135 /// An interior node: it conducts to its neighbours and loses to nothing.
136 ///
137 /// A winding inside a housing has no room to convect into — only metal to conduct through —
138 /// and giving it an `Environment` with the numbers zeroed would need *two* knobs right
139 /// (`convection` **and** `area`, or the radiative term still runs). Absence of a loss path
140 /// is the absence of a thing, and this workspace spells that with `Option` everywhere else:
141 /// `Substance::thermal`, `Bar1D::boundary`.
142 pub fn node(
143 &mut self,
144 label: impl Into<String>,
145 substance: Substance,
146 volume: Volume,
147 thickness: Length,
148 initial: Temperature,
149 ) -> Node {
150 self.push_node(label, substance, volume, thickness, initial, None)
151 }
152
153 /// A node that also loses heat to its surroundings, like a [`LumpedMass`](crate::LumpedMass).
154 pub fn node_losing_to(
155 &mut self,
156 label: impl Into<String>,
157 substance: Substance,
158 volume: Volume,
159 thickness: Length,
160 initial: Temperature,
161 environment: Environment,
162 ) -> Node {
163 self.push_node(
164 label,
165 substance,
166 volume,
167 thickness,
168 initial,
169 Some(environment),
170 )
171 }
172
173 fn push_node(
174 &mut self,
175 label: impl Into<String>,
176 substance: Substance,
177 volume: Volume,
178 thickness: Length,
179 initial: Temperature,
180 environment: Option<Environment>,
181 ) -> Node {
182 let capacity = substance
183 .heat_capacity(volume)
184 .map(|c| c.to_si())
185 .unwrap_or(f64::NAN);
186 let index = self.nodes.len() as u32;
187 self.nodes.push(NodeState {
188 label: label.into(),
189 capacity,
190 thickness: thickness.to_si(),
191 substance,
192 temperature: initial.to_si(),
193 reference: initial.to_si(),
194 environment,
195 });
196 Node {
197 index,
198 network: self.id,
199 }
200 }
201
202 /// Join two nodes by a conductance, in W/K.
203 ///
204 /// Refuses a self-link, a negative conductance and a handle from a different network. Two
205 /// links between the same pair **accumulate**, because parallel conductances add — the same
206 /// convention [`Exchange::publish`](pantometry_core::Exchange::publish) follows for repeated
207 /// offers on one channel.
208 pub fn link(&mut self, a: Node, b: Node, ua: Conductance) -> Result<(), Violation> {
209 let (i, j) = (self.resolve(a)?, self.resolve(b)?);
210 if i == j {
211 return Err(Violation::at(
212 format!("{}/{}", self.name, self.nodes[i].label),
213 "a node cannot conduct to itself",
214 0.0,
215 ));
216 }
217 let w = ua.to_si();
218 // `!is_finite` first, so NaN is rejected by the branch that reads as rejecting it rather
219 // than by a negated comparison that happens to be false.
220 if !w.is_finite() || w < 0.0 {
221 return Err(Violation::at(
222 format!(
223 "{}/{}-{}",
224 self.name, self.nodes[i].label, self.nodes[j].label
225 ),
226 "conductance must be finite and not negative",
227 w,
228 ));
229 }
230 if let Some(existing) = self
231 .links
232 .iter_mut()
233 .find(|l| (l.a == i && l.b == j) || (l.a == j && l.b == i))
234 {
235 existing.ua += w;
236 } else {
237 self.links.push(Link { a: i, b: j, ua: w });
238 }
239 Ok(())
240 }
241
242 /// Where heat taken off the bus arrives.
243 ///
244 /// A network takes a share of the [`HEAT`] channel like any other consumer, but the joules
245 /// have no *place* — so, exactly as [`Bar1D`](crate::Bar1D) puts lumped heat in its first
246 /// cell, the network needs telling which node it lands on. Naming it is better than
247 /// defaulting to node zero, which would be a silent choice about the physics.
248 pub fn absorbing(&mut self, node: Node) -> Result<(), Violation> {
249 self.absorbing = Some(self.resolve(node)?);
250 Ok(())
251 }
252
253 fn resolve(&self, node: Node) -> Result<usize, Violation> {
254 if node.network != self.id {
255 return Err(Violation::at(
256 self.name.clone(),
257 "a node handle from a different network",
258 node.index as f64,
259 ));
260 }
261 let i = node.index as usize;
262 if i >= self.nodes.len() {
263 return Err(Violation::at(
264 self.name.clone(),
265 "a node handle from a later state of this network",
266 node.index as f64,
267 ));
268 }
269 Ok(i)
270 }
271
272 /// A node's temperature. Total, because a handle cannot name a node that is not here.
273 pub fn temperature(&self, node: Node) -> Temperature {
274 Temperature::from_si(self.nodes[node.index as usize].temperature)
275 }
276
277 /// How far a node has moved from where it started.
278 pub fn rise(&self, node: Node) -> Temperature {
279 let n = &self.nodes[node.index as usize];
280 Temperature::from_si(n.temperature - n.reference)
281 }
282
283 /// The label a node was given, for a violation or a legend.
284 pub fn label(&self, node: Node) -> &str {
285 &self.nodes[node.index as usize].label
286 }
287
288 /// A node by label, for a caller that built the network from a file and has names rather
289 /// than handles. The seam between a name-shaped format and a handle-shaped API.
290 pub fn node_named(&self, label: &str) -> Option<Node> {
291 self.nodes
292 .iter()
293 .position(|n| n.label == label)
294 .map(|i| Node {
295 index: i as u32,
296 network: self.id,
297 })
298 }
299
300 /// The conductance of the whole heat path from a node to ambient, at an operating point.
301 ///
302 /// `ΔP/ΔT` at the node: solve the balance twice a little apart and take the slope. For a
303 /// network with no radiation this is exact and independent of `at` — it is the series
304 /// conductance of the links and environments between this node and the air, so a winding
305 /// reaching air through 0.9 and 2.4 W/K of joints and then 0.294 W/K of convection reports
306 /// 0.203 W/K. With radiation it is the *local* slope, which is the right thing rather than a
307 /// compromise: everything asking for this quantity is asking a derivative question.
308 ///
309 /// The reason it exists is that the caller was computing it by hand. A sizing tool built
310 /// against 0.6.0 had to assemble `1/(1/0.9 + 1/2.4 + 1/(7·A))` out of numbers this network
311 /// already holds, in order to hand the result to
312 /// [`Winding::runaway_current`](https://docs.rs/pantometry-electrical) — and a network with one
313 /// more joint, or an environment on a middle node, is a formula the caller would have got
314 /// wrong silently. See `FRICTION.md` 20.
315 ///
316 /// Errors for the same reasons [`ThermalNetwork::steady_state`] does: no environment
317 /// anywhere, a singular balance, a solve that would not converge.
318 pub fn path_conductance(&self, node: Node, at: Power) -> Result<Conductance, Violation> {
319 let p = at.to_si();
320 // A relative step, floored so an operating point of zero still has one. The linear case
321 // is exact for any step; this size keeps the radiative slope local while staying far
322 // above the 1e-12 residual the solve converges to, so the difference is signal.
323 let step = (p.abs() * 1e-4).max(1e-6);
324 let lo = self.steady_state(Power::from_si(p))?;
325 let hi = self.steady_state(Power::from_si(p + step))?;
326 let dt = hi.temperature(node).to_si() - lo.temperature(node).to_si();
327 // NaN rejected by the branch that reads as rejecting it, not by a negated comparison.
328 if !dt.is_finite() || dt <= 0.0 {
329 return Err(Violation::at(
330 format!("{}/{}", self.name, self.nodes[node.index as usize].label),
331 "more power did not make this node hotter, so it has no path conductance",
332 dt,
333 ));
334 }
335 Ok(Conductance::w_per_k(step / dt))
336 }
337
338 /// Every node, in the order they were added, with its label.
339 ///
340 /// A [`Node`] can only come from [`node`](ThermalNetwork::node) or
341 /// [`node_losing_to`](ThermalNetwork::node_losing_to), which is what makes a dangling link
342 /// unrepresentable — and it also meant a caller holding a network it did not build could not
343 /// walk it at all. [`nodes`](ThermalNetwork::nodes) returned a count and there was no way to
344 /// turn a count into anything. Found the first time the consumer app tried to print a
345 /// network's temperatures, which is a smaller version of `FRICTION.md`'s recurring finding:
346 /// the API is comfortable when the parts are known at compile time and awkward the moment
347 /// they are not.
348 pub fn handles(&self) -> impl Iterator<Item = (Node, &str)> + '_ {
349 let id = self.id;
350 self.nodes.iter().enumerate().map(move |(i, n)| {
351 (
352 Node {
353 index: i as u32,
354 network: id,
355 },
356 n.label.as_str(),
357 )
358 })
359 }
360
361 /// How many nodes.
362 pub fn nodes(&self) -> usize {
363 self.nodes.len()
364 }
365
366 /// Watts crossing the joint between two nodes right now, positive from `a` to `b`.
367 ///
368 /// Zero if they are not linked, which is a real answer rather than a missing one.
369 pub fn heat_flow(&self, a: Node, b: Node) -> Power {
370 let (Ok(i), Ok(j)) = (self.resolve(a), self.resolve(b)) else {
371 return Power::from_si(0.0);
372 };
373 let w = self
374 .links
375 .iter()
376 .find(|l| (l.a == i && l.b == j) || (l.a == j && l.b == i))
377 .map(|l| l.ua)
378 .unwrap_or(0.0);
379 Power::from_si(w * (self.nodes[i].temperature - self.nodes[j].temperature))
380 }
381
382 /// Heat taken off the bus over the run.
383 pub fn absorbed_energy(&self) -> Energy {
384 Energy::from_si(self.absorbed)
385 }
386
387 /// Heat shed to the environments over the run.
388 pub fn lost_energy(&self) -> Energy {
389 Energy::from_si(self.lost)
390 }
391
392 /// Whether a node's lumped approximation applies, from its own conductivity and its own
393 /// environment. `None` for an interior node, which has no film coefficient to compare
394 /// against — and that is worth knowing rather than papering over.
395 pub fn biot_number(&self, node: Node) -> Option<f64> {
396 let n = &self.nodes[node.index as usize];
397 let e = n.environment.as_ref()?;
398 let k = n.substance.thermal.map(|t| t.conductivity.to_si())?;
399 if k <= 0.0 {
400 return None;
401 }
402 Some(e.convection_w_per_m2_k * n.thickness / k)
403 }
404
405 /// A node's local time constant: its capacity over everything that carries heat away from
406 /// it, links included.
407 pub fn time_constant(&self, node: Node) -> Time {
408 let i = node.index as usize;
409 let g = self.node_conductance(i);
410 if g <= 0.0 || !self.nodes[i].capacity.is_finite() {
411 return Time::from_si(f64::INFINITY);
412 }
413 Time::from_si(self.nodes[i].capacity / g)
414 }
415
416 /// Everything conducting heat out of node `i`: its environment linearised at its current
417 /// temperature, plus every link on it.
418 /// **Where it all ends up, without marching there.**
419 ///
420 /// Solves for the temperatures at which every node's heat in equals its heat out, given
421 /// `power` arriving at the absorbing node. That is the number a designer actually wants —
422 /// *will the winding survive* — and stepping to it is both slow and approximate: the
423 /// assembly's time constant is `C/G` over the whole thing, so reaching one part in a
424 /// thousand of the answer takes about seven of them, and every step of that is an explicit
425 /// Euler step accumulating its own error.
426 ///
427 /// This is not the implicit stepping this workspace declines to have. Nothing about
428 /// [`Domain::step`] changes, the kernel is untouched, and no schedule learns anything: it is
429 /// a question asked *of* a network — where does this end up — answered by solving the same
430 /// balance the step loop converges to. The network is not modified; the temperatures come
431 /// back and what you do with them is yours.
432 ///
433 /// # The nonlinearity, and why Newton rather than one solve
434 ///
435 /// With emissivity zero the balance is linear and this converges in a single iteration, to
436 /// machine precision. Radiation makes it `T⁴` and one solve would be an answer to the
437 /// linearised problem rather than to the problem — the mistake
438 /// [`LumpedMass::equilibrium_rise`](crate::LumpedMass::equilibrium_rise) was written to
439 /// correct on a single body, and it is the same mistake here with more nodes. So: Newton,
440 /// with `4εσAT³` as the radiative part of the Jacobian, which is exactly the
441 /// `linearised_loss_conductance` the step limit already uses.
442 ///
443 /// The linear cases exit after one solve. The radiative ones take **more than they look like
444 /// they should**, and the bound on them was wrong at first: it was set to twice the worst
445 /// case the mild tests exercised, and refused a kilowatt.
446 ///
447 /// The cost is the overshoot on the first step. At ambient the radiative slope `4εσAT³` is
448 /// tiny against what the balance eventually needs, so the first solve lands far above the
449 /// answer and Newton walks down at the `3/4` error ratio a quartic gives. Counted on one
450 /// radiating node:
451 ///
452 /// ```text
453 /// 1 kW 12 iterations 2 037 K
454 /// 100 kW 24 6 646 K
455 /// 10 MW 36 21 039 K
456 /// 1 GW 48 66 533 K
457 /// 1 TW 66 374 142 K
458 /// ```
459 ///
460 /// Roughly twelve more per factor of a hundred in power, so the limit of 100 covers anything
461 /// a caller could mean. Exhausting it returns a `Violation` rather than the last iterate,
462 /// because an unconverged guess is a plausible temperature for a balance that was never
463 /// struck.
464 ///
465 /// # What it refuses
466 ///
467 /// **A network with no environment anywhere**, when power is arriving. Heat has nowhere to
468 /// go, so no steady state exists and the balance has no solution — the matrix is singular.
469 /// Marching such a network is perfectly well defined; it simply heats up forever. Returning
470 /// a plausible number here would be the worst outcome, so it is named instead.
471 ///
472 /// ```
473 /// # use pantometry_thermal::{Environment, ThermalNetwork};
474 /// # use pantometry_core::Substance;
475 /// # use pantometry_units::{Area, Conductance, Length, Power, Temperature, Volume};
476 /// let mut net = ThermalNetwork::new("motor");
477 /// let winding = net.node("winding", Substance::copper(), Volume::from_si(18e-6),
478 /// Length::mm(2.0), Temperature::celsius(25.0));
479 /// let case = net.node_losing_to("case", Substance::aluminium_6061(), Volume::from_si(220e-6),
480 /// Length::mm(4.0), Temperature::celsius(25.0),
481 /// Environment::still_air(Temperature::celsius(25.0),
482 /// Area::from_si(0.042)));
483 /// net.link(winding, case, Conductance::w_per_k(0.9)).unwrap();
484 /// net.absorbing(winding).unwrap();
485 ///
486 /// let settled = net.steady_state(Power::w(6.0)).unwrap();
487 /// // The joint carries the full 6 W at steady state, so the drop across it is P/K.
488 /// let drop = settled.temperature(winding).to_si() - settled.temperature(case).to_si();
489 /// assert!((drop - 6.0 / 0.9).abs() < 1e-9, "{drop}");
490 /// ```
491 pub fn steady_state(&self, power: Power) -> Result<SteadyState, Violation> {
492 let n = self.nodes.len();
493 if n == 0 {
494 return Err(Violation::at(
495 self.name.clone(),
496 "a network with no nodes has no steady state",
497 0.0,
498 ));
499 }
500 let p = power.to_si();
501 if !p.is_finite() {
502 return Err(Violation::at(self.name.clone(), "power is not finite", p));
503 }
504 let sink = match self.absorbing {
505 Some(i) => i,
506 None if p == 0.0 => 0, // unused: with no power the source vector is zero anyway
507 None => {
508 return Err(Violation::at(
509 self.name.clone(),
510 "power was given but no node was named to absorb it",
511 p,
512 ))
513 }
514 };
515 if p != 0.0 && !self.nodes.iter().any(|node| node.environment.is_some()) {
516 return Err(Violation::at(
517 self.name.clone(),
518 "no node loses heat to an environment, so heat has nowhere to go and there is \
519 no steady state — it warms without limit",
520 p,
521 ));
522 }
523
524 // Start from where the network is. For the linear case the start is irrelevant; for the
525 // radiative one it is a better guess than ambient whenever the caller has already
526 // stepped, and no worse when they have not.
527 let mut t: Vec<f64> = self.nodes.iter().map(|node| node.temperature).collect();
528
529 // Newton. Eight is generous: the linear case converges on the first, and a radiative
530 // one from a cold start has taken four in every case measured. The count is bounded so
531 // a pathological input reports rather than spins.
532 let mut converged = false;
533 for _ in 0..NEWTON_STEPS {
534 // Residual: what each node is failing to balance, in watts.
535 let mut r = vec![0.0; n];
536 r[sink] += p;
537 for l in &self.links {
538 let q = l.ua * (t[l.a] - t[l.b]);
539 r[l.a] -= q;
540 r[l.b] += q;
541 }
542 for (i, node) in self.nodes.iter().enumerate() {
543 if let Some(e) = &node.environment {
544 r[i] -= e
545 .loss_from(Temperature::from_si(t[i]), emissivity_of(node))
546 .to_si();
547 }
548 }
549 if r.iter().all(|v| v.abs() < 1e-12 * p.abs().max(1.0)) {
550 converged = true;
551 break;
552 }
553
554 // Jacobian of that residual. Off-diagonal `+UA`, diagonal minus the row sum minus
555 // the environment's slope — the same conductance `max_stable_dt` divides by.
556 let mut j = vec![0.0; n * n];
557 for l in &self.links {
558 j[l.a * n + l.b] += l.ua;
559 j[l.b * n + l.a] += l.ua;
560 j[l.a * n + l.a] -= l.ua;
561 j[l.b * n + l.b] -= l.ua;
562 }
563 for (i, node) in self.nodes.iter().enumerate() {
564 if let Some(e) = &node.environment {
565 j[i * n + i] -= crate::linearised_loss_conductance(
566 e,
567 Temperature::from_si(t[i]),
568 emissivity_of(node),
569 );
570 }
571 }
572
573 // J·dT = −r, then T += dT.
574 for v in r.iter_mut() {
575 *v = -*v;
576 }
577 let step = solve(&mut j, &mut r, n).ok_or_else(|| {
578 Violation::at(
579 self.name.clone(),
580 "the steady-state balance is singular; some part of this network has no \
581 path to an environment",
582 p,
583 )
584 })?;
585 let mut moved: f64 = 0.0;
586 for (ti, d) in t.iter_mut().zip(&step) {
587 *ti += d;
588 moved = moved.max(d.abs());
589 }
590 if moved < 1e-12 {
591 converged = true;
592 break;
593 }
594 }
595
596 // Not converging is a real answer and it is not this one. Returning whatever the last
597 // iterate happened to be would be a plausible temperature for a balance that was never
598 // struck — found by instrumenting the iteration count, which showed the loop had no way
599 // to report exhausting itself.
600 if !converged {
601 return Err(Violation::at(
602 self.name.clone(),
603 "the steady-state balance did not converge in the iterations allowed",
604 p,
605 ));
606 }
607
608 for (i, v) in t.iter().enumerate() {
609 if !v.is_finite() {
610 return Err(Violation::at(
611 format!("{}/{}", self.name, self.nodes[i].label),
612 "steady-state temperature is not finite",
613 *v,
614 ));
615 }
616 }
617 Ok(SteadyState {
618 network: self.id,
619 temperatures: t,
620 })
621 }
622
623 fn node_conductance(&self, i: usize) -> f64 {
624 let n = &self.nodes[i];
625 let env = n
626 .environment
627 .as_ref()
628 .map(|e| {
629 crate::linearised_loss_conductance(
630 e,
631 Temperature::from_si(n.temperature),
632 n.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0),
633 )
634 })
635 .unwrap_or(0.0);
636 env + self
637 .links
638 .iter()
639 .filter(|l| l.a == i || l.b == i)
640 .map(|l| l.ua)
641 .sum::<f64>()
642 }
643}
644
645/// How many Newton steps `ThermalNetwork::steady_state` will take before giving up.
646///
647/// The measured iteration counts that set this are in that method's own documentation, where a
648/// caller will read them. Each iteration is one dense solve of an n×n system with n the node
649/// count, so for the handful of nodes a lumped network has, even sixty-six is microseconds.
650const NEWTON_STEPS: usize = 100;
651
652/// What [`ThermalNetwork::steady_state`] found: a temperature per node.
653///
654/// A separate type rather than a `Vec<Temperature>` so it is read with the same [`Node`] handles
655/// the network was built with, and so a handle from a *different* network is refused here too
656/// rather than indexing into whatever sits at that position.
657#[derive(Clone, Debug)]
658pub struct SteadyState {
659 network: u64,
660 temperatures: Vec<f64>,
661}
662
663impl SteadyState {
664 /// The settled temperature of a node.
665 ///
666 /// # Panics
667 ///
668 /// If the handle came from a different network, for the same reason the rest of this module
669 /// does: the alternative is answering a question about a node the caller did not mean.
670 pub fn temperature(&self, node: Node) -> Temperature {
671 assert_eq!(
672 node.network, self.network,
673 "this Node belongs to a different network"
674 );
675 Temperature::from_si(self.temperatures[node.index as usize])
676 }
677
678 /// How many nodes it covers.
679 pub fn nodes(&self) -> usize {
680 self.temperatures.len()
681 }
682}
683
684fn emissivity_of(node: &NodeState) -> f64 {
685 node.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0)
686}
687
688/// Dense Gaussian elimination with partial pivoting, in place. `None` if singular.
689///
690/// Written here rather than pulled in: a thermal network is a handful of nodes, this is forty
691/// lines, and a linear-algebra dependency would have to clear `deny.toml` and the WebAssembly
692/// jobs to save them. Partial pivoting by magnitude, which is deterministic — the same matrix
693/// gives the same pivots on every platform, as everything in this workspace must.
694fn solve(a: &mut [f64], b: &mut [f64], n: usize) -> Option<Vec<f64>> {
695 for col in 0..n {
696 let (mut best, mut best_at) = (a[col * n + col].abs(), col);
697 for row in col + 1..n {
698 let v = a[row * n + col].abs();
699 if v > best {
700 best = v;
701 best_at = row;
702 }
703 }
704 // Scaled against the largest entry, so "singular" means singular rather than "small
705 // because the conductances are in milliwatts per kelvin".
706 let scale = a.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-300);
707 if best <= scale * 1e-14 {
708 return None;
709 }
710 if best_at != col {
711 for k in 0..n {
712 a.swap(col * n + k, best_at * n + k);
713 }
714 b.swap(col, best_at);
715 }
716 let pivot = a[col * n + col];
717 for row in col + 1..n {
718 let factor = a[row * n + col] / pivot;
719 if factor == 0.0 {
720 continue;
721 }
722 for k in col..n {
723 a[row * n + k] -= factor * a[col * n + k];
724 }
725 b[row] -= factor * b[col];
726 }
727 }
728 let mut x = vec![0.0; n];
729 for row in (0..n).rev() {
730 let mut acc = b[row];
731 for k in row + 1..n {
732 acc -= a[row * n + k] * x[k];
733 }
734 x[row] = acc / a[row * n + row];
735 }
736 Some(x)
737}
738
739impl Domain for ThermalNetwork {
740 fn books_balance(&self) -> bool {
741 true
742 }
743
744 fn name(&self) -> &str {
745 &self.name
746 }
747
748 fn kind(&self) -> Kind {
749 Kind::Evolving
750 }
751
752 /// A tenth of the fastest node's time constant.
753 ///
754 /// The explicit limit is set by whichever node empties quickest, and in a network that is
755 /// often **not** a node touching the outside: a small winding on a stiff link to a large
756 /// housing is far faster than the housing is, and its limit comes entirely from the link.
757 /// That is why [`ThermalNetwork`]'s violation names the node rather than only the network.
758 ///
759 /// A tenth for the same reason [`LumpedMass`](crate::LumpedMass) reports one: explicit Euler
760 /// is stable to `2τ` and accurate nowhere near it, so the number a scheduler should honour
761 /// is the accuracy limit. With one node and no links this is `LumpedMass::max_stable_dt`
762 /// exactly.
763 ///
764 /// State-dependent, because the environment term is linearised at each node's current
765 /// temperature — so the limit tightens as the network heats.
766 fn max_stable_dt(&self, _now: Time) -> Time {
767 let mut limit = f64::INFINITY;
768 for i in 0..self.nodes.len() {
769 let g = self.node_conductance(i);
770 let c = self.nodes[i].capacity;
771 if g > 0.0 && c.is_finite() && c > 0.0 {
772 limit = limit.min(c / g);
773 }
774 }
775 Time::from_si(limit / 10.0)
776 }
777
778 fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
779 let h = dt.to_si();
780 if h <= 0.0 {
781 return Ok(());
782 }
783 if self.nodes.is_empty() {
784 return Err(Violation::at(
785 self.name.clone(),
786 "a network with no nodes",
787 0.0,
788 ));
789 }
790
791 // Refuse rather than diverge, and name the node. `F_i` is the fraction of a node's
792 // capacity its own update moves in one step; past one the coefficient `1 - F_i` goes
793 // negative and the temperature oscillates. Same criterion `Bar1D` applies to its
794 // Fourier number, and the useful difference is that in a network the fast node is not
795 // obvious, so the site says which it is.
796 for i in 0..self.nodes.len() {
797 let c = self.nodes[i].capacity;
798 if !c.is_finite() || c <= 0.0 {
799 return Err(Violation::at(
800 format!("{}/{}", self.name, self.nodes[i].label),
801 "substance has no heat capacity",
802 c,
803 ));
804 }
805 let f = h * self.node_conductance(i) / c;
806 if f > 1.0 + 1e-12 {
807 return Err(Violation {
808 quantity: "network Fourier number".to_string(),
809 site: format!(
810 "{}/{} (explicit RC network)",
811 self.name, self.nodes[i].label
812 ),
813 before: 1.0,
814 after: f,
815 scale: 1.0,
816 tolerance: 1e-12,
817 });
818 }
819 }
820
821 // This substep's share of the channel, not the whole outer step's. See
822 // `Exchange::take_share`.
823 let gained = bus.take_share(HEAT, dt);
824 self.absorbed += gained;
825 if self.absorbing.is_none() && gained != 0.0 {
826 return Err(Violation::at(
827 self.name.clone(),
828 "heat arrived but no node was named to absorb it",
829 gained,
830 ));
831 }
832
833 // Jacobi: every flux from the same snapshot, so the answer does not depend on the order
834 // links were declared in. Gauss-Seidel would be a different scheme, not a rounding
835 // difference.
836 let before: Vec<f64> = self.nodes.iter().map(|n| n.temperature).collect();
837 let mut delta = vec![0.0; self.nodes.len()];
838
839 // The arriving heat is a term of the same right-hand side as the fluxes, so it joins
840 // `delta` rather than being added to the temperature first.
841 //
842 // Applying it first is the obvious reading and it is wrong in a way conservation cannot
843 // see: the absorbing node's outgoing flux is then computed from the already-raised
844 // temperature, and the steady state — which explicit Euler otherwise reaches *exactly* —
845 // acquires a bias of `K·h/C` on the joint next to the source. The excess simply lands in
846 // the neighbour, so every total stays right. Measured on a three-node ladder before the
847 // fix: the first joint sat 0.31% low against `P/K`, against a predicted `Kh/C = 0.0031006`
848 // and an observed 0.0031005, while the far joint and the environment drop were exact to
849 // six figures. That agreement is what identified it; the audit was silent throughout.
850 if let Some(i) = self.absorbing {
851 delta[i] += gained;
852 }
853
854 // Each link's flux computed **once** and applied twice with opposite signs. Computing it
855 // from each side separately gives two values differing in the last bit, and the network
856 // then leaks about 1e-16 per link per step — a drift that is invisible per step and is
857 // not invisible over a long run.
858 for l in &self.links {
859 let q = l.ua * (before[l.a] - before[l.b]) * h;
860 delta[l.a] -= q;
861 delta[l.b] += q;
862 }
863
864 for (i, n) in self.nodes.iter().enumerate() {
865 if let Some(e) = &n.environment {
866 let lost = e
867 .loss_from(
868 Temperature::from_si(before[i]),
869 n.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0),
870 )
871 .to_si()
872 * h;
873 delta[i] -= lost;
874 self.lost += lost;
875 }
876 }
877
878 for (i, n) in self.nodes.iter_mut().enumerate() {
879 n.temperature += delta[i] / n.capacity;
880 }
881 Ok(())
882 }
883
884 /// What every node is holding, plus what the environments have taken.
885 ///
886 /// One `add` per node rather than one for the sum, so `Ledger`'s scale is the largest single
887 /// node's holding instead of a near-zero net — which is what the scale exists for, and the
888 /// mistake `NBody::ledger` made until a test proved its momentum audit was inert.
889 ///
890 /// Measured from each node's **initial** temperature. An interior node has no ambient to
891 /// measure from, and `Bar1D`'s reasoning applies anyway: differencing absolute enthalpies
892 /// leaves a rounding floor that gets worse on refinement.
893 fn ledger(&self) -> Ledger {
894 let mut ledger = Ledger::new();
895 for n in &self.nodes {
896 ledger.add(quantity::ENERGY, n.capacity * (n.temperature - n.reference));
897 }
898 ledger.add(quantity::ENERGY, self.lost);
899 ledger
900 }
901
902 /// Every node's temperature and both running totals.
903 ///
904 /// All of it, because `ledger` reads all of it. `LumpedMass` saved its temperature and not
905 /// its `lost`, and a rewound iterative sweep therefore reported heat it had already shed —
906 /// which went unnoticed for as long as it did because nothing in the workspace had a
907 /// residual, so the restore branch never ran.
908 fn checkpoint(&mut self) {
909 self.saved = Some((
910 self.nodes.iter().map(|n| n.temperature).collect(),
911 self.absorbed,
912 self.lost,
913 ));
914 }
915
916 fn restore(&mut self) {
917 if let Some((temps, absorbed, lost)) = self.saved.clone() {
918 for (n, t) in self.nodes.iter_mut().zip(temps) {
919 n.temperature = t;
920 }
921 self.absorbed = absorbed;
922 self.lost = lost;
923 }
924 }
925
926 fn supports_restore(&self) -> bool {
927 true
928 }
929
930 /// Every node, by the name it was given.
931 ///
932 /// Not a summary. The number a network exists to produce is the *drop across a joint*, and a
933 /// mean over the nodes reports neither end of it.
934 fn readings(&self) -> Vec<Reading> {
935 self.handles()
936 .map(|(node, label)| {
937 Reading::new(
938 &self.name,
939 label,
940 self.temperature(node).to_si() - 273.15,
941 "C",
942 )
943 })
944 .collect()
945 }
946
947 fn as_any(&self) -> Option<&dyn std::any::Any> {
948 Some(self)
949 }
950
951 fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
952 Some(self)
953 }
954
955 /// **`None`, and not as an oversight.**
956 ///
957 /// A network is a graph with no embedding. Its nodes have capacities, not positions, and a
958 /// conductance is not a distance — two nodes joined by 0.8 W/K are not "0.8 apart" in any
959 /// space a field could be sampled over. Interpolating between them would invent a continuum
960 /// with less justification than a box of atoms or a set of orbits would have, and those
961 /// decline too.
962 fn as_field(&self) -> Option<&dyn pantometry_core::ScalarField> {
963 None
964 }
965}