use std::f64::consts::PI;
pub struct Coordinates;
impl Coordinates {
pub fn hms_to_decimal(hours: u8, minutes: u8, seconds: f64) -> f64 {
hours as f64 + minutes as f64 / 60.0 + seconds / 3600.0
}
pub fn decimal_to_hms(decimal_hours: f64) -> (u8, u8, f64) {
let normalized = decimal_hours.rem_euclid(24.0);
let hours = normalized.floor() as u8;
let remaining = (normalized - hours as f64) * 60.0;
let minutes = remaining.floor() as u8;
let seconds = (remaining - minutes as f64) * 60.0;
(hours, minutes, seconds)
}
pub fn decimal_hours_to_string(decimal_hours: f64) -> String {
let (h, m, s) = Self::decimal_to_hms(decimal_hours);
format!("{:02}:{:02}:{:05.2}", h, m, s)
}
pub fn dms_to_decimal(degrees: i16, minutes: u8, seconds: f64) -> f64 {
let sign = if degrees >= 0 { 1.0 } else { -1.0 };
sign * (degrees.abs() as f64 + minutes as f64 / 60.0 + seconds / 3600.0)
}
pub fn decimal_to_dms(decimal_degrees: f64) -> (i16, u8, f64) {
let sign = if decimal_degrees >= 0.0 { 1 } else { -1 };
let abs_value = decimal_degrees.abs();
let degrees = abs_value.floor() as i16 * sign;
let remaining = (abs_value - abs_value.floor()) * 60.0;
let minutes = remaining.floor() as u8;
let seconds = (remaining - remaining.floor()) * 60.0;
(degrees, minutes, seconds)
}
pub fn decimal_degrees_to_string(decimal_degrees: f64) -> String {
let (d, m, s) = Self::decimal_to_dms(decimal_degrees);
let sign = if decimal_degrees >= 0.0 { '+' } else { '-' };
format!("{}{}°{:02}'{:05.2}\"", sign, d.abs(), m, s)
}
pub fn ra_hours_to_degrees(ra_hours: f64) -> f64 {
ra_hours * 15.0
}
pub fn ra_degrees_to_hours(ra_degrees: f64) -> f64 {
ra_degrees / 15.0
}
pub fn normalize_ra(ra_hours: f64) -> f64 {
ra_hours.rem_euclid(24.0)
}
pub fn normalize_dec(dec_degrees: f64) -> f64 {
dec_degrees.clamp(-90.0, 90.0)
}
pub fn normalize_azimuth(azimuth: f64) -> f64 {
azimuth.rem_euclid(360.0)
}
pub fn normalize_altitude(altitude: f64) -> f64 {
altitude.clamp(-90.0, 90.0)
}
pub fn equatorial_to_horizontal(
ra_hours: f64,
dec_degrees: f64,
latitude: f64,
lst_hours: f64,
) -> (f64, f64) {
let dec = dec_degrees.to_radians();
let lat = latitude.to_radians();
let ha_hours = lst_hours - ra_hours;
let ha = (ha_hours * 15.0).to_radians();
let sin_alt = dec.sin() * lat.sin() + dec.cos() * lat.cos() * ha.cos();
let alt = sin_alt.asin();
let cos_az = (dec.sin() - alt.sin() * lat.sin()) / (alt.cos() * lat.cos());
let cos_az_clamped = cos_az.clamp(-1.0, 1.0);
let mut az = cos_az_clamped.acos();
if ha.sin() > 0.0 {
az = 2.0 * PI - az;
}
(az.to_degrees(), alt.to_degrees())
}
pub fn horizontal_to_equatorial(
azimuth: f64,
altitude: f64,
latitude: f64,
lst_hours: f64,
) -> (f64, f64) {
let az = azimuth.to_radians();
let alt = altitude.to_radians();
let lat = latitude.to_radians();
let sin_dec = alt.sin() * lat.sin() + alt.cos() * lat.cos() * az.cos();
let dec = sin_dec.asin();
let cos_ha = (alt.sin() - dec.sin() * lat.sin()) / (dec.cos() * lat.cos());
let cos_ha_clamped = cos_ha.clamp(-1.0, 1.0);
let mut ha = cos_ha_clamped.acos();
if az.sin() > 0.0 {
ha = 2.0 * PI - ha;
}
let ha_hours = ha.to_degrees() / 15.0;
let ra_hours = Self::normalize_ra(lst_hours - ha_hours);
(ra_hours, dec.to_degrees())
}
pub fn julian_to_lst(jd: f64, longitude: f64) -> f64 {
let t = (jd - 2451545.0) / 36525.0;
let gmst = 280.46061837 + 360.98564736629 * (jd - 2451545.0) + 0.000387933 * t * t
- t * t * t / 38710000.0;
let lst = (gmst + longitude) / 15.0;
Self::normalize_ra(lst)
}
pub fn to_julian_date(year: i32, month: u8, day: f64) -> f64 {
let (y, m) = if month <= 2 {
(year - 1, month + 12)
} else {
(year, month)
};
let a = (y as f64 / 100.0).floor();
let b = 2.0 - a + (a / 4.0).floor();
(365.25 * (y as f64 + 4716.0)).floor() + (30.6001 * (m as f64 + 1.0)).floor() + day + b
- 1524.5
}
pub fn angular_separation(ra1: f64, dec1: f64, ra2: f64, dec2: f64) -> f64 {
let ra1_rad = Self::ra_hours_to_degrees(ra1).to_radians();
let dec1_rad = dec1.to_radians();
let ra2_rad = Self::ra_hours_to_degrees(ra2).to_radians();
let dec2_rad = dec2.to_radians();
let cos_sep = dec1_rad.sin() * dec2_rad.sin()
+ dec1_rad.cos() * dec2_rad.cos() * (ra1_rad - ra2_rad).cos();
cos_sep.clamp(-1.0, 1.0).acos().to_degrees()
}
pub fn deg_per_sec_to_arcsec_per_sec(deg_per_sec: f64) -> f64 {
deg_per_sec * 3600.0
}
pub fn arcsec_per_sec_to_deg_per_sec(arcsec_per_sec: f64) -> f64 {
arcsec_per_sec / 3600.0
}
pub const SIDEREAL_RATE_ARCSEC_PER_SEC: f64 = 15.041067;
pub const SIDEREAL_RATE_DEG_PER_HOUR: f64 = 15.0;
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EquatorialPosition {
pub ra: f64,
pub dec: f64,
}
impl EquatorialPosition {
pub fn new(ra: f64, dec: f64) -> Self {
Self {
ra: Coordinates::normalize_ra(ra),
dec: Coordinates::normalize_dec(dec),
}
}
pub fn from_hms_dms(ra_h: u8, ra_m: u8, ra_s: f64, dec_d: i16, dec_m: u8, dec_s: f64) -> Self {
Self::new(
Coordinates::hms_to_decimal(ra_h, ra_m, ra_s),
Coordinates::dms_to_decimal(dec_d, dec_m, dec_s),
)
}
pub fn to_horizontal(&self, latitude: f64, lst_hours: f64) -> HorizontalPosition {
let (az, alt) =
Coordinates::equatorial_to_horizontal(self.ra, self.dec, latitude, lst_hours);
HorizontalPosition::new(az, alt)
}
pub fn ra_hms(&self) -> (u8, u8, f64) {
Coordinates::decimal_to_hms(self.ra)
}
pub fn dec_dms(&self) -> (i16, u8, f64) {
Coordinates::decimal_to_dms(self.dec)
}
pub fn angular_separation(&self, other: &EquatorialPosition) -> f64 {
Coordinates::angular_separation(self.ra, self.dec, other.ra, other.dec)
}
}
impl std::fmt::Display for EquatorialPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (ra_h, ra_m, ra_s) = self.ra_hms();
let (dec_d, dec_m, dec_s) = self.dec_dms();
let dec_sign = if self.dec >= 0.0 { '+' } else { '-' };
write!(
f,
"RA {:02}h{:02}m{:05.2}s, Dec {}{}°{:02}'{:05.2}\"",
ra_h,
ra_m,
ra_s,
dec_sign,
dec_d.abs(),
dec_m,
dec_s
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct HorizontalPosition {
pub azimuth: f64,
pub altitude: f64,
}
impl HorizontalPosition {
pub fn new(azimuth: f64, altitude: f64) -> Self {
Self {
azimuth: Coordinates::normalize_azimuth(azimuth),
altitude: Coordinates::normalize_altitude(altitude),
}
}
pub fn from_dms(az_d: u16, az_m: u8, az_s: f64, alt_d: i8, alt_m: u8, alt_s: f64) -> Self {
Self::new(
az_d as f64 + az_m as f64 / 60.0 + az_s / 3600.0,
Coordinates::dms_to_decimal(alt_d as i16, alt_m, alt_s),
)
}
pub fn to_equatorial(&self, latitude: f64, lst_hours: f64) -> EquatorialPosition {
let (ra, dec) =
Coordinates::horizontal_to_equatorial(self.azimuth, self.altitude, latitude, lst_hours);
EquatorialPosition::new(ra, dec)
}
pub fn azimuth_dms(&self) -> (u16, u8, f64) {
let degrees = self.azimuth.floor() as u16;
let remaining = (self.azimuth - degrees as f64) * 60.0;
let minutes = remaining.floor() as u8;
let seconds = (remaining - minutes as f64) * 60.0;
(degrees, minutes, seconds)
}
pub fn altitude_dms(&self) -> (i8, u8, f64) {
let (d, m, s) = Coordinates::decimal_to_dms(self.altitude);
(d as i8, m, s)
}
pub fn is_above_horizon(&self) -> bool {
self.altitude > 0.0
}
pub fn is_above_limit(&self, limit_degrees: f64) -> bool {
self.altitude > limit_degrees
}
}
impl std::fmt::Display for HorizontalPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let (az_d, az_m, az_s) = self.azimuth_dms();
let (alt_d, alt_m, alt_s) = self.altitude_dms();
let alt_sign = if self.altitude >= 0.0 { '+' } else { '-' };
write!(
f,
"Az {}°{:02}'{:05.2}\", Alt {}{}°{:02}'{:05.2}\"",
az_d,
az_m,
az_s,
alt_sign,
alt_d.abs(),
alt_m,
alt_s
)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TrackingRates {
pub ra_rate: f64,
pub dec_rate: f64,
}
impl TrackingRates {
pub fn new(ra_rate: f64, dec_rate: f64) -> Self {
Self { ra_rate, dec_rate }
}
pub fn sidereal() -> Self {
Self {
ra_rate: Coordinates::SIDEREAL_RATE_ARCSEC_PER_SEC,
dec_rate: 0.0,
}
}
pub fn zero() -> Self {
Self {
ra_rate: 0.0,
dec_rate: 0.0,
}
}
pub fn is_valid_for_satellite(&self) -> bool {
let max_rate = 7200.0; self.ra_rate.abs() <= max_rate && self.dec_rate.abs() <= max_rate
}
}
impl Default for TrackingRates {
fn default() -> Self {
Self::sidereal()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_hms_to_decimal() {
let ra = Coordinates::hms_to_decimal(12, 30, 0.0);
assert!((ra - 12.5).abs() < 0.0001);
let ra = Coordinates::hms_to_decimal(18, 36, 56.0);
assert!((ra - 18.6155556).abs() < 0.0001);
}
#[test]
fn test_decimal_to_hms() {
let (h, m, s) = Coordinates::decimal_to_hms(12.5);
assert_eq!(h, 12);
assert_eq!(m, 30);
assert!(s.abs() < 0.01);
}
#[test]
fn test_dms_to_decimal() {
let dec = Coordinates::dms_to_decimal(45, 30, 0.0);
assert!((dec - 45.5).abs() < 0.0001);
let dec = Coordinates::dms_to_decimal(-23, 26, 21.0);
assert!((dec - (-23.439167)).abs() < 0.0001);
}
#[test]
fn test_decimal_to_dms() {
let (d, m, s) = Coordinates::decimal_to_dms(45.5);
assert_eq!(d, 45);
assert_eq!(m, 30);
assert!(s.abs() < 0.01);
let (d, m, s) = Coordinates::decimal_to_dms(-23.5);
assert_eq!(d, -23);
assert_eq!(m, 30);
assert!(s.abs() < 0.01);
}
#[test]
fn test_equatorial_position() {
let pos = EquatorialPosition::from_hms_dms(18, 36, 56.0, 38, 47, 1.0);
assert!((pos.ra - 18.6155556).abs() < 0.0001);
assert!((pos.dec - 38.7836111).abs() < 0.0001);
}
#[test]
fn test_horizontal_position() {
let pos = HorizontalPosition::new(180.0, 45.0);
assert!(pos.is_above_horizon());
assert!(pos.is_above_limit(30.0));
assert!(!pos.is_above_limit(60.0));
}
#[test]
fn test_angular_separation() {
let sep = Coordinates::angular_separation(12.0, 45.0, 12.0, 45.0);
assert!(sep.abs() < 0.0001);
let sep = Coordinates::angular_separation(0.0, 0.0, 6.0, 0.0);
assert!((sep - 90.0).abs() < 0.001);
}
#[test]
fn test_normalize_ra() {
assert!((Coordinates::normalize_ra(25.0) - 1.0).abs() < 0.0001);
assert!((Coordinates::normalize_ra(-1.0) - 23.0).abs() < 0.0001);
}
#[test]
fn test_tracking_rates() {
let sidereal = TrackingRates::sidereal();
assert!((sidereal.ra_rate - Coordinates::SIDEREAL_RATE_ARCSEC_PER_SEC).abs() < 0.001);
assert!(sidereal.dec_rate.abs() < 0.001);
}
}