orbit: add TLE parser
This commit is contained in:
parent
3bec39d181
commit
7c66653fa1
|
|
@ -11,3 +11,5 @@ publish = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
apsis-core.workspace = true
|
||||||
|
apsis-time.workspace = true
|
||||||
|
|
|
||||||
|
|
@ -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(())
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>;
|
||||||
|
|
@ -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};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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)));
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue