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,
)
}
}
fn ascii_guard(line: &str, label: &str) -> Result<(), String> {
if line.is_ascii() {
Ok(())
} else {
Err(format!("non-ASCII characters in TLE {label}: {line:?}"))
}
}
fn col<'a>(line: &'a str, a: usize, z: usize, what: &str) -> Result<&'a str, String> {
line.get(a..z)
.map(str::trim)
.ok_or_else(|| format!("TLE truncated reading {what} (columns {}..{})", a + 1, z))
}
pub fn tle_checksum(line: &str) -> u32 {
line.bytes()
.take(68)
.map(|b| match b {
b'0'..=b'9' => (b - b'0') as u32,
b'-' => 1,
_ => 0,
})
.sum::<u32>()
% 10
}
pub fn verify_checksum(line: &str, label: &str) -> Result<(), String> {
let want = line
.as_bytes()
.get(68)
.copied()
.ok_or_else(|| format!("TLE {label} too short for a column-69 checksum"))?;
if !want.is_ascii_digit() {
return Err(format!("TLE {label} has no checksum digit in column 69"));
}
let want = (want - b'0') as u32;
let got = tle_checksum(line);
if want != got {
return Err(format!(
"TLE {label} checksum mismatch: column 69 says {want}, computed {got}"
));
}
Ok(())
}
pub fn parse_tle(line1: &str, line2: &str) -> Result<Tle, String> {
ascii_guard(line1, "line 1")?;
ascii_guard(line2, "line 2")?;
if !line1.starts_with("1 ") || line1.len() < 63 {
return Err(format!("not a TLE line 1: {line1:?}"));
}
if !line2.starts_with("2 ") || line2.len() < 63 {
return Err(format!("not a TLE line 2: {line2:?}"));
}
let num = |s: &str, what: &str| -> Result<f64, String> {
s.trim()
.parse::<f64>()
.map_err(|_| format!("invalid {what} in TLE: {s:?}"))
};
let yy: i64 = col(line1, 18, 20, "epoch year")?.parse().map_err(|_| {
format!(
"invalid epoch year in TLE: {:?}",
col(line1, 18, 20, "").ok()
)
})?;
let year = if yy < 57 { 2000 + yy } else { 1900 + yy };
let epochdays = num(col(line1, 20, 32, "epoch day")?, "epoch day")?;
let epoch_days_1950 = days_1950_to_year(year) + epochdays;
let bstar = parse_decimal_exp(
col(line1, 53, 54, "bstar sign")?,
col(line1, 54, 59, "bstar mantissa")?,
col(line1, 59, 61, "bstar exponent")?,
"bstar",
)?;
let inclo = num(col(line2, 8, 16, "inclination")?, "inclination")?;
let nodeo = num(col(line2, 17, 25, "RAAN")?, "RAAN")?;
let ecco = num(
&format!("0.{}", col(line2, 26, 33, "eccentricity")?),
"eccentricity",
)?;
let argpo = num(
col(line2, 34, 42, "argument of perigee")?,
"argument of perigee",
)?;
let mo = num(col(line2, 43, 51, "mean anomaly")?, "mean anomaly")?;
let no_rev_day = num(col(line2, 52, 63, "mean motion")?, "mean motion")?;
check_elements(inclo, ecco, no_rev_day)?;
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 check_elements(
inclination_deg: f64,
eccentricity: f64,
mean_motion_rev_day: f64,
) -> Result<(), String> {
if !(0.0..=180.0).contains(&inclination_deg) {
return Err(format!(
"inclination out of range [0, 180] deg: {inclination_deg}"
));
}
if !(0.0..1.0).contains(&eccentricity) {
return Err(format!("eccentricity out of range [0, 1): {eccentricity}"));
}
if mean_motion_rev_day.is_nan() || mean_motion_rev_day <= 0.0 {
return Err(format!("non-positive mean motion: {mean_motion_rev_day}"));
}
Ok(())
}
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> {
ascii_guard(line2, "line 2")?;
if !line2.starts_with("2 ") || line2.len() < 63 {
return Err(format!("not a TLE line 2: {line2:?}"));
}
let num = |s: &str, what: &str| -> Result<f64, String> {
s.parse::<f64>()
.map_err(|_| format!("invalid {what} in TLE: {s:?}"))
};
let inclination_deg = num(col(line2, 8, 16, "inclination")?, "inclination")?;
let raan_deg = num(col(line2, 17, 25, "RAAN")?, "RAAN")?;
let ecc = num(
&format!("0.{}", col(line2, 26, 33, "eccentricity")?),
"eccentricity",
)?;
let argp_deg = num(
col(line2, 34, 42, "argument of perigee")?,
"argument of perigee",
)?;
let mean_anomaly_deg = num(col(line2, 43, 51, "mean anomaly")?, "mean anomaly")?;
let mean_motion_rev_day = num(col(line2, 52, 63, "mean motion")?, "mean motion")?;
check_elements(inclination_deg, ecc, 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()
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ParseOpts {
pub strict_checksum: bool,
}
pub fn parse_propagators(text: &str) -> Result<Vec<crate::orbit::Propagator>, String> {
parse_propagators_opts(text, ParseOpts::default())
}
pub fn parse_propagators_opts(
text: &str,
opts: ParseOpts,
) -> 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 {
if opts.strict_checksum {
verify_checksum(line, "line 2")?;
}
match pending_l1.take() {
Some(l1) => {
if opts.strict_checksum {
verify_checksum(l1, "line 1")?;
}
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());
}
#[test]
fn rejects_non_ascii_line_without_panicking() {
let bad = ISS_L2.replacen("51.6400", "51.64é0", 1);
assert!(parse_line2(&bad).is_err());
assert!(parse_tle(VER_L1, &bad).is_err());
assert!(parse_tle(&bad.replacen("2 ", "1 ", 1), VER_L2).is_err());
}
#[test]
fn rejects_out_of_range_elements() {
let bad_incl = "2 25544 251.6400 247.4627 0006703 130.5360 325.0288 15.72125391563537";
assert!(parse_line2(bad_incl).is_err());
let zero_n = "2 25544 51.6400 247.4627 0006703 130.5360 325.0288 00.00000000000000";
assert!(parse_line2(zero_n).is_err());
}
#[test]
fn strict_checksum_rejects_corrupt_lenient_accepts() {
let base = &ISS_L2[..68]; let ck = tle_checksum(base);
let good = format!("{base}{ck}");
let bad = format!("{base}{}", (ck + 1) % 10);
assert_eq!(parse_propagators(&good).unwrap().len(), 1);
assert_eq!(parse_propagators(&bad).unwrap().len(), 1);
let strict = ParseOpts {
strict_checksum: true,
};
assert_eq!(parse_propagators_opts(&good, strict).unwrap().len(), 1);
assert!(parse_propagators_opts(&bad, strict).is_err());
assert!(verify_checksum(&good, "l2").is_ok());
assert!(verify_checksum(&bad, "l2").is_err());
}
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");
}
}