use std::cmp::Ordering;
use thiserror::Error;
use uom::si::{
f64::{TemperatureInterval, ThermodynamicTemperature},
temperature_interval,
};
use crate::support::control::SwitchState;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Deadband(TemperatureInterval);
#[derive(Debug, Error)]
#[error("deadband must be non-negative, got {value:?}")]
pub struct InvalidDeadband {
pub value: TemperatureInterval,
}
impl Deadband {
pub fn new(value: TemperatureInterval) -> Result<Self, InvalidDeadband> {
let zero = TemperatureInterval::new::<temperature_interval::kelvin>(0.0);
match value.partial_cmp(&zero) {
Some(Ordering::Greater | Ordering::Equal) => Ok(Self(value)),
Some(Ordering::Less) | None => Err(InvalidDeadband { value }),
}
}
#[must_use]
pub fn value(self) -> TemperatureInterval {
self.0
}
}
#[must_use]
pub fn heating(input: SetpointThermostatInput) -> SwitchState {
let SetpointThermostatInput {
state,
temperature,
setpoint,
deadband,
} = input;
match state {
SwitchState::Off => {
if temperature <= setpoint - deadband.value() {
SwitchState::On
} else {
SwitchState::Off
}
}
SwitchState::On => {
if temperature >= setpoint {
SwitchState::Off
} else {
SwitchState::On
}
}
}
}
#[must_use]
pub fn cooling(input: SetpointThermostatInput) -> SwitchState {
let SetpointThermostatInput {
state,
temperature,
setpoint,
deadband,
} = input;
match state {
SwitchState::Off => {
if temperature >= setpoint + deadband.value() {
SwitchState::On
} else {
SwitchState::Off
}
}
SwitchState::On => {
if temperature <= setpoint {
SwitchState::Off
} else {
SwitchState::On
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SetpointThermostatInput {
pub state: SwitchState,
pub temperature: ThermodynamicTemperature,
pub setpoint: ThermodynamicTemperature,
pub deadband: Deadband,
}
impl SetpointThermostatInput {
#[must_use]
pub fn with_state(self, state: SwitchState) -> Self {
Self { state, ..self }
}
#[must_use]
pub fn with_temperature(self, temperature: ThermodynamicTemperature) -> Self {
Self {
temperature,
..self
}
}
#[must_use]
pub fn with_setpoint(self, setpoint: ThermodynamicTemperature) -> Self {
Self { setpoint, ..self }
}
#[must_use]
pub fn with_deadband(self, deadband: Deadband) -> Self {
Self { deadband, ..self }
}
}
#[cfg(test)]
mod tests {
use super::*;
use uom::si::{
temperature_interval::degree_celsius as delta_celsius,
thermodynamic_temperature::degree_celsius,
};
const SETPOINT: f64 = 20.0;
const DEADBAND: f64 = 2.0;
fn test_input(state: SwitchState, temperature: f64) -> SetpointThermostatInput {
SetpointThermostatInput {
state,
temperature: ThermodynamicTemperature::new::<degree_celsius>(temperature),
setpoint: ThermodynamicTemperature::new::<degree_celsius>(SETPOINT),
deadband: Deadband::new(TemperatureInterval::new::<delta_celsius>(DEADBAND)).unwrap(),
}
}
mod deadband {
use super::*;
#[test]
fn rejects_negative() {
let negative = TemperatureInterval::new::<delta_celsius>(-1.0);
assert!(Deadband::new(negative).is_err());
}
#[test]
fn rejects_nan() {
let nan = TemperatureInterval::new::<delta_celsius>(f64::NAN);
assert!(Deadband::new(nan).is_err());
}
#[test]
fn accepts_zero() {
let zero = TemperatureInterval::new::<delta_celsius>(0.0);
assert!(Deadband::new(zero).is_ok());
}
#[test]
fn accepts_positive() {
let positive = TemperatureInterval::new::<delta_celsius>(2.0);
assert!(Deadband::new(positive).is_ok());
}
}
mod heating {
use super::*;
#[test]
fn turns_on_at_or_below_threshold() {
let on_threshold = SETPOINT - DEADBAND;
let input = test_input(SwitchState::Off, on_threshold);
assert_eq!(super::super::heating(input), SwitchState::On);
let input = test_input(SwitchState::Off, on_threshold - 0.1);
assert_eq!(super::super::heating(input), SwitchState::On);
}
#[test]
fn stays_on_below_setpoint() {
let input = test_input(SwitchState::On, SETPOINT - 0.1);
assert_eq!(super::super::heating(input), SwitchState::On);
}
#[test]
fn turns_off_at_or_above_setpoint() {
let input = test_input(SwitchState::On, SETPOINT);
assert_eq!(super::super::heating(input), SwitchState::Off);
let input = test_input(SwitchState::On, SETPOINT + 0.1);
assert_eq!(super::super::heating(input), SwitchState::Off);
}
#[test]
fn stays_off_in_deadband() {
let on_threshold = SETPOINT - DEADBAND;
let midpoint = f64::midpoint(SETPOINT, on_threshold);
let input = test_input(SwitchState::Off, midpoint);
assert_eq!(super::super::heating(input), SwitchState::Off);
}
}
mod cooling {
use super::*;
#[test]
fn turns_on_at_or_above_threshold() {
let on_threshold = SETPOINT + DEADBAND;
let input = test_input(SwitchState::Off, on_threshold);
assert_eq!(super::super::cooling(input), SwitchState::On);
let input = test_input(SwitchState::Off, on_threshold + 0.1);
assert_eq!(super::super::cooling(input), SwitchState::On);
}
#[test]
fn stays_on_above_setpoint() {
let input = test_input(SwitchState::On, SETPOINT + 0.1);
assert_eq!(super::super::cooling(input), SwitchState::On);
}
#[test]
fn turns_off_at_or_below_setpoint() {
let input = test_input(SwitchState::On, SETPOINT);
assert_eq!(super::super::cooling(input), SwitchState::Off);
let input = test_input(SwitchState::On, SETPOINT - 0.1);
assert_eq!(super::super::cooling(input), SwitchState::Off);
}
#[test]
fn stays_off_in_deadband() {
let on_threshold = SETPOINT + DEADBAND;
let midpoint = f64::midpoint(SETPOINT, on_threshold);
let input = test_input(SwitchState::Off, midpoint);
assert_eq!(super::super::cooling(input), SwitchState::Off);
}
}
}