use chrono::{DateTime, Timelike, Utc};
use serde::{Deserialize, Serialize};
use crate::mood::{Emotion, MoodVector};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[non_exhaustive]
pub enum Chronotype {
EarlyBird,
MorningLeaning,
Neutral,
EveningLeaning,
NightOwl,
}
impl Chronotype {
pub const ALL: &'static [Chronotype] = &[
Self::EarlyBird,
Self::MorningLeaning,
Self::Neutral,
Self::EveningLeaning,
Self::NightOwl,
];
#[must_use]
#[inline]
pub fn phase_shift_hours(self) -> f32 {
match self {
Self::EarlyBird => -2.0,
Self::MorningLeaning => -1.0,
Self::Neutral => 0.0,
Self::EveningLeaning => 1.0,
Self::NightOwl => 2.0,
}
}
}
impl_display!(Chronotype {
EarlyBird => "early bird",
MorningLeaning => "morning-leaning",
Neutral => "neutral",
EveningLeaning => "evening-leaning",
NightOwl => "night owl",
});
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CircadianRhythm {
pub chronotype: Chronotype,
pub primary_amplitude: f32,
pub secondary_amplitude: f32,
pub utc_offset_hours: f32,
}
impl Default for CircadianRhythm {
fn default() -> Self {
Self {
chronotype: Chronotype::Neutral,
primary_amplitude: 0.3,
secondary_amplitude: 0.1,
utc_offset_hours: 0.0,
}
}
}
impl CircadianRhythm {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn with_chronotype(chronotype: Chronotype) -> Self {
Self {
chronotype,
..Self::default()
}
}
#[must_use]
#[inline]
fn local_hour(&self, now: DateTime<Utc>) -> f64 {
let utc_hour =
now.hour() as f64 + now.minute() as f64 / 60.0 + now.second() as f64 / 3600.0;
let shifted =
utc_hour + self.utc_offset_hours as f64 + self.chronotype.phase_shift_hours() as f64;
shifted.rem_euclid(24.0)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
#[inline]
pub fn alertness(&self, now: DateTime<Utc>) -> f32 {
let h = self.local_hour(now);
let tau = std::f64::consts::TAU;
let primary = (tau * (h - 10.0) / 24.0).cos() as f32;
let secondary = -(tau * (h - 14.0) / 12.0).cos() as f32;
let raw = 0.5 + self.primary_amplitude * primary + self.secondary_amplitude * secondary;
raw.clamp(0.0, 1.0)
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn mood_modulation(&self, now: DateTime<Utc>) -> MoodVector {
let a = self.alertness(now);
let deviation = a - 0.5;
let mut delta = MoodVector::neutral();
delta.set(Emotion::Joy, deviation * 0.1);
delta.set(Emotion::Interest, deviation * 0.15);
delta.set(Emotion::Arousal, deviation * 0.1);
delta
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
#[inline]
pub fn decay_rate_modifier(&self, now: DateTime<Utc>) -> f32 {
let a = self.alertness(now);
0.7 + a * 0.6
}
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
#[inline]
pub fn energy_recovery_modifier(&self, now: DateTime<Utc>) -> f32 {
let a = self.alertness(now);
0.6 + a * 0.8
}
}
#[cfg(feature = "traits")]
#[cfg_attr(feature = "tracing", tracing::instrument(skip_all))]
#[must_use]
pub fn circadian_from_personality(profile: &crate::traits::PersonalityProfile) -> CircadianRhythm {
use crate::traits::TraitKind;
let precision = profile.get_trait(TraitKind::Precision).normalized();
let formality = profile.get_trait(TraitKind::Formality).normalized();
let creativity = profile.get_trait(TraitKind::Creativity).normalized();
let risk_tolerance = profile.get_trait(TraitKind::RiskTolerance).normalized();
let morning_pull = (precision + formality) / 2.0;
let evening_pull = (creativity + risk_tolerance) / 2.0;
let net = morning_pull - evening_pull;
let chronotype = if net > 0.5 {
Chronotype::EarlyBird
} else if net > 0.15 {
Chronotype::MorningLeaning
} else if net < -0.5 {
Chronotype::NightOwl
} else if net < -0.15 {
Chronotype::EveningLeaning
} else {
Chronotype::Neutral
};
CircadianRhythm {
chronotype,
..CircadianRhythm::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::TimeZone;
fn time_at_hour(hour: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 15, hour, 0, 0).unwrap()
}
fn time_at_hour_min(hour: u32, min: u32) -> DateTime<Utc> {
Utc.with_ymd_and_hms(2026, 6, 15, hour, min, 0).unwrap()
}
#[test]
fn test_circadian_default() {
let c = CircadianRhythm::new();
assert_eq!(c.chronotype, Chronotype::Neutral);
assert!((c.primary_amplitude - 0.3).abs() < f32::EPSILON);
}
#[test]
fn test_alertness_bounded() {
let c = CircadianRhythm::new();
for hour in 0..24 {
let a = c.alertness(time_at_hour(hour));
assert!(
(0.0..=1.0).contains(&a),
"hour {hour}: alertness {a} out of bounds"
);
}
}
#[test]
fn test_alertness_peak_morning() {
let c = CircadianRhythm::new();
let morning = c.alertness(time_at_hour(10)); let afternoon = c.alertness(time_at_hour(16));
let night = c.alertness(time_at_hour(4));
assert!(
morning > afternoon,
"morning={morning} should > afternoon={afternoon}"
);
assert!(morning > night, "morning={morning} should > night={night}");
}
#[test]
fn test_alertness_trough_night() {
let c = CircadianRhythm::new();
let night = c.alertness(time_at_hour(4));
let day = c.alertness(time_at_hour(12));
assert!(night < day, "night={night} should < day={day}");
}
#[test]
fn test_post_lunch_dip() {
let c = CircadianRhythm::new();
let pre_lunch = c.alertness(time_at_hour(11));
let post_lunch = c.alertness(time_at_hour_min(14, 0));
assert!(
post_lunch < pre_lunch,
"post_lunch={post_lunch} should < pre_lunch={pre_lunch}"
);
}
#[test]
fn test_chronotype_shifts_peak() {
let early = CircadianRhythm::with_chronotype(Chronotype::EarlyBird);
let late = CircadianRhythm::with_chronotype(Chronotype::NightOwl);
let t = time_at_hour(12);
assert!(
early.alertness(t) > late.alertness(t),
"early={} late={}",
early.alertness(t),
late.alertness(t)
);
}
#[test]
fn test_utc_offset() {
let mut c = CircadianRhythm::new();
c.utc_offset_hours = 5.0; let a = c.alertness(time_at_hour(5));
assert!(a > 0.6, "UTC+5 at 05:00 UTC (10:00 local): {a}");
}
#[test]
fn test_mood_modulation_bounded() {
let c = CircadianRhythm::new();
for hour in 0..24 {
let delta = c.mood_modulation(time_at_hour(hour));
for &e in Emotion::ALL {
let v = delta.get(e);
assert!((-1.0..=1.0).contains(&v), "hour {hour} {e}: {v}");
}
}
}
#[test]
fn test_mood_modulation_peak_positive() {
let c = CircadianRhythm::new();
let delta = c.mood_modulation(time_at_hour(10)); assert!(delta.joy > 0.0, "peak joy: {}", delta.joy);
assert!(delta.interest > 0.0, "peak interest: {}", delta.interest);
}
#[test]
fn test_decay_rate_modifier_range() {
let c = CircadianRhythm::new();
for hour in 0..24 {
let m = c.decay_rate_modifier(time_at_hour(hour));
assert!((0.7..=1.3).contains(&m), "hour {hour}: decay modifier {m}");
}
}
#[test]
fn test_energy_recovery_modifier_range() {
let c = CircadianRhythm::new();
for hour in 0..24 {
let m = c.energy_recovery_modifier(time_at_hour(hour));
assert!(
(0.6..=1.4).contains(&m),
"hour {hour}: recovery modifier {m}"
);
}
}
#[test]
fn test_chronotype_display() {
assert_eq!(Chronotype::EarlyBird.to_string(), "early bird");
assert_eq!(Chronotype::NightOwl.to_string(), "night owl");
assert_eq!(Chronotype::Neutral.to_string(), "neutral");
}
#[test]
fn test_chronotype_all() {
assert_eq!(Chronotype::ALL.len(), 5);
}
#[test]
fn test_chronotype_phase_shifts() {
assert!((Chronotype::EarlyBird.phase_shift_hours() - (-2.0)).abs() < f32::EPSILON);
assert!(Chronotype::Neutral.phase_shift_hours().abs() < f32::EPSILON);
assert!((Chronotype::NightOwl.phase_shift_hours() - 2.0).abs() < f32::EPSILON);
}
#[test]
fn test_serde_rhythm() {
let c = CircadianRhythm::with_chronotype(Chronotype::NightOwl);
let json = serde_json::to_string(&c).unwrap();
let c2: CircadianRhythm = serde_json::from_str(&json).unwrap();
assert_eq!(c2.chronotype, Chronotype::NightOwl);
}
#[test]
fn test_serde_chronotype() {
let ct = Chronotype::EarlyBird;
let json = serde_json::to_string(&ct).unwrap();
let ct2: Chronotype = serde_json::from_str(&json).unwrap();
assert_eq!(ct2, ct);
}
#[cfg(feature = "traits")]
#[test]
fn test_circadian_from_personality_precise() {
let mut p = crate::traits::PersonalityProfile::new("precise");
p.set_trait(
crate::traits::TraitKind::Precision,
crate::traits::TraitLevel::Highest,
);
p.set_trait(
crate::traits::TraitKind::Formality,
crate::traits::TraitLevel::Highest,
);
let c = circadian_from_personality(&p);
assert_eq!(c.chronotype, Chronotype::EarlyBird);
}
#[cfg(feature = "traits")]
#[test]
fn test_circadian_from_personality_creative() {
let mut p = crate::traits::PersonalityProfile::new("creative");
p.set_trait(
crate::traits::TraitKind::Creativity,
crate::traits::TraitLevel::Highest,
);
p.set_trait(
crate::traits::TraitKind::RiskTolerance,
crate::traits::TraitLevel::Highest,
);
let c = circadian_from_personality(&p);
assert_eq!(c.chronotype, Chronotype::NightOwl);
}
#[cfg(feature = "traits")]
#[test]
fn test_circadian_from_personality_balanced() {
let p = crate::traits::PersonalityProfile::new("balanced");
let c = circadian_from_personality(&p);
assert_eq!(c.chronotype, Chronotype::Neutral);
}
}