use crate::bodies::solar_system::Sun;
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::event::search::intervals;
use crate::qtty::*;
use crate::time::{complement_within, Interval, JulianDate, ModifiedJulianDate};
const SCAN_STEP: Days = Quantity::<Hour>::new(2.0).to_const::<Day>();
pub(crate) fn sun_altitude_rad(mjd: ModifiedJulianDate, site: &Geodetic<ECEF>) -> Quantity<Radian> {
let jd: JulianDate = mjd.to::<crate::JD>();
Sun::get_horizontal::<AstronomicalUnit>(jd, *site)
.alt()
.to::<Radian>()
}
pub(crate) fn find_day_periods(
site: Geodetic<ECEF>,
period: Interval<ModifiedJulianDate>,
threshold: Degrees,
) -> Vec<Interval<ModifiedJulianDate>> {
let thr = threshold.to::<Radian>();
let f = |t: ModifiedJulianDate| -> Radians { sun_altitude_rad(t, &site) };
intervals::above_threshold_periods_directed(period, SCAN_STEP, &f, thr)
}
pub(crate) fn find_night_periods(
site: Geodetic<ECEF>,
period: Interval<ModifiedJulianDate>,
twilight: Degrees,
) -> Vec<Interval<ModifiedJulianDate>> {
let days = find_day_periods(site, period, twilight);
complement_within(period, &days)
}
pub(crate) fn find_sun_range_periods(
site: Geodetic<ECEF>,
period: Interval<ModifiedJulianDate>,
range: (Degrees, Degrees),
) -> Vec<Interval<ModifiedJulianDate>> {
let h_min = range.0.to::<Radian>();
let h_max = range.1.to::<Radian>();
let f = |t: ModifiedJulianDate| -> Radians { sun_altitude_rad(t, &site) };
intervals::in_range_periods_directed(period, SCAN_STEP, &f, h_min, h_max)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeZone, Utc};
fn greenwich_site() -> Geodetic<ECEF> {
Geodetic::<ECEF>::new(
Degrees::new(0.0),
Degrees::new(51.4769),
Quantity::<Meter>::new(0.0),
)
}
fn utc_mjd(year: i32, month: u32, day: u32) -> ModifiedJulianDate {
ModifiedJulianDate::from(
Utc.with_ymd_and_hms(year, month, day, 0, 0, 0)
.single()
.unwrap(),
)
}
fn assert_periods_close(
actual: &[Interval<ModifiedJulianDate>],
expected: &[Interval<ModifiedJulianDate>],
) {
assert_eq!(
actual.len(),
expected.len(),
"actual={actual:?} expected={expected:?}"
);
for (a, e) in actual.iter().zip(expected.iter()) {
assert!(
(a.start.raw() - e.start.raw()).abs() < Days::new(1e-6),
"start mismatch: actual={a:?} expected={e:?}"
);
assert!(
(a.end.raw() - e.end.raw()).abs() < Days::new(1e-6),
"end mismatch: actual={a:?} expected={e:?}"
);
}
}
fn generic_day_periods(
site: Geodetic<ECEF>,
period: Interval<ModifiedJulianDate>,
threshold: Degrees,
) -> Vec<Interval<ModifiedJulianDate>> {
let f = |t: ModifiedJulianDate| -> Radians { sun_altitude_rad(t, &site) };
intervals::above_threshold_periods(period, SCAN_STEP, &f, threshold.to::<Radian>())
}
#[test]
fn test_sun_altitude_basic() {
let site = greenwich_site();
let mjd: ModifiedJulianDate = crate::J2000.to::<crate::MJD>();
let alt = sun_altitude_rad(mjd, &site);
assert!(
alt > Radians::new(-std::f64::consts::FRAC_PI_2)
&& alt < Radians::new(std::f64::consts::FRAC_PI_2)
);
}
#[test]
fn test_find_night_periods() {
use crate::event::solar::twilight;
let site = greenwich_site();
let mjd_start = crate::time::ModifiedJulianDate::new(60000.0);
let mjd_end = crate::time::ModifiedJulianDate::new(60007.0);
let period = Interval::new(mjd_start, mjd_end);
let nights = find_night_periods(site, period, twilight::ASTRONOMICAL);
assert!(
!nights.is_empty(),
"Should find night periods at 51° latitude"
);
for night in &nights {
assert!(
(night.end.raw() - night.start.raw()) > Days::new(0.0),
"Night duration should be positive"
);
assert!(
(night.end.raw() - night.start.raw()) < Days::new(1.0),
"Night should be less than 24 hours"
);
}
}
#[test]
fn test_find_altitude_range_periods() {
let site = greenwich_site();
let mjd_start = crate::time::ModifiedJulianDate::new(60000.0);
let mjd_end = crate::time::ModifiedJulianDate::new(60007.0);
let period = Interval::new(mjd_start, mjd_end);
let nights =
find_sun_range_periods(site, period, (Degrees::new(-90.0), Degrees::new(-18.0)));
assert!(!nights.is_empty(), "Should find night periods using range");
let nautical =
find_sun_range_periods(site, period, (Degrees::new(-18.0), Degrees::new(-12.0)));
assert!(
!nautical.is_empty(),
"Should find nautical twilight periods"
);
}
#[test]
fn directed_solar_threshold_periods_match_generic_scan() {
let site = greenwich_site();
let period = Interval::new(utc_mjd(2026, 1, 1), utc_mjd(2026, 1, 8));
for threshold in [
Degrees::new(0.0),
Degrees::new(-6.0),
Degrees::new(-12.0),
Degrees::new(-18.0),
] {
let directed_days = find_day_periods(site, period, threshold);
let generic_days = generic_day_periods(site, period, threshold);
assert_periods_close(&directed_days, &generic_days);
let directed_nights = find_night_periods(site, period, threshold);
let generic_nights = complement_within(period, &generic_days);
assert_periods_close(&directed_nights, &generic_nights);
}
}
#[test]
fn directed_solar_range_periods_match_generic_scan() {
let site = greenwich_site();
let period = Interval::new(utc_mjd(2026, 1, 1), utc_mjd(2026, 1, 8));
let f = |t: ModifiedJulianDate| -> Radians { sun_altitude_rad(t, &site) };
let directed =
find_sun_range_periods(site, period, (Degrees::new(-18.0), Degrees::new(-12.0)));
let generic = intervals::in_range_periods(
period,
SCAN_STEP,
&f,
Degrees::new(-18.0).to::<Radian>(),
Degrees::new(-12.0).to::<Radian>(),
);
assert_periods_close(&directed, &generic);
}
#[test]
fn directed_solar_no_crossing_matches_generic_scan() {
let site = Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(80.0), Meters::new(0.0));
let period = Interval::new(utc_mjd(2026, 6, 20), utc_mjd(2026, 6, 27));
let threshold = Degrees::new(-18.0);
let directed_nights = find_night_periods(site, period, threshold);
let generic_days = generic_day_periods(site, period, threshold);
let generic_nights = complement_within(period, &generic_days);
assert_periods_close(&directed_nights, &generic_nights);
assert!(
directed_nights.is_empty(),
"80°N near solstice should stay above -18°"
);
}
}