use pantometry_core::conserved::quantity;
use pantometry_core::{Domain, Exchange, Kind, Ledger, Reading, Substance, Violation};
use pantometry_units::{Conductance, Energy, Length, Power, Temperature, Time, Volume};
use crate::{Environment, HEAT};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Node {
index: u32,
network: u64,
}
struct NodeState {
label: String,
capacity: f64,
thickness: f64,
substance: Substance,
temperature: f64,
reference: f64,
environment: Option<Environment>,
}
struct Link {
a: usize,
b: usize,
ua: f64,
}
pub struct ThermalNetwork {
name: String,
id: u64,
nodes: Vec<NodeState>,
links: Vec<Link>,
absorbing: Option<usize>,
absorbed: f64,
lost: f64,
saved: Option<Saved>,
}
type Saved = (Vec<f64>, f64, f64);
fn identity(name: &str) -> u64 {
let mut h: u64 = 0xcbf2_9ce4_8422_2325;
for b in name.as_bytes() {
h ^= *b as u64;
h = h.wrapping_mul(0x0000_0100_0000_01b3);
}
h
}
impl ThermalNetwork {
pub fn new(name: impl Into<String>) -> ThermalNetwork {
let name = name.into();
let id = identity(&name);
ThermalNetwork {
name,
id,
nodes: Vec::new(),
links: Vec::new(),
absorbing: None,
absorbed: 0.0,
lost: 0.0,
saved: None,
}
}
pub fn node(
&mut self,
label: impl Into<String>,
substance: Substance,
volume: Volume,
thickness: Length,
initial: Temperature,
) -> Node {
self.push_node(label, substance, volume, thickness, initial, None)
}
pub fn node_losing_to(
&mut self,
label: impl Into<String>,
substance: Substance,
volume: Volume,
thickness: Length,
initial: Temperature,
environment: Environment,
) -> Node {
self.push_node(
label,
substance,
volume,
thickness,
initial,
Some(environment),
)
}
fn push_node(
&mut self,
label: impl Into<String>,
substance: Substance,
volume: Volume,
thickness: Length,
initial: Temperature,
environment: Option<Environment>,
) -> Node {
let capacity = substance
.heat_capacity(volume)
.map(|c| c.to_si())
.unwrap_or(f64::NAN);
let index = self.nodes.len() as u32;
self.nodes.push(NodeState {
label: label.into(),
capacity,
thickness: thickness.to_si(),
substance,
temperature: initial.to_si(),
reference: initial.to_si(),
environment,
});
Node {
index,
network: self.id,
}
}
pub fn link(&mut self, a: Node, b: Node, ua: Conductance) -> Result<(), Violation> {
let (i, j) = (self.resolve(a)?, self.resolve(b)?);
if i == j {
return Err(Violation::at(
format!("{}/{}", self.name, self.nodes[i].label),
"a node cannot conduct to itself",
0.0,
));
}
let w = ua.to_si();
if !w.is_finite() || w < 0.0 {
return Err(Violation::at(
format!(
"{}/{}-{}",
self.name, self.nodes[i].label, self.nodes[j].label
),
"conductance must be finite and not negative",
w,
));
}
if let Some(existing) = self
.links
.iter_mut()
.find(|l| (l.a == i && l.b == j) || (l.a == j && l.b == i))
{
existing.ua += w;
} else {
self.links.push(Link { a: i, b: j, ua: w });
}
Ok(())
}
pub fn absorbing(&mut self, node: Node) -> Result<(), Violation> {
self.absorbing = Some(self.resolve(node)?);
Ok(())
}
fn resolve(&self, node: Node) -> Result<usize, Violation> {
if node.network != self.id {
return Err(Violation::at(
self.name.clone(),
"a node handle from a different network",
node.index as f64,
));
}
let i = node.index as usize;
if i >= self.nodes.len() {
return Err(Violation::at(
self.name.clone(),
"a node handle from a later state of this network",
node.index as f64,
));
}
Ok(i)
}
pub fn temperature(&self, node: Node) -> Temperature {
Temperature::from_si(self.nodes[node.index as usize].temperature)
}
pub fn rise(&self, node: Node) -> Temperature {
let n = &self.nodes[node.index as usize];
Temperature::from_si(n.temperature - n.reference)
}
pub fn label(&self, node: Node) -> &str {
&self.nodes[node.index as usize].label
}
pub fn node_named(&self, label: &str) -> Option<Node> {
self.nodes
.iter()
.position(|n| n.label == label)
.map(|i| Node {
index: i as u32,
network: self.id,
})
}
pub fn path_conductance(&self, node: Node, at: Power) -> Result<Conductance, Violation> {
let p = at.to_si();
let step = (p.abs() * 1e-4).max(1e-6);
let lo = self.steady_state(Power::from_si(p))?;
let hi = self.steady_state(Power::from_si(p + step))?;
let dt = hi.temperature(node).to_si() - lo.temperature(node).to_si();
if !dt.is_finite() || dt <= 0.0 {
return Err(Violation::at(
format!("{}/{}", self.name, self.nodes[node.index as usize].label),
"more power did not make this node hotter, so it has no path conductance",
dt,
));
}
Ok(Conductance::w_per_k(step / dt))
}
pub fn handles(&self) -> impl Iterator<Item = (Node, &str)> + '_ {
let id = self.id;
self.nodes.iter().enumerate().map(move |(i, n)| {
(
Node {
index: i as u32,
network: id,
},
n.label.as_str(),
)
})
}
pub fn nodes(&self) -> usize {
self.nodes.len()
}
pub fn heat_flow(&self, a: Node, b: Node) -> Power {
let (Ok(i), Ok(j)) = (self.resolve(a), self.resolve(b)) else {
return Power::from_si(0.0);
};
let w = self
.links
.iter()
.find(|l| (l.a == i && l.b == j) || (l.a == j && l.b == i))
.map(|l| l.ua)
.unwrap_or(0.0);
Power::from_si(w * (self.nodes[i].temperature - self.nodes[j].temperature))
}
pub fn absorbed_energy(&self) -> Energy {
Energy::from_si(self.absorbed)
}
pub fn lost_energy(&self) -> Energy {
Energy::from_si(self.lost)
}
pub fn biot_number(&self, node: Node) -> Option<f64> {
let n = &self.nodes[node.index as usize];
let e = n.environment.as_ref()?;
let k = n.substance.thermal.map(|t| t.conductivity.to_si())?;
if k <= 0.0 {
return None;
}
Some(e.convection_w_per_m2_k * n.thickness / k)
}
pub fn time_constant(&self, node: Node) -> Time {
let i = node.index as usize;
let g = self.node_conductance(i);
if g <= 0.0 || !self.nodes[i].capacity.is_finite() {
return Time::from_si(f64::INFINITY);
}
Time::from_si(self.nodes[i].capacity / g)
}
pub fn steady_state(&self, power: Power) -> Result<SteadyState, Violation> {
let n = self.nodes.len();
if n == 0 {
return Err(Violation::at(
self.name.clone(),
"a network with no nodes has no steady state",
0.0,
));
}
let p = power.to_si();
if !p.is_finite() {
return Err(Violation::at(self.name.clone(), "power is not finite", p));
}
let sink = match self.absorbing {
Some(i) => i,
None if p == 0.0 => 0, None => {
return Err(Violation::at(
self.name.clone(),
"power was given but no node was named to absorb it",
p,
))
}
};
if p != 0.0 && !self.nodes.iter().any(|node| node.environment.is_some()) {
return Err(Violation::at(
self.name.clone(),
"no node loses heat to an environment, so heat has nowhere to go and there is \
no steady state — it warms without limit",
p,
));
}
let mut t: Vec<f64> = self.nodes.iter().map(|node| node.temperature).collect();
let mut converged = false;
for _ in 0..NEWTON_STEPS {
let mut r = vec![0.0; n];
r[sink] += p;
for l in &self.links {
let q = l.ua * (t[l.a] - t[l.b]);
r[l.a] -= q;
r[l.b] += q;
}
for (i, node) in self.nodes.iter().enumerate() {
if let Some(e) = &node.environment {
r[i] -= e
.loss_from(Temperature::from_si(t[i]), emissivity_of(node))
.to_si();
}
}
if r.iter().all(|v| v.abs() < 1e-12 * p.abs().max(1.0)) {
converged = true;
break;
}
let mut j = vec![0.0; n * n];
for l in &self.links {
j[l.a * n + l.b] += l.ua;
j[l.b * n + l.a] += l.ua;
j[l.a * n + l.a] -= l.ua;
j[l.b * n + l.b] -= l.ua;
}
for (i, node) in self.nodes.iter().enumerate() {
if let Some(e) = &node.environment {
j[i * n + i] -= crate::linearised_loss_conductance(
e,
Temperature::from_si(t[i]),
emissivity_of(node),
);
}
}
for v in r.iter_mut() {
*v = -*v;
}
let step = solve(&mut j, &mut r, n).ok_or_else(|| {
Violation::at(
self.name.clone(),
"the steady-state balance is singular; some part of this network has no \
path to an environment",
p,
)
})?;
let mut moved: f64 = 0.0;
for (ti, d) in t.iter_mut().zip(&step) {
*ti += d;
moved = moved.max(d.abs());
}
if moved < 1e-12 {
converged = true;
break;
}
}
if !converged {
return Err(Violation::at(
self.name.clone(),
"the steady-state balance did not converge in the iterations allowed",
p,
));
}
for (i, v) in t.iter().enumerate() {
if !v.is_finite() {
return Err(Violation::at(
format!("{}/{}", self.name, self.nodes[i].label),
"steady-state temperature is not finite",
*v,
));
}
}
Ok(SteadyState {
network: self.id,
temperatures: t,
})
}
fn node_conductance(&self, i: usize) -> f64 {
let n = &self.nodes[i];
let env = n
.environment
.as_ref()
.map(|e| {
crate::linearised_loss_conductance(
e,
Temperature::from_si(n.temperature),
n.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0),
)
})
.unwrap_or(0.0);
env + self
.links
.iter()
.filter(|l| l.a == i || l.b == i)
.map(|l| l.ua)
.sum::<f64>()
}
}
const NEWTON_STEPS: usize = 100;
#[derive(Clone, Debug)]
pub struct SteadyState {
network: u64,
temperatures: Vec<f64>,
}
impl SteadyState {
pub fn temperature(&self, node: Node) -> Temperature {
assert_eq!(
node.network, self.network,
"this Node belongs to a different network"
);
Temperature::from_si(self.temperatures[node.index as usize])
}
pub fn nodes(&self) -> usize {
self.temperatures.len()
}
}
fn emissivity_of(node: &NodeState) -> f64 {
node.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0)
}
fn solve(a: &mut [f64], b: &mut [f64], n: usize) -> Option<Vec<f64>> {
for col in 0..n {
let (mut best, mut best_at) = (a[col * n + col].abs(), col);
for row in col + 1..n {
let v = a[row * n + col].abs();
if v > best {
best = v;
best_at = row;
}
}
let scale = a.iter().fold(0.0f64, |m, v| m.max(v.abs())).max(1e-300);
if best <= scale * 1e-14 {
return None;
}
if best_at != col {
for k in 0..n {
a.swap(col * n + k, best_at * n + k);
}
b.swap(col, best_at);
}
let pivot = a[col * n + col];
for row in col + 1..n {
let factor = a[row * n + col] / pivot;
if factor == 0.0 {
continue;
}
for k in col..n {
a[row * n + k] -= factor * a[col * n + k];
}
b[row] -= factor * b[col];
}
}
let mut x = vec![0.0; n];
for row in (0..n).rev() {
let mut acc = b[row];
for k in row + 1..n {
acc -= a[row * n + k] * x[k];
}
x[row] = acc / a[row * n + row];
}
Some(x)
}
impl Domain for ThermalNetwork {
fn books_balance(&self) -> bool {
true
}
fn name(&self) -> &str {
&self.name
}
fn kind(&self) -> Kind {
Kind::Evolving
}
fn max_stable_dt(&self, _now: Time) -> Time {
let mut limit = f64::INFINITY;
for i in 0..self.nodes.len() {
let g = self.node_conductance(i);
let c = self.nodes[i].capacity;
if g > 0.0 && c.is_finite() && c > 0.0 {
limit = limit.min(c / g);
}
}
Time::from_si(limit / 10.0)
}
fn step(&mut self, _t: Time, dt: Time, bus: &mut Exchange) -> Result<(), Violation> {
let h = dt.to_si();
if h <= 0.0 {
return Ok(());
}
if self.nodes.is_empty() {
return Err(Violation::at(
self.name.clone(),
"a network with no nodes",
0.0,
));
}
for i in 0..self.nodes.len() {
let c = self.nodes[i].capacity;
if !c.is_finite() || c <= 0.0 {
return Err(Violation::at(
format!("{}/{}", self.name, self.nodes[i].label),
"substance has no heat capacity",
c,
));
}
let f = h * self.node_conductance(i) / c;
if f > 1.0 + 1e-12 {
return Err(Violation {
quantity: "network Fourier number".to_string(),
site: format!(
"{}/{} (explicit RC network)",
self.name, self.nodes[i].label
),
before: 1.0,
after: f,
scale: 1.0,
tolerance: 1e-12,
});
}
}
let gained = bus.take_share(HEAT, dt);
self.absorbed += gained;
if self.absorbing.is_none() && gained != 0.0 {
return Err(Violation::at(
self.name.clone(),
"heat arrived but no node was named to absorb it",
gained,
));
}
let before: Vec<f64> = self.nodes.iter().map(|n| n.temperature).collect();
let mut delta = vec![0.0; self.nodes.len()];
if let Some(i) = self.absorbing {
delta[i] += gained;
}
for l in &self.links {
let q = l.ua * (before[l.a] - before[l.b]) * h;
delta[l.a] -= q;
delta[l.b] += q;
}
for (i, n) in self.nodes.iter().enumerate() {
if let Some(e) = &n.environment {
let lost = e
.loss_from(
Temperature::from_si(before[i]),
n.substance.thermal.map(|t| t.emissivity).unwrap_or(0.0),
)
.to_si()
* h;
delta[i] -= lost;
self.lost += lost;
}
}
for (i, n) in self.nodes.iter_mut().enumerate() {
n.temperature += delta[i] / n.capacity;
}
Ok(())
}
fn ledger(&self) -> Ledger {
let mut ledger = Ledger::new();
for n in &self.nodes {
ledger.add(quantity::ENERGY, n.capacity * (n.temperature - n.reference));
}
ledger.add(quantity::ENERGY, self.lost);
ledger
}
fn checkpoint(&mut self) {
self.saved = Some((
self.nodes.iter().map(|n| n.temperature).collect(),
self.absorbed,
self.lost,
));
}
fn restore(&mut self) {
if let Some((temps, absorbed, lost)) = self.saved.clone() {
for (n, t) in self.nodes.iter_mut().zip(temps) {
n.temperature = t;
}
self.absorbed = absorbed;
self.lost = lost;
}
}
fn supports_restore(&self) -> bool {
true
}
fn readings(&self) -> Vec<Reading> {
self.handles()
.map(|(node, label)| {
Reading::new(
&self.name,
label,
self.temperature(node).to_si() - 273.15,
"C",
)
})
.collect()
}
fn as_any(&self) -> Option<&dyn std::any::Any> {
Some(self)
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
fn as_field(&self) -> Option<&dyn pantometry_core::ScalarField> {
None
}
}