use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
pub enum Curve {
Linear(f32, f32),
Power(f32),
Logistic(f32, f32),
Step(f32),
Hours(f32, f32),
Days(f32, f32),
}
impl Curve {
pub fn eval(self, x: f32) -> f32 {
match self {
Curve::Linear(m, b) => (m * x + b).clamp(0.0, 1.0),
Curve::Power(k) => x.clamp(0.0, 1.0).powf(k),
Curve::Logistic(k, x0) => logistic(k, x0, x),
Curve::Step(at) => {
if x >= at {
1.0
} else {
0.0
}
}
Curve::Hours(from, to) => hour_window(from, to, x * 24.0),
Curve::Days(from, to) => day_window(from, to, x * f32::from(DAYS_PER_WEEK)),
}
}
}
pub fn logistic(k: f32, x0: f32, x: f32) -> f32 {
1.0 / (1.0 + (-k * (x - x0)).exp())
}
pub fn hour_window(from: f32, to: f32, hour: f32) -> f32 {
wrapped_window(from, to, hour, 24.0)
}
pub const DAYS_PER_WEEK: u8 = 7;
pub fn day_window(from: f32, to: f32, day: f32) -> f32 {
wrapped_window(from, to, day, f32::from(DAYS_PER_WEEK))
}
fn wrapped_window(from: f32, to: f32, at: f32, period: f32) -> f32 {
let at = at.rem_euclid(period);
let inside = if from < to {
at >= from && at < to
} else if from > to {
at >= from || at < to
} else {
false
};
if inside { 1.0 } else { 0.0 }
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn linear_clamps_to_unit_interval() {
assert!((Curve::Linear(-1.0, 1.0).eval(0.25) - 0.75).abs() < 1e-6);
assert_eq!(Curve::Linear(2.0, 0.0).eval(0.9), 1.0);
assert_eq!(Curve::Linear(-1.0, 0.0).eval(0.5), 0.0);
}
#[test]
fn logistic_is_half_at_its_midpoint() {
assert!((logistic(8.0, 0.7, 0.7) - 0.5).abs() < 1e-6);
assert!(logistic(8.0, 0.7, 1.0) > 0.9);
assert!(logistic(8.0, 0.7, 0.0) < 0.01);
}
#[test]
fn hour_window_wraps_past_midnight() {
assert_eq!(hour_window(22.0, 6.0, 23.0), 1.0);
assert_eq!(hour_window(22.0, 6.0, 3.0), 1.0);
assert_eq!(hour_window(22.0, 6.0, 12.0), 0.0);
assert_eq!(hour_window(8.0, 17.0, 17.0), 0.0);
assert_eq!(hour_window(8.0, 8.0, 8.0), 0.0);
}
#[test]
fn hours_curve_reads_a_normalised_hour() {
assert_eq!(Curve::Hours(22.0, 6.0).eval(23.0 / 24.0), 1.0);
assert_eq!(Curve::Hours(8.0, 17.0).eval(12.0 / 24.0), 1.0);
}
#[test]
fn step_and_power() {
assert_eq!(Curve::Step(0.5).eval(0.5), 1.0);
assert_eq!(Curve::Step(0.5).eval(0.49), 0.0);
assert!((Curve::Power(2.0).eval(0.5) - 0.25).abs() < 1e-6);
}
#[test]
fn day_window_wraps_past_the_end_of_the_week() {
assert_eq!(day_window(5.0, 7.0, 5.0), 1.0);
assert_eq!(day_window(5.0, 7.0, 6.99), 1.0);
assert_eq!(day_window(5.0, 7.0, 4.99), 0.0);
assert_eq!(day_window(6.0, 1.0, 6.5), 1.0);
assert_eq!(day_window(6.0, 1.0, 0.5), 1.0);
assert_eq!(day_window(6.0, 1.0, 1.0), 0.0);
assert_eq!(day_window(2.0, 2.0, 2.0), 0.0, "from == to is empty");
assert_eq!(day_window(4.75, 6.0, 4.74), 0.0);
assert_eq!(day_window(4.75, 6.0, 4.75), 1.0);
assert_eq!(day_window(4.75, 6.0, 5.99), 1.0);
assert_eq!(day_window(4.75, 6.0, 6.0), 0.0);
assert_eq!(day_window(0.0, 5.0, 7.5), 1.0);
}
#[test]
fn days_curve_puts_the_midnight_tick_on_the_new_day() {
for day in 0..7u8 {
let first = f32::from(day) / 7.0;
assert_eq!(
Curve::Days(f32::from(day), f32::from(day) + 1.0).eval(first),
1.0,
"day {day}"
);
let last_tick = (f32::from(day) + 23.99 / 24.0) / 7.0;
assert_eq!(
Curve::Days(f32::from(day), f32::from(day) + 1.0).eval(last_tick),
1.0,
"end of day {day}"
);
let next_day = (f32::from(day) + 1.0) / 7.0;
assert_eq!(
Curve::Days(f32::from(day), f32::from(day) + 1.0).eval(next_day),
0.0,
"start of day {}",
day + 1
);
}
}
}