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 reaches the top of the deadband and off
33    /// when it reaches the bottom.
34    ///
35    /// # Arguments
36    ///
37    /// * `setpoint` - the target reading. One that is not a number never switches.
38    /// * `hysteresis` - half the deadband width; its magnitude is used, and one that is not
39    ///   a number is taken as zero.
40    ///
41    /// # Returns
42    ///
43    /// A thermostat whose output starts off.
44    pub fn cooling(setpoint: f32, hysteresis: f32) -> Self {
45        Self {
46            setpoint,
47            hysteresis: magnitude(hysteresis),
48            cools: true,
49            on: false,
50        }
51    }
52
53    /// Creates a thermostat that drives a heater.
54    ///
55    /// The output turns on when the reading reaches the bottom of the deadband and off
56    /// when it reaches the top.
57    ///
58    /// # Arguments
59    ///
60    /// * `setpoint` - the target reading. One that is not a number never switches.
61    /// * `hysteresis` - half the deadband width; its magnitude is used, and one that is not
62    ///   a number is taken as zero.
63    ///
64    /// # Returns
65    ///
66    /// A thermostat whose output starts off.
67    pub fn heating(setpoint: f32, hysteresis: f32) -> Self {
68        Self {
69            setpoint,
70            hysteresis: magnitude(hysteresis),
71            cools: false,
72            on: false,
73        }
74    }
75
76    /// Updates the controller with a reading and returns whether the output is on.
77    ///
78    /// A reading that is not a finite number, such as the NaN a failed probe reports, is
79    /// ignored and the output stays as it was: a heater that was on stays on. A node that
80    /// must fail safe checks the reading itself and decides what off means for its plant.
81    ///
82    /// # Arguments
83    ///
84    /// * `reading` - the latest measured value.
85    ///
86    /// # Returns
87    ///
88    /// `true` if the cooler or heater should be running.
89    pub fn update(&mut self, reading: f32) -> bool {
90        if !reading.is_finite() {
91            return self.on;
92        }
93        let upper = self.setpoint + self.hysteresis;
94        let lower = self.setpoint - self.hysteresis;
95        if self.cools {
96            if reading >= upper {
97                self.on = true;
98            } else if reading <= lower {
99                self.on = false;
100            }
101        } else if reading <= lower {
102            self.on = true;
103        } else if reading >= upper {
104            self.on = false;
105        }
106        self.on
107    }
108
109    /// Returns whether the output is currently on.
110    ///
111    /// # Returns
112    ///
113    /// `true` if the most recent [`update`](Self::update) left the output running.
114    pub fn is_on(&self) -> bool {
115        self.on
116    }
117}
118
119// The magnitude of a hysteresis, taken by hand because `f32::abs` lives in `std`, with one
120// that is not a number read as zero.
121fn magnitude(value: f32) -> f32 {
122    if value.is_nan() {
123        0.0
124    } else if value < 0.0 {
125        -value
126    } else {
127        value
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    use super::*;
134
135    #[test]
136    fn cooling_switches_around_the_deadband() {
137        let mut fridge = Thermostat::cooling(4.0, 0.5);
138        assert!(!fridge.is_on());
139        assert!(fridge.update(4.6)); // above 4.5: on
140        assert!(fridge.update(4.2)); // in the deadband: holds on
141        assert!(!fridge.update(3.4)); // below 3.5: off
142        assert!(!fridge.update(4.2)); // in the deadband: holds off
143    }
144
145    #[test]
146    fn heating_switches_the_other_way() {
147        let mut heater = Thermostat::heating(20.0, 1.0);
148        assert!(heater.update(18.5)); // below 19.0: on
149        assert!(heater.update(19.5)); // in the deadband: holds on
150        assert!(!heater.update(21.5)); // above 21.0: off
151    }
152
153    #[test]
154    fn the_edge_of_the_deadband_switches() {
155        let mut fridge = Thermostat::cooling(4.0, 0.5);
156        assert!(fridge.update(4.5));
157        assert!(!fridge.update(3.5));
158    }
159
160    #[test]
161    fn a_reading_that_is_not_a_number_holds_the_output() {
162        let mut heater = Thermostat::heating(20.0, 1.0);
163        assert!(heater.update(18.5));
164        assert!(heater.update(f32::NAN));
165        assert!(heater.update(f32::INFINITY));
166        assert!(!heater.update(21.5));
167        assert!(!heater.update(f32::NEG_INFINITY));
168    }
169
170    #[test]
171    fn a_hysteresis_that_is_not_a_number_is_taken_as_zero() {
172        let mut fridge = Thermostat::cooling(4.0, f32::NAN);
173        assert!(fridge.update(4.1));
174        assert!(!fridge.update(3.9));
175    }
176
177    #[test]
178    fn negative_hysteresis_is_treated_as_its_magnitude() {
179        let mut fridge = Thermostat::cooling(4.0, -0.5);
180        assert!(fridge.update(4.6));
181        assert!(!fridge.update(3.4));
182    }
183}