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(
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}