use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[non_exhaustive]
pub enum ActivityPattern {
Diurnal,
Nocturnal,
Crepuscular,
Cathemeral,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircadianClock {
pub pattern: ActivityPattern,
pub period_hours: f32,
pub phase_offset: f32,
pub amplitude: f32,
}
impl CircadianClock {
#[must_use]
pub fn new(pattern: ActivityPattern) -> Self {
Self {
pattern,
period_hours: 24.0,
phase_offset: 0.0,
amplitude: 0.7,
}
}
#[must_use]
pub fn activity_level(&self, hour: f32) -> f32 {
if self.pattern == ActivityPattern::Cathemeral {
return 1.0;
}
if self.period_hours <= 0.0 {
return 1.0;
}
let phase = core::f32::consts::TAU * (hour - self.phase_offset) / self.period_hours;
let raw = match self.pattern {
ActivityPattern::Diurnal => (phase - core::f32::consts::PI).cos(),
ActivityPattern::Nocturnal => -(phase - core::f32::consts::PI).cos(),
ActivityPattern::Crepuscular => -(2.0 * phase).cos(),
_ => return 1.0,
};
let normalized = (raw + 1.0) * 0.5; 1.0 - self.amplitude + self.amplitude * normalized
}
#[must_use]
pub fn drive_modifier(&self, hour: f32, is_rest_drive: bool) -> f32 {
let activity = self.activity_level(hour);
if is_rest_drive {
1.0 + (1.0 - activity) * self.amplitude
} else {
activity
}
}
}
#[must_use]
pub fn zeitgeber_correction(
current_phase: f32,
light_phase: f32,
period: f32,
entrainment_rate: f32,
) -> f32 {
if period <= 0.0 {
return 0.0;
}
let entrainment_rate = entrainment_rate.clamp(0.0, 1.0);
let mut diff = light_phase - current_phase;
let half = period * 0.5;
if diff > half {
diff -= period;
} else if diff < -half {
diff += period;
}
diff * entrainment_rate
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diurnal_peak_at_noon() {
let clock = CircadianClock::new(ActivityPattern::Diurnal);
let noon = clock.activity_level(12.0);
let midnight = clock.activity_level(0.0);
assert!(
noon > midnight,
"diurnal should peak at noon: noon={noon}, midnight={midnight}"
);
}
#[test]
fn nocturnal_peak_at_midnight() {
let clock = CircadianClock::new(ActivityPattern::Nocturnal);
let noon = clock.activity_level(12.0);
let midnight = clock.activity_level(0.0);
assert!(
midnight > noon,
"nocturnal should peak at midnight: midnight={midnight}, noon={noon}"
);
}
#[test]
fn crepuscular_peaks_at_dawn_and_dusk() {
let clock = CircadianClock::new(ActivityPattern::Crepuscular);
let dawn = clock.activity_level(6.0);
let noon = clock.activity_level(12.0);
let dusk = clock.activity_level(18.0);
assert!(dawn > noon, "crepuscular should peak at dawn over noon");
assert!(dusk > noon, "crepuscular should peak at dusk over noon");
}
#[test]
fn cathemeral_always_one() {
let clock = CircadianClock::new(ActivityPattern::Cathemeral);
for hour in [0.0, 6.0, 12.0, 18.0, 23.0] {
assert!(
(clock.activity_level(hour) - 1.0).abs() < f32::EPSILON,
"cathemeral should always be 1.0"
);
}
}
#[test]
fn activity_within_bounds() {
let clock = CircadianClock::new(ActivityPattern::Diurnal);
for hour in 0..24 {
let level = clock.activity_level(hour as f32);
let min = 1.0 - clock.amplitude;
assert!(
level >= min - f32::EPSILON && level <= 1.0 + f32::EPSILON,
"activity out of bounds at hour {hour}: {level}"
);
}
}
#[test]
fn rest_drive_inverse_of_activity() {
let clock = CircadianClock::new(ActivityPattern::Diurnal);
let active_rest = clock.drive_modifier(12.0, true);
let inactive_rest = clock.drive_modifier(0.0, true);
assert!(
inactive_rest > active_rest,
"rest drive should be higher when inactive"
);
}
#[test]
fn zeitgeber_entrains_toward_light() {
let correction = zeitgeber_correction(2.0, 0.0, 24.0, 0.2);
assert!(correction < 0.0, "should pull phase backward toward light");
let correction2 = zeitgeber_correction(0.0, 2.0, 24.0, 0.2);
assert!(correction2 > 0.0, "should pull phase forward toward light");
}
#[test]
fn zeitgeber_wraps_around_cycle() {
let correction = zeitgeber_correction(23.0, 1.0, 24.0, 1.0);
assert!(
correction > 0.0 && correction < 5.0,
"should take short path: {correction}"
);
}
#[test]
fn custom_period() {
let mut clock = CircadianClock::new(ActivityPattern::Diurnal);
clock.period_hours = 30.0;
let peak = clock.activity_level(15.0); let trough = clock.activity_level(0.0);
assert!(peak > trough);
}
#[test]
fn zero_period_safe() {
let mut clock = CircadianClock::new(ActivityPattern::Diurnal);
clock.period_hours = 0.0;
assert!((clock.activity_level(12.0) - 1.0).abs() < f32::EPSILON);
assert!((zeitgeber_correction(0.0, 1.0, 0.0, 0.2)).abs() < f32::EPSILON);
}
#[test]
fn serde_roundtrip_activity_pattern() {
for p in [
ActivityPattern::Diurnal,
ActivityPattern::Nocturnal,
ActivityPattern::Crepuscular,
ActivityPattern::Cathemeral,
] {
let json = serde_json::to_string(&p).unwrap();
let p2: ActivityPattern = serde_json::from_str(&json).unwrap();
assert_eq!(p, p2);
}
}
#[test]
fn serde_roundtrip_circadian_clock() {
let clock = CircadianClock::new(ActivityPattern::Nocturnal);
let json = serde_json::to_string(&clock).unwrap();
let clock2: CircadianClock = serde_json::from_str(&json).unwrap();
assert_eq!(clock.pattern, clock2.pattern);
assert!((clock.period_hours - clock2.period_hours).abs() < f32::EPSILON);
assert!((clock.amplitude - clock2.amplitude).abs() < f32::EPSILON);
}
}