Skip to main content

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}