use arika::SimpleEci;
use arika::earth::eop::{NutationCorrections, PolarMotion, Ut1Offset};
use arika::epoch::{Epoch, Ut1, Utc};
use arika::frame::{self, Rotation, Vec3};
pub fn simple_eci_to_geodetic_latlon(position_eci: &SimpleEci, epoch: &Epoch<Utc>) -> (f64, f64) {
let ecef = Rotation::<frame::SimpleEci, frame::SimpleEcef>::from_era(epoch.gmst())
.transform(position_eci);
let geod = ecef.to_geodetic();
(geod.latitude.to_degrees(), geod.longitude.to_degrees())
}
pub fn precise_gcrs_to_geodetic_latlon<P>(
position_gcrs: &Vec3<frame::Gcrs>,
utc: &Epoch<Utc>,
eop: &P,
) -> (f64, f64)
where
P: Ut1Offset + NutationCorrections + PolarMotion + ?Sized,
{
let rot = Rotation::<frame::Gcrs, frame::Itrs>::iau2006_full_from_utc(utc, eop);
let pos_itrs: Vec3<frame::Itrs> = rot.transform(position_gcrs);
let geod = pos_itrs.to_geodetic();
(geod.latitude.to_degrees(), geod.longitude.to_degrees())
}
#[allow(dead_code)]
fn _touch_ut1_for_phase_4b(_: &Epoch<Ut1>) {}
pub fn epoch_to_day_of_year_and_ut(epoch: &Epoch) -> (u32, f64) {
let dt = epoch.to_datetime();
let doy = day_of_year(dt.year, dt.month, dt.day);
let ut_sec = dt.hour as f64 * 3600.0 + dt.min as f64 * 60.0 + dt.sec;
(doy, ut_sec)
}
fn day_of_year(year: i32, month: u32, day: u32) -> u32 {
let is_leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
let days_in_month = [0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut doy = 0u32;
for m in 1..month {
doy += days_in_month[m as usize];
if m == 2 && is_leap {
doy += 1;
}
}
doy + day
}
pub fn local_solar_time(ut_sec: f64, longitude_deg: f64, epoch: &Epoch) -> f64 {
let eot_hours = arika::sun::equation_of_time(epoch);
let lst = ut_sec / 3600.0 + longitude_deg / 15.0 + eot_hours;
((lst % 24.0) + 24.0) % 24.0
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn day_of_year_jan1() {
assert_eq!(day_of_year(2024, 1, 1), 1);
}
#[test]
fn day_of_year_dec31_non_leap() {
assert_eq!(day_of_year(2023, 12, 31), 365);
}
#[test]
fn day_of_year_dec31_leap() {
assert_eq!(day_of_year(2024, 12, 31), 366);
}
#[test]
fn day_of_year_mar1_leap() {
assert_eq!(day_of_year(2024, 3, 1), 61);
}
#[test]
fn day_of_year_mar1_non_leap() {
assert_eq!(day_of_year(2023, 3, 1), 60);
}
#[test]
fn local_solar_time_greenwich_noon() {
let epoch = Epoch::from_gregorian(2024, 4, 15, 12, 0, 0.0); let lst = local_solar_time(43200.0, 0.0, &epoch);
assert!((lst - 12.0).abs() < 0.05, "lst={lst}");
}
#[test]
fn local_solar_time_east_90() {
let epoch = Epoch::from_gregorian(2024, 4, 15, 0, 0, 0.0);
let lst = local_solar_time(0.0, 90.0, &epoch);
assert!((lst - 6.0).abs() < 0.05, "lst={lst}");
}
#[test]
fn local_solar_time_west_90() {
let epoch = Epoch::from_gregorian(2024, 4, 15, 0, 0, 0.0);
let lst = local_solar_time(0.0, -90.0, &epoch);
assert!((lst - 18.0).abs() < 0.05, "lst={lst}");
}
#[test]
fn local_solar_time_wraps_24() {
let epoch = Epoch::from_gregorian(2024, 4, 15, 23, 0, 0.0);
let lst = local_solar_time(23.0 * 3600.0, 30.0, &epoch);
assert!((lst - 1.0).abs() < 0.05, "lst={lst}");
}
#[test]
fn local_solar_time_eot_effect_february() {
let epoch = Epoch::from_gregorian(2024, 2, 12, 12, 0, 0.0);
let lst = local_solar_time(43200.0, 0.0, &epoch);
assert!(
lst > 11.65 && lst < 11.85,
"Feb EoT should shift LST behind: lst={lst}"
);
}
#[test]
fn epoch_to_day_of_year_and_ut_vernal_equinox_2024() {
let epoch = Epoch::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let (doy, ut_sec) = epoch_to_day_of_year_and_ut(&epoch);
assert_eq!(doy, 80);
assert!((ut_sec - 43200.0).abs() < 1.0); }
#[test]
fn simple_eci_to_latlon_on_equator_at_gmst_zero() {
let epoch = Epoch::<Utc>::from_jd(2451545.0); let gmst = epoch.gmst();
let r = 6778.0; let pos = SimpleEci::new(r * gmst.cos(), r * gmst.sin(), 0.0);
let (lat, lon) = simple_eci_to_geodetic_latlon(&pos, &epoch);
assert!(lat.abs() < 0.1, "lat={lat}, expected ~0");
assert!(lon.abs() < 0.1, "lon={lon}, expected ~0");
}
#[test]
fn simple_eci_to_latlon_north_pole() {
let epoch = Epoch::<Utc>::from_jd(2451545.0);
let pos = SimpleEci::new(0.0, 0.0, 6378.0);
let (lat, _lon) = simple_eci_to_geodetic_latlon(&pos, &epoch);
assert!((lat - 90.0).abs() < 0.1, "lat={lat}, expected ~90");
}
#[test]
fn simple_eci_to_latlon_matches_arika_geodetic_at_iss_inclination() {
let epoch = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let gmst = epoch.gmst();
let expected_lat: f64 = 51.6;
let expected_lon: f64 = 30.0;
let geod = arika::earth::Geodetic {
latitude: expected_lat.to_radians(),
longitude: expected_lon.to_radians(),
altitude: 400.0,
};
let ecef = arika::SimpleEcef::from(geod);
let eci = Rotation::<frame::SimpleEcef, frame::SimpleEci>::from_era(gmst).transform(&ecef);
let (lat_deg, lon_deg) = simple_eci_to_geodetic_latlon(&eci, &epoch);
assert!(
(lat_deg - expected_lat).abs() < 0.01,
"lat={lat_deg}, expected {expected_lat} (geodetic, not geocentric)"
);
assert!(
(lon_deg - expected_lon).abs() < 0.01,
"lon={lon_deg}, expected {expected_lon}"
);
}
#[test]
fn simple_eci_to_latlon_matches_arika_geodetic_at_polar() {
let epoch = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let gmst = epoch.gmst();
let expected_lat: f64 = 80.0;
let expected_lon: f64 = -45.0;
let geod = arika::earth::Geodetic {
latitude: expected_lat.to_radians(),
longitude: expected_lon.to_radians(),
altitude: 800.0,
};
let ecef = arika::SimpleEcef::from(geod);
let eci = Rotation::<frame::SimpleEcef, frame::SimpleEci>::from_era(gmst).transform(&ecef);
let (lat_deg, lon_deg) = simple_eci_to_geodetic_latlon(&eci, &epoch);
assert!(
(lat_deg - expected_lat).abs() < 0.01,
"lat={lat_deg}, expected {expected_lat} (geodetic, not geocentric)"
);
let lon_diff = ((lon_deg - expected_lon + 180.0) % 360.0 - 180.0).abs();
assert!(lon_diff < 0.01, "lon={lon_deg}, expected {expected_lon}");
}
struct ZeroEop;
impl Ut1Offset for ZeroEop {
fn dut1(&self, _utc_mjd: f64) -> f64 {
0.0
}
}
impl PolarMotion for ZeroEop {
fn x_pole(&self, _utc_mjd: f64) -> f64 {
0.0
}
fn y_pole(&self, _utc_mjd: f64) -> f64 {
0.0
}
}
impl NutationCorrections for ZeroEop {
fn dx(&self, _utc_mjd: f64) -> f64 {
0.0
}
fn dy(&self, _utc_mjd: f64) -> f64 {
0.0
}
}
#[test]
fn precise_gcrs_to_latlon_roundtrips_iss_inclination() {
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let expected_lat: f64 = 51.6;
let expected_lon: f64 = 30.0;
let geod = arika::earth::Geodetic {
latitude: expected_lat.to_radians(),
longitude: expected_lon.to_radians(),
altitude: 400.0,
};
let ecef_simple = arika::SimpleEcef::from(geod);
let pos_itrs: Vec3<frame::Itrs> = Vec3::from_raw(*ecef_simple.inner());
let rot = Rotation::<frame::Gcrs, frame::Itrs>::iau2006_full_from_utc(&utc, &ZeroEop);
let pos_gcrs: Vec3<frame::Gcrs> = rot.inverse().transform(&pos_itrs);
let (lat_deg, lon_deg) = precise_gcrs_to_geodetic_latlon(&pos_gcrs, &utc, &ZeroEop);
assert!(
(lat_deg - expected_lat).abs() < 1e-9,
"lat={lat_deg}, expected {expected_lat}"
);
assert!(
(lon_deg - expected_lon).abs() < 1e-9,
"lon={lon_deg}, expected {expected_lon}"
);
}
#[test]
fn precise_vs_simple_path_shows_expected_precession_magnitude() {
let utc = Epoch::<Utc>::from_gregorian(2024, 3, 20, 12, 0, 0.0);
let pos_eci = SimpleEci::new(6778.0, 0.0, 0.0);
let (lat_simple, lon_simple) = simple_eci_to_geodetic_latlon(&pos_eci, &utc);
let pos_gcrs: Vec3<frame::Gcrs> = Vec3::from_raw(*pos_eci.inner());
let (lat_precise, lon_precise) = precise_gcrs_to_geodetic_latlon(&pos_gcrs, &utc, &ZeroEop);
let lat_delta = (lat_simple - lat_precise).abs();
let lon_delta = (lon_simple - lon_precise).abs();
assert!(
lat_delta < 1.0,
"precise−simple lat = {lat_delta}° exceeds 1° bound (sign flip?)"
);
assert!(
lon_delta < 1.0,
"precise−simple lon = {lon_delta}° exceeds 1° bound (sign flip?)"
);
assert!(
lat_delta > 0.01 || lon_delta > 0.01,
"precise vs simple differ by <0.01° (lat={lat_delta}, lon={lon_delta}) — \
precession/nutation not being applied"
);
}
}