use crate::orbit::{Orbit, MU_EARTH};
use crate::sgp4::{GravConst, Sgp4};
const SECONDS_PER_DAY: f64 = 86_400.0;
fn is_leap(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
}
fn days_1950_to_year(year: i64) -> f64 {
let mut days = 0i64;
let mut y = 1950;
while y < year {
days += if is_leap(y) { 366 } else { 365 };
y += 1;
}
days as f64
}
#[derive(Clone, Copy, Debug)]
pub struct Tle {
pub epoch_days_1950: f64,
pub bstar: f64,
pub ecco: f64,
pub argpo_rad: f64,
pub inclo_rad: f64,
pub mo_rad: f64,
pub no_kozai_rad_min: f64,
pub nodeo_rad: f64,
}
impl Tle {
pub fn to_sgp4(&self, grav: GravConst, afspc: bool) -> Sgp4 {
Sgp4::new(
grav,
afspc,
self.epoch_days_1950,
self.bstar,
self.ecco,
self.argpo_rad,
self.inclo_rad,
self.mo_rad,
self.no_kozai_rad_min,
self.nodeo_rad,
)
}
}
pub fn parse_tle(line1: &str, line2: &str) -> Result<Tle, String> {
let l1 = line1;
if !l1.starts_with("1 ") || l1.len() < 63 {
return Err(format!("not a TLE line 1: {l1:?}"));
}
let num = |s: &str, what: &str| -> Result<f64, String> {
s.trim()
.parse::<f64>()
.map_err(|_| format!("invalid {what} in TLE: {s:?}"))
};
let yy: i64 = l1[18..20]
.trim()
.parse()
.map_err(|_| format!("invalid epoch year in TLE: {:?}", &l1[18..20]))?;
let year = if yy < 57 { 2000 + yy } else { 1900 + yy };
let epochdays = num(&l1[20..32], "epoch day")?;
let epoch_days_1950 = days_1950_to_year(year) + epochdays;
let bstar = parse_decimal_exp(&l1[53..54], &l1[54..59], &l1[59..61], "bstar")?;
if !line2.starts_with("2 ") || line2.len() < 63 {
return Err(format!("not a TLE line 2: {line2:?}"));
}
let f = |a: usize, z: usize| line2[a..z].trim();
let inclo = num(f(8, 16), "inclination")?;
let nodeo = num(f(17, 25), "RAAN")?;
let ecco = num(&format!("0.{}", f(26, 33)), "eccentricity")?;
let argpo = num(f(34, 42), "argument of perigee")?;
let mo = num(f(43, 51), "mean anomaly")?;
let no_rev_day = num(f(52, 63), "mean motion")?;
Ok(Tle {
epoch_days_1950,
bstar,
ecco,
argpo_rad: argpo.to_radians(),
inclo_rad: inclo.to_radians(),
mo_rad: mo.to_radians(),
no_kozai_rad_min: no_rev_day * std::f64::consts::TAU / 1440.0,
nodeo_rad: nodeo.to_radians(),
})
}
fn parse_decimal_exp(sign: &str, mant: &str, exp: &str, what: &str) -> Result<f64, String> {
let m: f64 = format!("0.{}", mant.trim())
.parse()
.map_err(|_| format!("invalid {what} mantissa in TLE: {mant:?}"))?;
let v = if sign.trim() == "-" { -m } else { m };
let e: i32 = exp
.trim()
.parse()
.map_err(|_| format!("invalid {what} exponent in TLE: {exp:?}"))?;
Ok(v * 10f64.powi(e))
}
pub fn parse_line2(line2: &str) -> Result<Orbit, String> {
let b = line2.as_bytes();
if !line2.starts_with("2 ") || b.len() < 63 {
return Err(format!("not a TLE line 2: {line2:?}"));
}
let field = |a: usize, z: usize| line2[a..z].trim();
let num = |s: &str, what: &str| -> Result<f64, String> {
s.parse::<f64>()
.map_err(|_| format!("invalid {what} in TLE: {s:?}"))
};
let inclination_deg = num(field(8, 16), "inclination")?;
let raan_deg = num(field(17, 25), "RAAN")?;
let ecc = num(&format!("0.{}", field(26, 33)), "eccentricity")?;
let argp_deg = num(field(34, 42), "argument of perigee")?;
let mean_anomaly_deg = num(field(43, 51), "mean anomaly")?;
let mean_motion_rev_day = num(field(52, 63), "mean motion")?;
if mean_motion_rev_day <= 0.0 {
return Err(format!("non-positive mean motion: {mean_motion_rev_day}"));
}
let n = mean_motion_rev_day * std::f64::consts::TAU / SECONDS_PER_DAY;
let a = (MU_EARTH / (n * n)).cbrt();
Ok(Orbit::keplerian(
a,
ecc,
inclination_deg.to_radians(),
raan_deg.to_radians(),
argp_deg.to_radians(),
mean_anomaly_deg.to_radians(),
))
}
pub fn parse_set(text: &str) -> Result<Vec<Orbit>, String> {
text.lines()
.map(str::trim)
.filter(|l| l.starts_with("2 ") && l.len() >= 63)
.map(parse_line2)
.collect()
}
pub fn parse_propagators(text: &str) -> Result<Vec<crate::orbit::Propagator>, String> {
use crate::orbit::Propagator;
let grav = crate::sgp4::wgs72();
let mut out = Vec::new();
let mut pending_l1: Option<&str> = None;
for raw in text.lines() {
let line = raw.trim();
if line.starts_with("1 ") && line.len() >= 63 {
pending_l1 = Some(line);
} else if line.starts_with("2 ") && line.len() >= 63 {
match pending_l1.take() {
Some(l1) => {
let tle = parse_tle(l1, line)?;
out.push(Propagator::Sgp4(Box::new(tle.to_sgp4(grav, false))));
}
None => out.push(Propagator::Kepler(parse_line2(line)?)),
}
} else {
pending_l1 = None;
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orbit::R_EARTH_M;
const ISS_L2: &str = "2 25544 51.6400 247.4627 0006703 130.5360 325.0288 15.72125391563537";
#[test]
fn parses_iss_line2_elements() {
let o = parse_line2(ISS_L2).expect("valid TLE line 2");
assert!((o.inclination_rad.to_degrees() - 51.64).abs() < 1e-6);
assert!((o.raan_rad.to_degrees() - 247.4627).abs() < 1e-6);
assert!((o.eccentricity - 0.0006703).abs() < 1e-9);
assert!((o.argp_rad.to_degrees() - 130.536).abs() < 1e-6);
assert!((o.u0_rad.to_degrees() - 325.0288).abs() < 1e-6);
let alt_km = (o.radius_m - R_EARTH_M) / 1000.0;
assert!((300.0..450.0).contains(&alt_km), "altitude {alt_km} km");
}
#[test]
fn semi_major_axis_matches_mean_motion() {
let o = parse_line2(ISS_L2).unwrap();
let expected_period = SECONDS_PER_DAY / 15.721_253_91;
assert!((o.period_s() - expected_period).abs() / expected_period < 1e-9);
}
#[test]
fn parses_three_line_set_and_ignores_other_lines() {
let text = "ISS (ZARYA)\n\
1 25544U 98067A 24001.00000000 .00000000 00000-0 00000-0 0 9990\n\
2 25544 51.6400 247.4627 0006703 130.5360 325.0288 15.72125391563537\n\
GPS BIIR-2\n\
1 28474U 04045A 24001.00000000 .00000000 00000-0 00000-0 0 9990\n\
2 28474 55.0000 10.0000 0100000 90.0000 270.0000 2.00561000000000";
let sats = parse_set(text).expect("valid set");
assert_eq!(sats.len(), 2);
assert!((sats[1].eccentricity - 0.01).abs() < 1e-9);
assert!((sats[1].inclination_rad.to_degrees() - 55.0).abs() < 1e-6);
}
#[test]
fn rejects_non_line2_and_short_lines() {
assert!(parse_line2("1 25544U 98067A 24001.00000000").is_err());
assert!(parse_line2("2 25544 51.64").is_err());
assert!(parse_set("nothing here\n1 ...\n").unwrap().is_empty());
}
const VER_L1: &str = "1 00005U 58002B 00179.78495062 .00000023 00000-0 28098-4 0 4753";
const VER_L2: &str = "2 00005 34.2682 348.7242 1859667 331.7664 19.3264 10.82419157413667";
#[test]
fn parse_tle_fields_and_epoch() {
let t = parse_tle(VER_L1, VER_L2).expect("valid full TLE");
assert!(
(t.epoch_days_1950 - (18262.0 + 179.784_950_62)).abs() < 1e-6,
"epoch {}",
t.epoch_days_1950
);
assert!((t.bstar - 0.28098e-4).abs() < 1e-12, "bstar {}", t.bstar);
assert!((t.ecco - 0.1859667).abs() < 1e-9);
assert!((t.inclo_rad.to_degrees() - 34.2682).abs() < 1e-6);
let expect_nm = 10.824_191_57 * std::f64::consts::TAU / 1440.0;
assert!((t.no_kozai_rad_min - expect_nm).abs() < 1e-12);
}
#[test]
fn parse_propagators_chooses_sgp4_for_full_tles_and_kepler_for_line2() {
use crate::orbit::Propagator;
let full = format!("{VER_L1}\n{VER_L2}");
let one = parse_propagators(&full).unwrap();
assert_eq!(one.len(), 1);
assert!(matches!(one[0], Propagator::Sgp4(_)));
let bare = parse_propagators(VER_L2).unwrap();
assert_eq!(bare.len(), 1);
assert!(matches!(bare[0], Propagator::Kepler(_)));
let mixed = format!("NAME\n{VER_L1}\n{VER_L2}\n{VER_L2}");
let two = parse_propagators(&mixed).unwrap();
assert_eq!(two.len(), 2);
assert!(matches!(two[0], Propagator::Sgp4(_)));
assert!(matches!(two[1], Propagator::Kepler(_)));
}
#[test]
fn sgp4_propagator_position_is_finite_and_moves() {
let p = &parse_propagators(&format!("{VER_L1}\n{VER_L2}")).unwrap()[0];
let p0 = p.position_eci(0.0);
let p1 = p.position_eci(600.0);
assert!(p0.iter().all(|c| c.is_finite()) && p1.iter().all(|c| c.is_finite()));
let moved = (0..3).map(|k| (p1[k] - p0[k]).powi(2)).sum::<f64>().sqrt();
assert!(moved > 1.0e5, "moved only {moved} m");
let r0 = p0.iter().map(|c| c * c).sum::<f64>().sqrt();
assert!((6.5e6..8.0e6).contains(&r0), "radius {r0} m");
}
}