use crate::utils::RefreshableSingleton;
use std::num::ParseFloatError;
use std::sync::atomic::{AtomicBool, Ordering};
use crate::utils::datadir;
use crate::utils::{download_file, download_if_not_exist};
use crate::{Instant, TimeLike, TimeScale};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum Error {
#[error("Invalid entry in EOP file")]
InvalidEntry,
#[error(
"Data directory is read-only. Try setting the environment variable SATKIT_DATA \
to a writeable directory and re-starting or explicitly set data directory"
)]
DataDirReadOnly,
#[error("EOP byte buffer is not valid UTF-8: {0}")]
Utf8(#[from] std::str::Utf8Error),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
ParseFloat(#[from] ParseFloatError),
#[error(transparent)]
Datadir(#[from] crate::utils::datadir::Error),
#[error(transparent)]
Download(#[from] crate::utils::download::Error),
}
pub type Result<T> = std::result::Result<T, Error>;
#[derive(Debug)]
#[allow(non_snake_case)]
struct EOPEntry {
mjd_utc: f64,
xp: f64,
yp: f64,
dut1: f64,
lod: f64,
dX: f64,
dY: f64,
observed: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EopStatus {
Observed,
Predicted,
Extrapolated,
BeforeTable,
NotLoaded,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct EopCoverage {
pub first: Instant,
pub last_observed: Instant,
pub last: Instant,
}
fn parse_csv(text: &str) -> Result<Vec<EOPEntry>> {
text.lines()
.skip(1)
.map(|line| -> Result<EOPEntry> {
let lvals: Vec<&str> = line.split(",").collect();
if lvals.len() < 12 {
return Err(Error::InvalidEntry);
}
Ok(EOPEntry {
mjd_utc: lvals[1].parse()?,
xp: lvals[2].parse()?,
yp: lvals[3].parse()?,
dut1: lvals[4].parse()?,
lod: lvals[5].parse()?,
dX: lvals[8].parse()?,
dY: lvals[9].parse()?,
observed: lvals[11].trim() != "P",
})
})
.collect()
}
fn load_eop_file_csv() -> Result<Vec<EOPEntry>> {
let path = crate::utils::datadir::path_for("EOP-All.csv")?;
download_if_not_exist(&path, Some("https://celestrak.org/SpaceData/"))?;
parse_csv(&std::fs::read_to_string(&path)?)
}
fn beyond_table(mjd_utc: f64, last: &EOPEntry) -> bool {
mjd_utc > last.mjd_utc
}
static WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
static EXTRAP_WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
static NOT_LOADED_WARNING_SHOWN: AtomicBool = AtomicBool::new(false);
static EOP: RefreshableSingleton<Vec<EOPEntry>> = RefreshableSingleton::new();
fn ensure_default_loaded() {
EOP.ensure_default_loaded(|| load_eop_file_csv().ok());
}
pub fn init_from_bytes(bytes: &[u8]) -> Result<()> {
EOP.set(parse_csv(std::str::from_utf8(bytes)?)?);
Ok(())
}
pub fn init_from_path(path: &std::path::Path) -> Result<()> {
EOP.set(parse_csv(&std::fs::read_to_string(path)?)?);
Ok(())
}
pub fn disable_eop_time_warning() {
WARNING_SHOWN.store(true, Ordering::Relaxed);
EXTRAP_WARNING_SHOWN.store(true, Ordering::Relaxed);
NOT_LOADED_WARNING_SHOWN.store(true, Ordering::Relaxed);
}
pub fn coverage() -> Option<EopCoverage> {
ensure_default_loaded();
let guard = EOP.read();
let eop = guard.as_ref()?;
let first = eop.first()?;
let last = eop.last()?;
let last_observed = eop.iter().rev().find(|e| e.observed).unwrap_or(first);
Some(EopCoverage {
first: Instant::from_mjd_utc(first.mjd_utc),
last_observed: Instant::from_mjd_utc(last_observed.mjd_utc),
last: Instant::from_mjd_utc(last.mjd_utc),
})
}
pub fn status<T: TimeLike>(tm: &T) -> EopStatus {
let mjd_utc = tm.as_mjd_with_scale(TimeScale::UTC);
ensure_default_loaded();
let guard = EOP.read();
let Some(eop) = guard.as_ref().filter(|e| !e.is_empty()) else {
return EopStatus::NotLoaded;
};
if mjd_utc < eop[0].mjd_utc {
return EopStatus::BeforeTable;
}
if mjd_utc > eop[eop.len() - 1].mjd_utc {
return EopStatus::Extrapolated;
}
let last_observed = eop
.iter()
.rev()
.find(|e| e.observed)
.map_or(-1.0, |e| e.mjd_utc);
if mjd_utc <= last_observed {
EopStatus::Observed
} else {
EopStatus::Predicted
}
}
pub fn update() -> Result<()> {
let d = datadir()?;
if d.metadata()?.permissions().readonly() {
return Err(Error::DataDirReadOnly);
}
let url = "https://celestrak.org/SpaceData/EOP-All.csv";
download_file(url, &d, true)?;
EOP.set(load_eop_file_csv()?);
Ok(())
}
pub fn eop_from_mjd_utc(mjd_utc: f64) -> Option<[f64; 6]> {
ensure_default_loaded();
let guard = EOP.read();
let Some(eop) = guard.as_ref().filter(|e| !e.is_empty()) else {
if !NOT_LOADED_WARNING_SHOWN.swap(true, Ordering::Relaxed) {
eprintln!(
"Warning: no Earth Orientation Parameters (EOP) table is loaded; polar motion, \
UT1-UTC and nutation corrections are being treated as zero, which biases \
Earth-fixed frame transforms and orbit propagation by metres.\n\
Run `satkit::utils::update_datafiles()` (Python: `satkit.utils.update_datafiles()`) \
to download EOP-All.csv, or set SATKIT_DATA to a directory containing it.\n\
To disable: `satkit::earth_orientation_params::disable_eop_time_warning()`"
);
}
return None;
};
let idx = eop.partition_point(|x| x.mjd_utc <= mjd_utc);
if idx == 0 {
if !WARNING_SHOWN.swap(true, Ordering::Relaxed) {
eprintln!(
"Warning: EOP data not available for MJD UTC = {mjd_utc} (too early).\n\
Run `satkit::utils::update_datafiles()` to download the most recent data.\n\
To disable: `satkit::earth_orientation_params::disable_eop_time_warning()`"
);
}
return None;
}
if idx >= eop.len() {
let last = &eop[eop.len() - 1];
if beyond_table(mjd_utc, last) && !EXTRAP_WARNING_SHOWN.swap(true, Ordering::Relaxed) {
eprintln!(
"Warning: EOP data ends at {} (MJD {}); the request for MJD UTC = {mjd_utc} and \
all later epochs use the last entry's values held constant. Polar motion and \
UT1-UTC drift by ~0.1 arcsec / ~10 ms over a few months, i.e. metres at LEO.\n\
Run `satkit::utils::update_datafiles()` (Python: `satkit.utils.update_datafiles()`) \
to download the most recent EOP-All.csv.\n\
To disable: `satkit::earth_orientation_params::disable_eop_time_warning()`",
Instant::from_mjd_utc(last.mjd_utc),
last.mjd_utc
);
}
return Some([last.dut1, last.xp, last.yp, last.lod, last.dX, last.dY]);
}
let v0 = &eop[idx - 1];
let v1 = &eop[idx];
let g1 = (mjd_utc - v0.mjd_utc) / (v1.mjd_utc - v0.mjd_utc);
let g0 = 1.0 - g1;
Some([
g0.mul_add(v0.dut1, g1 * v1.dut1),
g0.mul_add(v0.xp, g1 * v1.xp),
g0.mul_add(v0.yp, g1 * v1.yp),
g0.mul_add(v0.lod, g1 * v1.lod),
g0.mul_add(v0.dX, g1 * v1.dX),
g0.mul_add(v0.dY, g1 * v1.dY),
])
}
#[inline]
pub fn get<T: crate::TimeLike>(tm: &T) -> Option<[f64; 6]> {
eop_from_mjd_utc(tm.as_mjd_with_scale(crate::TimeScale::UTC))
}
#[inline]
pub fn get_or_zero<T: crate::TimeLike>(tm: &T) -> [f64; 6] {
get(tm).unwrap_or([0.0; 6])
}
#[inline]
pub fn eop_from_mjd_utc_or_zero(mjd_utc: f64) -> [f64; 6] {
eop_from_mjd_utc(mjd_utc).unwrap_or([0.0; 6])
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn loaded() {
ensure_default_loaded();
let guard = EOP.read();
let eop = guard
.as_ref()
.expect("default EOP load should succeed in tests");
assert!(eop[0].mjd_utc >= 0.0);
}
#[test]
fn test_time_bound() {
let tm = crate::Instant::from_rfc3339("2056-04-16T17:52:50.805408Z").unwrap();
let eop = eop_from_mjd_utc(tm.as_mjd_with_scale(crate::TimeScale::UTC));
assert!(eop.is_some());
let tm = crate::Instant::from_rfc3339("1950-04-16T17:52:50.805408Z").unwrap();
let eop = eop_from_mjd_utc(tm.as_mjd_with_scale(crate::TimeScale::UTC));
assert!(eop.is_none());
}
#[test]
fn coverage_and_status() {
let c = coverage().expect("EOP table loaded in tests");
assert!(c.first < c.last_observed);
assert!(c.last_observed <= c.last);
let t = crate::Instant::from_rfc3339("2006-04-16T17:52:50.805408Z").unwrap();
assert_eq!(status(&t), EopStatus::Observed);
assert_eq!(status(&c.first), EopStatus::Observed);
assert_eq!(status(&c.last_observed), EopStatus::Observed);
let late = c.last + crate::Duration::from_days(10.0);
assert_eq!(status(&late), EopStatus::Extrapolated);
assert!(eop_from_mjd_utc(late.as_mjd_utc()).is_some());
if c.last_observed < c.last {
let mid = c.last_observed + crate::Duration::from_days(1.0);
assert_eq!(status(&mid), EopStatus::Predicted);
}
let early = crate::Instant::from_rfc3339("1950-04-16T00:00:00Z").unwrap();
assert_eq!(status(&early), EopStatus::BeforeTable);
}
#[test]
fn last_row_epoch_is_inside_table() {
let csv = "DATE,MJD,X,Y,UT1-UTC,LOD,DPSI,DEPS,DX,DY,DAT,DATA_TYPE\n\
2024-01-01,60310,0.1,0.2,0.01,0.001,0,0,0.3,0.4,37,O\n\
2024-01-02,60311,0.5,0.6,0.02,0.002,0,0,0.7,0.8,37,P\n";
let table = parse_csv(csv).unwrap();
let last = &table[1];
assert!(!beyond_table(last.mjd_utc, last));
assert!(!beyond_table(last.mjd_utc - 0.5, last));
assert!(beyond_table(last.mjd_utc + 1e-9, last));
}
#[test]
fn parse_retains_data_type() {
let text = "DATE,MJD,X,Y,UT1-UTC,LOD,DPSI,DEPS,DX,DY,DAT,DATA_TYPE\n\
2024-01-10,60319,0.119289,0.206294,0.0074355,-0.0004170,-0.112002,-0.006175,0.000248,-0.000168,37,O\n\
2024-01-11,60320,0.118000,0.207000,0.0075000,-0.0004000,-0.112000,-0.006100,0.000240,-0.000160,37,P\n";
let rows = parse_csv(text).unwrap();
assert!(rows[0].observed);
assert!(!rows[1].observed);
}
#[test]
fn checkval() {
let tm = crate::Instant::from_rfc3339("2006-04-16T17:52:50.805408Z").unwrap();
let v: Option<[f64; 6]> = eop_from_mjd_utc(tm.as_mjd_utc());
assert!(v.is_some());
let v = eop_from_mjd_utc(59464.00).unwrap();
const TRUTH: [f64; 4] = [-0.1145667, 0.241155, 0.317274, -0.0002255];
for it in v.iter().zip(TRUTH.iter()) {
let (a, b) = it;
assert!(((a - b) / b).abs() < 1.0e-3);
}
}
#[test]
fn checkinterp() {
let mjd0: f64 = 57909.00;
const TRUTH0: [f64; 4] = [0.3754421, 0.102693, 0.458455, 0.0011699];
const TRUTH1: [f64; 4] = [0.3743358, 0.104031, 0.458373, 0.0010383];
for x in 0..101 {
let dt: f64 = x as f64 / 100.0;
let vt = eop_from_mjd_utc(mjd0 + dt).unwrap();
let g0: f64 = 1.0 - dt;
let g1: f64 = dt;
for it in vt.iter().zip(TRUTH0.iter().zip(TRUTH1.iter())) {
let (v, (v0, v1)) = it;
let vtest: f64 = g0 * v0 + g1 * v1;
assert!(((v - vtest) / v).abs() < 1.0e-5);
}
}
}
}