use chrono::{DateTime, Duration, TimeZone};
use chrono_tz::Tz;
use crate::astro::{moon, sun, Location};
#[derive(Clone, Copy)]
enum EventSource {
Solar(sun::SolarEvent),
Moon(moon::LunarEvent),
}
#[derive(Clone, Copy)]
struct EventDefinition {
label: &'static str,
source: EventSource,
}
const EVENT_DEFINITIONS: &[EventDefinition] = &[
EventDefinition {
label: "☀️ Solar noon",
source: EventSource::Solar(sun::SolarEvent::SolarNoon),
},
EventDefinition {
label: "🌇 Sunset",
source: EventSource::Solar(sun::SolarEvent::Sunset),
},
EventDefinition {
label: "🌕 Moonrise",
source: EventSource::Moon(moon::LunarEvent::Moonrise),
},
EventDefinition {
label: "🌆 Civil dusk",
source: EventSource::Solar(sun::SolarEvent::CivilDusk),
},
EventDefinition {
label: "⛵ Nautical dusk",
source: EventSource::Solar(sun::SolarEvent::NauticalDusk),
},
EventDefinition {
label: "🌠 Astro dusk",
source: EventSource::Solar(sun::SolarEvent::AstronomicalDusk),
},
EventDefinition {
label: "🔭 Astro dawn",
source: EventSource::Solar(sun::SolarEvent::AstronomicalDawn),
},
EventDefinition {
label: "⚓ Nautical dawn",
source: EventSource::Solar(sun::SolarEvent::NauticalDawn),
},
EventDefinition {
label: "🏙️ Civil dawn",
source: EventSource::Solar(sun::SolarEvent::CivilDawn),
},
EventDefinition {
label: "🌅 Sunrise",
source: EventSource::Solar(sun::SolarEvent::Sunrise),
},
EventDefinition {
label: "🌑 Moonset",
source: EventSource::Moon(moon::LunarEvent::Moonset),
},
];
pub fn collect_events_within_window(
location: &Location,
reference: &DateTime<Tz>,
window: Duration,
) -> Vec<(DateTime<Tz>, &'static str)> {
let max_delta = window.num_seconds().abs();
let mut events = Vec::new();
for offset in -1..=1 {
let shifted = if offset == 0 {
*reference
} else {
reference
.checked_add_signed(Duration::days(offset as i64))
.unwrap_or(*reference)
};
for definition in EVENT_DEFINITIONS {
let maybe_time = match definition.source {
EventSource::Solar(event) => sun::solar_event_time(location, &shifted, event),
EventSource::Moon(event) => moon::lunar_event_time(location, &shifted, event),
};
if let Some(event_time) = maybe_time {
let delta = event_time.signed_duration_since(reference);
if delta.num_seconds().abs() <= max_delta {
events.push((event_time, definition.label));
}
}
}
}
let astro_dawn = events
.iter()
.find(|(_, label)| label.contains("Astro dawn"))
.map(|(dt, _)| *dt);
let astro_dusk = events
.iter()
.find(|(_, label)| label.contains("Astro dusk"))
.map(|(dt, _)| *dt);
let dark_windows = calculate_dark_windows(location, reference, window, astro_dawn, astro_dusk);
for (dt, label) in dark_windows {
events.push((dt, label));
}
events.sort_by_key(|(dt, _)| *dt);
events.dedup_by(|a, b| a.0 == b.0 && a.1 == b.1);
events
}
fn is_moon_sufficiently_dark(
location: &Location,
time: &DateTime<Tz>,
buffer_minutes: i64,
) -> bool {
let moon_now = moon::lunar_position(location, time);
if moon_now.altitude >= 0.0 {
return false;
}
if let Some(time_past) = time.checked_sub_signed(Duration::minutes(buffer_minutes)) {
let moon_past = moon::lunar_position(location, &time_past);
if moon_past.altitude >= 0.0 {
return false; }
}
if let Some(time_future) = time.checked_add_signed(Duration::minutes(buffer_minutes)) {
let moon_future = moon::lunar_position(location, &time_future);
if moon_future.altitude >= 0.0 {
return false; }
}
true
}
fn calculate_dark_windows(
location: &Location,
reference: &DateTime<Tz>,
window: Duration,
astro_dawn: Option<DateTime<Tz>>,
astro_dusk: Option<DateTime<Tz>>,
) -> Vec<(DateTime<Tz>, &'static str)> {
const MOON_GLOW_BUFFER_MINUTES: i64 = 15; const SAMPLE_INTERVAL_MINUTES: i64 = 1;
let start_time = reference.checked_sub_signed(window).unwrap_or(*reference);
let end_time = reference.checked_add_signed(window).unwrap_or(*reference);
let mut events = Vec::new();
let mut in_dark_window = false;
let mut current_time = start_time;
let mut prev_time = start_time;
let mut first_sample = true;
while current_time <= end_time {
let sun_pos = sun::solar_position(location, ¤t_time);
let sun_dark = sun_pos.altitude < -18.0;
let moon_dark =
is_moon_sufficiently_dark(location, ¤t_time, MOON_GLOW_BUFFER_MINUTES);
let is_dark = sun_dark && moon_dark;
if is_dark && !in_dark_window {
if !first_sample {
let prev_moon_dark =
is_moon_sufficiently_dark(location, &prev_time, MOON_GLOW_BUFFER_MINUTES);
if prev_moon_dark && moon_dark {
if let Some(ref dusk_time) = astro_dusk {
let time_diff = dusk_time
.signed_duration_since(prev_time)
.num_seconds()
.abs();
if time_diff <= 120 {
events.push((*dusk_time, "🌌 Dark win start"));
in_dark_window = true;
first_sample = false;
prev_time = current_time;
current_time = current_time
.checked_add_signed(Duration::minutes(SAMPLE_INTERVAL_MINUTES))
.unwrap_or(end_time);
continue;
}
}
}
let refined_time =
refine_dark_window_transition(location, &prev_time, ¤t_time, true);
events.push((refined_time, "🌌 Dark win start"));
}
in_dark_window = true;
} else if !is_dark && in_dark_window {
if !first_sample {
if moon_dark {
if let Some(ref dawn_time) = astro_dawn {
let time_diff = dawn_time
.signed_duration_since(prev_time)
.num_seconds()
.abs();
if time_diff <= 120 {
events.push((*dawn_time, "🌄 Dark win end"));
in_dark_window = false;
first_sample = false;
prev_time = current_time;
current_time = current_time
.checked_add_signed(Duration::minutes(SAMPLE_INTERVAL_MINUTES))
.unwrap_or(end_time);
continue;
}
}
}
let refined_time =
refine_dark_window_transition(location, &prev_time, ¤t_time, false);
events.push((refined_time, "🌄 Dark win end"));
}
in_dark_window = false;
}
first_sample = false;
prev_time = current_time;
current_time = current_time
.checked_add_signed(Duration::minutes(SAMPLE_INTERVAL_MINUTES))
.unwrap_or(end_time);
}
events
}
fn refine_dark_window_transition(
location: &Location,
start: &DateTime<Tz>,
end: &DateTime<Tz>,
looking_for_start: bool,
) -> DateTime<Tz> {
const MOON_GLOW_BUFFER_MINUTES: i64 = 15; const TOLERANCE_SECONDS: i64 = 5;
let mut left = *start;
let mut right = *end;
while right.signed_duration_since(left).num_seconds() > TOLERANCE_SECONDS {
let mid_seconds = (left.timestamp() + right.timestamp()) / 2;
let mid = left.timezone().timestamp_opt(mid_seconds, 0).unwrap();
let sun_pos = sun::solar_position(location, &mid);
let sun_dark = sun_pos.altitude < -18.0;
let moon_dark = is_moon_sufficiently_dark(location, &mid, MOON_GLOW_BUFFER_MINUTES);
let is_dark = sun_dark && moon_dark;
if looking_for_start {
if is_dark {
right = mid; } else {
left = mid; }
} else {
if is_dark {
left = mid; } else {
right = mid; }
}
}
let mid_seconds = (left.timestamp() + right.timestamp()) / 2;
left.timezone().timestamp_opt(mid_seconds, 0).unwrap()
}