use std::cell::Cell;
use super::provider::AzimuthProvider;
use super::search::{SearchOpts, DEFAULT_SCAN_STEP, EXTREMA_SCAN_STEP};
use super::types::{AzimuthCrossingEvent, AzimuthExtremum, AzimuthExtremumKind, AzimuthQuery};
use crate::calculus::altitude::CrossingDirection;
use crate::calculus::math_core::{extrema, intervals};
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::time::{complement_within, ModifiedJulianDate, Period, MJD};
use qtty::*;
fn scan_step_for<T: AzimuthProvider>(target: &T, opts: &SearchOpts) -> Days {
opts.scan_step_days
.or_else(|| target.scan_step_hint())
.unwrap_or(DEFAULT_SCAN_STEP)
}
fn make_az_bearing_sin_fn<'a, T: AzimuthProvider>(
target: &'a T,
site: &'a Geodetic<ECEF>,
bearing_rad: f64,
) -> impl Fn(ModifiedJulianDate) -> Radians + 'a {
let site = *site;
move |t: ModifiedJulianDate| {
let az = target.azimuth_at(&site, t).value();
Radians::new((az - bearing_rad).sin())
}
}
fn make_az_cosine_fn<'a, T: AzimuthProvider>(
target: &'a T,
site: &'a Geodetic<ECEF>,
mid_rad: f64,
cos_hw: f64,
) -> impl Fn(ModifiedJulianDate) -> Radians + 'a {
let site = *site;
move |t: ModifiedJulianDate| {
let az = target.azimuth_at(&site, t).value();
Radians::new((az - mid_rad).cos() - cos_hw)
}
}
fn make_az_unwrapped_fn<'a, T: AzimuthProvider>(
target: &'a T,
site: Geodetic<ECEF>,
) -> impl Fn(ModifiedJulianDate) -> Radians + 'a {
let prev_raw: Cell<f64> = Cell::new(f64::NAN);
let offset: Cell<f64> = Cell::new(0.0f64);
move |t: ModifiedJulianDate| {
let raw = target.azimuth_at(&site, t).value(); let p = prev_raw.get();
if !p.is_nan() {
let diff = raw - p;
if diff > std::f64::consts::PI {
offset.set(offset.get() - std::f64::consts::TAU);
} else if diff < -std::f64::consts::PI {
offset.set(offset.get() + std::f64::consts::TAU);
}
}
prev_raw.set(raw);
Radians::new(raw + offset.get())
}
}
pub fn azimuth_crossings<T: AzimuthProvider>(
target: &T,
observer: &Geodetic<ECEF>,
window: Period<MJD>,
bearing: Degrees,
opts: SearchOpts,
) -> Vec<AzimuthCrossingEvent> {
let bearing_rad = bearing.to::<Radian>().value();
let f = make_az_bearing_sin_fn(target, observer, bearing_rad);
let step = scan_step_for(target, &opts);
let mut raw = intervals::find_crossings(window, step, &f, Radians::new(0.0));
let labeled = intervals::label_crossings(&mut raw, &f, Radians::new(0.0));
labeled
.iter()
.map(|lc| AzimuthCrossingEvent {
mjd: lc.t,
direction: if lc.direction > 0 {
CrossingDirection::Rising
} else {
CrossingDirection::Setting
},
})
.collect()
}
pub fn azimuth_extrema<T: AzimuthProvider>(
target: &T,
observer: &Geodetic<ECEF>,
window: Period<MJD>,
opts: SearchOpts,
) -> Vec<AzimuthExtremum> {
let step = opts
.scan_step_days
.or_else(|| target.scan_step_hint())
.unwrap_or(EXTREMA_SCAN_STEP);
let tol = opts.time_tolerance;
let f = make_az_unwrapped_fn(target, *observer);
let raw: Vec<extrema::Extremum<Radian>> = extrema::find_extrema_tol(window, step, &f, tol);
raw.iter()
.map(|ext| {
let az_wrapped = ext.value.value().rem_euclid(std::f64::consts::TAU);
let az_deg = Radians::new(az_wrapped).to::<Degree>();
AzimuthExtremum {
mjd: ext.t,
azimuth: az_deg,
kind: match ext.kind {
extrema::ExtremumKind::Maximum => AzimuthExtremumKind::Max,
extrema::ExtremumKind::Minimum => AzimuthExtremumKind::Min,
},
}
})
.collect()
}
pub(crate) fn azimuth_range_periods<T: AzimuthProvider>(
target: &T,
query: &AzimuthQuery,
) -> Vec<Period<MJD>> {
let step = target.scan_step_hint().unwrap_or(DEFAULT_SCAN_STEP);
let min_deg = query.min_azimuth.value();
let max_deg = query.max_azimuth.value();
if min_deg <= max_deg {
let mid_rad = ((min_deg + max_deg) / 2.0).to_radians();
let hw_rad = ((max_deg - min_deg) / 2.0).to_radians();
let cos_hw = hw_rad.cos();
let f = make_az_cosine_fn(target, &query.observer, mid_rad, cos_hw);
intervals::above_threshold_periods(query.window, step, &f, Radians::new(0.0))
} else {
let mid_rad = ((max_deg + min_deg) / 2.0).to_radians();
let hw_rad = ((min_deg - max_deg) / 2.0).to_radians();
let cos_hw = hw_rad.cos();
let f = make_az_cosine_fn(target, &query.observer, mid_rad, cos_hw);
let outside = intervals::above_threshold_periods(query.window, step, &f, Radians::new(0.0));
complement_within(query.window, &outside)
}
}
pub fn azimuth_ranges<T: AzimuthProvider>(
target: &T,
observer: &Geodetic<ECEF>,
window: Period<MJD>,
min_az: Degrees,
max_az: Degrees,
_opts: SearchOpts,
) -> Vec<Period<MJD>> {
target.azimuth_periods(&AzimuthQuery {
observer: *observer,
window,
min_azimuth: min_az,
max_azimuth: max_az,
})
}
pub fn in_azimuth_range<T: AzimuthProvider>(
target: &T,
observer: &Geodetic<ECEF>,
window: Period<MJD>,
min_az: Degrees,
max_az: Degrees,
opts: SearchOpts,
) -> Vec<Period<MJD>> {
azimuth_ranges(target, observer, window, min_az, max_az, opts)
}
pub fn outside_azimuth_range<T: AzimuthProvider>(
target: &T,
observer: &Geodetic<ECEF>,
window: Period<MJD>,
min_az: Degrees,
max_az: Degrees,
opts: SearchOpts,
) -> Vec<Period<MJD>> {
let inside = azimuth_ranges(target, observer, window, min_az, max_az, opts);
complement_within(window, &inside)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::bodies::solar_system::Sun;
fn greenwich() -> Geodetic<ECEF> {
Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(51.4769), Meters::new(0.0))
}
fn one_day() -> Period<MJD> {
Period::new(
ModifiedJulianDate::new(60000.0),
ModifiedJulianDate::new(60001.0),
)
}
#[test]
fn crossings_finds_south_transit() {
let events = azimuth_crossings(
&Sun,
&greenwich(),
one_day(),
Degrees::new(180.0), SearchOpts::default(),
);
assert!(
!events.is_empty(),
"should find the Sun's southward transit"
);
}
#[test]
fn extrema_are_valid_if_present() {
let exts = azimuth_extrema(&Sun, &greenwich(), one_day(), SearchOpts::default());
for e in &exts {
assert!(
e.azimuth.value() >= 0.0 && e.azimuth.value() < 360.0,
"extremum azimuth out of [0°, 360°): {}",
e.azimuth.value()
);
}
}
#[test]
fn range_periods_non_wrap() {
let periods = in_azimuth_range(
&Sun,
&greenwich(),
one_day(),
Degrees::new(90.0), Degrees::new(270.0), SearchOpts::default(),
);
assert!(!periods.is_empty(), "Sun should be east-to-west in 24h");
}
#[test]
fn range_periods_wrap_around() {
let periods = in_azimuth_range(
&Sun,
&greenwich(),
Period::new(
ModifiedJulianDate::new(60000.0),
ModifiedJulianDate::new(60007.0),
),
Degrees::new(340.0),
Degrees::new(20.0),
SearchOpts::default(),
);
let _ = periods;
}
#[test]
fn outside_is_complement_of_in_range() {
let window = one_day();
let observer = greenwich();
let inside = in_azimuth_range(
&Sun,
&observer,
window,
Degrees::new(90.0),
Degrees::new(270.0),
SearchOpts::default(),
);
let outside = outside_azimuth_range(
&Sun,
&observer,
window,
Degrees::new(90.0),
Degrees::new(270.0),
SearchOpts::default(),
);
let total: f64 = inside
.iter()
.chain(outside.iter())
.map(|p| p.duration_days().value())
.sum();
let window_duration = window.duration_days().value();
assert!(
(total - window_duration).abs() < 1e-6,
"inside + outside durations must equal the window"
);
}
}