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