use time::Duration;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Rc2 {
pub air_capacity_kwh_per_k: f64,
pub mass_capacity_kwh_per_k: f64,
pub r_air_out_k_per_kw: f64,
pub r_air_mass_k_per_kw: f64,
}
#[derive(Debug, Clone, Copy, PartialEq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ThermalState {
pub indoor_c: f64,
pub mass_c: f64,
}
impl ThermalState {
#[must_use]
pub const fn uniform(temperature_c: f64) -> Self {
Self {
indoor_c: temperature_c,
mass_c: temperature_c,
}
}
}
impl Rc2 {
#[must_use]
pub const fn house() -> Self {
Self {
air_capacity_kwh_per_k: 0.6,
mass_capacity_kwh_per_k: 12.0,
r_air_out_k_per_kw: 6.0,
r_air_mass_k_per_kw: 0.4,
}
}
#[must_use]
pub fn is_valid(&self) -> bool {
[
self.air_capacity_kwh_per_k,
self.mass_capacity_kwh_per_k,
self.r_air_out_k_per_kw,
self.r_air_mass_k_per_kw,
]
.iter()
.all(|v| v.is_finite() && *v > 0.0)
}
#[must_use]
pub fn steady_state_heat_kw(&self, indoor_c: f64, outdoor_c: f64) -> f64 {
(indoor_c - outdoor_c) / self.r_air_out_k_per_kw
}
#[must_use]
pub fn stored_kwh(&self, state: ThermalState, reference_c: f64) -> f64 {
(state.indoor_c - reference_c) * self.air_capacity_kwh_per_k
+ (state.mass_c - reference_c) * self.mass_capacity_kwh_per_k
}
#[must_use]
pub fn discretise(&self, dt: Duration) -> Rc2Discrete {
let hours = dt.as_seconds_f64() / 3600.0;
if !(self.is_valid() && hours.is_finite() && hours > 0.0) {
return Rc2Discrete::HOLD;
}
let to_out = 1.0 / (self.r_air_out_k_per_kw * self.air_capacity_kwh_per_k);
let to_mass = 1.0 / (self.r_air_mass_k_per_kw * self.air_capacity_kwh_per_k);
let from_air = 1.0 / (self.r_air_mass_k_per_kw * self.mass_capacity_kwh_per_k);
let inv_c_air = 1.0 / self.air_capacity_kwh_per_k;
let mut m = [[0.0_f64; 4]; 4];
m[0] = [-(to_out + to_mass), to_mass, inv_c_air, to_out];
m[1] = [from_air, -from_air, 0.0, 0.0];
let e = expm4(&m, hours);
Rc2Discrete {
a: [[e[0][0], e[0][1]], [e[1][0], e[1][1]]],
b_heat: [e[0][2], e[1][2]],
b_outdoor: [e[0][3], e[1][3]],
}
}
#[must_use]
pub fn step(
&self,
state: ThermalState,
heat_kw: f64,
outdoor_c: f64,
dt: Duration,
) -> ThermalState {
self.discretise(dt).step(state, heat_kw, outdoor_c)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct CopCurve {
pub at_zero: f64,
pub slope_per_k: f64,
}
impl Default for CopCurve {
fn default() -> Self {
Self::air_source()
}
}
impl CopCurve {
#[must_use]
pub const fn air_source() -> Self {
Self {
at_zero: 3.2,
slope_per_k: 0.06,
}
}
#[must_use]
pub fn at(&self, outdoor_c: f64) -> f64 {
if !outdoor_c.is_finite() {
return self.at_zero.clamp(1.0, 6.0);
}
(self.at_zero + outdoor_c * self.slope_per_k).clamp(1.0, 6.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Rc2Discrete {
pub a: [[f64; 2]; 2],
pub b_heat: [f64; 2],
pub b_outdoor: [f64; 2],
}
impl Rc2Discrete {
pub const HOLD: Self = Self {
a: [[1.0, 0.0], [0.0, 1.0]],
b_heat: [0.0, 0.0],
b_outdoor: [0.0, 0.0],
};
#[must_use]
pub fn step(&self, state: ThermalState, heat_kw: f64, outdoor_c: f64) -> ThermalState {
ThermalState {
indoor_c: self.a[0][0] * state.indoor_c
+ self.a[0][1] * state.mass_c
+ self.b_heat[0] * heat_kw
+ self.b_outdoor[0] * outdoor_c,
mass_c: self.a[1][0] * state.indoor_c
+ self.a[1][1] * state.mass_c
+ self.b_heat[1] * heat_kw
+ self.b_outdoor[1] * outdoor_c,
}
}
#[must_use]
pub fn is_contraction(&self) -> bool {
let [[a, b], [c, d]] = self.a;
let trace = a + d;
let det = a * d - b * c;
let disc = trace * trace - 4.0 * det;
let radius = if disc >= 0.0 {
let root = disc.sqrt();
f64::midpoint(trace, root)
.abs()
.max(f64::midpoint(trace, -root).abs())
} else {
det.abs().sqrt()
};
radius < 1.0
}
}
fn expm4(m: &[[f64; 4]; 4], t: f64) -> [[f64; 4]; 4] {
let mut scaled = [[0.0_f64; 4]; 4];
let mut norm = 0.0_f64;
for i in 0..4 {
let mut row = 0.0;
for j in 0..4 {
scaled[i][j] = m[i][j] * t;
row += scaled[i][j].abs();
}
norm = norm.max(row);
}
let squarings = if norm > 0.5 {
(norm / 0.5).log2().ceil().clamp(0.0, 60.0) as u32
} else {
0
};
let shrink = f64::from(2.0_f32).powi(-(squarings as i32));
for row in &mut scaled {
for v in row.iter_mut() {
*v *= shrink;
}
}
let mut result = identity4();
let mut term = identity4();
for k in 1..=18 {
term = mul4(&term, &scaled);
let inv = 1.0 / f64::from(k);
for row in &mut term {
for v in row.iter_mut() {
*v *= inv;
}
}
for i in 0..4 {
for j in 0..4 {
result[i][j] += term[i][j];
}
}
}
for _ in 0..squarings {
result = mul4(&result, &result);
}
result
}
const fn identity4() -> [[f64; 4]; 4] {
let mut m = [[0.0; 4]; 4];
m[0][0] = 1.0;
m[1][1] = 1.0;
m[2][2] = 1.0;
m[3][3] = 1.0;
m
}
fn mul4(a: &[[f64; 4]; 4], b: &[[f64; 4]; 4]) -> [[f64; 4]; 4] {
let mut out = [[0.0_f64; 4]; 4];
for i in 0..4 {
for k in 0..4 {
let aik = a[i][k];
if aik == 0.0 {
continue;
}
for j in 0..4 {
out[i][j] += aik * b[k][j];
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
const QUARTER: Duration = Duration::minutes(15);
fn explicit_euler(
rc: &Rc2,
state: ThermalState,
heat_kw: f64,
outdoor_c: f64,
hours: f64,
) -> ThermalState {
let air_gain = hours / rc.air_capacity_kwh_per_k;
let mass_gain = hours / rc.mass_capacity_kwh_per_k;
ThermalState {
indoor_c: state.indoor_c + heat_kw * air_gain
- (state.indoor_c - outdoor_c) * (air_gain / rc.r_air_out_k_per_kw)
- (state.indoor_c - state.mass_c) * (air_gain / rc.r_air_mass_k_per_kw),
mass_c: state.mass_c
+ (state.indoor_c - state.mass_c) * (mass_gain / rc.r_air_mass_k_per_kw),
}
}
#[test]
fn explicit_euler_rings_where_the_exact_step_decays() {
let rc = Rc2::house();
let exact = rc.discretise(QUARTER);
let mut euler = ThermalState::uniform(21.0);
let mut settled = ThermalState::uniform(21.0);
let mut euler_track = Vec::new();
let mut exact_track = Vec::new();
for k in 0..12 {
let heat = if k < 4 { 6.0 } else { 0.0 };
euler = explicit_euler(&rc, euler, heat, 2.0, 0.25);
settled = exact.step(settled, heat, 2.0);
euler_track.push(euler.indoor_c);
exact_track.push(settled.indoor_c);
}
let tail = &exact_track[4..];
assert!(
tail.windows(2).all(|w| w[1] < w[0]),
"the exact model should cool monotonically: {tail:?}"
);
let euler_tail = &euler_track[4..];
assert!(
euler_tail.windows(2).any(|w| w[1] > w[0]),
"explicit Euler should ring here: {euler_tail:?}"
);
}
#[test]
fn explicit_euler_overstates_the_heat_gain_by_two_thirds() {
let rc = Rc2::house();
let d = rc.discretise(QUARTER);
assert!((d.b_heat[0] - 0.2538).abs() < 5e-4, "{:?}", d.b_heat);
assert!(d.b_heat[1] > 0.0, "the fabric takes some: {:?}", d.b_heat);
let naive = 0.25 / rc.air_capacity_kwh_per_k;
assert!((naive / d.b_heat[0] - 1.64).abs() < 0.02);
}
#[test]
fn explicit_euler_diverges_outright_for_a_flat() {
let flat = Rc2 {
air_capacity_kwh_per_k: 0.3,
..Rc2::house()
};
let mut state = ThermalState::uniform(21.0);
for _ in 0..40 {
state = explicit_euler(&flat, state, 0.0, 5.0, 0.25);
}
assert!(
!(-30.0..=60.0).contains(&state.indoor_c),
"explicit Euler should diverge here, ended at {} °C",
state.indoor_c
);
assert!(flat.discretise(QUARTER).is_contraction());
}
#[test]
fn the_exact_step_is_a_contraction_at_every_step_size() {
let rc = Rc2::house();
for minutes in [1_i64, 5, 15, 60, 240, 1440] {
let d = rc.discretise(Duration::minutes(minutes));
assert!(
d.is_contraction(),
"unstable at a {minutes}-minute step: {:?}",
d.a
);
}
}
#[test]
fn a_quarter_hour_step_matches_a_thousand_small_ones() {
let rc = Rc2::house();
let start = ThermalState {
indoor_c: 19.0,
mass_c: 21.5,
};
let coarse = rc.step(start, 3.0, -2.0, QUARTER);
let fine_step = rc.discretise(Duration::milliseconds(900));
let mut fine = start;
for _ in 0..1000 {
fine = fine_step.step(fine, 3.0, -2.0);
}
assert!(
(coarse.indoor_c - fine.indoor_c).abs() < 1e-9,
"{} vs {}",
coarse.indoor_c,
fine.indoor_c
);
assert!((coarse.mass_c - fine.mass_c).abs() < 1e-9);
}
#[test]
fn with_no_heat_the_house_relaxes_towards_outdoors_and_stops_there() {
let rc = Rc2::house();
let step = rc.discretise(Duration::hours(1));
let mut state = ThermalState::uniform(21.0);
for _ in 0..2000 {
state = step.step(state, 0.0, 3.0);
}
assert!((state.indoor_c - 3.0).abs() < 1e-6, "{state:?}");
assert!((state.mass_c - 3.0).abs() < 1e-6, "{state:?}");
}
#[test]
fn the_steady_state_heat_holds_the_house_exactly() {
let rc = Rc2::house();
let step = rc.discretise(QUARTER);
let heat = rc.steady_state_heat_kw(21.0, -5.0);
let mut state = ThermalState::uniform(21.0);
for _ in 0..96 {
state = step.step(state, heat, -5.0);
}
assert!((state.indoor_c - 21.0).abs() < 1e-9, "{state:?}");
assert!((state.mass_c - 21.0).abs() < 1e-9, "{state:?}");
}
#[test]
fn the_fabric_is_the_storage_and_it_is_slower_than_the_air() {
let rc = Rc2::house();
let step = rc.discretise(QUARTER);
let mut state = ThermalState::uniform(20.0);
for _ in 0..4 {
state = step.step(state, 6.0, 0.0);
}
let air_rise = state.indoor_c - 20.0;
let mass_rise = state.mass_c - 20.0;
assert!(air_rise > mass_rise, "{state:?}");
assert!(
mass_rise > 0.0,
"the fabric must take some of it: {state:?}"
);
}
#[test]
fn the_rows_of_the_transition_sum_to_one_when_outdoors_is_ignored() {
let rc = Rc2::house();
for minutes in [1_i64, 15, 180] {
let d = rc.discretise(Duration::minutes(minutes));
let held = d.step(ThermalState::uniform(21.0), 0.0, 21.0);
assert!((held.indoor_c - 21.0).abs() < 1e-9, "{minutes} min");
assert!((held.mass_c - 21.0).abs() < 1e-9, "{minutes} min");
}
}
#[test]
fn stored_energy_counts_both_masses() {
let rc = Rc2::house();
let state = ThermalState {
indoor_c: 22.0,
mass_c: 21.0,
};
assert!((rc.stored_kwh(state, 21.0) - 0.6).abs() < 1e-12);
}
#[test]
fn nonsense_parameters_hold_the_temperature_instead_of_exploding() {
let broken = Rc2 {
air_capacity_kwh_per_k: 0.0,
..Rc2::house()
};
let d = broken.discretise(QUARTER);
assert_eq!(d, Rc2Discrete::HOLD);
let state = d.step(ThermalState::uniform(21.0), 5.0, -10.0);
assert_eq!(state, ThermalState::uniform(21.0));
}
#[test]
fn a_zero_length_step_changes_nothing() {
let d = Rc2::house().discretise(Duration::ZERO);
assert_eq!(d, Rc2Discrete::HOLD);
}
}