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}