Skip to main content

pamoja_kit/
thermostat.rs

1//! Keeping a reading near a setpoint with on/off control.
2
3/// A hysteresis (bang-bang) controller for a single on/off actuator.
4///
5/// This is the controller behind "keep a temperature". It switches a cooler or
6/// heater on and off to hold a reading near a setpoint. A deadband around the
7/// setpoint - the hysteresis - stops the output chattering when the reading hovers
8/// at the threshold, which protects relays and compressors that have a limited
9/// number of switching cycles in them.
10///
11/// # Examples
12///
13/// ```
14/// use pamoja_kit::Thermostat;
15///
16/// let mut fridge = Thermostat::cooling(4.0, 0.5);
17/// assert!(fridge.update(5.0)); // above the deadband: the cooler runs
18/// assert!(fridge.update(4.2)); // inside the deadband: it holds its state
19/// assert!(!fridge.update(3.4)); // below the deadband: the cooler stops
20/// ```
21#[derive(Clone, Copy, Debug, PartialEq)]
22pub struct Thermostat {
23    setpoint: f32,
24    hysteresis: f32,
25    cools: bool,
26    on: bool,
27}
28
29impl Thermostat {
30    /// Creates a thermostat that drives a cooler, such as a fridge.
31    ///
32    /// The output turns on when the reading rises above the deadband and off when
33    /// it falls below it.
34    ///
35    /// # Arguments
36    ///
37    /// * `setpoint` - the target reading.
38    /// * `hysteresis` - half the deadband width; its magnitude is used.
39    ///
40    /// # Returns
41    ///
42    /// A thermostat whose output starts off.
43    pub fn cooling(setpoint: f32, hysteresis: f32) -> Self {
44        Self {
45            setpoint,
46            hysteresis: magnitude(hysteresis),
47            cools: true,
48            on: false,
49        }
50    }
51
52    /// Creates a thermostat that drives a heater.
53    ///
54    /// The output turns on when the reading falls below the deadband and off when
55    /// it rises above it.
56    ///
57    /// # Arguments
58    ///
59    /// * `setpoint` - the target reading.
60    /// * `hysteresis` - half the deadband width; its magnitude is used.
61    ///
62    /// # Returns
63    ///
64    /// A thermostat whose output starts off.
65    pub fn heating(setpoint: f32, hysteresis: f32) -> Self {
66        Self {
67            setpoint,
68            hysteresis: magnitude(hysteresis),
69            cools: false,
70            on: false,
71        }
72    }
73
74    /// Updates the controller with a reading and returns whether the output is on.
75    ///
76    /// # Arguments
77    ///
78    /// * `reading` - the latest measured value.
79    ///
80    /// # Returns
81    ///
82    /// `true` if the cooler or heater should be running.
83    pub fn update(&mut self, reading: f32) -> bool {
84        let upper = self.setpoint + self.hysteresis;
85        let lower = self.setpoint - self.hysteresis;
86        if self.cools {
87            if reading >= upper {
88                self.on = true;
89            } else if reading <= lower {
90                self.on = false;
91            }
92        } else if reading <= lower {
93            self.on = true;
94        } else if reading >= upper {
95            self.on = false;
96        }
97        self.on
98    }
99
100    /// Returns whether the output is currently on.
101    ///
102    /// # Returns
103    ///
104    /// `true` if the most recent [`update`](Self::update) left the output running.
105    pub fn is_on(&self) -> bool {
106        self.on
107    }
108}
109
110// `f32::abs` lives in `std`, so this `no_std` crate takes the magnitude by hand.
111fn magnitude(value: f32) -> f32 {
112    if value < 0.0 {
113        -value
114    } else {
115        value
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn cooling_switches_around_the_deadband() {
125        let mut fridge = Thermostat::cooling(4.0, 0.5);
126        assert!(!fridge.is_on());
127        assert!(fridge.update(4.6)); // above 4.5: on
128        assert!(fridge.update(4.2)); // in the deadband: holds on
129        assert!(!fridge.update(3.4)); // below 3.5: off
130        assert!(!fridge.update(4.2)); // in the deadband: holds off
131    }
132
133    #[test]
134    fn heating_switches_the_other_way() {
135        let mut heater = Thermostat::heating(20.0, 1.0);
136        assert!(heater.update(18.5)); // below 19.0: on
137        assert!(heater.update(19.5)); // in the deadband: holds on
138        assert!(!heater.update(21.5)); // above 21.0: off
139    }
140
141    #[test]
142    fn negative_hysteresis_is_treated_as_its_magnitude() {
143        let mut fridge = Thermostat::cooling(4.0, -0.5);
144        assert!(fridge.update(4.6));
145        assert!(!fridge.update(3.4));
146    }
147}