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]>
361 lines
12 KiB
Rust
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(_))));
|
|
}
|
|
}
|