mod aux_heat_flow;
mod buoyancy;
mod energy_balance;
mod environment;
mod fluid;
mod geometry;
mod insulation;
mod location;
mod mass_balance;
mod node;
mod port_flow;
use std::{array, ops::Div, ops::Mul};
use thiserror::Error;
use uom::{
ConstZero,
si::f64::{
HeatCapacity, Ratio, TemperatureInterval, ThermalConductance, ThermalConductivity,
ThermodynamicTemperature, Time, Volume, VolumeRate,
},
};
use geometry::NodeGeometry;
use node::{Adjacent, Node};
pub use aux_heat_flow::{AuxHeatFlow, ValidatedPower};
pub use environment::Environment;
pub use fluid::Fluid;
pub use geometry::Geometry;
pub use insulation::Insulation;
pub use location::{Location, PortLocation};
pub use port_flow::PortFlow;
pub type TemperatureRate = <TemperatureInterval as Div<Time>>::Output;
type InverseHeatCapacity = <Ratio as Div<HeatCapacity>>::Output;
type InverseVolume = <Ratio as Div<Volume>>::Output;
type TemperatureFlow = <VolumeRate as Mul<TemperatureInterval>>::Output;
#[derive(Debug, Error)]
pub enum StratifiedTankError {
#[error("geometry is invalid: {0}")]
Geometry(String),
#[error("aux[{index}] location is invalid: {context}")]
AuxLocation {
index: usize,
context: String,
},
#[error("port[{index}] inlet location is invalid: {context}")]
PortInletLocation {
index: usize,
context: String,
},
#[error("port[{index}] outlet location is invalid: {context}")]
PortOutletLocation {
index: usize,
context: String,
},
#[error("flow rate must be non-negative and finite, got {0:?}")]
NegativePortFlowRate(VolumeRate),
#[error("inlet temperature must be finite, got {0:?}")]
InvalidInletTemperature(ThermodynamicTemperature),
#[error("auxiliary power must be strictly positive, got {0:?}")]
NonPositiveAuxPower(uom::si::f64::Power),
}
#[derive(Debug)]
pub struct StratifiedTank<const N: usize, const P: usize, const Q: usize> {
nodes: [Node<P, Q>; N],
vols: [Volume; N],
}
#[derive(Debug, Clone, Copy)]
pub struct StratifiedTankInput<const N: usize, const P: usize, const Q: usize> {
pub temperatures: [ThermodynamicTemperature; N],
pub port_flows: [PortFlow; P],
pub aux_heat_flows: [AuxHeatFlow; Q],
pub environment: Environment,
}
#[derive(Debug, Clone, Copy)]
pub struct StratifiedTankOutput<const N: usize> {
pub temperatures: [ThermodynamicTemperature; N],
pub derivatives: [TemperatureRate; N],
}
impl<const P: usize, const Q: usize> StratifiedTank<0, P, Q> {
pub fn new<const N: usize>(
fluid: Fluid,
geometry: Geometry,
insulation: Insulation,
aux_locations: [Location; Q],
port_locations: [PortLocation; P],
) -> Result<StratifiedTank<N, P, Q>, StratifiedTankError> {
let node_geometries = geometry
.into_node_geometries::<N>()
.map_err(StratifiedTankError::Geometry)?;
let heights = node_geometries.map(|n| n.height);
let mut aux_weight_by_node = [[0.0; Q]; N];
for (index, loc) in aux_locations.iter().enumerate() {
let weights = loc
.into_weights(&heights)
.map_err(|context| StratifiedTankError::AuxLocation { index, context })?;
for node_idx in 0..N {
aux_weight_by_node[node_idx][index] = weights[node_idx];
}
}
let mut inlet_weight_by_node = [[0.0; P]; N];
let mut outlet_weight_by_node = [[0.0; P]; N];
for (index, port_loc) in port_locations.iter().enumerate() {
let inlet_weights = port_loc
.inlet
.into_weights(&heights)
.map_err(|context| StratifiedTankError::PortInletLocation { index, context })?;
let outlet_weights = port_loc
.outlet
.into_weights(&heights)
.map_err(|context| StratifiedTankError::PortOutletLocation { index, context })?;
for node_idx in 0..N {
inlet_weight_by_node[node_idx][index] = inlet_weights[node_idx];
outlet_weight_by_node[node_idx][index] = outlet_weights[node_idx];
}
}
let nodes = array::from_fn(|i| {
let node = node_geometries[i];
let ua = node_ua(
i,
N,
fluid.thermal_conductivity,
insulation,
&node_geometries,
);
Node {
inv_volume: node.volume.recip(),
inv_heat_capacity: (node.volume * fluid.density * fluid.specific_heat).recip(),
ua,
aux_heat_weights: aux_weight_by_node[i],
port_inlet_weights: inlet_weight_by_node[i],
port_outlet_weights: outlet_weight_by_node[i],
}
});
Ok(StratifiedTank {
nodes,
vols: node_geometries.map(|n| n.volume),
})
}
}
impl<const N: usize, const P: usize, const Q: usize> StratifiedTank<N, P, Q> {
#[must_use]
pub fn evaluate(&self, input: &StratifiedTankInput<N, P, Q>) -> StratifiedTankOutput<N> {
let StratifiedTankInput {
temperatures: t_guess,
port_flows,
aux_heat_flows,
environment,
} = input;
let mut temperatures = *t_guess;
buoyancy::stabilize(&mut temperatures, &self.vols);
let flow_rates: [VolumeRate; P] = port_flows.map(|pf| pf.rate());
let upward_flows = mass_balance::compute_upward_flows(
&flow_rates,
&self.nodes.map(|n| n.port_inlet_weights),
&self.nodes.map(|n| n.port_outlet_weights),
);
let derivatives = array::from_fn(|i| {
self.deriv_from_flows(i, &temperatures, &upward_flows, port_flows)
+ self.deriv_from_aux(i, aux_heat_flows)
+ self.deriv_from_conduction(i, &temperatures, environment)
});
StratifiedTankOutput {
temperatures,
derivatives,
}
}
fn deriv_from_flows(
&self,
i: usize,
temperatures: &[ThermodynamicTemperature; N],
upward_flows: &[VolumeRate; N],
port_flows: &[PortFlow; P],
) -> TemperatureRate {
let node = self.nodes[i];
let flow_from_below = if i > 0 && upward_flows[i - 1] > VolumeRate::ZERO {
Some((upward_flows[i - 1], temperatures[i - 1]))
} else {
None
};
let flow_from_above = if i < N - 1 && upward_flows[i] < VolumeRate::ZERO {
Some((-upward_flows[i], temperatures[i + 1]))
} else {
None
};
let inflows = port_flows
.iter()
.zip(node.port_inlet_weights)
.map(|(pf, weight)| (pf.rate() * weight, pf.inlet_temperature))
.chain(flow_from_below)
.chain(flow_from_above);
energy_balance::derivative_from_fluid_flows(temperatures[i], node.inv_volume, inflows)
}
fn deriv_from_aux(&self, i: usize, aux_heat_flows: &[AuxHeatFlow; Q]) -> TemperatureRate {
let node = self.nodes[i];
let heat_flows = aux_heat_flows
.iter()
.zip(node.aux_heat_weights)
.map(|(q_dot, weight)| q_dot.signed() * weight);
energy_balance::derivative_from_heat_flows(node.inv_heat_capacity, heat_flows)
}
fn deriv_from_conduction(
&self,
i: usize,
temperatures: &[ThermodynamicTemperature; N],
env: &Environment,
) -> TemperatureRate {
let node = self.nodes[i];
let t_bottom = if i == 0 {
env.bottom
} else {
temperatures[i - 1]
};
let t_top = if i == N - 1 {
env.top
} else {
temperatures[i + 1]
};
energy_balance::derivative_from_conduction(
temperatures[i],
Adjacent {
bottom: t_bottom,
side: env.side,
top: t_top,
},
node.ua,
node.inv_heat_capacity,
)
}
}
fn ua_between_nodes(
k: ThermalConductivity,
below: NodeGeometry,
above: NodeGeometry,
) -> ThermalConductance {
let r_below = 0.5 * below.height / (k * below.area.top);
let r_above = 0.5 * above.height / (k * above.area.bottom);
(r_below + r_above).recip()
}
fn node_ua<const N: usize>(
i: usize,
n: usize,
k: ThermalConductivity,
insulation: Insulation,
node_geometries: &[NodeGeometry; N],
) -> Adjacent<ThermalConductance> {
let node = node_geometries[i];
Adjacent {
bottom: if i == 0 {
match insulation {
Insulation::Adiabatic => ThermalConductance::ZERO,
}
} else {
ua_between_nodes(k, node_geometries[i - 1], node)
},
side: match insulation {
Insulation::Adiabatic => ThermalConductance::ZERO,
},
top: if i == n - 1 {
match insulation {
Insulation::Adiabatic => ThermalConductance::ZERO,
}
} else {
ua_between_nodes(k, node, node_geometries[i + 1])
},
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::f64::consts::PI;
use approx::assert_relative_eq;
use uom::si::{
f64::{Length, MassDensity, Power, SpecificHeatCapacity, ThermalConductivity, VolumeRate},
length::meter,
mass_density::kilogram_per_cubic_meter,
power::kilowatt,
specific_heat_capacity::kilojoule_per_kilogram_kelvin,
thermodynamic_temperature::degree_celsius,
volume_rate::gallon_per_minute,
};
fn test_tank() -> StratifiedTank<3, 1, 1> {
let fluid = Fluid {
density: MassDensity::new::<kilogram_per_cubic_meter>(1000.0),
specific_heat: SpecificHeatCapacity::new::<kilojoule_per_kilogram_kelvin>(4.0),
thermal_conductivity: ThermalConductivity::ZERO,
};
let geometry = Geometry::VerticalCylinder {
diameter: Length::new::<meter>((4.0 / PI).sqrt()),
height: Length::new::<meter>(3.0),
};
let insulation = Insulation::Adiabatic;
let aux_locations = [Location::tank_top()];
let port_locations = [PortLocation {
inlet: Location::tank_bottom(),
outlet: Location::tank_top(),
}];
StratifiedTank::new(fluid, geometry, insulation, aux_locations, port_locations).unwrap()
}
fn port_flow(gpm: f64, celsius: f64) -> PortFlow {
PortFlow::new(
VolumeRate::new::<gallon_per_minute>(gpm),
ThermodynamicTemperature::new::<degree_celsius>(celsius),
)
.unwrap()
}
fn zero_port_flows<const P: usize>() -> [PortFlow; P] {
[port_flow(0.0, 25.0); P]
}
fn k_per_s(rate: TemperatureRate) -> f64 {
use uom::si::{temperature_interval::kelvin as delta_kelvin, time::second};
(rate * Time::new::<second>(1.0)).get::<delta_kelvin>()
}
#[test]
fn at_equilibrium_all_zero_derivatives() {
let tank = test_tank();
let t = ThermodynamicTemperature::new::<degree_celsius>(20.0);
let input = StratifiedTankInput {
temperatures: [t; 3],
port_flows: zero_port_flows(),
aux_heat_flows: [AuxHeatFlow::None],
environment: Environment {
bottom: t,
side: t,
top: t,
},
};
let out = tank.evaluate(&input);
assert!(out.temperatures.iter().all(|&node_t| node_t == t));
for deriv in out.derivatives {
assert_relative_eq!(k_per_s(deriv), 0.0);
}
}
#[test]
fn aux_only_heats_target_node() {
let tank = test_tank();
let t = ThermodynamicTemperature::new::<degree_celsius>(50.0);
let input = StratifiedTankInput {
temperatures: [t; 3],
port_flows: zero_port_flows(),
aux_heat_flows: [AuxHeatFlow::heating(Power::new::<kilowatt>(20.0)).unwrap()],
environment: Environment {
bottom: t,
side: t,
top: t,
},
};
let out = tank.evaluate(&input);
assert_relative_eq!(k_per_s(out.derivatives[0]), 0.0);
assert_relative_eq!(k_per_s(out.derivatives[1]), 0.0);
assert_relative_eq!(k_per_s(out.derivatives[2]), 0.005);
}
}