From 7c66653fa124b2796da363c6fddccf8dc8e123f3 Mon Sep 17 00:00:00 2001 From: ApsisRS contributors Date: Sat, 26 Sep 2026 14:02:01 +0800 Subject: [PATCH] orbit: add TLE parser --- crates/orbit/apsis-tle/Cargo.toml | 2 + crates/orbit/apsis-tle/src/checksum.rs | 76 ++++ crates/orbit/apsis-tle/src/element.rs | 435 +++++++++++++++++++++++ crates/orbit/apsis-tle/src/field.rs | 106 ++++++ crates/orbit/apsis-tle/src/lib.rs | 51 ++- crates/orbit/apsis-tle/tests/checksum.rs | 72 ++++ crates/orbit/apsis-tle/tests/element.rs | 168 +++++++++ 7 files changed, 909 insertions(+), 1 deletion(-) create mode 100644 crates/orbit/apsis-tle/src/checksum.rs create mode 100644 crates/orbit/apsis-tle/src/element.rs create mode 100644 crates/orbit/apsis-tle/src/field.rs create mode 100644 crates/orbit/apsis-tle/tests/checksum.rs create mode 100644 crates/orbit/apsis-tle/tests/element.rs diff --git a/crates/orbit/apsis-tle/Cargo.toml b/crates/orbit/apsis-tle/Cargo.toml index 373cbfd..5ecaf87 100644 --- a/crates/orbit/apsis-tle/Cargo.toml +++ b/crates/orbit/apsis-tle/Cargo.toml @@ -11,3 +11,5 @@ publish = false workspace = true [dependencies] +apsis-core.workspace = true +apsis-time.workspace = true diff --git a/crates/orbit/apsis-tle/src/checksum.rs b/crates/orbit/apsis-tle/src/checksum.rs new file mode 100644 index 0000000..9041781 --- /dev/null +++ b/crates/orbit/apsis-tle/src/checksum.rs @@ -0,0 +1,76 @@ +//! Line checksums for the two-line element format. +//! +//! Each line ends with a single digit that is the checksum of the preceding +//! columns. The checksum is the sum of the decimal digits on the line, where +//! a minus sign contributes one and every other character contributes +//! nothing, reduced modulo ten. +//! +//! The checksum digit itself is the last character of the line and is not +//! part of the sum. + +use crate::field::{ElementError, ElementErrorKind, Result}; + +/// Returns the checksum of the first 68 columns of a line. +/// +/// The input may include the trailing checksum digit, which is ignored +/// because only the first 68 columns contribute to the sum. A line shorter +/// than 68 characters contributes only the columns it has. +/// +/// A minus sign contributes one, a decimal digit contributes its value, and +/// every other character contributes nothing. +pub fn line_checksum(line: &str) -> u32 { + line.chars() + .take(68) + .map(|character| match character { + '-' => 1, + '0'..='9' => character as u32 - '0' as u32, + _ => 0, + }) + .sum::() + % 10 +} + +/// Verifies that the last character of a line is its checksum. +/// +/// The line must be at least 69 characters long, because the checksum +/// occupies column 69. The expected value is compared against +/// [`line_checksum`] of the same line. +/// +/// `line_number` is the one based number used in any error that is +/// returned, so that a caller parsing a multi-line input can report which +/// line failed. +pub fn verify_line_checksum(line: &str, line_number: usize) -> Result<()> { + let checksum_columns = (69, 69); + // The line must contain at least 69 characters. Counting characters + // rather than bytes is deliberate: the format is ASCII, but a caller + // may hand in text with a non-ASCII character by mistake, and counting + // characters gives a clearer failure than slicing through one. + let characters: Vec = line.chars().collect(); + if characters.len() < 69 { + return Err(ElementError::new( + ElementErrorKind::TooShort, + line_number, + Some(checksum_columns), + ) + .with_detail(format!("found {} characters, need 69", characters.len()))); + } + let expected = characters[68]; + let Some(expected) = expected.to_digit(10) else { + return Err(ElementError::new( + ElementErrorKind::InvalidField, + line_number, + Some(checksum_columns), + ) + .with_detail(format!("checksum character is {expected:?}, not a digit"))); + }; + let actual = line_checksum(line); + if actual != expected { + return Err(ElementError::new( + ElementErrorKind::Checksum, + line_number, + Some(checksum_columns), + ) + .with_detail(format!("computed {actual}, line states {expected}"))); + } + Ok(()) +} diff --git a/crates/orbit/apsis-tle/src/element.rs b/crates/orbit/apsis-tle/src/element.rs new file mode 100644 index 0000000..5b820aa --- /dev/null +++ b/crates/orbit/apsis-tle/src/element.rs @@ -0,0 +1,435 @@ +//! Structured fields of a two-line element set. + +use crate::checksum::verify_line_checksum; +use crate::field::{ElementError, ElementErrorKind, Result}; +use apsis_time::{Epoch, TimeScale}; + +/// The security classification of an element set. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Classification { + /// Unclassified. + Unclassified, + /// Classified as secret by the originating authority. + Secret, + /// Classified at some level the parser does not recognise. + Other(char), +} + +impl Classification { + /// Returns the character used in the element set. + pub const fn as_char(self) -> char { + match self { + Self::Unclassified => 'U', + Self::Secret => 'S', + Self::Other(character) => character, + } + } +} + +/// A parsed two-line element set. +/// +/// The fields are the mean elements and identification data exactly as +/// written in the element set. Units are the ones the format uses, named in +/// each field's documentation. Converting them to a state vector is the +/// concern of `apsis-sgp4`. +#[derive(Debug, Clone, PartialEq)] +pub struct TwoLineElement { + /// Satellite name from the optional first line, if one was present. + pub name: Option, + /// Satellite catalog number. + pub satellite_number: u32, + /// Classification of the element set. + pub classification: Classification, + /// International designator, such as `98067A`. + pub international_designator: String, + /// Epoch of the mean elements, on the UTC scale. + /// + /// The format stores the epoch as a two digit year and a fraction of a + /// day. The two digit year is expanded with the convention that years + /// `57` through `99` are in the twentieth century and years `00` + /// through `56` are in the twenty-first, which places the boundary + /// before the start of the satellite catalogue. + pub epoch: Epoch, + /// First derivative of mean motion divided by two, in revolutions per + /// day squared. + /// + /// The format stores half of the derivative, which is why the field is + /// documented as `ndot / 2`. + pub mean_motion_dot: f64, + /// Second derivative of mean motion divided by six, in revolutions per + /// day cubed. + /// + /// The format stores one sixth of the derivative, which is why the + /// field is documented as `nddot / 6`. + pub mean_motion_double_dot: f64, + /// The `BSTAR` drag term, in units of inverse Earth radii. + /// + /// The term is a free parameter of the SGP4 drag model. Its units are + /// the reciprocal of the reference distance used by the model, which is + /// the Earth equatorial radius in kilometers. + pub bstar: f64, + /// The element set number, which increments when a satellite's elements + /// are refreshed. + pub element_set_number: u32, + /// Orbital inclination, in degrees. + pub inclination: f64, + /// Right ascension of the ascending node, in degrees. + pub right_ascension: f64, + /// Orbital eccentricity, dimensionless. + pub eccentricity: f64, + /// Argument of perigee, in degrees. + pub argument_of_perigee: f64, + /// Mean anomaly, in degrees. + pub mean_anomaly: f64, + /// Mean motion, in revolutions per day. + pub mean_motion: f64, + /// Revolution number at epoch. + pub revolution_number: u32, +} + +/// Returns the one based inclusive character range of a field, as a slice of +/// characters. +/// +/// Ranges are inclusive of both ends, matching the column numbering in the +/// format definition. +fn field(line: &str, first: usize, last: usize) -> Option { + if first < 1 || last < first { + return None; + } + let characters: Vec = line.chars().collect(); + if last > characters.len() { + return None; + } + Some(characters[first - 1..last].iter().collect()) +} + +/// Reads a field and returns it trimmed of surrounding spaces. +fn trimmed_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + match field(line, first, last) { + Some(text) => Ok(text.trim().to_string()), + None => Err(ElementError::new( + ElementErrorKind::TooShort, + line_number, + Some((first, last)), + )), + } +} + +/// Parses an unsigned integer field. +fn unsigned_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + let text = trimmed_field(line, first, last, line_number)?; + text.parse::().map_err(|_| { + ElementError::new( + ElementErrorKind::InvalidField, + line_number, + Some((first, last)), + ) + .with_detail(format!("expected an unsigned integer, found {text:?}")) + }) +} + +/// Parses a floating point field written with an explicit decimal point. +fn float_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + let text = trimmed_field(line, first, last, line_number)?; + text.parse::().map_err(|_| { + ElementError::new( + ElementErrorKind::InvalidField, + line_number, + Some((first, last)), + ) + .with_detail(format!("expected a number, found {text:?}")) + }) +} + +/// Parses an exponent field of the form `smmmmmse` where the first `s` is an +/// optional sign for the mantissa, `e` is a signed single digit exponent, and +/// the mantissa is interpreted with an assumed decimal point before its first +/// digit. +/// +/// For example ` 12345-3` is `0.12345e-3` and `-11606-4` is `-0.11606e-4`. +/// An all blank field is zero, which is how the format writes an absent +/// term. +fn exponent_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + let text = trimmed_field(line, first, last, line_number)?; + let columns = Some((first, last)); + if text.is_empty() { + return Ok(0.0); + } + + // The last character is the exponent sign and the one before it is the + // exponent magnitude, so the mantissa is everything before those two. + let characters: Vec = text.chars().collect(); + if characters.len() < 3 { + return Err( + ElementError::new(ElementErrorKind::InvalidField, line_number, columns) + .with_detail(format!("exponent field {text:?} is too short")), + ); + } + let exponent_sign = match characters[characters.len() - 2] { + '+' => 1, + '-' => -1, + other => { + return Err(ElementError::new( + ElementErrorKind::UnexpectedCharacter, + line_number, + columns, + ) + .with_detail(format!("exponent sign is {other:?}, expected + or -"))); + } + }; + let exponent_magnitude = characters[characters.len() - 1]; + let Some(exponent_magnitude) = exponent_magnitude.to_digit(10) else { + return Err( + ElementError::new(ElementErrorKind::UnexpectedCharacter, line_number, columns) + .with_detail(format!( + "exponent is {exponent_magnitude:?}, expected a digit" + )), + ); + }; + + let mantissa: String = characters[..characters.len() - 2].iter().collect(); + let mantissa = mantissa.replace(' ', ""); + let (mantissa_sign, mantissa) = if let Some(rest) = mantissa.strip_prefix('-') { + (-1.0, rest.to_string()) + } else if let Some(rest) = mantissa.strip_prefix('+') { + (1.0, rest.to_string()) + } else { + (1.0, mantissa) + }; + let digits: String = mantissa + .chars() + .filter(|character| character.is_ascii_digit()) + .collect(); + if digits.is_empty() { + return Err( + ElementError::new(ElementErrorKind::InvalidField, line_number, columns) + .with_detail(format!("mantissa is {mantissa:?}, expected digits")), + ); + } + let value = format!("0.{digits}").parse::().map_err(|_| { + ElementError::new(ElementErrorKind::InvalidField, line_number, columns) + .with_detail(format!("mantissa is {mantissa:?}")) + })?; + let exponent = exponent_sign * exponent_magnitude as i32; + Ok(mantissa_sign * value * 10f64.powi(exponent)) +} + +/// Parses the eccentricity field, which is written with an assumed decimal +/// point before its first digit. +fn assumed_decimal_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + let text = trimmed_field(line, first, last, line_number)?; + let columns = Some((first, last)); + if text.is_empty() || !text.chars().all(|character| character.is_ascii_digit()) { + return Err( + ElementError::new(ElementErrorKind::InvalidField, line_number, columns) + .with_detail(format!("expected digits, found {text:?}")), + ); + } + format!("0.{text}").parse::().map_err(|_| { + ElementError::new(ElementErrorKind::InvalidField, line_number, columns) + .with_detail(format!("eccentricity is {text:?}")) + }) +} + +/// Expands a two digit year using the convention that `57` through `99` are +/// in the twentieth century and `00` through `56` are in the twenty-first. +/// +/// The boundary sits before the start of the satellite catalogue, so no +/// catalogue entry is placed a century away from its true date. +pub const fn expand_two_digit_year(two_digit: u32) -> i32 { + if two_digit >= 57 { + 1900 + two_digit as i32 + } else { + 2000 + two_digit as i32 + } +} + +/// Parses the epoch field, which is a two digit year and a fractional day of +/// year, and resolves it on the UTC scale. +fn epoch_field(line: &str, first: usize, last: usize, line_number: usize) -> Result { + let text = trimmed_field(line, first, last, line_number)?; + let columns = Some((first, last)); + let invalid = |detail: String| { + ElementError::new(ElementErrorKind::InvalidField, line_number, columns).with_detail(detail) + }; + + // The field is a two digit year followed by three digits of day of year + // and then a fraction of that day. + if text.len() < 5 { + return Err(invalid(format!("epoch {text:?} is too short"))); + } + let (year_digits, rest) = text.split_at(2); + let (day_digits, fraction_digits) = rest.split_at(3); + if !year_digits.chars().all(|c| c.is_ascii_digit()) + || !day_digits.chars().all(|c| c.is_ascii_digit()) + { + return Err(invalid(format!("epoch {text:?} is not numeric"))); + } + let year = expand_two_digit_year( + year_digits + .parse::() + .map_err(|_| invalid(format!("year {year_digits:?}")))?, + ); + let day_of_year = day_digits + .parse::() + .map_err(|_| invalid(format!("day of year {day_digits:?}")))?; + if !(1..=366).contains(&day_of_year) { + return Err(invalid(format!( + "day of year {day_of_year} is out of range" + ))); + } + let fraction = if fraction_digits.is_empty() { + 0.0 + } else { + // The field writes the decimal point explicitly, so the fraction + // begins with a dot. Strip it before forming the value, otherwise + // the parse would see two decimal points. + let digits = fraction_digits.strip_prefix('.').unwrap_or(fraction_digits); + format!("0.{digits}") + .parse::() + .map_err(|_| invalid(format!("day fraction {fraction_digits:?}")))? + }; + + let calendar = calendar_from_day_of_year(year, day_of_year)?; + let mjd = apsis_time::mjd_from_ymd(calendar.0, calendar.1, calendar.2) + fraction; + Epoch::from_mjd(mjd, TimeScale::Utc) + .ok_or_else(|| invalid(format!("epoch {text:?} is outside the leap second table"))) +} + +/// Converts a year and a day of year, both one based, to a calendar date. +fn calendar_from_day_of_year(year: i32, day_of_year: u32) -> Result<(i32, u32, u32)> { + let mut remaining = day_of_year; + let mut month = 1; + while month <= 12 { + let length = apsis_time::days_in_month(year, month); + if remaining <= length { + return Ok((year, month, remaining)); + } + remaining -= length; + month += 1; + } + Err( + ElementError::new(ElementErrorKind::InvalidField, 0, None).with_detail(format!( + "day of year {day_of_year} does not exist in {year}" + )), + ) +} + +impl TwoLineElement { + /// Parses a two-line element set from one, two, or three lines. + /// + /// Line endings may be `\n`, `\r\n`, or a mix; blank lines and lines + /// that are entirely whitespace are ignored. A leading line that does + /// not begin with `1 ` is taken as the satellite name, which is how the + /// optional three line form is written. + /// + /// Each line is checked for the expected line number in column one, for + /// its length, and for its checksum. The element set number of the two + /// lines must agree. + /// + /// Returns an error describing the first failure and the line on which + /// it occurred. + pub fn parse(text: &str) -> Result { + let lines: Vec<&str> = text + .lines() + .filter(|line| !line.trim().is_empty()) + .collect(); + + let (name, first, second) = match lines.len() { + 2 => (None, lines[0], lines[1]), + 3 => (Some(lines[0].trim().to_string()), lines[1], lines[2]), + count => { + return Err(ElementError::new(ElementErrorKind::TooShort, 1, None) + .with_detail(format!("expected 2 or 3 non-empty lines, found {count}"))); + } + }; + + // The name line, when present, is not part of the checksummed + // format. + let first_number = if name.is_some() { 2 } else { 1 }; + let second_number = first_number + 1; + + verify_line_checksum(first, first_number)?; + verify_line_checksum(second, second_number)?; + + if !first.starts_with('1') { + return Err(ElementError::new( + ElementErrorKind::UnexpectedCharacter, + first_number, + Some((1, 1)), + ) + .with_detail("first data line must begin with 1")); + } + if !second.starts_with('2') { + return Err(ElementError::new( + ElementErrorKind::UnexpectedCharacter, + second_number, + Some((1, 1)), + ) + .with_detail("second data line must begin with 2")); + } + + let satellite_number = unsigned_field(first, 3, 7, first_number)?; + let second_satellite_number = unsigned_field(second, 3, 7, second_number)?; + if satellite_number != second_satellite_number { + return Err(ElementError::new( + ElementErrorKind::MismatchedElementSet, + second_number, + Some((3, 7)), + ) + .with_detail(format!( + "line {first_number} has {satellite_number}, line {second_number} has {second_satellite_number}" + ))); + } + + let classification = match field(first, 8, 8).as_deref() { + Some("U") => Classification::Unclassified, + Some("S") => Classification::Secret, + Some(other) => { + let character = other.chars().next().unwrap_or(' '); + Classification::Other(character) + } + None => { + return Err(ElementError::new( + ElementErrorKind::TooShort, + first_number, + Some((8, 8)), + )); + } + }; + + let international_designator = trimmed_field(first, 10, 17, first_number)?; + let epoch = epoch_field(first, 19, 32, first_number)?; + let mean_motion_dot = float_field(first, 34, 43, first_number)?; + let mean_motion_double_dot = exponent_field(first, 45, 52, first_number)?; + let bstar = exponent_field(first, 54, 61, first_number)?; + let element_set_number = unsigned_field(first, 65, 68, first_number)?; + + let inclination = float_field(second, 9, 16, second_number)?; + let right_ascension = float_field(second, 18, 25, second_number)?; + let eccentricity = assumed_decimal_field(second, 27, 33, second_number)?; + let argument_of_perigee = float_field(second, 35, 42, second_number)?; + let mean_anomaly = float_field(second, 44, 51, second_number)?; + let mean_motion = float_field(second, 53, 63, second_number)?; + let revolution_number = unsigned_field(second, 64, 68, second_number)?; + + Ok(Self { + name, + satellite_number, + classification, + international_designator, + epoch, + mean_motion_dot, + mean_motion_double_dot, + bstar, + element_set_number, + inclination, + right_ascension, + eccentricity, + argument_of_perigee, + mean_anomaly, + mean_motion, + revolution_number, + }) + } +} diff --git a/crates/orbit/apsis-tle/src/field.rs b/crates/orbit/apsis-tle/src/field.rs new file mode 100644 index 0000000..a2cbeb1 --- /dev/null +++ b/crates/orbit/apsis-tle/src/field.rs @@ -0,0 +1,106 @@ +//! The error type and result alias for element parsing. + +use core::fmt; + +/// The category of a parse failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum ElementErrorKind { + /// A line is shorter than the format requires. + TooShort, + /// A fixed width field does not contain a well formed value. + InvalidField, + /// The checksum at the end of a line does not match the line contents. + Checksum, + /// The two lines carry different element set numbers. + MismatchedElementSet, + /// A character where a digit or a specific symbol was expected. + UnexpectedCharacter, +} + +impl fmt::Display for ElementErrorKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let text = match self { + Self::TooShort => "line is too short", + Self::InvalidField => "field is not well formed", + Self::Checksum => "line checksum does not match", + Self::MismatchedElementSet => "element set numbers differ between lines", + Self::UnexpectedCharacter => "unexpected character", + }; + f.write_str(text) + } +} + +/// A failure while parsing a two-line element set. +/// +/// The error records which line failed and, where applicable, which columns +/// were being read, so that a caller can point at the offending text. Line +/// numbers are one based and column ranges are the one based inclusive +/// range from the format definition. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ElementError { + kind: ElementErrorKind, + line: usize, + columns: Option<(usize, usize)>, + detail: Option, +} + +impl ElementError { + /// Creates an error with the given kind, line number, and columns. + /// + /// The line number is one based. The column range is the one based + /// inclusive range that was being read, if the failure is tied to a + /// field. + pub fn new(kind: ElementErrorKind, line: usize, columns: Option<(usize, usize)>) -> Self { + Self { + kind, + line, + columns, + detail: None, + } + } + + /// Attaches a description of the offending text. + pub fn with_detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + /// Returns the category of this failure. + pub fn kind(&self) -> ElementErrorKind { + self.kind + } + + /// Returns the one based number of the line that failed. + pub fn line(&self) -> usize { + self.line + } + + /// Returns the one based inclusive column range that failed, if any. + pub fn columns(&self) -> Option<(usize, usize)> { + self.columns + } + + /// Returns the detail text attached to this error, if any. + pub fn detail(&self) -> Option<&str> { + self.detail.as_deref() + } +} + +impl fmt::Display for ElementError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "line {}: {}", self.line, self.kind)?; + if let Some((first, last)) = self.columns { + write!(f, " (columns {first}-{last})")?; + } + if let Some(detail) = &self.detail { + write!(f, ": {detail}")?; + } + Ok(()) + } +} + +impl core::error::Error for ElementError {} + +/// A [`Result`](core::result::Result) whose error type is [`ElementError`]. +pub type Result = core::result::Result; diff --git a/crates/orbit/apsis-tle/src/lib.rs b/crates/orbit/apsis-tle/src/lib.rs index b70a638..60b12d7 100644 --- a/crates/orbit/apsis-tle/src/lib.rs +++ b/crates/orbit/apsis-tle/src/lib.rs @@ -1 +1,50 @@ -//! Two-Line Element set parsing. +//! Parsing of two-line element sets. +//! +//! A two-line element set (TLE) describes a satellite orbit in the form +//! used by the North American Aerospace Defense Command and distributed by +//! Space-Track. The name of the satellite is carried on an optional first +//! line, and the two following lines carry the orbital data. +//! +//! The format is fixed width. Every field occupies a fixed range of columns +//! and the checksum of each line is the sum of its digits modulo ten, where +//! a minus sign counts as one. This crate parses a TLE into structured +//! fields and verifies the line checksums and the matching element set +//! number. It does not interpret the fields; the mean elements and their +//! units are the concern of `apsis-sgp4`. +//! +//! Column positions are one based, matching the documentation of the format. +//! Two conventions from the format are worth stating explicitly, because +//! they are easy to misread: +//! +//! - An exponent field is written as a signed single digit followed by the +//! magnitude. For example ` 12345-3` means `0.12345e-3`, not `12345e-3`. +//! - An assumed decimal point is written without a decimal point. For +//! example the eccentricity field `0006703` means `0.0006703`. +//! +//! Source: the field layout follows the format description published by +//! CelesTrak, *FAQs: Two-Line Element Set Format*, which documents the +//! columns defined by the original NORAD specification. The checksum rule +//! is the one stated there and used by Space-Track. +//! +//! ``` +//! use apsis_tle::TwoLineElement; +//! +//! let text = concat!( +//! "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n", +//! "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\r\n", +//! ); +//! let element = TwoLineElement::parse(text).expect("sample is well formed"); +//! assert_eq!(element.satellite_number, 25544); +//! assert!((element.eccentricity - 0.0006703).abs() < 1e-12); +//! ``` + +#![forbid(unsafe_code)] +#![deny(missing_docs)] + +mod checksum; +mod element; +mod field; + +pub use checksum::{line_checksum, verify_line_checksum}; +pub use element::{Classification, TwoLineElement}; +pub use field::{ElementError, ElementErrorKind, Result}; diff --git a/crates/orbit/apsis-tle/tests/checksum.rs b/crates/orbit/apsis-tle/tests/checksum.rs new file mode 100644 index 0000000..9e3f122 --- /dev/null +++ b/crates/orbit/apsis-tle/tests/checksum.rs @@ -0,0 +1,72 @@ +//! Tests for line checksums. + +use apsis_tle::{line_checksum, verify_line_checksum}; + +const ISS_LINE_1: &str = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; +const ISS_LINE_2: &str = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"; + +#[test] +fn checksum_of_the_sample_lines_matches_the_final_digit() { + assert_eq!(line_checksum(ISS_LINE_1), 7); + assert_eq!(line_checksum(ISS_LINE_2), 7); +} + +#[test] +fn verification_accepts_a_well_formed_line() { + assert!(verify_line_checksum(ISS_LINE_1, 1).is_ok()); + assert!(verify_line_checksum(ISS_LINE_2, 2).is_ok()); +} + +#[test] +fn verification_rejects_a_corrupted_digit() { + // Changing a digit in the body changes the sum. + let corrupted = "1 35544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"; + let error = verify_line_checksum(corrupted, 1).expect_err("checksum should fail"); + assert_eq!(error.kind(), apsis_tle::ElementErrorKind::Checksum); + assert_eq!(error.line(), 1); +} + +#[test] +fn verification_rejects_a_corrupted_checksum_digit() { + let corrupted = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2928"; + assert!(verify_line_checksum(corrupted, 1).is_err()); +} + +#[test] +fn minus_signs_contribute_one_each() { + // A line of only minus signs, padded to 68 columns, sums to eight + // modulo ten. + let line = format!("{}{}", "-".repeat(68), "8"); + assert_eq!(line_checksum(&line), 8); +} + +#[test] +fn decimal_points_and_spaces_contribute_nothing() { + let line = format!("{}{}{}", " ".repeat(34), ".".repeat(34), "0"); + assert_eq!(line_checksum(&line), 0); +} + +#[test] +fn only_the_first_68_columns_contribute() { + let mut line = format!("{}0", "1".repeat(68)); + assert_eq!(line_checksum(&line), 8); + // Appending digits beyond column 68 must not change the sum. + line.push('9'); + assert_eq!(line_checksum(&line), 8); +} + +#[test] +fn short_lines_are_rejected_with_the_column_range() { + let error = verify_line_checksum("1 25544", 2).expect_err("line is too short"); + assert_eq!(error.kind(), apsis_tle::ElementErrorKind::TooShort); + assert_eq!(error.line(), 2); + assert_eq!(error.columns(), Some((69, 69))); +} + +#[test] +fn a_non_numeric_checksum_is_rejected() { + let line = format!("{}X", "1".repeat(68)); + let error = verify_line_checksum(&line, 3).expect_err("checksum is not a digit"); + assert_eq!(error.kind(), apsis_tle::ElementErrorKind::InvalidField); + assert_eq!(error.line(), 3); +} diff --git a/crates/orbit/apsis-tle/tests/element.rs b/crates/orbit/apsis-tle/tests/element.rs new file mode 100644 index 0000000..1f9f616 --- /dev/null +++ b/crates/orbit/apsis-tle/tests/element.rs @@ -0,0 +1,168 @@ +//! Tests for parsing a complete two-line element set. + +use apsis_time::TimeScale; +use apsis_tle::{Classification, ElementErrorKind, TwoLineElement}; + +/// The sample element set for the International Space Station used by the +/// `sgp4` reference implementation. +const ISS: &str = concat!( + "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927\r\n", + "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537\r\n", +); + +#[test] +fn sample_fields_match_the_reference_parser() { + let element = TwoLineElement::parse(ISS).expect("sample is well formed"); + assert_eq!(element.name, None); + assert_eq!(element.satellite_number, 25544); + assert_eq!(element.classification, Classification::Unclassified); + assert_eq!(element.international_designator, "98067A"); + assert_eq!(element.element_set_number, 292); + assert_eq!(element.revolution_number, 56353); + assert!((element.inclination - 51.6416).abs() < 1e-12); + assert!((element.right_ascension - 247.4627).abs() < 1e-12); + assert!((element.eccentricity - 0.0006703).abs() < 1e-15); + assert!((element.argument_of_perigee - 130.5360).abs() < 1e-12); + assert!((element.mean_anomaly - 325.0288).abs() < 1e-12); + assert!((element.mean_motion - 15.72125391).abs() < 1e-9); +} + +#[test] +fn exponent_fields_use_an_assumed_decimal_point() { + let element = TwoLineElement::parse(ISS).expect("sample is well formed"); + // `-11606-4` is -0.11606e-4. + assert!((element.bstar - -1.1606e-5).abs() < 1e-18); + // `00000-0` is zero. + assert_eq!(element.mean_motion_double_dot, 0.0); + // `-.00002182` is written with an explicit decimal point and no leading + // zero. + assert!((element.mean_motion_dot - -2.182e-5).abs() < 1e-18); +} + +#[test] +fn epoch_resolves_to_the_expected_day() { + let element = TwoLineElement::parse(ISS).expect("sample is well formed"); + // The reference parser reports Julian Date 2454730.01782528 for the + // epoch, which is Modified Julian Date 54729.51782528 on the UTC scale. + // Day 264 of 2008 is 2008-09-20, and the fraction is 12:25:40. + let mjd = element + .epoch + .mjd(TimeScale::Utc) + .expect("epoch is covered by the leap table"); + assert!((mjd - 54_729.517_825_28).abs() < 1e-9, "got {mjd}"); + + let calendar = element + .epoch + .calendar(TimeScale::Utc) + .expect("epoch is covered by the leap table"); + assert_eq!((calendar.year, calendar.month, calendar.day), (2008, 9, 20)); + assert_eq!(calendar.hour, 12); + assert_eq!(calendar.minute, 25); + assert!( + (calendar.second - 40.104).abs() < 0.01, + "got {}", + calendar.second + ); +} + +#[test] +fn three_line_form_captures_the_name() { + let text = format!("ISS (ZARYA)\r\n{ISS}"); + let element = TwoLineElement::parse(&text).expect("three line form is valid"); + assert_eq!(element.name.as_deref(), Some("ISS (ZARYA)")); + assert_eq!(element.satellite_number, 25544); +} + +#[test] +fn line_endings_and_blank_lines_do_not_matter() { + for text in [ + ISS.replace("\r\n", "\n"), + format!("\n{ISS}\n"), + format!(" \n{ISS} \n"), + ISS.replace("\r\n", "\r\n\n"), + ] { + assert!(TwoLineElement::parse(&text).is_ok(), "failed for {text:?}"); + } +} + +#[test] +fn classification_is_read_from_the_sample() { + let element = TwoLineElement::parse(ISS).expect("sample is well formed"); + assert_eq!(element.classification.as_char(), 'U'); + + let secret = mutate(ISS, 0, 7, 'S'); + let element = TwoLineElement::parse(&secret).expect("valid after checksum fix"); + assert_eq!(element.classification, Classification::Secret); +} + +/// Replaces the character at the given zero based column of the given zero +/// based line and recomputes that line's checksum. +fn mutate(text: &str, line_index: usize, column: usize, replacement: char) -> String { + let mut lines: Vec = text.lines().map(str::to_string).collect(); + let mut characters: Vec = lines[line_index].chars().collect(); + characters[column] = replacement; + let body: String = characters[..68].iter().collect(); + let checksum = apsis_tle::line_checksum(&body); + lines[line_index] = format!("{body}{checksum}"); + format!("{}\r\n{}\r\n", lines[0], lines[1]) +} + +#[test] +fn mismatched_satellite_numbers_are_rejected() { + // Change a digit of the second line's satellite number and repair the + // checksum, so that only the mismatch remains. The satellite number + // occupies one based columns 3 to 7, which are zero based 2 to 6. + let text = mutate(ISS, 1, 2, '3'); + let error = TwoLineElement::parse(&text).expect_err("numbers differ"); + assert_eq!(error.kind(), ElementErrorKind::MismatchedElementSet); + assert_eq!(error.line(), 2); +} + +#[test] +fn a_wrong_leading_digit_is_rejected() { + let text = mutate(ISS, 0, 0, '3'); + let error = TwoLineElement::parse(&text).expect_err("first line must start with 1"); + assert_eq!(error.kind(), ElementErrorKind::UnexpectedCharacter); + assert_eq!(error.line(), 1); +} + +#[test] +fn a_corrupted_checksum_is_reported_with_its_line() { + let text = ISS.replace("15.72125391563537", "15.72125391563538"); + let error = TwoLineElement::parse(&text).expect_err("checksum fails"); + assert_eq!(error.kind(), ElementErrorKind::Checksum); + assert_eq!(error.line(), 2); +} + +#[test] +fn too_few_lines_are_rejected() { + let error = TwoLineElement::parse("1 25544U 98067A\n").expect_err("only one line"); + assert_eq!(error.kind(), ElementErrorKind::TooShort); +} + +#[test] +fn an_empty_input_is_rejected() { + assert!(TwoLineElement::parse("").is_err()); + assert!(TwoLineElement::parse("\n\n").is_err()); +} + +#[test] +fn a_non_numeric_field_is_reported_with_its_columns() { + // Replace the first inclination digit with a letter, so that the + // checksum still matches but the field cannot be read. + let text = mutate(ISS, 1, 8, 'X'); + + let error = TwoLineElement::parse(&text).expect_err("inclination is not numeric"); + assert_eq!(error.kind(), ElementErrorKind::InvalidField); + assert_eq!(error.line(), 2); + assert_eq!(error.columns(), Some((9, 16))); +} + +#[test] +fn a_negative_satellite_number_is_rejected() { + let text = mutate(ISS, 0, 2, '-'); + + let error = TwoLineElement::parse(&text).expect_err("satellite number is not unsigned"); + assert_eq!(error.kind(), ElementErrorKind::InvalidField); + assert_eq!(error.columns(), Some((3, 7))); +}