use crate::astro::earth_rotation::jd_ut1_from_tt_eop;
use crate::astro::nutation::nutation_iau2000b;
use crate::astro::precession;
use crate::astro::sidereal::gast_iau2006;
use crate::astro::sidereal::SIDEREAL_DAY;
use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::coordinates::spherical;
use crate::coordinates::transform::AstroContext;
use crate::time::JulianDate;
use crate::time::{ModifiedJulianDate, Period, MJD};
use qtty::*;
const SIDEREAL_DAY_DAYS: Days = SIDEREAL_DAY.to_const::<Day>();
const DEGENERATE_B_EPS: Quantity<Unitless> = Quantity::new(1e-15);
const CROSSING_EDGE_EPS: Days = Days::new(1e-12);
#[derive(Debug, Clone, Copy)]
pub(crate) enum ThresholdResult {
AlwaysAbove,
NeverAbove,
Crossings { h0: Radians },
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct StarAltitudeParams {
a: Quantity<Unitless>,
b: Quantity<Unitless>,
ra_corrected: Degrees,
lon: Degrees,
}
impl StarAltitudeParams {
pub fn from_j2000(
equatorial_j2000: spherical::direction::EquatorialMeanJ2000,
site: &Geodetic<ECEF>,
epoch_jd: JulianDate,
) -> Self {
let ra_rad = equatorial_j2000.ra().to::<Radian>();
let dec_rad = equatorial_j2000.dec().to::<Radian>();
let (sin_ra, cos_ra) = ra_rad.sin_cos();
let (sin_dec, cos_dec) = dec_rad.sin_cos();
let x0 = cos_dec * cos_ra;
let y0 = cos_dec * sin_ra;
let z0 = sin_dec;
let nut = nutation_iau2000b(epoch_jd);
let npb = precession::precession_nutation_matrix(epoch_jd, nut.dpsi, nut.deps);
let [x_t, y_t, z_t] = npb.apply_array([x0, y0, z0]);
let ra_corrected = Degrees::new(y_t.atan2(x_t).to_degrees());
let dec = Radians::new(z_t.asin());
let lat_rad = site.lat.to::<Radian>();
Self {
a: Quantity::new(dec.sin() * lat_rad.sin()),
b: Quantity::new(dec.cos() * lat_rad.cos()),
ra_corrected,
lon: site.lon,
}
}
pub fn threshold_ha(&self, threshold: Radians) -> ThresholdResult {
let sin_h: Quantity<Unitless> = Quantity::new(threshold.sin());
if self.b.abs() < DEGENERATE_B_EPS {
return if self.a > sin_h {
ThresholdResult::AlwaysAbove
} else {
ThresholdResult::NeverAbove
};
}
let cos_h0 = (sin_h - self.a) / self.b;
if cos_h0 <= -1.0 {
ThresholdResult::AlwaysAbove
} else if cos_h0 >= 1.0 {
ThresholdResult::NeverAbove
} else {
ThresholdResult::Crossings {
h0: Radians::new(cos_h0.acos()),
}
}
}
#[inline]
fn hour_angle(&self, mjd: ModifiedJulianDate) -> Degrees {
let jd: JulianDate = mjd.into();
let ctx: AstroContext = AstroContext::default();
let eop = ctx.eop_at(jd);
let jd_ut1 = jd_ut1_from_tt_eop(jd, &eop);
let nut = nutation_iau2000b(jd);
let gast = gast_iau2006(jd_ut1, jd, nut.dpsi, nut.true_obliquity());
let lst_rad = gast + self.lon.to::<Radian>();
let ha_rad = lst_rad - self.ra_corrected.to::<Radian>();
ha_rad.to::<Degree>()
}
pub fn predict_crossings(
&self,
period: Period<MJD>,
h0: Radians,
) -> Vec<(ModifiedJulianDate, i32)> {
let h0_deg = h0.to::<Degree>();
let ha_start = self.hour_angle(period.start);
let mut crossings = Vec::new();
for &(ha_target, dir) in &[(Degrees::FULL_TURN - h0_deg, 1_i32), (h0_deg, -1_i32)] {
let dha = (ha_target - ha_start).normalize();
let phase = (dha / Degrees::FULL_TURN).simplify();
let dt_first: Days = (SIDEREAL_DAY_DAYS * phase).to::<Day>();
let mut dt = dt_first;
while period.start + dt <= period.end + CROSSING_EDGE_EPS {
let t_cross = (period.start + dt).max(period.start).min(period.end);
crossings.push((t_cross, dir));
dt += SIDEREAL_DAY_DAYS;
}
}
crossings.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
crossings
}
}
#[cfg(test)]
mod tests {
use super::*;
fn equatorial_j2000(ra: Degrees, dec: Degrees) -> spherical::direction::EquatorialMeanJ2000 {
spherical::direction::EquatorialMeanJ2000::new(ra, dec)
}
fn greenwich() -> Geodetic<ECEF> {
Geodetic::<ECEF>::new(
Degrees::new(0.0),
Degrees::new(51.4769),
Quantity::<Meter>::new(0.0),
)
}
#[test]
fn polaris_circumpolar_at_greenwich() {
let params = StarAltitudeParams::from_j2000(
equatorial_j2000(
Degrees::new(37.95), Degrees::new(89.26), ),
&greenwich(),
JulianDate::J2000,
);
match params.threshold_ha(Radians::new(0.0)) {
ThresholdResult::AlwaysAbove => {} other => panic!("Polaris should be circumpolar at 51°N, got {:?}", other),
}
}
#[test]
fn sirius_has_crossings_at_greenwich() {
let params = StarAltitudeParams::from_j2000(
equatorial_j2000(
Degrees::new(101.287), Degrees::new(-16.716), ),
&greenwich(),
JulianDate::J2000,
);
match params.threshold_ha(Radians::new(0.0)) {
ThresholdResult::Crossings { h0 } => {
assert!(h0 > Radians::zero() && h0 < Radians::HALF_TURN);
}
other => panic!(
"Sirius should have horizon crossings at 51°N, got {:?}",
other
),
}
}
#[test]
fn never_visible_star_at_greenwich() {
let params = StarAltitudeParams::from_j2000(
equatorial_j2000(Degrees::new(0.0), Degrees::new(-80.0)),
&greenwich(),
JulianDate::J2000,
);
match params.threshold_ha(Radians::new(0.0)) {
ThresholdResult::NeverAbove => {} other => panic!(
"Star at Dec=−80° should never be visible at 51°N, got {:?}",
other
),
}
}
#[test]
fn predict_crossings_count_7_days() {
let params = StarAltitudeParams::from_j2000(
equatorial_j2000(Degrees::new(101.287), Degrees::new(-16.716)),
&greenwich(),
JulianDate::J2000,
);
let period = Period::new(
ModifiedJulianDate::new(60000.0),
ModifiedJulianDate::new(60007.0),
);
if let ThresholdResult::Crossings { h0 } = params.threshold_ha(Radians::new(0.0)) {
let crossings = params.predict_crossings(period, h0);
assert!(
crossings.len() >= 13 && crossings.len() <= 16,
"expected ~14 crossings in 7 days, got {}",
crossings.len()
);
for (t, _) in &crossings {
assert!(*t >= period.start - Days::new(1e-10));
assert!(*t <= period.end + Days::new(1e-10));
}
let rises: usize = crossings.iter().filter(|(_, d)| *d > 0).count();
let sets: usize = crossings.iter().filter(|(_, d)| *d < 0).count();
assert!((rises as i32 - sets as i32).unsigned_abs() <= 1);
} else {
panic!("Sirius should have crossings at greenwich");
}
}
}