pamoja-kit 0.1.17

Goal-named helper math for the pamoja device SDK: smoothing/filtering, calibration and units, PID and on/off control, prediction and anomaly detection, rolling stats, wheel kinematics (differential, Ackermann, skid-steer, mecanum), odometry, waypoint guidance, motion safety, and geo/IMU/weather helpers; no_std-friendly.
Documentation
//! Keeping a reading near a setpoint with on/off control.

/// A hysteresis (bang-bang) controller for a single on/off actuator.
///
/// This is the controller behind "keep a temperature". It switches a cooler or
/// heater on and off to hold a reading near a setpoint. A deadband around the
/// setpoint - the hysteresis - stops the output chattering when the reading hovers
/// at the threshold, which protects relays and compressors that have a limited
/// number of switching cycles in them.
///
/// # Examples
///
/// ```
/// use pamoja_kit::Thermostat;
///
/// let mut fridge = Thermostat::cooling(4.0, 0.5);
/// assert!(fridge.update(5.0)); // above the deadband: the cooler runs
/// assert!(fridge.update(4.2)); // inside the deadband: it holds its state
/// assert!(!fridge.update(3.4)); // below the deadband: the cooler stops
/// ```
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Thermostat {
    setpoint: f32,
    hysteresis: f32,
    cools: bool,
    on: bool,
}

impl Thermostat {
    /// Creates a thermostat that drives a cooler, such as a fridge.
    ///
    /// The output turns on when the reading rises above the deadband and off when
    /// it falls below it.
    ///
    /// # Arguments
    ///
    /// * `setpoint` - the target reading.
    /// * `hysteresis` - half the deadband width; its magnitude is used.
    ///
    /// # Returns
    ///
    /// A thermostat whose output starts off.
    pub fn cooling(setpoint: f32, hysteresis: f32) -> Self {
        Self {
            setpoint,
            hysteresis: magnitude(hysteresis),
            cools: true,
            on: false,
        }
    }

    /// Creates a thermostat that drives a heater.
    ///
    /// The output turns on when the reading falls below the deadband and off when
    /// it rises above it.
    ///
    /// # Arguments
    ///
    /// * `setpoint` - the target reading.
    /// * `hysteresis` - half the deadband width; its magnitude is used.
    ///
    /// # Returns
    ///
    /// A thermostat whose output starts off.
    pub fn heating(setpoint: f32, hysteresis: f32) -> Self {
        Self {
            setpoint,
            hysteresis: magnitude(hysteresis),
            cools: false,
            on: false,
        }
    }

    /// Updates the controller with a reading and returns whether the output is on.
    ///
    /// # Arguments
    ///
    /// * `reading` - the latest measured value.
    ///
    /// # Returns
    ///
    /// `true` if the cooler or heater should be running.
    pub fn update(&mut self, reading: f32) -> bool {
        let upper = self.setpoint + self.hysteresis;
        let lower = self.setpoint - self.hysteresis;
        if self.cools {
            if reading >= upper {
                self.on = true;
            } else if reading <= lower {
                self.on = false;
            }
        } else if reading <= lower {
            self.on = true;
        } else if reading >= upper {
            self.on = false;
        }
        self.on
    }

    /// Returns whether the output is currently on.
    ///
    /// # Returns
    ///
    /// `true` if the most recent [`update`](Self::update) left the output running.
    pub fn is_on(&self) -> bool {
        self.on
    }
}

// `f32::abs` lives in `std`, so this `no_std` crate takes the magnitude by hand.
fn magnitude(value: f32) -> f32 {
    if value < 0.0 {
        -value
    } else {
        value
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn cooling_switches_around_the_deadband() {
        let mut fridge = Thermostat::cooling(4.0, 0.5);
        assert!(!fridge.is_on());
        assert!(fridge.update(4.6)); // above 4.5: on
        assert!(fridge.update(4.2)); // in the deadband: holds on
        assert!(!fridge.update(3.4)); // below 3.5: off
        assert!(!fridge.update(4.2)); // in the deadband: holds off
    }

    #[test]
    fn heating_switches_the_other_way() {
        let mut heater = Thermostat::heating(20.0, 1.0);
        assert!(heater.update(18.5)); // below 19.0: on
        assert!(heater.update(19.5)); // in the deadband: holds on
        assert!(!heater.update(21.5)); // above 21.0: off
    }

    #[test]
    fn negative_hysteresis_is_treated_as_its_magnitude() {
        let mut fridge = Thermostat::cooling(4.0, -0.5);
        assert!(fridge.update(4.6));
        assert!(!fridge.update(3.4));
    }
}