use std::fs;
#[cfg(any(feature = "xml", feature = "json", feature = "csv"))]
use std::collections::HashMap;
#[cfg(feature = "xml")]
use roxmltree::{Document, Node};
#[cfg(feature = "json")]
use serde_json::Value;
#[cfg(feature = "csv")]
use csv::{ReaderBuilder, WriterBuilder};
use crate::sgp4::{Sgp4, Sgp4Error, init_sgp4};
use crate::time::{DateTime, Timezone, dayofyr2utc, validate_datetime};
#[derive(Default, Clone)]
pub struct GenPerturbElementSet {
pub common_name: String,
pub satellite_catalog_number: i32,
pub classification: char,
pub international_designator: String,
pub epoch_datetime: DateTime,
pub first_derivative_of_mean_motion: f64,
pub second_derivative_of_mean_motion: f64,
pub bstar: f64,
pub ephemeris_type: i32,
pub element_set_number: i32,
pub inclination: f64,
pub right_ascension_of_ascending_node: f64,
pub eccentricity: f64,
pub argument_of_perigee: f64,
pub mean_anomaly: f64,
pub mean_motion: f64,
pub revolution_number_at_epoch: i64,
pub tle_checksum_valid: Option<bool>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum GpError {
InvalidTleLine0,
InvalidTleLine1,
InvalidTleLine2,
InvalidTleEpoch,
MismatchedTleCatalog,
Io(String),
InvalidTLECatalogNumber,
InvalidTLEDateTime,
InvalidTLELine,
InvalidTLEInternationalDesignator,
InvalidOmmField,
InvalidOmmEpoch,
InvalidOmmXml,
InvalidOmmJson,
InvalidOmmCsv,
Sgp4(Sgp4Error),
}
trait FromOmm: Sized {
fn omm_default() -> Self;
fn from_omm(field: &str, value: &str) -> Result<Self, GpError>;
}
impl FromOmm for String {
fn omm_default() -> Self {
String::new()
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
Ok(value.to_string())
}
}
impl FromOmm for char {
fn omm_default() -> Self {
'U'
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
Ok(value.chars().next().unwrap_or('U'))
}
}
impl FromOmm for f64 {
fn omm_default() -> Self {
0.0
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
let normalized = value.replace(['D', 'd'], "E");
normalized
.parse::<f64>()
.map_err(|_| GpError::InvalidOmmField)
}
}
impl FromOmm for i32 {
fn omm_default() -> Self {
0
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
value.parse::<i32>().map_err(|_| GpError::InvalidOmmField)
}
}
impl FromOmm for i64 {
fn omm_default() -> Self {
0
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
value.parse::<i64>().map_err(|_| GpError::InvalidOmmField)
}
}
impl FromOmm for DateTime {
fn omm_default() -> Self {
DateTime::default()
}
fn from_omm(_field: &str, value: &str) -> Result<Self, GpError> {
parse_omm_epoch(value)
}
}
#[cfg(feature = "csv")]
const OMM_CSV_HEADERS: [&str; 17] = [
"OBJECT_NAME",
"OBJECT_ID",
"EPOCH",
"MEAN_MOTION",
"ECCENTRICITY",
"INCLINATION",
"RA_OF_ASC_NODE",
"ARG_OF_PERICENTER",
"MEAN_ANOMALY",
"EPHEMERIS_TYPE",
"CLASSIFICATION_TYPE",
"NORAD_CAT_ID",
"ELEMENT_SET_NO",
"REV_AT_EPOCH",
"BSTAR",
"MEAN_MOTION_DOT",
"MEAN_MOTION_DDOT",
];
pub fn from_tle_lines(line1: &str, line2: &str, line0: Option<&str>) -> Result<Sgp4, GpError> {
let mut gp = GenPerturbElementSet::default();
if let Some(name_line) = line0 {
if name_line.is_empty() || name_line.len() > 24 {
return Err(GpError::InvalidTleLine0);
}
gp.common_name = name_line.to_string();
}
if line1.len() != 69 || !line1.is_ascii() || !line1.starts_with("1 ") {
return Err(GpError::InvalidTleLine1);
}
if line2.len() != 69 || !line2.is_ascii() || !line2.starts_with("2 ") {
return Err(GpError::InvalidTleLine2);
}
gp.tle_checksum_valid = Some(tle_checksum(line1) && tle_checksum(line2));
gp.satellite_catalog_number =
parse_tle_catalog(&line1[2..7]).ok_or(GpError::InvalidTleLine1)?;
let catalog2 = parse_tle_catalog(&line2[2..7]).ok_or(GpError::InvalidTleLine2)?;
if catalog2 != gp.satellite_catalog_number {
return Err(GpError::MismatchedTleCatalog);
}
gp.classification = line1[7..8]
.trim()
.parse::<char>()
.map_err(|_| GpError::InvalidTleLine1)?;
let intl_des = line1[9..17].trim();
if intl_des.is_empty() {
gp.international_designator = String::new();
} else if intl_des.len() < 2 {
return Err(GpError::InvalidTleLine1);
} else {
let launch_year = tle_full_year(
intl_des[..2]
.parse::<i32>()
.map_err(|_| GpError::InvalidTleLine1)?,
);
gp.international_designator = format!("{}-{}", launch_year, &intl_des[2..]);
}
let yr_two_digit: i32 = line1[18..20]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
let epoch_year = tle_full_year(yr_two_digit);
let epoch_day: f64 = line1[20..32]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
gp.epoch_datetime = dayofyr2utc(epoch_year, epoch_day).map_err(|_| GpError::InvalidTleEpoch)?;
gp.first_derivative_of_mean_motion = line1[33..43]
.trim()
.parse::<f64>()
.map_err(|_| GpError::InvalidTleLine1)?
* 2.0;
let nddot_mantissa: f64 = if line1.as_bytes()[44] == b'-' {
format!("-0.{}", line1[45..50].trim())
.parse()
.map_err(|_| GpError::InvalidTleLine1)?
} else {
format!("0.{}", line1[45..50].trim())
.parse()
.map_err(|_| GpError::InvalidTleLine1)?
};
let nddot_exp: i32 = line1[50..52]
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
gp.second_derivative_of_mean_motion = nddot_mantissa * 10.0_f64.powi(nddot_exp) * 6.0;
let bstar_mantissa: f64 = if line1.as_bytes()[53] == b'-' {
format!("-0.{}", line1[54..59].trim())
.parse()
.map_err(|_| GpError::InvalidTleLine1)?
} else {
format!("0.{}", line1[54..59].trim())
.parse()
.map_err(|_| GpError::InvalidTleLine1)?
};
let bstar_exp: i32 = line1[59..61]
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
gp.bstar = bstar_mantissa * 10.0_f64.powi(bstar_exp);
if line1[62..63].trim().is_empty() {
gp.ephemeris_type = 0;
} else {
gp.ephemeris_type = line1[62..63]
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
}
gp.element_set_number = line1[64..68]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine1)?;
gp.inclination = line2[8..16]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.right_ascension_of_ascending_node = line2[17..25]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.eccentricity = format!("0.{}", line2[26..33].trim())
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.argument_of_perigee = line2[34..42]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.mean_anomaly = line2[43..51]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.mean_motion = line2[52..63]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
gp.revolution_number_at_epoch = line2[63..68]
.trim()
.parse()
.map_err(|_| GpError::InvalidTleLine2)?;
init_sgp4(&gp, None).map_err(GpError::Sgp4)
}
pub fn from_tle_string(tle_string: &str) -> Result<Vec<Sgp4>, GpError> {
let lines: Vec<&str> = tle_string
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
let mut sgp4s = Vec::new();
let mut i = 0;
while i < lines.len() {
if lines[i].starts_with('1') {
if i + 1 >= lines.len() {
break;
}
if lines[i + 1].starts_with('2') {
let sgp4 = from_tle_lines(lines[i], lines[i + 1], None)?;
sgp4s.push(sgp4);
i += 2;
} else {
i += 1;
}
} else {
if i + 2 >= lines.len() {
break;
}
if lines[i + 1].starts_with('1') && lines[i + 2].starts_with('2') {
let sgp4 = from_tle_lines(lines[i + 1], lines[i + 2], Some(lines[i]))?;
sgp4s.push(sgp4);
i += 3;
} else {
i += 1;
}
}
}
Ok(sgp4s)
}
pub fn from_tle_file(file_path: &str) -> Result<Vec<Sgp4>, GpError> {
let tle_string = fs::read_to_string(file_path).map_err(|err| GpError::Io(err.to_string()))?;
from_tle_string(&tle_string)
}
fn calc_checksum(line: &str) -> i32 {
let mut checksum = 0;
for c in line.chars().take(68) {
match c {
'0'..='9' => checksum += (c as u8 - b'0') as i32,
'-' => checksum += 1,
_ => {}
}
}
checksum %= 10;
checksum
}
fn tle_checksum(line: &str) -> bool {
if line.len() < 69 {
return false;
}
let Ok(digit) = line[68..69].parse::<i32>() else {
return false;
};
calc_checksum(line) == digit
}
fn tle_full_year(two_digit_year: i32) -> i32 {
if two_digit_year < 57 {
2000 + two_digit_year
} else {
1900 + two_digit_year
}
}
fn parse_tle_catalog(field: &str) -> Option<i32> {
let catalog = field.trim();
let first_char = catalog.chars().next()?;
if first_char.is_ascii_digit() {
catalog.parse().ok()
} else if first_char.is_ascii_alphabetic() {
let alpha = alpha5_digit(first_char)?;
let rest: i32 = catalog[1..].parse().ok()?;
Some(alpha * 10000 + rest)
} else {
None
}
}
fn alpha5_digit(c: char) -> Option<i32> {
let c_upper = c.to_ascii_uppercase();
match c_upper {
'A'..='H' => Some((c_upper as i32) - ('A' as i32) + 10),
'J'..='N' => Some((c_upper as i32) - ('J' as i32) + 18),
'P'..='Z' => Some((c_upper as i32) - ('P' as i32) + 23),
_ => None, }
}
fn alpha5_letter(digit: i32) -> Option<char> {
match digit {
10..=17 => Some((b'A' + (digit as u8 - 10)) as char),
18..=22 => Some((b'J' + (digit as u8 - 18)) as char),
23..=33 => Some((b'P' + (digit as u8 - 23)) as char),
_ => None, }
}
fn format_tle_catalog_number(catalog: i32) -> Result<String, GpError> {
if catalog < 0 {
return Err(GpError::InvalidTLECatalogNumber);
}
if catalog < 100000 {
return Ok(format!("{:>5}", catalog));
}
let first = catalog / 10000;
let rest = catalog % 10000;
let Some(letter) = alpha5_letter(first) else {
return Err(GpError::InvalidTLECatalogNumber);
};
Ok(format!("{}{:04}", letter, rest))
}
fn format_tle_intl_des(intl: &str) -> Result<String, GpError> {
let intl = intl.trim();
if intl.is_empty() {
return Ok(" ".to_string());
}
let field = if let Some((year_str, rest)) = intl.split_once('-') {
let launch_len = rest.bytes().take_while(u8::is_ascii_digit).count();
let part_code = &rest[launch_len..];
if launch_len > 3 || part_code.len() > 3 {
return Err(GpError::InvalidTLEInternationalDesignator);
}
let year = year_str.parse::<i32>().unwrap_or(0);
let yy = ((year % 100) + 100) % 100;
format!("{:02}{}", yy, rest)
} else if intl.len() > 8 {
return Err(GpError::InvalidTLEInternationalDesignator);
} else {
intl.to_string()
};
Ok(format!("{:<8}", field))
}
fn format_tle_epoch(epoch: &DateTime) -> Result<String, GpError> {
if !(1957..=2056).contains(&epoch.year) {
return Err(GpError::InvalidTLEDateTime);
}
let two_digit_year = ((epoch.year % 100) + 100) % 100;
let is_leap = (epoch.year % 4 == 0 && epoch.year % 100 != 0) || (epoch.year % 400 == 0);
let days_per_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
let mut day_int = epoch.day;
for m in 1..epoch.month {
let days_this_month = if m == 2 && is_leap {
29 } else {
days_per_month[(m - 1) as usize]
};
day_int += days_this_month;
}
let mut second = epoch.second;
if second < 0.0 {
second = 0.0;
}
let day_frac = (epoch.hour as f64 * 3600.0 + epoch.minute as f64 * 60.0 + second) / 86400.0;
let dayofyr = day_int as f64 + day_frac;
Ok(format!("{:02}{:012.8}", two_digit_year, dayofyr))
}
fn format_tle_ndot(value: f64) -> String {
let mut digits = (value.abs() * 1e8).round() as i64;
if digits > 99999999 {
digits = 99999999;
}
if value < 0.0 && digits != 0 {
return format!("-.{:08}", digits);
}
format!(" .{:08}", digits)
}
fn format_tle_exp(value: f64) -> String {
if !value.is_finite() || value == 0.0 {
return " 00000+0".to_string();
}
let sign = if value < 0.0 { '-' } else { ' ' };
let abs = value.abs();
let mut exp = abs.log10().floor() as i32 + 1;
let mantissa = abs / 10f64.powi(exp);
let mut digits = (mantissa * 100000.0).round() as i32;
if digits >= 100000 {
digits = 10000;
exp += 1;
}
if digits == 0 {
return " 00000+0".to_string();
}
if exp > 9 {
exp = 9;
digits = 99999;
} else if exp < -9 {
return " 00000+0".to_string();
}
let exp_sign = if exp >= 0 { '+' } else { '-' };
format!("{}{:05}{}{}", sign, digits, exp_sign, exp.abs())
}
fn format_tle_eccentricity(eccentricity: f64) -> String {
let mut digits = (eccentricity.abs() * 1e7).round() as i64;
digits = digits.clamp(0, 9999999);
format!("{:07}", digits)
}
fn tle_line_with_checksum(line68: &str) -> Result<String, GpError> {
if line68.len() != 68 {
return Err(GpError::InvalidTLELine);
}
Ok(format!("{}{}", line68, calc_checksum(line68)))
}
fn format_tle_line1(gp: &GenPerturbElementSet) -> Result<String, GpError> {
let catalog = format_tle_catalog_number(gp.satellite_catalog_number)?;
let classification = if gp.classification.is_ascii_graphic() {
gp.classification
} else {
'U'
};
let intl_des = format_tle_intl_des(&gp.international_designator)?;
let epoch = format_tle_epoch(&gp.epoch_datetime)?;
let ndot = format_tle_ndot(gp.first_derivative_of_mean_motion / 2.0);
let nddot = format_tle_exp(gp.second_derivative_of_mean_motion / 6.0);
let bstar = format_tle_exp(gp.bstar);
let ephem_type = if (0..=9).contains(&gp.ephemeris_type) {
gp.ephemeris_type
} else {
0
};
let elset = gp.element_set_number.rem_euclid(10000);
let line68 = format!(
"1 {}{} {} {} {} {} {} {} {:>4}",
catalog, classification, intl_des, epoch, ndot, nddot, bstar, ephem_type, elset
);
tle_line_with_checksum(&line68)
}
fn format_tle_line2(gp: &GenPerturbElementSet) -> Result<String, GpError> {
let catalog = format_tle_catalog_number(gp.satellite_catalog_number)?;
let inclination = format!("{:8.4}", gp.inclination);
let right_ascension_of_ascending_node = format!("{:8.4}", gp.right_ascension_of_ascending_node);
let ecc = format_tle_eccentricity(gp.eccentricity);
let argument_of_perigee = format!("{:8.4}", gp.argument_of_perigee);
let mean_anomaly = format!("{:8.4}", gp.mean_anomaly);
let mean_motion = format!("{:11.8}", gp.mean_motion);
let rev = format!("{:>5}", gp.revolution_number_at_epoch.rem_euclid(100000));
let line68 = format!(
"2 {} {} {} {} {} {} {}{}",
catalog,
inclination,
right_ascension_of_ascending_node,
ecc,
argument_of_perigee,
mean_anomaly,
mean_motion,
rev
);
tle_line_with_checksum(&line68)
}
fn gp_to_tle(gp: &GenPerturbElementSet) -> Result<String, GpError> {
let mut tle = String::new();
let name = gp.common_name.trim();
if !name.is_empty() {
let mut name = name.to_string();
if name.len() > 24 {
name.truncate(24);
}
tle.push_str(&name);
tle.push('\n');
}
tle.push_str(&format_tle_line1(gp)?);
tle.push('\n');
tle.push_str(&format_tle_line2(gp)?);
tle.push('\n');
Ok(tle)
}
pub fn to_tle_string(sgp4s: &[Sgp4]) -> Result<String, GpError> {
let mut records = String::new();
for sgp4 in sgp4s {
records.push_str(&gp_to_tle(&sgp4.gp)?);
}
Ok(records)
}
pub fn to_tle_file(sgp4s: &[Sgp4], tle_file_path: &str) -> Result<(), GpError> {
let tle_string = to_tle_string(sgp4s)?;
fs::write(tle_file_path, tle_string).map_err(|err| GpError::Io(err.to_string()))?;
Ok(())
}
pub fn from_omm_kvn_lines(lines: &[&str]) -> Result<Sgp4, GpError> {
sgp4_from_omm_lookup(|field| kvn_lookup(lines, field))
}
pub fn from_omm_kvn_string(omm_kvn_string: &str) -> Result<Vec<Sgp4>, GpError> {
let mut sgp4s = Vec::new();
let mut current_lines: Vec<&str> = Vec::new();
for line in omm_kvn_string.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
if trimmed.to_ascii_uppercase().starts_with("CCSDS_OMM_VERS") && !current_lines.is_empty() {
let sgp4 = from_omm_kvn_lines(¤t_lines)?;
sgp4s.push(sgp4);
current_lines.clear();
}
current_lines.push(trimmed);
}
if !current_lines.is_empty() {
let sgp4 = from_omm_kvn_lines(¤t_lines)?;
sgp4s.push(sgp4);
}
Ok(sgp4s)
}
pub fn from_omm_kvn_file(omm_kvn_file_path: &str) -> Result<Vec<Sgp4>, GpError> {
let omm_kvn_string =
fs::read_to_string(omm_kvn_file_path).map_err(|err| GpError::Io(err.to_string()))?;
from_omm_kvn_string(&omm_kvn_string)
}
#[cfg(test)]
fn kvn_parse<T: FromOmm>(lines: &[&str], field: &str) -> Result<T, GpError> {
omm_typed_value(kvn_lookup(lines, field), field)
}
fn kvn_lookup(lines: &[&str], field: &str) -> Option<String> {
for line in lines {
let line = line.trim();
if line.is_empty() || line.to_ascii_uppercase().starts_with("COMMENT") {
continue;
}
let Some((keyword, value)) = line.split_once('=') else {
continue;
};
if !keyword.trim().eq_ignore_ascii_case(field) {
continue;
}
return Some(clean_kvn_value(value));
}
None
}
fn omm_typed_value<T: FromOmm>(value: Option<String>, field: &str) -> Result<T, GpError> {
match value {
Some(v) if !v.is_empty() => T::from_omm(field, &v),
_ => Ok(T::omm_default()),
}
}
fn sgp4_from_omm_lookup<F>(lookup: F) -> Result<Sgp4, GpError>
where
F: Fn(&str) -> Option<String>,
{
let gp = GenPerturbElementSet {
common_name: omm_typed_value(lookup("OBJECT_NAME"), "OBJECT_NAME")?,
satellite_catalog_number: omm_typed_value(lookup("NORAD_CAT_ID"), "NORAD_CAT_ID")?,
classification: omm_typed_value(lookup("CLASSIFICATION_TYPE"), "CLASSIFICATION_TYPE")?,
international_designator: omm_typed_value(lookup("OBJECT_ID"), "OBJECT_ID")?,
epoch_datetime: omm_typed_value(lookup("EPOCH"), "EPOCH")?,
first_derivative_of_mean_motion: omm_typed_value::<f64>(
lookup("MEAN_MOTION_DOT"),
"MEAN_MOTION_DOT",
)? * 2.0,
second_derivative_of_mean_motion: omm_typed_value::<f64>(
lookup("MEAN_MOTION_DDOT"),
"MEAN_MOTION_DDOT",
)? * 6.0,
bstar: omm_typed_value(lookup("BSTAR"), "BSTAR")?,
ephemeris_type: omm_typed_value(lookup("EPHEMERIS_TYPE"), "EPHEMERIS_TYPE")?,
element_set_number: omm_typed_value(lookup("ELEMENT_SET_NO"), "ELEMENT_SET_NO")?,
inclination: omm_typed_value(lookup("INCLINATION"), "INCLINATION")?,
right_ascension_of_ascending_node: omm_typed_value(
lookup("RA_OF_ASC_NODE"),
"RA_OF_ASC_NODE",
)?,
eccentricity: omm_typed_value(lookup("ECCENTRICITY"), "ECCENTRICITY")?,
argument_of_perigee: omm_typed_value(lookup("ARG_OF_PERICENTER"), "ARG_OF_PERICENTER")?,
mean_anomaly: omm_typed_value(lookup("MEAN_ANOMALY"), "MEAN_ANOMALY")?,
mean_motion: omm_typed_value(lookup("MEAN_MOTION"), "MEAN_MOTION")?,
revolution_number_at_epoch: omm_typed_value(lookup("REV_AT_EPOCH"), "REV_AT_EPOCH")?,
tle_checksum_valid: None,
};
init_sgp4(&gp, None).map_err(GpError::Sgp4)
}
fn clean_kvn_value(value: &str) -> String {
let mut v = value.trim();
if v.len() >= 2 && v.starts_with('"') && v.ends_with('"') {
v = &v[1..v.len() - 1];
v = v.trim();
}
if let Some(idx) = v.find('[') {
v = v[..idx].trim();
}
v.to_string()
}
fn format_kvn_line(keyword: &str, value: &str) -> String {
if keyword.len() >= 14 {
format!("{} = {}", keyword, value)
} else {
format!("{:<14} = {}", keyword, value)
}
}
fn trim_decimal_zeros(value: &str) -> String {
if !value.contains('.') {
return value.to_string();
}
let trimmed = value.trim_end_matches('0');
if trimmed.ends_with('.') {
return trimmed.trim_end_matches('.').to_string();
}
trimmed.to_string()
}
fn strip_leading_decimal_zero(value: &str) -> String {
if let Some(rest) = value.strip_prefix("-0.") {
return format!("-.{}", rest);
}
if let Some(rest) = value.strip_prefix("0.") {
return format!(".{}", rest);
}
value.to_string()
}
fn format_omm_decimal(value: f64) -> String {
if value == 0.0 {
return "0".to_string();
}
let rounded = (value * 1e12).round() / 1e12;
strip_leading_decimal_zero(&trim_decimal_zeros(&format!("{:.12}", rounded)))
}
fn format_omm_sci(value: f64) -> String {
if value == 0.0 {
return "0".to_string();
}
let sign = if value < 0.0 { "-" } else { "" };
let abs = value.abs();
let mut exp = abs.log10().floor() as i32 + 1;
let mut mantissa = abs / 10f64.powi(exp);
if mantissa >= 1.0 {
mantissa /= 10.0;
exp += 1;
} else if mantissa < 0.1 {
mantissa *= 10.0;
exp -= 1;
}
const SCALE: f64 = 10_000_000_000.0;
let mut digits = (mantissa * SCALE).round() as i64;
if digits >= 10_000_000_000 {
exp += 1;
digits = 1_000_000_000;
}
let mut frac = format!("{:010}", digits);
frac = frac.trim_end_matches('0').to_string();
if frac.is_empty() {
frac = "0".to_string();
}
let exp_sign = if exp >= 0 { "+" } else { "-" };
format!("{}.{}E{}{}", sign, frac, exp_sign, exp.abs())
}
fn format_omm_epoch(epoch: &DateTime) -> String {
let mut second = epoch.second;
if second < 0.0 {
second = 0.0;
}
if second >= 60.0 {
second = 59.999999;
}
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:09.6}",
epoch.year, epoch.month, epoch.day, epoch.hour, epoch.minute, second
)
}
fn format_omm_classification(classification: char) -> String {
if classification.is_ascii_graphic() {
return classification.to_string();
}
"U".to_string()
}
fn gp_to_omm_kvn(gp: &GenPerturbElementSet) -> String {
let mean_motion_dot = gp.first_derivative_of_mean_motion / 2.0;
let mean_motion_ddot = gp.second_derivative_of_mean_motion / 6.0;
vec![
format_kvn_line("CCSDS_OMM_VERS", "2.0"),
format_kvn_line("CREATION_DATE", ""),
format_kvn_line("ORIGINATOR", ""),
String::new(),
format_kvn_line("OBJECT_NAME", &gp.common_name),
format_kvn_line("OBJECT_ID", &gp.international_designator),
format_kvn_line("CENTER_NAME", "EARTH"),
format_kvn_line("REF_FRAME", "TEME"),
format_kvn_line("TIME_SYSTEM", "UTC"),
format_kvn_line("MEAN_ELEMENT_THEORY", "SGP/SGP4"),
String::new(),
format_kvn_line("EPOCH", &format_omm_epoch(&gp.epoch_datetime)),
format_kvn_line("MEAN_MOTION", &format_omm_decimal(gp.mean_motion)),
format_kvn_line("ECCENTRICITY", &format_omm_decimal(gp.eccentricity)),
format_kvn_line("INCLINATION", &format_omm_decimal(gp.inclination)),
format_kvn_line(
"RA_OF_ASC_NODE",
&format_omm_decimal(gp.right_ascension_of_ascending_node),
),
format_kvn_line(
"ARG_OF_PERICENTER",
&format_omm_decimal(gp.argument_of_perigee),
),
format_kvn_line("MEAN_ANOMALY", &format_omm_decimal(gp.mean_anomaly)),
String::new(),
format_kvn_line("EPHEMERIS_TYPE", &gp.ephemeris_type.to_string()),
format_kvn_line(
"CLASSIFICATION_TYPE",
&format_omm_classification(gp.classification),
),
format_kvn_line("NORAD_CAT_ID", &gp.satellite_catalog_number.to_string()),
format_kvn_line("ELEMENT_SET_NO", &gp.element_set_number.to_string()),
format_kvn_line("REV_AT_EPOCH", &gp.revolution_number_at_epoch.to_string()),
format_kvn_line("BSTAR", &format_omm_sci(gp.bstar)),
format_kvn_line("MEAN_MOTION_DOT", &format_omm_sci(mean_motion_dot)),
format_kvn_line("MEAN_MOTION_DDOT", &format_omm_sci(mean_motion_ddot)),
String::new(),
]
.join("\n")
}
pub fn to_omm_kvn_string(sgp4s: &[Sgp4]) -> String {
let mut records = String::new();
for sgp4 in sgp4s {
records.push_str(&gp_to_omm_kvn(&sgp4.gp));
}
records
}
pub fn to_omm_kvn_file(sgp4s: &[Sgp4], omm_kvn_file_path: &str) -> Result<(), GpError> {
let omm_kvn_string = to_omm_kvn_string(sgp4s);
fs::write(omm_kvn_file_path, omm_kvn_string).map_err(|err| GpError::Io(err.to_string()))?;
Ok(())
}
#[cfg(feature = "xml")]
fn xml_omm_fields(omm: Node) -> HashMap<String, String> {
let mut fields = HashMap::new();
for node in omm.descendants() {
if !node.is_element() {
continue;
}
let name = node.tag_name().name();
if name.eq_ignore_ascii_case("omm") || name.eq_ignore_ascii_case("COMMENT") {
continue;
}
if node.children().any(|child| child.is_element()) {
continue;
}
let text = node.text().unwrap_or("").trim();
fields.insert(name.to_ascii_uppercase(), text.to_string());
}
fields
}
#[cfg(feature = "xml")]
pub fn from_omm_xml_string(omm_xml_string: &str) -> Result<Vec<Sgp4>, GpError> {
let doc = Document::parse(omm_xml_string).map_err(|_| GpError::InvalidOmmXml)?;
let mut sgp4s = Vec::new();
for node in doc.descendants() {
if !node.is_element() {
continue;
}
if !node.tag_name().name().eq_ignore_ascii_case("omm") {
continue;
}
let fields = xml_omm_fields(node);
let sgp4 = sgp4_from_omm_lookup(|field| fields.get(&field.to_ascii_uppercase()).cloned())?;
sgp4s.push(sgp4);
}
Ok(sgp4s)
}
#[cfg(feature = "xml")]
pub fn from_omm_xml_file(omm_xml_file_path: &str) -> Result<Vec<Sgp4>, GpError> {
let omm_xml_string =
fs::read_to_string(omm_xml_file_path).map_err(|err| GpError::Io(err.to_string()))?;
from_omm_xml_string(&omm_xml_string)
}
#[cfg(feature = "xml")]
fn escape_xml_text(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for c in value.chars() {
match c {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
_ => escaped.push(c),
}
}
escaped
}
#[cfg(feature = "xml")]
fn format_xml_tag(name: &str, value: &str) -> String {
if value.is_empty() {
format!("<{} />", name)
} else {
format!("<{}>{}</{}>", name, escape_xml_text(value), name)
}
}
#[cfg(feature = "xml")]
fn gp_to_omm_xml(gp: &GenPerturbElementSet) -> String {
let mean_motion_dot = gp.first_derivative_of_mean_motion / 2.0;
let mean_motion_ddot = gp.second_derivative_of_mean_motion / 6.0;
let mut xml = String::from("<omm id=\"CCSDS_OMM_VERS\" version=\"2.0\">\n");
xml.push_str("<header><CREATION_DATE /><ORIGINATOR /></header>");
xml.push_str("<body><segment><metadata>");
xml.push_str(&format_xml_tag("OBJECT_NAME", &gp.common_name));
xml.push_str(&format_xml_tag("OBJECT_ID", &gp.international_designator));
xml.push_str(&format_xml_tag("CENTER_NAME", "EARTH"));
xml.push_str(&format_xml_tag("REF_FRAME", "TEME"));
xml.push_str(&format_xml_tag("TIME_SYSTEM", "UTC"));
xml.push_str(&format_xml_tag("MEAN_ELEMENT_THEORY", "SGP4"));
xml.push_str("</metadata><data><meanElements>");
xml.push_str(&format_xml_tag(
"EPOCH",
&format_omm_epoch(&gp.epoch_datetime),
));
xml.push_str(&format_xml_tag(
"MEAN_MOTION",
&format_omm_decimal(gp.mean_motion),
));
xml.push_str(&format_xml_tag(
"ECCENTRICITY",
&format_omm_decimal(gp.eccentricity),
));
xml.push_str(&format_xml_tag(
"INCLINATION",
&format_omm_decimal(gp.inclination),
));
xml.push_str(&format_xml_tag(
"RA_OF_ASC_NODE",
&format_omm_decimal(gp.right_ascension_of_ascending_node),
));
xml.push_str(&format_xml_tag(
"ARG_OF_PERICENTER",
&format_omm_decimal(gp.argument_of_perigee),
));
xml.push_str(&format_xml_tag(
"MEAN_ANOMALY",
&format_omm_decimal(gp.mean_anomaly),
));
xml.push_str("</meanElements><tleParameters>");
xml.push_str(&format_xml_tag(
"EPHEMERIS_TYPE",
&gp.ephemeris_type.to_string(),
));
xml.push_str(&format_xml_tag(
"CLASSIFICATION_TYPE",
&format_omm_classification(gp.classification),
));
xml.push_str(&format_xml_tag(
"NORAD_CAT_ID",
&gp.satellite_catalog_number.to_string(),
));
xml.push_str(&format_xml_tag(
"ELEMENT_SET_NO",
&gp.element_set_number.to_string(),
));
xml.push_str(&format_xml_tag(
"REV_AT_EPOCH",
&gp.revolution_number_at_epoch.to_string(),
));
xml.push_str(&format_xml_tag("BSTAR", &format_omm_sci(gp.bstar)));
xml.push_str(&format_xml_tag(
"MEAN_MOTION_DOT",
&format_omm_sci(mean_motion_dot),
));
xml.push_str(&format_xml_tag(
"MEAN_MOTION_DDOT",
&format_omm_sci(mean_motion_ddot),
));
xml.push_str("</tleParameters></data></segment></body></omm>\n");
xml
}
#[cfg(feature = "xml")]
pub fn to_omm_xml_string(sgp4s: &[Sgp4]) -> String {
let mut xml = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
<ndm xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" \
xsi:noNamespaceSchemaLocation=\"https://sanaregistry.org/r/ndmxml_unqualified/ndmxml-2.0.0-master-2.0.xsd\">\n",
);
for sgp4 in sgp4s {
xml.push_str(&gp_to_omm_xml(&sgp4.gp));
}
xml.push_str("</ndm>\n");
xml
}
#[cfg(feature = "xml")]
pub fn to_omm_xml_file(sgp4s: &[Sgp4], omm_xml_file_path: &str) -> Result<(), GpError> {
let omm_xml_string = to_omm_xml_string(sgp4s);
fs::write(omm_xml_file_path, omm_xml_string).map_err(|err| GpError::Io(err.to_string()))?;
Ok(())
}
#[cfg(feature = "json")]
fn sgp4_from_json_record(record: &Value) -> Result<Sgp4, GpError> {
let fields = json_omm_fields(record)?;
sgp4_from_omm_lookup(|field| fields.get(&field.to_ascii_uppercase()).cloned())
}
#[cfg(feature = "json")]
pub fn from_omm_json_string(omm_json_string: &str) -> Result<Vec<Sgp4>, GpError> {
let value: Value =
serde_json::from_str(omm_json_string).map_err(|_| GpError::InvalidOmmJson)?;
let mut sgp4s = Vec::new();
match &value {
Value::Array(records) => {
for record in records {
sgp4s.push(sgp4_from_json_record(record)?);
}
}
Value::Object(_) => {
sgp4s.push(sgp4_from_json_record(&value)?);
}
_ => return Err(GpError::InvalidOmmJson),
}
Ok(sgp4s)
}
#[cfg(feature = "json")]
fn json_omm_fields(record: &Value) -> Result<HashMap<String, String>, GpError> {
let Some(object) = record.as_object() else {
return Err(GpError::InvalidOmmJson);
};
let mut fields = HashMap::new();
for (key, value) in object {
if key.eq_ignore_ascii_case("COMMENT") {
continue;
}
let text = match value {
Value::Null => String::new(),
Value::String(s) => s.trim().to_string(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Array(_) | Value::Object(_) => continue,
};
fields.insert(key.to_ascii_uppercase(), text);
}
Ok(fields)
}
#[cfg(feature = "json")]
pub fn from_omm_json_file(omm_json_file_path: &str) -> Result<Vec<Sgp4>, GpError> {
let omm_json_string =
fs::read_to_string(omm_json_file_path).map_err(|err| GpError::Io(err.to_string()))?;
from_omm_json_string(&omm_json_string)
}
#[cfg(feature = "json")]
fn format_omm_json_number(value: f64) -> String {
if value == 0.0 {
return "0".to_string();
}
let rounded = (value * 1e12).round() / 1e12;
match serde_json::Number::from_f64(rounded) {
Some(n) => n.to_string(),
None => "0".to_string(),
}
}
#[cfg(feature = "json")]
fn gp_to_omm_json(gp: &GenPerturbElementSet) -> String {
let mean_motion_dot = gp.first_derivative_of_mean_motion / 2.0;
let mean_motion_ddot = gp.second_derivative_of_mean_motion / 6.0;
let fields = [
format!(
" \"OBJECT_NAME\": {}",
serde_json::to_string(&gp.common_name).unwrap()
),
format!(
" \"OBJECT_ID\": {}",
serde_json::to_string(&gp.international_designator).unwrap()
),
format!(
" \"EPOCH\": {}",
serde_json::to_string(&format_omm_epoch(&gp.epoch_datetime)).unwrap()
),
format!(
" \"MEAN_MOTION\": {}",
format_omm_json_number(gp.mean_motion)
),
format!(
" \"ECCENTRICITY\": {}",
format_omm_json_number(gp.eccentricity)
),
format!(
" \"INCLINATION\": {}",
format_omm_json_number(gp.inclination)
),
format!(
" \"RA_OF_ASC_NODE\": {}",
format_omm_json_number(gp.right_ascension_of_ascending_node)
),
format!(
" \"ARG_OF_PERICENTER\": {}",
format_omm_json_number(gp.argument_of_perigee)
),
format!(
" \"MEAN_ANOMALY\": {}",
format_omm_json_number(gp.mean_anomaly)
),
format!(" \"EPHEMERIS_TYPE\": {}", gp.ephemeris_type),
format!(
" \"CLASSIFICATION_TYPE\": {}",
serde_json::to_string(&format_omm_classification(gp.classification)).unwrap()
),
format!(" \"NORAD_CAT_ID\": {}", gp.satellite_catalog_number),
format!(" \"ELEMENT_SET_NO\": {}", gp.element_set_number),
format!(
" \"REV_AT_EPOCH\": {}",
gp.revolution_number_at_epoch
),
format!(" \"BSTAR\": {}", format_omm_json_number(gp.bstar)),
format!(
" \"MEAN_MOTION_DOT\": {}",
format_omm_json_number(mean_motion_dot)
),
format!(
" \"MEAN_MOTION_DDOT\": {}",
format_omm_json_number(mean_motion_ddot)
),
];
format!(" {{\n{}\n }}", fields.join(",\n"))
}
#[cfg(feature = "json")]
pub fn to_omm_json_string(sgp4s: &[Sgp4]) -> String {
if sgp4s.is_empty() {
return "[]\n".to_string();
}
let mut objects = Vec::new();
for sgp4 in sgp4s {
objects.push(gp_to_omm_json(&sgp4.gp));
}
format!("[\n{}\n]\n", objects.join(",\n"))
}
#[cfg(feature = "json")]
pub fn to_omm_json_file(sgp4s: &[Sgp4], omm_json_file_path: &str) -> Result<(), GpError> {
let omm_json_string = to_omm_json_string(sgp4s);
fs::write(omm_json_file_path, omm_json_string).map_err(|err| GpError::Io(err.to_string()))?;
Ok(())
}
#[cfg(feature = "csv")]
fn sgp4_from_csv_record(headers: &[String], record: &csv::StringRecord) -> Result<Sgp4, GpError> {
let fields = csv_omm_fields(headers, record);
sgp4_from_omm_lookup(|field| fields.get(&field.to_ascii_uppercase()).cloned())
}
#[cfg(feature = "csv")]
pub fn from_omm_csv_string(omm_csv_string: &str) -> Result<Vec<Sgp4>, GpError> {
let mut reader = ReaderBuilder::new()
.has_headers(true)
.from_reader(omm_csv_string.as_bytes());
let headers: Vec<String> = reader
.headers()
.map_err(|_| GpError::InvalidOmmCsv)?
.iter()
.map(|header| header.trim().to_ascii_uppercase())
.collect();
let mut sgp4s = Vec::new();
for result in reader.records() {
let record = result.map_err(|_| GpError::InvalidOmmCsv)?;
sgp4s.push(sgp4_from_csv_record(&headers, &record)?);
}
Ok(sgp4s)
}
#[cfg(feature = "csv")]
fn csv_omm_fields(headers: &[String], record: &csv::StringRecord) -> HashMap<String, String> {
let mut fields = HashMap::new();
for (index, header) in headers.iter().enumerate() {
if header.is_empty() || header.eq_ignore_ascii_case("COMMENT") {
continue;
}
let text = record.get(index).unwrap_or("").trim().to_string();
fields.insert(header.clone(), text);
}
fields
}
#[cfg(feature = "csv")]
pub fn from_omm_csv_file(omm_csv_file_path: &str) -> Result<Vec<Sgp4>, GpError> {
let omm_csv_string =
fs::read_to_string(omm_csv_file_path).map_err(|err| GpError::Io(err.to_string()))?;
from_omm_csv_string(&omm_csv_string)
}
#[cfg(feature = "csv")]
fn gp_to_omm_csv_row(gp: &GenPerturbElementSet) -> [String; 17] {
let mean_motion_dot = gp.first_derivative_of_mean_motion / 2.0;
let mean_motion_ddot = gp.second_derivative_of_mean_motion / 6.0;
[
gp.common_name.clone(),
gp.international_designator.clone(),
format_omm_epoch(&gp.epoch_datetime),
format_omm_decimal(gp.mean_motion),
format_omm_decimal(gp.eccentricity),
format_omm_decimal(gp.inclination),
format_omm_decimal(gp.right_ascension_of_ascending_node),
format_omm_decimal(gp.argument_of_perigee),
format_omm_decimal(gp.mean_anomaly),
gp.ephemeris_type.to_string(),
format_omm_classification(gp.classification),
gp.satellite_catalog_number.to_string(),
gp.element_set_number.to_string(),
gp.revolution_number_at_epoch.to_string(),
format_omm_sci(gp.bstar),
format_omm_sci(mean_motion_dot),
format_omm_sci(mean_motion_ddot),
]
}
#[cfg(feature = "csv")]
pub fn to_omm_csv_string(sgp4s: &[Sgp4]) -> String {
let mut writer = WriterBuilder::new().from_writer(Vec::new());
writer
.write_record(OMM_CSV_HEADERS)
.expect("Cannot write OMM CSV");
for sgp4 in sgp4s {
writer
.write_record(gp_to_omm_csv_row(&sgp4.gp))
.expect("Cannot write OMM CSV");
}
let bytes = writer.into_inner().expect("Cannot write OMM CSV");
String::from_utf8(bytes).expect("Cannot write OMM CSV")
}
#[cfg(feature = "csv")]
pub fn to_omm_csv_file(sgp4s: &[Sgp4], omm_csv_file_path: &str) -> Result<(), GpError> {
let omm_csv_string = to_omm_csv_string(sgp4s);
fs::write(omm_csv_file_path, omm_csv_string).map_err(|err| GpError::Io(err.to_string()))?;
Ok(())
}
fn parse_omm_epoch(epoch: &str) -> Result<DateTime, GpError> {
let mut s = epoch.trim();
if s.ends_with('Z') || s.ends_with('z') {
s = &s[..s.len() - 1];
}
let (date, time) = if let Some((date, time)) = s.split_once('T') {
(date, time)
} else if let Some((date, time)) = s.split_once(' ') {
(date, time)
} else {
return Err(GpError::InvalidOmmEpoch);
};
let date_parts: Vec<&str> = date.split('-').collect();
let time_parts: Vec<&str> = time.split(':').collect();
if date_parts.len() != 3 || time_parts.len() != 3 {
return Err(GpError::InvalidOmmEpoch);
}
let year = date_parts[0]
.parse::<i32>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let month = date_parts[1]
.parse::<i32>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let day = date_parts[2]
.parse::<i32>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let hour = time_parts[0]
.parse::<i32>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let minute = time_parts[1]
.parse::<i32>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let second = time_parts[2]
.parse::<f64>()
.map_err(|_| GpError::InvalidOmmEpoch)?;
let datetime = DateTime {
year,
month,
day,
hour,
minute,
second,
timezone: Timezone::UTC,
};
validate_datetime(&datetime).map_err(|_| GpError::InvalidOmmEpoch)?;
Ok(datetime)
}
#[cfg(test)]
mod tests {
use super::*;
use serde::Deserialize;
use std::collections::HashMap;
use toml::from_str;
#[test]
fn test_tle_catalog_mismatch() {
let line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2921";
let line2 = "2 25545 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
let err = match from_tle_lines(line1, line2, None) {
Err(err) => err,
Ok(_) => panic!("expected MismatchedTleCatalog"),
};
assert_eq!(err, GpError::MismatchedTleCatalog);
}
#[test]
fn test_tle_file_io_error() {
let err = match from_tle_file("test/this_tle_file_does_not_exist.txt") {
Err(err) => err,
Ok(_) => panic!("expected GpError::Io"),
};
assert!(matches!(err, GpError::Io(_)));
}
#[test]
fn test_invalid_tle_catalog_number() {
let err = match format_tle_catalog_number(-1) {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLECatalogNumber"),
};
assert_eq!(err, GpError::InvalidTLECatalogNumber);
let err = match format_tle_catalog_number(340000) {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLECatalogNumber"),
};
assert_eq!(err, GpError::InvalidTLECatalogNumber);
}
#[test]
fn test_invalid_tle_datetime() {
let mut epoch = DateTime {
year: 1956,
month: 12,
day: 31,
hour: 0,
minute: 0,
second: 0.0,
timezone: Timezone::UTC,
};
let err = match format_tle_epoch(&epoch) {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLEDateTime"),
};
assert_eq!(err, GpError::InvalidTLEDateTime);
epoch.year = 2057;
let err = match format_tle_epoch(&epoch) {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLEDateTime"),
};
assert_eq!(err, GpError::InvalidTLEDateTime);
}
#[test]
fn test_invalid_tle_line() {
let err = match tle_line_with_checksum("1 25544U") {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLELine"),
};
assert_eq!(err, GpError::InvalidTLELine);
}
#[test]
fn test_invalid_tle_international_designator() {
let err = match format_tle_intl_des("2026-1234A") {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLEInternationalDesignator"),
};
assert_eq!(err, GpError::InvalidTLEInternationalDesignator);
let err = match format_tle_intl_des("2026-106ABCD") {
Err(err) => err,
Ok(_) => panic!("expected InvalidTLEInternationalDesignator"),
};
assert_eq!(err, GpError::InvalidTLEInternationalDesignator);
assert_eq!(format_tle_intl_des("1998-067A").unwrap(), "98067A ");
assert_eq!(format_tle_intl_des("2025-286AD").unwrap(), "25286AD ");
assert_eq!(format_tle_intl_des("2025-001ABC").unwrap(), "25001ABC");
}
#[test]
fn test_invalid_omm_field() {
let err = match from_omm_kvn_string("MEAN_MOTION = not_a_number") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmField"),
};
assert_eq!(err, GpError::InvalidOmmField);
let err = match from_omm_kvn_string("NORAD_CAT_ID = abc") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmField"),
};
assert_eq!(err, GpError::InvalidOmmField);
}
#[test]
fn test_invalid_omm_epoch() {
let err = match from_omm_kvn_string("EPOCH = not-an-epoch") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmEpoch"),
};
assert_eq!(err, GpError::InvalidOmmEpoch);
let err = match from_omm_kvn_string("EPOCH = 2026-06-14") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmEpoch"),
};
assert_eq!(err, GpError::InvalidOmmEpoch);
let err = match from_omm_kvn_string("EPOCH = 2026-06-14Txx:00:00") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmEpoch"),
};
assert_eq!(err, GpError::InvalidOmmEpoch);
for epoch in [
"2026-13-01T00:00:00",
"2026-02-29T00:00:00",
"2026-06-31T00:00:00",
"2026-06-14T24:00:00",
"2026-06-14T00:60:00",
"2026-06-14T00:00:61.0",
] {
let err = match from_omm_kvn_string(&format!("EPOCH = {epoch}\nMEAN_MOTION = 15.0")) {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmEpoch for {epoch}"),
};
assert_eq!(err, GpError::InvalidOmmEpoch, "{epoch}");
}
}
#[cfg(feature = "xml")]
#[test]
fn test_invalid_omm_xml() {
let err = match from_omm_xml_string("<<<not xml") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmXml"),
};
assert_eq!(err, GpError::InvalidOmmXml);
}
#[cfg(feature = "json")]
#[test]
fn test_invalid_omm_json() {
let err = match from_omm_json_string("not json") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmJson"),
};
assert_eq!(err, GpError::InvalidOmmJson);
let err = match from_omm_json_string("1") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmJson"),
};
assert_eq!(err, GpError::InvalidOmmJson);
let err = match from_omm_json_string("[1]") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmJson"),
};
assert_eq!(err, GpError::InvalidOmmJson);
}
#[cfg(feature = "csv")]
#[test]
fn test_invalid_omm_csv() {
let err = match from_omm_csv_string("OBJECT_NAME,MEAN_MOTION\nonly_one_field") {
Err(err) => err,
Ok(_) => panic!("expected InvalidOmmCsv"),
};
assert_eq!(err, GpError::InvalidOmmCsv);
}
#[test]
fn test_omm_file_io_error() {
let err = match from_omm_kvn_file("test/this_omm_file_does_not_exist.txt") {
Err(err) => err,
Ok(_) => panic!("expected GpError::Io"),
};
assert!(matches!(err, GpError::Io(_)));
}
#[test]
fn test_tle_full_year() {
assert_eq!(tle_full_year(0), 2000);
assert_eq!(tle_full_year(25), 2025);
assert_eq!(tle_full_year(56), 2056);
assert_eq!(tle_full_year(57), 1957);
assert_eq!(tle_full_year(98), 1998);
assert_eq!(tle_full_year(99), 1999);
}
#[test]
fn test_alpha5_digit() {
assert_eq!(alpha5_digit('A'), Some(10));
assert_eq!(alpha5_digit('H'), Some(17));
assert_eq!(alpha5_digit('J'), Some(18));
assert_eq!(alpha5_digit('N'), Some(22));
assert_eq!(alpha5_digit('P'), Some(23));
assert_eq!(alpha5_digit('Z'), Some(33));
assert_eq!(alpha5_digit('a'), Some(10));
assert_eq!(alpha5_digit('I'), None);
assert_eq!(alpha5_digit('O'), None);
assert_eq!(alpha5_digit('0'), None);
assert_eq!(alpha5_letter(10), Some('A'));
assert_eq!(alpha5_letter(17), Some('H'));
assert_eq!(alpha5_letter(18), Some('J'));
assert_eq!(alpha5_letter(22), Some('N'));
assert_eq!(alpha5_letter(23), Some('P'));
assert_eq!(alpha5_letter(33), Some('Z'));
assert_eq!(alpha5_letter(9), None);
assert_eq!(alpha5_letter(34), None);
}
#[test]
fn test_checksum_calculation() {
let tle_line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2921";
let checksum = calc_checksum(tle_line1);
assert_eq!(checksum, 1);
}
#[test]
fn test_checksum_comparison() {
let tle_line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2921";
let tle_line2 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2922";
let checksum = tle_checksum(tle_line1);
let checksum2 = tle_checksum(tle_line2);
assert!(checksum);
assert!(!checksum2);
}
#[test]
fn test_tle_checksum_warning() {
let line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2921";
let line2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
let bad_line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2922";
let sgp4 = from_tle_lines(line1, line2, None).expect("valid TLE should parse");
assert_eq!(sgp4.gp.tle_checksum_valid, Some(true));
let sgp4 = from_tle_lines(bad_line1, line2, None).expect("bad checksum should still parse");
assert_eq!(sgp4.gp.tle_checksum_valid, Some(false));
}
#[test]
fn test_invalid_tle_line_prefix_and_ascii() {
let line1 = "1 25544U 98067A 08264.51782528 -.00002182 -00100-2 -11606-4 0 2921";
let line2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537";
let bad_line1 = line1.replacen('1', "3", 1);
let bad_line2 = line2.replacen('2', "3", 1);
let no_space_line1 = format!("10{}", &line1[2..]);
assert!(matches!(
from_tle_lines(&bad_line1, line2, None),
Err(GpError::InvalidTleLine1)
));
assert!(matches!(
from_tle_lines(line1, &bad_line2, None),
Err(GpError::InvalidTleLine2)
));
assert!(matches!(
from_tle_lines(line2, line1, None),
Err(GpError::InvalidTleLine1)
));
assert!(matches!(
from_tle_lines(&no_space_line1, line2, None),
Err(GpError::InvalidTleLine1)
));
let utf8_line1 = format!("{}\u{e9}{}", &line1[..62], &line1[64..]);
let utf8_line2 = format!("{}\u{e9}{}", &line2[..15], &line2[17..]);
assert_eq!(utf8_line1.len(), 69);
assert_eq!(utf8_line2.len(), 69);
assert!(matches!(
from_tle_lines(&utf8_line1, line2, None),
Err(GpError::InvalidTleLine1)
));
assert!(matches!(
from_tle_lines(line1, &utf8_line2, None),
Err(GpError::InvalidTleLine2)
));
}
#[derive(Deserialize)]
struct ParsingCases {
test: HashMap<String, ParsingCase>,
}
#[derive(Deserialize)]
struct ParsingCase {
name: String,
#[serde(default)]
tle: String,
#[serde(default)]
omm_kvn: String,
#[serde(default)]
exception: bool,
#[serde(default)]
common_name: String,
#[serde(default)]
satellite_catalog_number: i32,
#[serde(default)]
classification: String,
#[serde(default)]
international_designator: String,
#[serde(default)]
epoch_datetime_year: i32,
#[serde(default)]
epoch_datetime_month: i32,
#[serde(default)]
epoch_datetime_day: i32,
#[serde(default)]
epoch_datetime_hour: i32,
#[serde(default)]
epoch_datetime_minute: i32,
#[serde(default)]
epoch_datetime_second: f64,
#[serde(default)]
first_derivative_of_mean_motion: f64,
#[serde(default)]
second_derivative_of_mean_motion: f64,
#[serde(default)]
bstar: f64,
#[serde(default)]
ephemeris_type: i32,
#[serde(default)]
element_set_number: i32,
#[serde(default)]
inclination: f64,
#[serde(default)]
right_ascension_of_ascending_node: f64,
#[serde(default)]
eccentricity: f64,
#[serde(default)]
argument_of_perigee: f64,
#[serde(default)]
mean_anomaly: f64,
#[serde(default)]
mean_motion: f64,
#[serde(default)]
revolution_number_at_epoch: i64,
}
const TLE_PARSE_TOL: f64 = 1e-9;
fn assert_near(key: &str, name: &str, source: &str, label: &str, value: f64, expected: f64) {
assert!(
(value - expected).abs() < TLE_PARSE_TOL,
"{key} ({name}) via {source}: {label} mismatch {value} vs {expected}"
);
}
fn assert_gp_matches(
key: &str,
name: &str,
source: &str,
gp: &GenPerturbElementSet,
case: &ParsingCase,
) {
assert_eq!(
gp.common_name, case.common_name,
"{key} ({name}) via {source}: common_name"
);
assert_eq!(
gp.satellite_catalog_number, case.satellite_catalog_number,
"{key} ({name}) via {source}: satellite_catalog_number"
);
assert_eq!(
gp.classification.to_string(),
case.classification,
"{key} ({name}) via {source}: classification"
);
assert_eq!(
gp.international_designator, case.international_designator,
"{key} ({name}) via {source}: international_designator"
);
assert_eq!(
gp.epoch_datetime.year, case.epoch_datetime_year,
"{key} ({name}) via {source}: epoch year"
);
assert_eq!(
gp.epoch_datetime.month, case.epoch_datetime_month,
"{key} ({name}) via {source}: epoch month"
);
assert_eq!(
gp.epoch_datetime.day, case.epoch_datetime_day,
"{key} ({name}) via {source}: epoch day"
);
assert_eq!(
gp.epoch_datetime.hour, case.epoch_datetime_hour,
"{key} ({name}) via {source}: epoch hour"
);
assert_eq!(
gp.epoch_datetime.minute, case.epoch_datetime_minute,
"{key} ({name}) via {source}: epoch minute"
);
assert_near(
key,
name,
source,
"epoch second",
gp.epoch_datetime.second,
case.epoch_datetime_second,
);
assert_near(
key,
name,
source,
"n-dot",
gp.first_derivative_of_mean_motion,
case.first_derivative_of_mean_motion,
);
assert_near(
key,
name,
source,
"n-ddot",
gp.second_derivative_of_mean_motion,
case.second_derivative_of_mean_motion,
);
assert_near(key, name, source, "bstar", gp.bstar, case.bstar);
assert_eq!(
gp.ephemeris_type, case.ephemeris_type,
"{key} ({name}) via {source}: ephemeris_type"
);
assert_eq!(
gp.element_set_number, case.element_set_number,
"{key} ({name}) via {source}: element_set_number"
);
assert_near(
key,
name,
source,
"inclination",
gp.inclination,
case.inclination,
);
assert_near(
key,
name,
source,
"raan",
gp.right_ascension_of_ascending_node,
case.right_ascension_of_ascending_node,
);
assert_near(
key,
name,
source,
"eccentricity",
gp.eccentricity,
case.eccentricity,
);
assert_near(
key,
name,
source,
"argp",
gp.argument_of_perigee,
case.argument_of_perigee,
);
assert_near(
key,
name,
source,
"mean anomaly",
gp.mean_anomaly,
case.mean_anomaly,
);
assert_near(
key,
name,
source,
"mean motion",
gp.mean_motion,
case.mean_motion,
);
assert_eq!(
gp.revolution_number_at_epoch, case.revolution_number_at_epoch,
"{key} ({name}) via {source}: revolution_number_at_epoch"
);
}
fn sgp4_from_case_tle(tle: &str) -> Result<Sgp4, GpError> {
let lines: Vec<&str> = tle
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
if lines[0].starts_with('1') {
from_tle_lines(lines[0], lines[1], None)
} else {
from_tle_lines(lines[1], lines[2], Some(lines[0]))
}
}
#[test]
fn test_tle_parsing_cases() {
let content = std::fs::read_to_string("test/tle_parsing_cases.toml")
.expect("could not read test/tle_parsing_cases.toml");
let cases: ParsingCases =
from_str(&content).expect("could not parse test/tle_parsing_cases.toml");
let from_file = from_tle_file("test/tle_parsing_cases.txt")
.expect("could not read test/tle_parsing_cases.txt");
let expected_file_count = cases.test.values().filter(|c| !c.exception).count();
assert_eq!(
from_file.len(),
expected_file_count,
"tle_parsing_cases.txt should contain one entry per non-error TOML case"
);
let mut keys: Vec<&String> = cases.test.keys().collect();
keys.sort();
for key in keys {
let case = &cases.test[key];
if case.exception {
assert!(
sgp4_from_case_tle(&case.tle).is_err(),
"case {key} ({}): expected from_tle_lines to return Err",
case.name
);
assert!(
from_tle_string(&case.tle).is_err(),
"case {key} ({}): expected from_tle_string to return Err",
case.name
);
continue;
}
let from_lines = sgp4_from_case_tle(&case.tle).unwrap_or_else(|err| {
panic!("case {key} ({}): from_tle_lines failed: {err:?}", case.name)
});
assert_gp_matches(key, &case.name, "from_tle_lines", &from_lines.gp, case);
let from_string = from_tle_string(&case.tle).unwrap_or_else(|err| {
panic!(
"case {key} ({}): from_tle_string failed: {err:?}",
case.name
)
});
assert_eq!(
from_string.len(),
1,
"case {key} ({}): expected one TLE from string",
case.name
);
assert_gp_matches(key, &case.name, "from_tle_string", &from_string[0].gp, case);
let file_match = from_file
.iter()
.find(|s| s.gp.satellite_catalog_number == case.satellite_catalog_number)
.unwrap_or_else(|| {
panic!(
"case {key} ({}): catalog {} missing from tle_parsing_cases.txt parse",
case.name, case.satellite_catalog_number
)
});
assert_gp_matches(key, &case.name, "from_tle_file", &file_match.gp, case);
}
}
fn load_omm_parsing_cases() -> ParsingCases {
let content = std::fs::read_to_string("test/omm_parsing_cases.toml")
.expect("could not read test/omm_parsing_cases.toml");
from_str(&content).expect("could not parse test/omm_parsing_cases.toml")
}
fn sorted_omm_keys(cases: &ParsingCases) -> Vec<&String> {
let mut keys: Vec<&String> = cases.test.keys().collect();
keys.sort();
keys
}
#[cfg(any(feature = "xml", feature = "json", feature = "csv"))]
fn assert_omm_file_matches_cases(
cases: &ParsingCases,
from_file: &[Sgp4],
source: &str,
fixture: &str,
) {
let expected_file_count = cases.test.values().filter(|c| !c.exception).count();
assert_eq!(
from_file.len(),
expected_file_count,
"{fixture} should contain one entry per non-error TOML case"
);
for key in sorted_omm_keys(cases) {
let case = &cases.test[key];
if case.exception {
continue;
}
let file_match = from_file
.iter()
.find(|s| s.gp.satellite_catalog_number == case.satellite_catalog_number)
.unwrap_or_else(|| {
panic!(
"case {key} ({}): catalog {} missing from {fixture} parse",
case.name, case.satellite_catalog_number
)
});
assert_gp_matches(key, &case.name, source, &file_match.gp, case);
}
}
fn sgp4_from_case_omm(omm_kvn: &str) -> Result<Sgp4, GpError> {
let lines: Vec<&str> = omm_kvn
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.collect();
from_omm_kvn_lines(&lines)
}
#[test]
fn test_omm_kvn_parsing_cases() {
let cases = load_omm_parsing_cases();
let from_file = from_omm_kvn_file("test/omm_parsing_cases.txt")
.expect("could not parse test/omm_parsing_cases.txt");
let expected_file_count = cases.test.values().filter(|c| !c.exception).count();
assert_eq!(
from_file.len(),
expected_file_count,
"omm_parsing_cases.txt should contain one entry per non-error TOML case"
);
for key in sorted_omm_keys(&cases) {
let case = &cases.test[key];
if case.exception {
assert!(
sgp4_from_case_omm(&case.omm_kvn).is_err(),
"case {key} ({}): expected from_omm_kvn_lines to return Err",
case.name
);
assert!(
from_omm_kvn_string(&case.omm_kvn).is_err(),
"case {key} ({}): expected from_omm_kvn_string to return Err",
case.name
);
continue;
}
let from_lines = sgp4_from_case_omm(&case.omm_kvn).unwrap_or_else(|err| {
panic!(
"case {key} ({}): from_omm_kvn_lines failed: {err:?}",
case.name
)
});
assert_gp_matches(key, &case.name, "from_omm_kvn_lines", &from_lines.gp, case);
let from_string = from_omm_kvn_string(&case.omm_kvn).unwrap_or_else(|err| {
panic!(
"case {key} ({}): from_omm_kvn_string failed: {err:?}",
case.name
)
});
assert_eq!(
from_string.len(),
1,
"case {key} ({}): expected one OMM from string",
case.name
);
assert_gp_matches(
key,
&case.name,
"from_omm_kvn_string",
&from_string[0].gp,
case,
);
let file_match = from_file
.iter()
.find(|s| s.gp.satellite_catalog_number == case.satellite_catalog_number)
.unwrap_or_else(|| {
panic!(
"case {key} ({}): catalog {} missing from omm_parsing_cases.txt parse",
case.name, case.satellite_catalog_number
)
});
assert_gp_matches(key, &case.name, "from_omm_kvn_file", &file_match.gp, case);
}
}
#[cfg(feature = "xml")]
#[test]
fn test_omm_xml_parsing_cases() {
let cases = load_omm_parsing_cases();
let from_xml_file = from_omm_xml_file("test/omm_parsing_cases.xml")
.expect("could not parse test/omm_parsing_cases.xml");
assert_omm_file_matches_cases(
&cases,
&from_xml_file,
"from_omm_xml_file",
"omm_parsing_cases.xml",
);
}
#[cfg(feature = "json")]
#[test]
fn test_omm_json_parsing_cases() {
let cases = load_omm_parsing_cases();
let from_json_file = from_omm_json_file("test/omm_parsing_cases.json")
.expect("could not parse test/omm_parsing_cases.json");
assert_omm_file_matches_cases(
&cases,
&from_json_file,
"from_omm_json_file",
"omm_parsing_cases.json",
);
}
#[cfg(feature = "csv")]
#[test]
fn test_omm_csv_parsing_cases() {
let cases = load_omm_parsing_cases();
let from_csv_file = from_omm_csv_file("test/omm_parsing_cases.csv")
.expect("could not parse test/omm_parsing_cases.csv");
assert_omm_file_matches_cases(
&cases,
&from_csv_file,
"from_omm_csv_file",
"omm_parsing_cases.csv",
);
}
fn assert_gp_eq(label: &str, original: &GenPerturbElementSet, exported: &GenPerturbElementSet) {
assert_eq!(
original.common_name, exported.common_name,
"{label}: common_name"
);
assert_eq!(
original.satellite_catalog_number, exported.satellite_catalog_number,
"{label}: satellite_catalog_number"
);
assert_eq!(
original.classification, exported.classification,
"{label}: classification"
);
assert_eq!(
original.international_designator, exported.international_designator,
"{label}: international_designator"
);
assert_eq!(
original.epoch_datetime.year, exported.epoch_datetime.year,
"{label}: epoch year"
);
assert_eq!(
original.epoch_datetime.month, exported.epoch_datetime.month,
"{label}: epoch month"
);
assert_eq!(
original.epoch_datetime.day, exported.epoch_datetime.day,
"{label}: epoch day"
);
assert_eq!(
original.epoch_datetime.hour, exported.epoch_datetime.hour,
"{label}: epoch hour"
);
assert_eq!(
original.epoch_datetime.minute, exported.epoch_datetime.minute,
"{label}: epoch minute"
);
assert!(
(original.epoch_datetime.second - exported.epoch_datetime.second).abs() < TLE_PARSE_TOL,
"{label}: epoch second {} vs {}",
original.epoch_datetime.second,
exported.epoch_datetime.second
);
assert!(
(original.first_derivative_of_mean_motion - exported.first_derivative_of_mean_motion)
.abs()
< TLE_PARSE_TOL,
"{label}: n-dot"
);
assert!(
(original.second_derivative_of_mean_motion - exported.second_derivative_of_mean_motion)
.abs()
< TLE_PARSE_TOL,
"{label}: n-ddot"
);
assert!(
(original.bstar - exported.bstar).abs() < TLE_PARSE_TOL,
"{label}: bstar"
);
assert_eq!(
original.ephemeris_type, exported.ephemeris_type,
"{label}: ephemeris_type"
);
assert_eq!(
original.element_set_number, exported.element_set_number,
"{label}: element_set_number"
);
assert!(
(original.inclination - exported.inclination).abs() < TLE_PARSE_TOL,
"{label}: inclination"
);
assert!(
(original.right_ascension_of_ascending_node
- exported.right_ascension_of_ascending_node)
.abs()
< TLE_PARSE_TOL,
"{label}: raan"
);
assert!(
(original.eccentricity - exported.eccentricity).abs() < TLE_PARSE_TOL,
"{label}: eccentricity"
);
assert!(
(original.argument_of_perigee - exported.argument_of_perigee).abs() < TLE_PARSE_TOL,
"{label}: argp"
);
assert!(
(original.mean_anomaly - exported.mean_anomaly).abs() < TLE_PARSE_TOL,
"{label}: mean anomaly"
);
assert!(
(original.mean_motion - exported.mean_motion).abs() < TLE_PARSE_TOL,
"{label}: mean motion"
);
assert_eq!(
original.revolution_number_at_epoch, exported.revolution_number_at_epoch,
"{label}: revolution_number_at_epoch"
);
}
fn export_test_path(file_name: &str) -> String {
let dir = std::path::Path::new("test/export");
std::fs::create_dir_all(dir).expect("could not create test/export");
dir.join(file_name)
.to_str()
.expect("export path is valid UTF-8")
.to_string()
}
#[test]
fn test_omm_kvn_export_empty() {
let exported = to_omm_kvn_string(&[]);
assert_eq!(exported, "");
}
#[test]
fn test_omm_kvn_export_string_roundtrip() {
let original = from_omm_kvn_file("test/omm_parsing_cases.txt")
.expect("could not parse test/omm_parsing_cases.txt");
let exported = to_omm_kvn_string(&original);
let reparsed = from_omm_kvn_string(&exported).unwrap();
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[test]
fn test_omm_kvn_export_file_roundtrip() {
let original = from_omm_kvn_file("test/omm_parsing_cases.txt")
.expect("could not parse test/omm_parsing_cases.txt");
let out_path = export_test_path("omm_kvn.txt");
to_omm_kvn_file(&original, &out_path).expect("could not write exported KVN file");
let reparsed = from_omm_kvn_file(&out_path).expect("could not parse exported KVN file");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "xml")]
#[test]
fn test_omm_xml_export_empty() {
let exported = to_omm_xml_string(&[]);
let reparsed = from_omm_xml_string(&exported).unwrap();
assert!(reparsed.is_empty());
}
#[cfg(feature = "xml")]
#[test]
fn test_omm_xml_export_string_roundtrip() {
let original = from_omm_xml_file("test/omm_parsing_cases.xml")
.expect("could not parse test/omm_parsing_cases.xml");
let exported = to_omm_xml_string(&original);
let reparsed = from_omm_xml_string(&exported).unwrap();
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "xml")]
#[test]
fn test_omm_xml_export_file_roundtrip() {
let original = from_omm_xml_file("test/omm_parsing_cases.xml")
.expect("could not parse test/omm_parsing_cases.xml");
let out_path = export_test_path("omm_xml.xml");
to_omm_xml_file(&original, &out_path).expect("could not write exported XML file");
let reparsed = from_omm_xml_file(&out_path).expect("could not parse exported XML file");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "json")]
#[test]
fn test_omm_json_export_empty() {
let exported = to_omm_json_string(&[]);
let reparsed = from_omm_json_string(&exported).unwrap();
assert!(reparsed.is_empty());
}
#[cfg(feature = "json")]
#[test]
fn test_omm_json_export_string_roundtrip() {
let original = from_omm_json_file("test/omm_parsing_cases.json")
.expect("could not parse test/omm_parsing_cases.json");
let exported = to_omm_json_string(&original);
let reparsed = from_omm_json_string(&exported).unwrap();
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "json")]
#[test]
fn test_omm_json_export_file_roundtrip() {
let original = from_omm_json_file("test/omm_parsing_cases.json")
.expect("could not parse test/omm_parsing_cases.json");
let out_path = export_test_path("omm_json.json");
to_omm_json_file(&original, &out_path).expect("could not write exported JSON file");
let reparsed = from_omm_json_file(&out_path).expect("could not parse exported JSON file");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "csv")]
#[test]
fn test_omm_csv_export_empty() {
let exported = to_omm_csv_string(&[]);
let reparsed = from_omm_csv_string(&exported).unwrap();
assert!(reparsed.is_empty());
}
#[cfg(feature = "csv")]
#[test]
fn test_omm_csv_export_string_roundtrip() {
let original = from_omm_csv_file("test/omm_parsing_cases.csv")
.expect("could not parse test/omm_parsing_cases.csv");
let exported = to_omm_csv_string(&original);
let reparsed = from_omm_csv_string(&exported).unwrap();
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[cfg(feature = "csv")]
#[test]
fn test_omm_csv_export_file_roundtrip() {
let original = from_omm_csv_file("test/omm_parsing_cases.csv")
.expect("could not parse test/omm_parsing_cases.csv");
let out_path = export_test_path("omm_csv.csv");
to_omm_csv_file(&original, &out_path).expect("could not write exported CSV file");
let reparsed = from_omm_csv_file(&out_path).expect("could not parse exported CSV file");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[test]
fn test_tle_export_empty() {
let exported = to_tle_string(&[]).expect("empty TLE export should succeed");
assert_eq!(exported, "");
}
#[test]
fn test_tle_export_string_roundtrip() {
let original = from_tle_file("test/tle_parsing_cases.txt")
.expect("could not read test/tle_parsing_cases.txt");
let exported = to_tle_string(&original).expect("TLE export should succeed");
let reparsed = from_tle_string(&exported).expect("exported TLE string should parse");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[test]
fn test_tle_export_file_roundtrip() {
let original = from_tle_file("test/tle_parsing_cases.txt")
.expect("could not read test/tle_parsing_cases.txt");
let out_path = export_test_path("tle.txt");
to_tle_file(&original, &out_path).expect("TLE file export should succeed");
let reparsed = from_tle_file(&out_path).expect("could not parse exported TLE file");
assert_eq!(original.len(), reparsed.len());
for (i, (a, b)) in original.iter().zip(reparsed.iter()).enumerate() {
assert_gp_eq(&format!("record {i}"), &a.gp, &b.gp);
}
}
#[test]
fn test_tle_export_line_format() {
let original = from_tle_file("test/tle_parsing_cases.txt")
.expect("could not read test/tle_parsing_cases.txt");
let exported = to_tle_string(&original).expect("TLE export should succeed");
for line in exported.lines() {
if line.starts_with('1') || line.starts_with('2') {
assert_eq!(line.len(), 69, "TLE data line length: {line}");
assert!(tle_checksum(line), "TLE checksum: {line}");
} else {
assert!(!line.is_empty() && line.len() <= 24, "TLE name: {line}");
}
}
}
#[test]
fn test_kvn_parse_defaults() {
let lines: [&str; 0] = [];
let name: String = kvn_parse(&lines, "OBJECT_NAME").unwrap();
let catalog: i32 = kvn_parse(&lines, "NORAD_CAT_ID").unwrap();
let rev: i64 = kvn_parse(&lines, "REV_AT_EPOCH").unwrap();
let motion: f64 = kvn_parse(&lines, "MEAN_MOTION").unwrap();
let class: char = kvn_parse(&lines, "CLASSIFICATION_TYPE").unwrap();
assert_eq!(name, "");
assert_eq!(catalog, 0);
assert_eq!(rev, 0);
assert_eq!(motion, 0.0);
assert_eq!(class, 'U');
}
}