Skip to main content

cadmus_core/frontlight/
auto.rs

1//! Automatic frontlight calculations based on sunrise and sunset.
2//!
3//! The logic in this module keeps brightness and warmth aligned with the
4//! current solar day for a given location.
5
6use crate::geolocation::Coordinates;
7
8use super::{LightLevel, LightLevels};
9use chrono::{DateTime, Local, NaiveDateTime, Timelike};
10
11const MINUTES_PER_DAY: f64 = 24.0 * 60.0;
12
13const WARMTH_TRANSITION_HOURS: f64 = 1.5;
14
15/// Computes the frontlight levels that should be active at the given time.
16///
17/// Brightness switches between `current_intensity` during daylight hours and
18/// `night_brightness` while the sun is down. Warmth ramps over a fixed
19/// transition window before sunrise and before sunset, reaching fully cool at
20/// sunrise and fully warm at sunset.
21pub fn compute_auto_frontlight_levels(
22    now: DateTime<Local>,
23    coordinates: Coordinates,
24    night_brightness: LightLevel,
25    current_intensity: LightLevel,
26) -> LightLevels {
27    let today = now.date_naive();
28    let coords: sunrise::Coordinates = coordinates.into();
29    let solar_day = sunrise::SolarDay::new(coords, today);
30    let sunrise_utc = solar_day.event_time(sunrise::SolarEvent::Sunrise);
31    let sunset_utc = solar_day.event_time(sunrise::SolarEvent::Sunset);
32
33    let minutes_since_midnight =
34        |dt: NaiveDateTime| -> f64 { (dt.hour() as f64 * 60.0) + dt.minute() as f64 };
35
36    let now_min = (now.hour() as f64 * 60.0) + now.minute() as f64;
37    let offset = *now.offset();
38    let sr_local = sunrise_utc.with_timezone(&offset).naive_local();
39    let ss_local = sunset_utc.with_timezone(&offset).naive_local();
40    let sr_min = minutes_since_midnight(sr_local);
41    let ss_min = minutes_since_midnight(ss_local);
42    let transition_min = WARMTH_TRANSITION_HOURS * 60.0;
43
44    let sun_is_down = now_min < sr_min || now_min > ss_min;
45
46    let intensity = if sun_is_down {
47        night_brightness
48    } else {
49        current_intensity
50    };
51
52    let evening_ramp_start = ss_min - transition_min;
53    let evening_ramp_end = ss_min;
54    let morning_ramp_start = sr_min - transition_min;
55    let morning_ramp_end = sr_min;
56
57    let normalized_minute = |minute: f64| minute.rem_euclid(MINUTES_PER_DAY);
58    let minute_in_wrapped_range = |minute: f64, start: f64, end: f64| {
59        let minute = normalized_minute(minute);
60        let start = normalized_minute(start);
61        let end = normalized_minute(end);
62
63        if start <= end {
64            minute >= start && minute < end
65        } else {
66            minute >= start || minute < end
67        }
68    };
69    let wrapped_range_progress = |minute: f64, start: f64, end: f64| {
70        let span = (end - start).rem_euclid(MINUTES_PER_DAY);
71        let elapsed = (minute - start).rem_euclid(MINUTES_PER_DAY);
72        elapsed / span
73    };
74
75    let warmth_fraction: f32 =
76        if minute_in_wrapped_range(now_min, evening_ramp_end, morning_ramp_start) {
77            1.0
78        } else if minute_in_wrapped_range(now_min, morning_ramp_end, evening_ramp_start) {
79            0.0
80        } else if minute_in_wrapped_range(now_min, evening_ramp_start, evening_ramp_end) {
81            wrapped_range_progress(now_min, evening_ramp_start, evening_ramp_end) as f32
82        } else {
83            1.0 - wrapped_range_progress(now_min, morning_ramp_start, morning_ramp_end) as f32
84        };
85
86    LightLevels {
87        intensity,
88        warmth: LightLevel::from_fraction(warmth_fraction),
89    }
90}
91
92#[cfg(test)]
93mod tests {
94    use super::*;
95    use chrono::TimeZone;
96
97    fn london() -> Coordinates {
98        Coordinates::new(51.5074, -0.1278).unwrap()
99    }
100
101    fn tromso() -> Coordinates {
102        Coordinates::new(69.6492, 18.9553).unwrap()
103    }
104
105    fn make_dt(date: &str, time: &str) -> DateTime<Local> {
106        let naive =
107            chrono::NaiveDateTime::parse_from_str(&format!("{date} {time}"), "%Y-%m-%d %H:%M:%S")
108                .unwrap();
109        Local.from_local_datetime(&naive).single().unwrap()
110    }
111
112    fn compute_expected_sun_times(date: chrono::NaiveDate, coords: Coordinates) -> (f64, f64) {
113        let day = sunrise::SolarDay::new(coords.into(), date);
114        let sr = day
115            .event_time(sunrise::SolarEvent::Sunrise)
116            .with_timezone(&Local)
117            .naive_local();
118        let ss = day
119            .event_time(sunrise::SolarEvent::Sunset)
120            .with_timezone(&Local)
121            .naive_local();
122        (
123            (sr.hour() as f64 * 60.0) + sr.minute() as f64,
124            (ss.hour() as f64 * 60.0) + ss.minute() as f64,
125        )
126    }
127
128    #[test]
129    fn night_brightness_is_applied_when_sun_is_down() {
130        let midnight = make_dt("2025-06-21", "00:00:00");
131        let levels = compute_auto_frontlight_levels(midnight, london(), 10.0.into(), 50.0.into());
132        assert_eq!(
133            levels.intensity, 10.0,
134            "night brightness should be night_brightness"
135        );
136    }
137
138    #[test]
139    fn day_brightness_preserves_current_intensity() {
140        let noon = make_dt("2025-06-21", "12:00:00");
141        let levels = compute_auto_frontlight_levels(noon, london(), 10.0.into(), 50.0.into());
142        assert_eq!(
143            levels.intensity, 50.0,
144            "day brightness should be current_intensity"
145        );
146    }
147
148    #[test]
149    fn warmth_is_zero_at_sunrise() {
150        let sr_utc = sunrise::SolarDay::new(
151            london().into(),
152            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
153        )
154        .event_time(sunrise::SolarEvent::Sunrise);
155        let sr_local = sr_utc.with_timezone(&Local);
156        let levels = compute_auto_frontlight_levels(sr_local, london(), 10.0.into(), 50.0.into());
157        assert!(
158            levels.warmth < 1.0,
159            "at sunrise warmth should be ~0, got {}",
160            levels.warmth
161        );
162    }
163
164    #[test]
165    fn warmth_is_one_hundred_at_sunset() {
166        let ss_utc = sunrise::SolarDay::new(
167            london().into(),
168            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
169        )
170        .event_time(sunrise::SolarEvent::Sunset);
171        let ss_local = ss_utc.with_timezone(&Local);
172        let levels = compute_auto_frontlight_levels(ss_local, london(), 10.0.into(), 50.0.into());
173        assert!(
174            (levels.warmth - 100.0).abs() < 1.0,
175            "at sunset warmth should be ~100, got {}",
176            levels.warmth
177        );
178    }
179
180    #[test]
181    fn warmth_is_zero_during_middle_of_day() {
182        let noon = make_dt("2025-06-21", "12:00:00");
183        let levels = compute_auto_frontlight_levels(noon, london(), 10.0.into(), 50.0.into());
184        assert!(
185            levels.warmth < 1.0,
186            "midday warmth should be ~0, got {}",
187            levels.warmth
188        );
189    }
190
191    #[test]
192    fn warmth_is_one_hundred_during_middle_of_night() {
193        let midnight = make_dt("2025-06-21", "00:00:00");
194        let levels = compute_auto_frontlight_levels(midnight, london(), 10.0.into(), 50.0.into());
195        assert!(
196            (levels.warmth - 100.0).abs() < 1.0,
197            "midnight warmth should be ~100, got {}",
198            levels.warmth
199        );
200    }
201
202    #[test]
203    fn warmth_ramps_from_zero_to_one_hundred_in_evening_transition() {
204        let (_, ss) = compute_expected_sun_times(
205            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
206            london(),
207        );
208        let transition = 90.0;
209
210        let ramp_start = (ss - transition) as i64;
211        let h = ramp_start / 60;
212        let m = ramp_start % 60;
213        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
214        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
215        assert!(
216            levels.warmth < 2.0,
217            "evening ramp start: warmth should be ~0, got {}",
218            levels.warmth
219        );
220
221        let midpoint = (ss - transition / 2.0) as i64;
222        let h = midpoint / 60;
223        let m = midpoint % 60;
224        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
225        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
226        assert!(
227            (levels.warmth - 50.0).abs() < 6.0,
228            "evening ramp midpoint: warmth should be ~50, got {}",
229            levels.warmth
230        );
231    }
232
233    #[test]
234    fn warmth_ramps_from_one_hundred_to_zero_in_morning_transition() {
235        let (sr, _) = compute_expected_sun_times(
236            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
237            london(),
238        );
239        let transition = 90.0;
240
241        let ramp_start = (sr - transition) as i64;
242        let h = ramp_start / 60;
243        let m = ramp_start % 60;
244        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
245        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
246        assert!(
247            (levels.warmth - 100.0).abs() < 2.0,
248            "morning ramp start: warmth should be ~100, got {}",
249            levels.warmth
250        );
251
252        let midpoint = (sr - transition / 2.0) as i64;
253        let h = midpoint / 60;
254        let m = midpoint % 60;
255        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
256        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
257        assert!(
258            (levels.warmth - 50.0).abs() < 6.0,
259            "morning ramp midpoint: warmth should be ~50, got {}",
260            levels.warmth
261        );
262    }
263
264    #[test]
265    fn evening_brightness_is_night_level() {
266        let (_, ss) = compute_expected_sun_times(
267            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
268            london(),
269        );
270        let post_sunset_min = ss + 30.0;
271        let h = post_sunset_min as i64 / 60;
272        let m = post_sunset_min as i64 % 60;
273        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
274        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
275        assert_eq!(
276            levels.intensity, 10.0,
277            "post-sunset brightness should be night_brightness"
278        );
279    }
280
281    #[test]
282    fn morning_brightness_is_night_level_before_sunrise() {
283        let (sr, _) = compute_expected_sun_times(
284            chrono::NaiveDate::from_ymd_opt(2025, 6, 21).unwrap(),
285            london(),
286        );
287        let pre_sunrise_min = sr - 120.0;
288        let h = pre_sunrise_min as i64 / 60;
289        let m = pre_sunrise_min as i64 % 60;
290        let t = make_dt("2025-06-21", &format!("{h:02}:{m:02}:00"));
291        let levels = compute_auto_frontlight_levels(t, london(), 10.0.into(), 50.0.into());
292        assert_eq!(
293            levels.intensity, 10.0,
294            "pre-sunrise brightness should be night_brightness"
295        );
296    }
297
298    #[test]
299    fn warmth_stays_continuous_across_midnight_when_morning_ramp_wraps() {
300        let coordinates = tromso();
301        let before_midnight = make_dt("2025-05-15", "23:59:00");
302        let after_midnight = make_dt("2025-05-16", "00:00:00");
303
304        let before_levels =
305            compute_auto_frontlight_levels(before_midnight, coordinates, 10.0.into(), 50.0.into());
306        let after_levels =
307            compute_auto_frontlight_levels(after_midnight, coordinates, 10.0.into(), 50.0.into());
308
309        assert!(
310            (f32::from(before_levels.warmth) - f32::from(after_levels.warmth)).abs() < 4.0,
311            "warmth should stay continuous across midnight, got {} then {}",
312            before_levels.warmth,
313            after_levels.warmth
314        );
315    }
316}