use ::time::{Duration, OffsetDateTime};
use crate::angle::{parse_dec, Angle, ParseMode};
use crate::coords::{precess, Equatorial};
use crate::error::{Error, Result};
use crate::time::{julian_epoch_of, lst};
const SIDEREAL_RATE_DEG_PER_DAY: f64 = 360.985_647_366_29;
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Location {
latitude: Angle,
longitude: Angle,
elevation_m: f64,
}
impl Location {
pub fn new(latitude: Angle, longitude: Angle, elevation_m: f64) -> Result<Self> {
let lat = latitude.degrees();
if !lat.is_finite() || !(-90.0..=90.0).contains(&lat) {
return Err(Error::OutOfRange {
what: "latitude",
value: lat,
});
}
let lon = longitude.degrees();
if !lon.is_finite() || !(-180.0..=180.0).contains(&lon) {
return Err(Error::OutOfRange {
what: "longitude",
value: lon,
});
}
if !elevation_m.is_finite() {
return Err(Error::OutOfRange {
what: "elevation",
value: elevation_m,
});
}
Ok(Self {
latitude,
longitude,
elevation_m,
})
}
pub fn parse(lat: &str, lon: &str, elevation_m: f64) -> Result<Self> {
let latitude = parse_site_angle(lat, ['N', 'S'])?;
let longitude = parse_site_angle(lon, ['E', 'W'])?;
Self::new(latitude, longitude, elevation_m)
}
pub fn latitude(self) -> Angle {
self.latitude
}
pub fn longitude(self) -> Angle {
self.longitude
}
pub fn elevation_m(self) -> f64 {
self.elevation_m
}
}
fn parse_site_angle(s: &str, hemis: [char; 2]) -> Result<Angle> {
let t = s.trim();
let (body, hemi) = match t.chars().last() {
Some(c) if c.is_ascii_alphabetic() => {
let u = c.to_ascii_uppercase();
if !"NSEW".contains(u) {
return Err(Error::ParseCoord(t.to_string()));
}
if !hemis.contains(&u) {
return Err(Error::ParseCoord(format!(
"hemisphere suffix {u:?} is not valid here: {t:?}"
)));
}
(t[..t.len() - 1].trim_end(), Some(u))
}
_ => (t, None),
};
let angle = parse_dec(body, ParseMode::Lenient)?;
match hemi {
None => Ok(angle),
Some(_) if angle.degrees().is_sign_negative() => Err(Error::ParseCoord(format!(
"explicit sign contradicts hemisphere suffix: {t:?}"
))),
Some(h) if h == hemis[1] => Ok(-angle),
Some(_) => Ok(angle),
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Horizontal {
pub altitude: Angle,
pub azimuth: Angle,
}
pub fn hour_angle(target: Equatorial, at: OffsetDateTime, site: &Location) -> Angle {
let of_date = precess(target, julian_epoch_of(at));
(lst(at, site.longitude()) - of_date.ra()).normalized_pm_180()
}
pub fn alt_az(target: Equatorial, at: OffsetDateTime, site: &Location) -> Horizontal {
let target = precess(target, julian_epoch_of(at));
let ha = hour_angle(target, at, site).radians();
let dec = target.dec().radians();
let lat = site.latitude().radians();
let sin_alt = dec.sin() * lat.sin() + dec.cos() * lat.cos() * ha.cos();
let alt = sin_alt.clamp(-1.0, 1.0).asin();
let denominator = alt.cos() * lat.cos();
let az_deg = if denominator.abs() < 1e-10 {
if ha.sin() > 0.0 {
180.0
} else {
0.0
}
} else {
let cos_az = ((dec.sin() - alt.sin() * lat.sin()) / denominator).clamp(-1.0, 1.0);
let az = cos_az.acos().to_degrees();
if ha.sin() > 0.0 {
360.0 - az
} else {
az
}
};
Horizontal {
altitude: Angle::from_radians(alt),
azimuth: Angle::from_degrees(az_deg).normalized_0_360(),
}
}
pub fn airmass(altitude: Angle) -> Result<f64> {
let h = checked_altitude(altitude)?;
Ok(1.0 / (h.to_radians().sin() + 0.50572 * (h + 6.079_95).powf(-1.636_4)))
}
pub fn refraction_apparent_to_true(apparent_alt: Angle) -> Result<Angle> {
let h = checked_altitude(apparent_alt)?;
let r_arcmin = 1.0 / (h + 7.31 / (h + 4.4)).to_radians().tan();
Ok(apparent_alt - Angle::from_arcminutes(r_arcmin.max(0.0)))
}
pub fn refraction_true_to_apparent(true_alt: Angle) -> Result<Angle> {
let h = checked_altitude(true_alt)?;
let r_arcmin = 1.02 / (h + 10.3 / (h + 5.11)).to_radians().tan();
Ok(true_alt + Angle::from_arcminutes(r_arcmin.max(0.0)))
}
fn checked_altitude(altitude: Angle) -> Result<f64> {
let h = altitude.degrees();
if !h.is_finite() || !(-1.0..=90.0 + 1e-9).contains(&h) {
return Err(Error::OutOfRange {
what: "altitude",
value: h,
});
}
Ok(h.min(90.0))
}
pub fn parallactic_angle(target: Equatorial, at: OffsetDateTime, site: &Location) -> Angle {
let target = precess(target, julian_epoch_of(at));
let ha = hour_angle(target, at, site).radians();
let dec = target.dec().radians();
let lat = site.latitude().radians();
Angle::from_radians(ha.sin().atan2(lat.tan() * dec.cos() - dec.sin() * ha.cos()))
}
pub fn transit(target: Equatorial, near: OffsetDateTime, site: &Location) -> OffsetDateTime {
culmination(target, near, site, 0.0)
}
fn culmination(
target: Equatorial,
near: OffsetDateTime,
site: &Location,
ha_target_deg: f64,
) -> OffsetDateTime {
let mut t = near;
for _ in 0..2 {
let off = Angle::from_degrees(hour_angle(target, t, site).degrees() - ha_target_deg)
.normalized_pm_180()
.degrees();
t -= Duration::seconds_f64(off / SIDEREAL_RATE_DEG_PER_DAY * 86_400.0);
}
t
}
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum CrossingOutcome {
AlwaysAbove,
NeverAbove,
Crosses {
rise: OffsetDateTime,
set: OffsetDateTime,
},
}
pub fn altitude_crossings(
target: Equatorial,
threshold: Angle,
night_of: OffsetDateTime,
site: &Location,
) -> CrossingOutcome {
let target = precess(target, julian_epoch_of(night_of));
match semi_arc(target, threshold, site) {
SemiArc::AlwaysAbove => CrossingOutcome::AlwaysAbove,
SemiArc::NeverAbove => CrossingOutcome::NeverAbove,
SemiArc::Half(semi_arc_deg) => {
let half = sidereal_duration(semi_arc_deg);
let t0 = transit(target, night_of, site);
CrossingOutcome::Crosses {
rise: t0 - half,
set: t0 + half,
}
}
}
}
enum SemiArc {
AlwaysAbove,
NeverAbove,
Half(f64),
}
fn semi_arc(target_of_date: Equatorial, threshold: Angle, site: &Location) -> SemiArc {
let phi = site.latitude().radians();
let dec = target_of_date.dec().radians();
let sin_h0 = threshold.radians().sin();
let denominator = phi.cos() * dec.cos();
if denominator.abs() < 1e-12 {
return if phi.sin() * dec.sin() >= sin_h0 {
SemiArc::AlwaysAbove
} else {
SemiArc::NeverAbove
};
}
let cos_h0 = (sin_h0 - phi.sin() * dec.sin()) / denominator;
if cos_h0 < -1.0 {
SemiArc::AlwaysAbove
} else if cos_h0 > 1.0 {
SemiArc::NeverAbove
} else {
SemiArc::Half(cos_h0.acos().to_degrees())
}
}
fn sidereal_duration(degrees: f64) -> Duration {
Duration::seconds_f64(degrees / SIDEREAL_RATE_DEG_PER_DAY * 86_400.0)
}
pub(crate) fn moving_body_crossings<F>(
position: F,
threshold: Angle,
near: OffsetDateTime,
site: &Location,
anchor_ha_deg: f64,
) -> CrossingOutcome
where
F: Fn(OffsetDateTime) -> Equatorial,
{
let mut anchor = near;
for _ in 0..3 {
anchor = culmination(position(anchor), anchor, site, anchor_ha_deg);
}
let pos = precess(position(anchor), julian_epoch_of(anchor));
let h0 = match semi_arc(pos, threshold, site) {
SemiArc::AlwaysAbove => return CrossingOutcome::AlwaysAbove,
SemiArc::NeverAbove => return CrossingOutcome::NeverAbove,
SemiArc::Half(h0) => h0,
};
let offset = if anchor_ha_deg == 0.0 { h0 } else { 180.0 - h0 };
let first_is_rising = anchor_ha_deg == 0.0;
let mut first = anchor - sidereal_duration(offset);
let mut second = anchor + sidereal_duration(offset);
for _ in 0..3 {
first = refine_crossing(&position, threshold, first, site, first_is_rising);
second = refine_crossing(&position, threshold, second, site, !first_is_rising);
}
if first_is_rising {
CrossingOutcome::Crosses {
rise: first,
set: second,
}
} else {
CrossingOutcome::Crosses {
rise: second,
set: first,
}
}
}
fn refine_crossing<F>(
position: &F,
threshold: Angle,
t: OffsetDateTime,
site: &Location,
rising: bool,
) -> OffsetDateTime
where
F: Fn(OffsetDateTime) -> Equatorial,
{
let pos = precess(position(t), julian_epoch_of(t));
match semi_arc(pos, threshold, site) {
SemiArc::AlwaysAbove | SemiArc::NeverAbove => t,
SemiArc::Half(h0) => {
let tr = culmination(pos, t, site, 0.0);
if rising {
tr - sidereal_duration(h0)
} else {
tr + sidereal_duration(h0)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use ::time::macros::datetime;
fn kitt_peak() -> Location {
Location::new(
Angle::from_degrees(31.9583),
Angle::from_degrees(-111.6),
2120.0,
)
.unwrap()
}
#[test]
fn location_validates_domains() {
let ok = |lat: f64, lon: f64| {
Location::new(Angle::from_degrees(lat), Angle::from_degrees(lon), 0.0)
};
assert!(ok(90.0, 180.0).is_ok());
assert!(ok(-90.0, -180.0).is_ok());
assert!(ok(90.1, 0.0).is_err());
assert!(ok(0.0, 180.1).is_err());
assert!(
Location::new(Angle::from_degrees(0.0), Angle::from_degrees(0.0), f64::NAN).is_err()
);
}
#[test]
fn parse_accepts_decimal_sexagesimal_and_suffix() {
let l = Location::parse("+52 05 32", "-111 36 00", 0.0).unwrap();
assert!((l.latitude().degrees() - 52.092_222).abs() < 3e-5); assert!((l.longitude().degrees() + 111.6).abs() < 3e-5);
let l = Location::parse("52.09 S", "4.31 W", 0.0).unwrap();
assert!((l.latitude().degrees() + 52.09).abs() < 1e-9);
assert!((l.longitude().degrees() + 4.31).abs() < 1e-9);
let l = Location::parse("31:57:30 N", "111:36:00 E", 2120.0).unwrap();
assert!((l.latitude().degrees() - 31.958_333).abs() < 3e-5);
assert!((l.longitude().degrees() - 111.6).abs() < 3e-5);
}
#[test]
fn parse_rejects_wrong_axis_conflicts_and_garbage() {
assert!(Location::parse("52.09 E", "4.31", 0.0).is_err()); assert!(Location::parse("52.09", "4.31 N", 0.0).is_err()); assert!(Location::parse("-52.09 S", "4.31", 0.0).is_err()); assert!(Location::parse("52 xx 09", "4.31", 0.0).is_err());
assert!(Location::parse("", "4.31", 0.0).is_err());
}
#[test]
fn airmass_matches_kasten_young_published_values() {
assert!((airmass(Angle::from_degrees(90.0)).unwrap() - 1.0).abs() < 1e-3);
assert!((airmass(Angle::from_degrees(30.0)).unwrap() - 1.994).abs() < 5e-3);
assert!((airmass(Angle::from_degrees(0.0)).unwrap() - 37.9).abs() < 0.2);
assert!(airmass(Angle::from_degrees(-2.0)).is_err());
}
#[test]
fn refraction_matches_published_values() {
let horizon = refraction_apparent_to_true(Angle::from_degrees(0.0)).unwrap();
assert!((horizon.arcminutes() + 34.5).abs() < 0.2, "{horizon:?}");
let mid = refraction_apparent_to_true(Angle::from_degrees(45.0)).unwrap();
assert!(((45.0 - mid.degrees()) * 60.0 - 1.0).abs() < 0.05);
let apparent = refraction_true_to_apparent(Angle::from_degrees(0.0)).unwrap();
assert!((apparent.arcminutes() - 28.9).abs() < 0.3, "{apparent:?}");
let z = Angle::from_degrees(90.0);
assert!(refraction_apparent_to_true(z).unwrap().degrees() <= 90.0);
assert!(refraction_true_to_apparent(z).unwrap().degrees() >= 90.0);
assert!(refraction_apparent_to_true(Angle::from_degrees(-1.5)).is_err());
}
#[test]
fn bennett_and_saemundsson_are_mutual_inverses_at_altitude() {
for h in [5.0, 10.0, 20.0, 45.0, 70.0, 89.0] {
let true_alt = Angle::from_degrees(h);
let apparent = refraction_true_to_apparent(true_alt).unwrap();
let back = refraction_apparent_to_true(apparent).unwrap();
let drift_arcmin = (back.degrees() - h) * 60.0;
assert!(drift_arcmin.abs() < 0.2, "h={h}: drift {drift_arcmin}′");
}
}
#[test]
fn transit_lands_at_zero_hour_angle() {
let m31 =
Equatorial::j2000(Angle::from_degrees(10.6847), Angle::from_degrees(41.2688)).unwrap();
let site = kitt_peak();
let t = transit(m31, datetime!(2026-07-11 09:00 UTC), &site);
let ha = hour_angle(m31, t, &site).degrees();
assert!(ha.abs() < 0.021, "residual HA {ha}°");
let dt = t - datetime!(2026-07-11 09:00 UTC);
assert!(dt.whole_hours().abs() <= 12, "{dt}");
}
}