1use 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
15pub 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}