orbit: add TLE parser

This commit is contained in:
ApsisRS contributors 2026-09-26 14:02:01 +08:00
parent 3bec39d181
commit 7c66653fa1
7 changed files with 909 additions and 1 deletions

View File

@ -11,3 +11,5 @@ publish = false
workspace = true workspace = true
[dependencies] [dependencies]
apsis-core.workspace = true
apsis-time.workspace = true

View File

@ -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::<u32>()
% 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<char> = 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(())
}

View File

@ -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<String>,
/// 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<String> {
if first < 1 || last < first {
return None;
}
let characters: Vec<char> = 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<String> {
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<u32> {
let text = trimmed_field(line, first, last, line_number)?;
text.parse::<u32>().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<f64> {
let text = trimmed_field(line, first, last, line_number)?;
text.parse::<f64>().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<f64> {
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<char> = 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::<f64>().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<f64> {
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::<f64>().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<Epoch> {
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::<u32>()
.map_err(|_| invalid(format!("year {year_digits:?}")))?,
);
let day_of_year = day_digits
.parse::<u32>()
.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::<f64>()
.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<Self> {
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,
})
}
}

View File

@ -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<String>,
}
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<String>) -> 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<T> = core::result::Result<T, ElementError>;

View File

@ -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};

View File

@ -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);
}

View File

@ -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<String> = text.lines().map(str::to_string).collect();
let mut characters: Vec<char> = 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)));
}