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}