open-standards/map/map-rs/crates/map-orbital/src/tle.rs
Transaction Science ba1212361f Add aac, map, sandbox standards; catalog the standards family
Commit the previously-untracked standard directories and catalog them in the
root README alongside proof:

- aac/    — communication cargo class: portable boards + a ten-level
            offline-first cascade (Apache-2.0 code / CC-BY-4.0 spec).
- map/    — geospatial cargo class: range-requestable signed tilesets +
            an F0–F4 fidelity cascade (Apache-2.0 code / CC-BY-4.0 spec).
- sandbox/— isolated, metered, attested execution across the heterogeneous
            compute fabric (Apache-2.0 code / CC-BY-4.0 spec).
- jouleclaw-otel-authz, jouleclaw-trust — new jouleclaw reference crates.

Root README now lists aac, map, sandbox, and proof in the catalog, the tree,
and the contribution guide.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
2026-06-07 17:04:36 -04:00

361 lines
12 KiB
Rust

//! 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<Utc>`.
pub epoch: DateTime<Utc>,
}
/// Parse a TLE with a satellite name line.
pub fn parse_tle(name: &str, line1: &str, line2: &str) -> OrbitalResult<TleElements> {
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<TleElements> {
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::<u32>(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::<u16>(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::<f64>(line1, 20, 32, "epoch day")?;
// First derivative of mean motion / 2
let mean_motion_dot = parse_field::<f64>(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::<u16>(line1, 64, 68, "element_set")?;
// ── Parse Line 2 ────────────────────────────────────────
let norad_id_2 = parse_field::<u32>(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::<f64>(line2, 8, 16, "inclination")?;
let inclination = inclination_deg * DEG_TO_RAD;
// RAAN (degrees -> radians)
let raan_deg = parse_field::<f64>(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::<f64>(line2, 34, 42, "arg_perigee")?;
let arg_perigee = arg_perigee_deg * DEG_TO_RAD;
// Mean anomaly (degrees -> radians)
let mean_anomaly_deg = parse_field::<f64>(line2, 43, 51, "mean_anomaly")?;
let mean_anomaly = mean_anomaly_deg * DEG_TO_RAD;
// Mean motion (rev/day)
let mean_motion = parse_field::<f64>(line2, 52, 63, "mean_motion")?;
// Revolution number at epoch
let rev_number = parse_field::<u32>(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<T: std::str::FromStr>(
line: &str,
start: usize,
end: usize,
name: &str,
) -> OrbitalResult<T>
where
T::Err: std::fmt::Display,
{
let s = line[start..end].trim();
s.parse::<T>().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<f64> {
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::<f64>().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::<f64>()
.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(_))));
}
}