use sgp4::{Constants, Elements, MinutesSinceEpoch};
use thiserror::Error;
use pyo3::exceptions::PyRuntimeError;
use pyo3::PyErr;
#[derive(Error, Debug)]
pub enum Sgp4Error {
#[error("Invalid orbital elements: {0}")]
InvalidElements(String),
#[error("Propagation failed: {0}")]
PropagationFailed(String),
#[error("TLE parsing failed: {0}")]
TleParsingFailed(String),
#[error("OMM parsing failed: {0}")]
OmmParsingFailed(String),
#[error("Time offset out of range: {0} minutes")]
TimeOutOfRange(f64),
}
impl From<Sgp4Error> for PyErr {
fn from(err: Sgp4Error) -> PyErr {
PyRuntimeError::new_err(err.to_string())
}
}
#[derive(Debug, Clone)]
pub struct SatelliteState {
pub position: [f64; 3],
pub velocity: [f64; 3],
pub time_offset_minutes: f64,
}
impl SatelliteState {
pub fn position_magnitude(&self) -> f64 {
(self.position[0].powi(2) + self.position[1].powi(2) + self.position[2].powi(2)).sqrt()
}
pub fn velocity_magnitude(&self) -> f64 {
(self.velocity[0].powi(2) + self.velocity[1].powi(2) + self.velocity[2].powi(2)).sqrt()
}
}
pub fn propagate_from_elements(
elements: &Elements,
time_offset_minutes: f64,
) -> Result<SatelliteState, Sgp4Error> {
const MAX_MINUTES: f64 = 1000.0 * 24.0 * 60.0; if time_offset_minutes.abs() > MAX_MINUTES {
return Err(Sgp4Error::TimeOutOfRange(time_offset_minutes));
}
let constants = Constants::from_elements(elements)
.map_err(|e| Sgp4Error::InvalidElements(e.to_string()))?;
let prediction = constants
.propagate(MinutesSinceEpoch(time_offset_minutes))
.map_err(|e| Sgp4Error::PropagationFailed(e.to_string()))?;
Ok(SatelliteState {
position: prediction.position,
velocity: prediction.velocity,
time_offset_minutes,
})
}
pub fn propagate_batch(
elements: &Elements,
time_offsets_minutes: &[f64],
) -> Result<Vec<SatelliteState>, Sgp4Error> {
let constants = Constants::from_elements(elements)
.map_err(|e| Sgp4Error::InvalidElements(e.to_string()))?;
time_offsets_minutes
.iter()
.map(|&offset| {
const MAX_MINUTES: f64 = 1000.0 * 24.0 * 60.0;
if offset.abs() > MAX_MINUTES {
return Err(Sgp4Error::TimeOutOfRange(offset));
}
let prediction = constants
.propagate(MinutesSinceEpoch(offset))
.map_err(|e| Sgp4Error::PropagationFailed(e.to_string()))?;
Ok(SatelliteState {
position: prediction.position,
velocity: prediction.velocity,
time_offset_minutes: offset,
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn get_test_iss_elements() -> Elements {
let tle = "ISS (ZARYA)\n\
1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\n\
2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
sgp4::parse_3les(tle).unwrap().into_iter().next().unwrap()
}
#[test]
fn test_propagate_at_epoch() {
let elements = get_test_iss_elements();
let state = propagate_from_elements(&elements, 0.0).unwrap();
let altitude_km = state.position_magnitude() - 6378.137; assert!(altitude_km > 300.0 && altitude_km < 500.0,
"ISS altitude should be ~400 km, got {}", altitude_km);
let speed = state.velocity_magnitude();
assert!(speed > 7.0 && speed < 8.0,
"ISS speed should be ~7.7 km/s, got {}", speed);
}
#[test]
fn test_propagate_one_orbit() {
let elements = get_test_iss_elements();
let period_minutes = 1440.0 / elements.mean_motion;
let state_epoch = propagate_from_elements(&elements, 0.0).unwrap();
let state_one_orbit = propagate_from_elements(&elements, period_minutes).unwrap();
let pos_diff = (
(state_epoch.position[0] - state_one_orbit.position[0]).powi(2) +
(state_epoch.position[1] - state_one_orbit.position[1]).powi(2) +
(state_epoch.position[2] - state_one_orbit.position[2]).powi(2)
).sqrt();
assert!(pos_diff < 100.0, "Position drift after one orbit: {} km", pos_diff);
}
#[test]
fn test_batch_propagation() {
let elements = get_test_iss_elements();
let time_offsets = vec![0.0, 30.0, 60.0, 90.0, 120.0];
let states = propagate_batch(&elements, &time_offsets).unwrap();
assert_eq!(states.len(), 5);
for (i, state) in states.iter().enumerate() {
assert_eq!(state.time_offset_minutes, time_offsets[i]);
}
}
#[test]
fn test_time_out_of_range() {
let elements = get_test_iss_elements();
let result = propagate_from_elements(&elements, 1500.0 * 24.0 * 60.0);
assert!(result.is_err());
assert!(matches!(result.unwrap_err(), Sgp4Error::TimeOutOfRange(_)));
}
}