Skip to main content

ThermalNetwork

Struct ThermalNetwork 

Source
pub struct ThermalNetwork { /* private fields */ }
Expand description

A network of lumped bodies joined by conductances.

Implementations§

Source§

impl ThermalNetwork

Source

pub fn new(name: impl Into<String>) -> ThermalNetwork

An empty network. Add nodes, then link them.

Source

pub fn node( &mut self, label: impl Into<String>, substance: Substance, volume: Volume, thickness: Length, initial: Temperature, ) -> Node

An interior node: it conducts to its neighbours and loses to nothing.

A winding inside a housing has no room to convect into — only metal to conduct through — and giving it an Environment with the numbers zeroed would need two knobs right (convection and area, or the radiative term still runs). Absence of a loss path is the absence of a thing, and this workspace spells that with Option everywhere else: Substance::thermal, Bar1D::boundary.

Source

pub fn node_losing_to( &mut self, label: impl Into<String>, substance: Substance, volume: Volume, thickness: Length, initial: Temperature, environment: Environment, ) -> Node

A node that also loses heat to its surroundings, like a LumpedMass.

Join two nodes by a conductance, in W/K.

Refuses a self-link, a negative conductance and a handle from a different network. Two links between the same pair accumulate, because parallel conductances add — the same convention Exchange::publish follows for repeated offers on one channel.

Source

pub fn absorbing(&mut self, node: Node) -> Result<(), Violation>

Where heat taken off the bus arrives.

A network takes a share of the HEAT channel like any other consumer, but the joules have no place — so, exactly as Bar1D puts lumped heat in its first cell, the network needs telling which node it lands on. Naming it is better than defaulting to node zero, which would be a silent choice about the physics.

Source

pub fn temperature(&self, node: Node) -> Temperature

A node’s temperature. Total, because a handle cannot name a node that is not here.

Source

pub fn rise(&self, node: Node) -> Temperature

How far a node has moved from where it started.

Source

pub fn label(&self, node: Node) -> &str

The label a node was given, for a violation or a legend.

Source

pub fn node_named(&self, label: &str) -> Option<Node>

A node by label, for a caller that built the network from a file and has names rather than handles. The seam between a name-shaped format and a handle-shaped API.

Source

pub fn path_conductance( &self, node: Node, at: Power, ) -> Result<Conductance, Violation>

The conductance of the whole heat path from a node to ambient, at an operating point.

ΔP/ΔT at the node: solve the balance twice a little apart and take the slope. For a network with no radiation this is exact and independent of at — it is the series conductance of the links and environments between this node and the air, so a winding reaching air through 0.9 and 2.4 W/K of joints and then 0.294 W/K of convection reports 0.203 W/K. With radiation it is the local slope, which is the right thing rather than a compromise: everything asking for this quantity is asking a derivative question.

The reason it exists is that the caller was computing it by hand. A sizing tool built against 0.6.0 had to assemble 1/(1/0.9 + 1/2.4 + 1/(7·A)) out of numbers this network already holds, in order to hand the result to Winding::runaway_current — and a network with one more joint, or an environment on a middle node, is a formula the caller would have got wrong silently. See FRICTION.md 20.

Errors for the same reasons ThermalNetwork::steady_state does: no environment anywhere, a singular balance, a solve that would not converge.

Source

pub fn handles(&self) -> impl Iterator<Item = (Node, &str)> + '_

Every node, in the order they were added, with its label.

A Node can only come from node or node_losing_to, which is what makes a dangling link unrepresentable — and it also meant a caller holding a network it did not build could not walk it at all. nodes returned a count and there was no way to turn a count into anything. Found the first time the consumer app tried to print a network’s temperatures, which is a smaller version of FRICTION.md’s recurring finding: the API is comfortable when the parts are known at compile time and awkward the moment they are not.

Source

pub fn nodes(&self) -> usize

How many nodes.

Source

pub fn heat_flow(&self, a: Node, b: Node) -> Power

Watts crossing the joint between two nodes right now, positive from a to b.

Zero if they are not linked, which is a real answer rather than a missing one.

Source

pub fn absorbed_energy(&self) -> Energy

Heat taken off the bus over the run.

Source

pub fn lost_energy(&self) -> Energy

Heat shed to the environments over the run.

Source

pub fn biot_number(&self, node: Node) -> Option<f64>

Whether a node’s lumped approximation applies, from its own conductivity and its own environment. None for an interior node, which has no film coefficient to compare against — and that is worth knowing rather than papering over.

Source

pub fn time_constant(&self, node: Node) -> Time

A node’s local time constant: its capacity over everything that carries heat away from it, links included.

Source

pub fn steady_state(&self, power: Power) -> Result<SteadyState, Violation>

Everything conducting heat out of node i: its environment linearised at its current temperature, plus every link on it. Where it all ends up, without marching there.

Solves for the temperatures at which every node’s heat in equals its heat out, given power arriving at the absorbing node. That is the number a designer actually wants — will the winding survive — and stepping to it is both slow and approximate: the assembly’s time constant is C/G over the whole thing, so reaching one part in a thousand of the answer takes about seven of them, and every step of that is an explicit Euler step accumulating its own error.

This is not the implicit stepping this workspace declines to have. Nothing about Domain::step changes, the kernel is untouched, and no schedule learns anything: it is a question asked of a network — where does this end up — answered by solving the same balance the step loop converges to. The network is not modified; the temperatures come back and what you do with them is yours.

§The nonlinearity, and why Newton rather than one solve

With emissivity zero the balance is linear and this converges in a single iteration, to machine precision. Radiation makes it T⁴ and one solve would be an answer to the linearised problem rather than to the problem — the mistake LumpedMass::equilibrium_rise was written to correct on a single body, and it is the same mistake here with more nodes. So: Newton, with 4εσAT³ as the radiative part of the Jacobian, which is exactly the linearised_loss_conductance the step limit already uses.

The linear cases exit after one solve. The radiative ones take more than they look like they should, and the bound on them was wrong at first: it was set to twice the worst case the mild tests exercised, and refused a kilowatt.

The cost is the overshoot on the first step. At ambient the radiative slope 4εσAT³ is tiny against what the balance eventually needs, so the first solve lands far above the answer and Newton walks down at the 3/4 error ratio a quartic gives. Counted on one radiating node:

    1 kW   12 iterations      2 037 K
  100 kW   24                 6 646 K
   10 MW   36                21 039 K
    1 GW   48                66 533 K
    1 TW   66               374 142 K

Roughly twelve more per factor of a hundred in power, so the limit of 100 covers anything a caller could mean. Exhausting it returns a Violation rather than the last iterate, because an unconverged guess is a plausible temperature for a balance that was never struck.

§What it refuses

A network with no environment anywhere, when power is arriving. Heat has nowhere to go, so no steady state exists and the balance has no solution — the matrix is singular. Marching such a network is perfectly well defined; it simply heats up forever. Returning a plausible number here would be the worst outcome, so it is named instead.

let mut net = ThermalNetwork::new("motor");
let winding = net.node("winding", Substance::copper(), Volume::from_si(18e-6),
                       Length::mm(2.0), Temperature::celsius(25.0));
let case = net.node_losing_to("case", Substance::aluminium_6061(), Volume::from_si(220e-6),
                              Length::mm(4.0), Temperature::celsius(25.0),
                              Environment::still_air(Temperature::celsius(25.0),
                                                     Area::from_si(0.042)));
net.link(winding, case, Conductance::w_per_k(0.9)).unwrap();
net.absorbing(winding).unwrap();

let settled = net.steady_state(Power::w(6.0)).unwrap();
// The joint carries the full 6 W at steady state, so the drop across it is P/K.
let drop = settled.temperature(winding).to_si() - settled.temperature(case).to_si();
assert!((drop - 6.0 / 0.9).abs() < 1e-9, "{drop}");

Trait Implementations§

Source§

impl Domain for ThermalNetwork

Source§

fn max_stable_dt(&self, _now: Time) -> Time

A tenth of the fastest node’s time constant.

The explicit limit is set by whichever node empties quickest, and in a network that is often not a node touching the outside: a small winding on a stiff link to a large housing is far faster than the housing is, and its limit comes entirely from the link. That is why ThermalNetwork’s violation names the node rather than only the network.

A tenth for the same reason LumpedMass reports one: explicit Euler is stable to and accurate nowhere near it, so the number a scheduler should honour is the accuracy limit. With one node and no links this is LumpedMass::max_stable_dt exactly.

State-dependent, because the environment term is linearised at each node’s current temperature — so the limit tightens as the network heats.

Source§

fn ledger(&self) -> Ledger

What every node is holding, plus what the environments have taken.

One add per node rather than one for the sum, so Ledger’s scale is the largest single node’s holding instead of a near-zero net — which is what the scale exists for, and the mistake NBody::ledger made until a test proved its momentum audit was inert.

Measured from each node’s initial temperature. An interior node has no ambient to measure from, and Bar1D’s reasoning applies anyway: differencing absolute enthalpies leaves a rounding floor that gets worse on refinement.

Source§

fn checkpoint(&mut self)

Every node’s temperature and both running totals.

All of it, because ledger reads all of it. LumpedMass saved its temperature and not its lost, and a rewound iterative sweep therefore reported heat it had already shed — which went unnoticed for as long as it did because nothing in the workspace had a residual, so the restore branch never ran.

Source§

fn readings(&self) -> Vec<Reading>

Every node, by the name it was given.

Not a summary. The number a network exists to produce is the drop across a joint, and a mean over the nodes reports neither end of it.

Source§

fn as_field(&self) -> Option<&dyn ScalarField>

None, and not as an oversight.

A network is a graph with no embedding. Its nodes have capacities, not positions, and a conductance is not a distance — two nodes joined by 0.8 W/K are not “0.8 apart” in any space a field could be sampled over. Interpolating between them would invent a continuum with less justification than a box of atoms or a set of orbits would have, and those decline too.

Source§

fn books_balance(&self) -> bool

Whether this domain’s books are exact: its ledger changes by precisely what it takes from the bus minus what it publishes, every step. Read more
Source§

fn name(&self) -> &str

What this domain is called. Used to look it up and to name it in a violation. Read more
Source§

fn kind(&self) -> Kind

Whether it has state to roll forward. Defaults to Kind::Evolving.
Source§

fn step( &mut self, _t: Time, dt: Time, bus: &mut Exchange, ) -> Result<(), Violation>

Advance by dt from t, reading inputs from bus and publishing outputs to it. A quasi-static domain ignores dt. Read more
Source§

fn restore(&mut self)

Restore the last Domain::checkpoint.
Source§

fn supports_restore(&self) -> bool

Whether Domain::checkpoint and Domain::restore actually do something. Read more
Source§

fn as_any(&self) -> Option<&dyn Any>

This domain as Any, so a caller can get the concrete type back out of a Simulation — see Simulation::domain_as. Read more
Source§

fn as_any_mut(&mut self) -> Option<&mut dyn Any>

The same, mutably, so a caller can write to a domain between steps. Read more
Source§

fn residual(&self) -> f64

How far this domain still is from agreeing with its neighbours, for Schedule::Iterative. Zero means converged.
Source§

fn as_bodies(&self) -> Option<&dyn Bodies>

This domain as a countable set of bodies, if that is what it is. Read more

Auto Trait Implementations§

Blanket Implementations§

Source§

impl<T> Any for T
where T: 'static + ?Sized,

Source§

fn type_id(&self) -> TypeId

Gets the TypeId of self. Read more
Source§

impl<T> Borrow<T> for T
where T: ?Sized,

Source§

fn borrow(&self) -> &T

Immutably borrows from an owned value. Read more
Source§

impl<T> BorrowMut<T> for T
where T: ?Sized,

Source§

fn borrow_mut(&mut self) -> &mut T

Mutably borrows from an owned value. Read more
Source§

impl<T> From<T> for T

Source§

fn from(t: T) -> T

Returns the argument unchanged.

Source§

impl<T, U> Into<U> for T
where U: From<T>,

Source§

fn into(self) -> U

Calls U::from(self).

That is, this conversion is whatever the implementation of From<T> for U chooses to do.

Source§

impl<T, U> TryFrom<U> for T
where U: Into<T>,

Source§

type Error = Infallible

The type returned in the event of a conversion error.
Source§

fn try_from(value: U) -> Result<T, <T as TryFrom<U>>::Error>

Performs the conversion.
Source§

impl<T, U> TryInto<U> for T
where U: TryFrom<T>,

Source§

type Error = <U as TryFrom<T>>::Error

The type returned in the event of a conversion error.
Source§

fn try_into(self) -> Result<U, <U as TryFrom<T>>::Error>

Performs the conversion.