//! Two-Line Element (TLE) set parser. //! //! Parses the standard NORAD TLE format used for distributing orbital //! element sets for Earth-orbiting objects. use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use crate::constants::DEG_TO_RAD; use crate::error::{OrbitalError, OrbitalResult}; use crate::julian::epoch_to_datetime; /// Parsed TLE orbital elements. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TleElements { /// Satellite name (from line 0, if provided). pub name: String, /// NORAD catalog number. pub norad_id: u32, /// Classification (U = unclassified, C = classified, S = secret). pub classification: char, /// International designator (launch year + launch number + piece). pub intl_designator: String, /// Epoch year (4-digit). pub epoch_year: u16, /// Epoch day of year (1-based, fractional). pub epoch_day: f64, /// First derivative of mean motion divided by 2 (rev/day^2). pub mean_motion_dot: f64, /// Second derivative of mean motion divided by 6 (rev/day^3). pub mean_motion_ddot: f64, /// BSTAR drag term (1/earth-radii). pub bstar: f64, /// Element set number. pub element_set: u16, /// Inclination (radians). pub inclination: f64, /// Right ascension of ascending node (radians). pub raan: f64, /// Eccentricity (dimensionless, 0 to <1). pub eccentricity: f64, /// Argument of perigee (radians). pub arg_perigee: f64, /// Mean anomaly (radians). pub mean_anomaly: f64, /// Mean motion (revolutions per day). pub mean_motion: f64, /// Revolution number at epoch. pub rev_number: u32, /// Computed epoch as a `DateTime`. pub epoch: DateTime, } /// Parse a TLE with a satellite name line. pub fn parse_tle(name: &str, line1: &str, line2: &str) -> OrbitalResult { let mut elements = parse_tle_pair(line1, line2)?; elements.name = name.trim().to_string(); Ok(elements) } /// Parse a TLE from just the two data lines (no name). pub fn parse_tle_pair(line1: &str, line2: &str) -> OrbitalResult { let line1 = line1.trim_end(); let line2 = line2.trim_end(); // Validate minimum length if line1.len() < 69 { return Err(OrbitalError::TleParseError(format!( "line 1 too short: {} chars (need 69)", line1.len() ))); } if line2.len() < 69 { return Err(OrbitalError::TleParseError(format!( "line 2 too short: {} chars (need 69)", line2.len() ))); } // Validate line numbers if line1.as_bytes()[0] != b'1' { return Err(OrbitalError::TleParseError( "line 1 does not start with '1'".into(), )); } if line2.as_bytes()[0] != b'2' { return Err(OrbitalError::TleParseError( "line 2 does not start with '2'".into(), )); } // Validate checksums validate_checksum(line1, 1)?; validate_checksum(line2, 2)?; // ── Parse Line 1 ──────────────────────────────────────── let norad_id = parse_field::(line1, 2, 7, "norad_id (line 1)")?; let classification = line1.as_bytes()[7] as char; let intl_designator = line1[9..17].trim().to_string(); // Epoch: columns 18-32 let epoch_year_2d = parse_field::(line1, 18, 20, "epoch year")?; let epoch_year = if epoch_year_2d >= 57 { 1900 + epoch_year_2d } else { 2000 + epoch_year_2d }; let epoch_day = parse_field::(line1, 20, 32, "epoch day")?; // First derivative of mean motion / 2 let mean_motion_dot = parse_field::(line1, 33, 43, "mean_motion_dot")?; // Second derivative of mean motion / 6 (implied decimal) let mean_motion_ddot = parse_implied_decimal(line1, 44, 52, "mean_motion_ddot")?; // BSTAR drag term (implied decimal) let bstar = parse_implied_decimal(line1, 53, 61, "bstar")?; // Element set number let element_set = parse_field::(line1, 64, 68, "element_set")?; // ── Parse Line 2 ──────────────────────────────────────── let norad_id_2 = parse_field::(line2, 2, 7, "norad_id (line 2)")?; if norad_id != norad_id_2 { return Err(OrbitalError::TleParseError(format!( "NORAD ID mismatch: line1={norad_id}, line2={norad_id_2}" ))); } // Inclination (degrees -> radians) let inclination_deg = parse_field::(line2, 8, 16, "inclination")?; let inclination = inclination_deg * DEG_TO_RAD; // RAAN (degrees -> radians) let raan_deg = parse_field::(line2, 17, 25, "raan")?; let raan = raan_deg * DEG_TO_RAD; // Eccentricity (implied leading "0.") let ecc_str = line2[26..33].trim(); let eccentricity: f64 = format!("0.{ecc_str}") .parse() .map_err(|e| OrbitalError::TleParseError(format!("eccentricity parse: {e}")))?; // Argument of perigee (degrees -> radians) let arg_perigee_deg = parse_field::(line2, 34, 42, "arg_perigee")?; let arg_perigee = arg_perigee_deg * DEG_TO_RAD; // Mean anomaly (degrees -> radians) let mean_anomaly_deg = parse_field::(line2, 43, 51, "mean_anomaly")?; let mean_anomaly = mean_anomaly_deg * DEG_TO_RAD; // Mean motion (rev/day) let mean_motion = parse_field::(line2, 52, 63, "mean_motion")?; // Revolution number at epoch let rev_number = parse_field::(line2, 63, 68, "rev_number")?; let epoch = epoch_to_datetime(epoch_year, epoch_day); Ok(TleElements { name: String::new(), norad_id, classification, intl_designator, epoch_year, epoch_day, mean_motion_dot, mean_motion_ddot, bstar, element_set, inclination, raan, eccentricity, arg_perigee, mean_anomaly, mean_motion, rev_number, epoch, }) } /// Validate a TLE line's checksum (mod-10 of digits, with '-' counting as 1). fn validate_checksum(line: &str, line_num: u8) -> OrbitalResult<()> { let bytes = line.as_bytes(); let expected = bytes[68] - b'0'; let mut sum: u32 = 0; for &b in &bytes[..68] { match b { b'0'..=b'9' => sum += (b - b'0') as u32, b'-' => sum += 1, _ => {} } } let got = (sum % 10) as u8; if got != expected { return Err(OrbitalError::TleChecksumError { line: line_num, expected, got, }); } Ok(()) } /// Parse a numeric field from a TLE line substring. fn parse_field( line: &str, start: usize, end: usize, name: &str, ) -> OrbitalResult where T::Err: std::fmt::Display, { let s = line[start..end].trim(); s.parse::().map_err(|e| { OrbitalError::TleParseError(format!("failed to parse {name} from '{s}': {e}")) }) } /// Parse a TLE field with implied decimal point and exponent. /// /// Handles formats like: /// - " 50000-4" -> 0.50000e-4 /// - "-11111-1" -> -0.11111e-1 /// - " 00000+0" -> 0.0 fn parse_implied_decimal( line: &str, start: usize, end: usize, name: &str, ) -> OrbitalResult { let raw = line[start..end].trim(); if raw.is_empty() || raw.chars().all(|c| c == ' ' || c == '0' || c == '+') { return Ok(0.0); } // Format: [+-]NNNNN[+-]N // The mantissa has an implied leading "0." // Find the exponent sign (the last + or - that isn't the leading sign) let bytes = raw.as_bytes(); let mut exp_pos = None; for i in (1..bytes.len()).rev() { if bytes[i] == b'+' || bytes[i] == b'-' { exp_pos = Some(i); break; } } let value = match exp_pos { Some(pos) => { let mantissa_str = &raw[..pos]; let exp_str = &raw[pos..]; // Build the number with explicit decimal point let sign = if mantissa_str.starts_with('-') { "-" } else { "" }; let digits: String = mantissa_str .chars() .filter(|c| c.is_ascii_digit()) .collect(); if digits.is_empty() || digits.chars().all(|c| c == '0') { 0.0 } else { let full = format!("{sign}0.{digits}e{exp_str}"); full.parse::().map_err(|e| { OrbitalError::TleParseError(format!( "failed to parse implied decimal {name} from '{raw}' (built '{full}'): {e}" )) })? } } None => { // No exponent found, try direct parse with implied decimal let sign = if raw.starts_with('-') { "-" } else { "" }; let digits: String = raw.chars().filter(|c| c.is_ascii_digit()).collect(); if digits.is_empty() { 0.0 } else { format!("{sign}0.{digits}") .parse::() .map_err(|e| { OrbitalError::TleParseError(format!( "failed to parse implied decimal {name} from '{raw}': {e}" )) })? } } }; Ok(value) } #[cfg(test)] mod tests { use super::*; const ISS_NAME: &str = "ISS (ZARYA)"; const ISS_LINE1: &str = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9009"; const ISS_LINE2: &str = "2 25544 51.6416 247.4627 0006703 30.0000 10.0000 15.49560000000007"; #[test] fn test_parse_iss_tle() { let tle = parse_tle(ISS_NAME, ISS_LINE1, ISS_LINE2).unwrap(); assert_eq!(tle.name, "ISS (ZARYA)"); assert_eq!(tle.norad_id, 25544); assert_eq!(tle.classification, 'U'); assert_eq!(tle.intl_designator, "98067A"); assert_eq!(tle.epoch_year, 2024); assert!((tle.epoch_day - 1.5).abs() < 1e-8); assert!((tle.mean_motion_dot - 0.00016717).abs() < 1e-10); assert!((tle.bstar - 0.10270e-3).abs() < 1e-10); assert!((tle.inclination - 51.6416_f64.to_radians()).abs() < 1e-6); assert!((tle.eccentricity - 0.0006703).abs() < 1e-8); assert!((tle.mean_motion - 15.4956).abs() < 0.001); } #[test] fn test_checksum_validation() { // Modify a digit to break checksum let bad_line1 = "1 25544U 98067A 24001.50000000 .00016717 00000-0 10270-3 0 9005"; let result = parse_tle_pair(bad_line1, ISS_LINE2); assert!(matches!(result, Err(OrbitalError::TleChecksumError { .. }))); } #[test] fn test_implied_decimal() { // " 50000-4" -> 0.50000e-4 assert!((parse_implied_decimal(" 50000-4 ", 0, 10, "test").unwrap() - 5e-5).abs() < 1e-12); // "-11111-1" -> -0.11111e-1 let val = parse_implied_decimal(" -11111-1 ", 1, 9, "test").unwrap(); assert!((val - (-0.011111)).abs() < 1e-8); } #[test] fn test_epoch_year_conversion() { let tle = parse_tle_pair(ISS_LINE1, ISS_LINE2).unwrap(); assert_eq!(tle.epoch_year, 2024); // Year 57+ should be 1900s // We can't easily test this without a different TLE, but the logic is: // 57 -> 1957, 99 -> 1999 } #[test] fn test_norad_id_mismatch() { let bad_line2 = "2 99999 51.6416 247.4627 0006703 30.0000 10.0000 15.49560000000002"; let result = parse_tle_pair(ISS_LINE1, bad_line2); assert!(matches!(result, Err(OrbitalError::TleParseError(_)))); } }