use super::{FileLocation, FormatError, ParseMode};
use std::io::{BufRead, BufReader, Read};
#[derive(Debug, Clone)]
pub struct OrbexOrbitEntry {
pub sat: String,
pub epoch_mjd: f64,
pub pos_m: [f64; 3],
pub vel_m_s: Option<[f64; 3]>,
}
#[derive(Debug, Clone)]
pub struct OrbexClockEntry {
pub sat: String,
pub epoch_mjd: f64,
pub bias_s: f64,
}
#[derive(Debug, Clone)]
pub struct OrbexAttitudeEntry {
pub sat: String,
pub epoch_mjd: f64,
pub quaternion: [f64; 4],
}
#[derive(Debug, Default)]
pub struct OrbexProduct {
pub version: String,
pub orbits: Vec<OrbexOrbitEntry>,
pub clocks: Vec<OrbexClockEntry>,
pub attitudes: Vec<OrbexAttitudeEntry>,
}
pub fn read_orbex<R: Read>(reader: R, mode: ParseMode) -> Result<OrbexProduct, FormatError> {
let mut product = OrbexProduct::default();
let buf = BufReader::new(reader);
#[derive(PartialEq, Clone, Copy)]
enum Section {
None,
Orb,
Clk,
Att,
}
let mut section = Section::None;
let mut current_epoch_mjd: f64 = 0.0;
for (lineno, result) in buf.lines().enumerate() {
let line = result.map_err(FormatError::Io)?;
let lineno = lineno + 1;
if line.starts_with("%=ORBEX") {
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.len() >= 2 {
product.version = parts[1].to_string();
}
continue;
}
if line.starts_with("%ENDORBEX") || line.starts_with("%EOF") {
break;
}
if line.starts_with('%') {
continue;
}
if line.starts_with('+') || line.starts_with('-') {
continue;
}
if line.starts_with("##") {
let rest = line.trim_start_matches('#').trim();
let parts: Vec<&str> = rest.split_whitespace().collect();
if parts.len() >= 3 {
let year: i32 = parts[0].parse().unwrap_or(2000);
let doy: u32 = parts[1].parse().unwrap_or(1);
let sod: f64 = parts[2].parse().unwrap_or(0.0);
current_epoch_mjd = orbex_epoch_to_mjd(year, doy, sod);
}
continue;
}
if line.starts_with("#ORB") {
section = Section::Orb;
continue;
}
if line.starts_with("#CLK") {
section = Section::Clk;
continue;
}
if line.starts_with("#ATT") {
section = Section::Att;
continue;
}
if line.starts_with('#') {
section = Section::None;
continue;
}
if !line.starts_with(' ') {
continue;
}
let parts: Vec<&str> = line.split_whitespace().collect();
if parts.is_empty() {
continue;
}
match section {
Section::Orb => {
if parts.len() < 4 {
if mode == ParseMode::Strict {
return Err(FormatError::located(
"ORBEX §3.3",
FileLocation::at_line(lineno),
format!("ORB record has {} fields, need ≥ 4", parts.len()),
));
}
continue;
}
let sat = parts[0].to_string();
let x = parse_f64(parts[1], lineno, "pos_x", mode)?;
let y = parse_f64(parts[2], lineno, "pos_y", mode)?;
let z = parse_f64(parts[3], lineno, "pos_z", mode)?;
let vel_m_s = if parts.len() >= 7 {
let vx = parse_f64(parts[4], lineno, "vel_x", mode)?;
let vy = parse_f64(parts[5], lineno, "vel_y", mode)?;
let vz = parse_f64(parts[6], lineno, "vel_z", mode)?;
Some([vx, vy, vz])
} else {
None
};
product.orbits.push(OrbexOrbitEntry {
sat,
epoch_mjd: current_epoch_mjd,
pos_m: [x, y, z],
vel_m_s,
});
}
Section::Clk => {
if parts.len() < 2 {
if mode == ParseMode::Strict {
return Err(FormatError::located(
"ORBEX §3.4",
FileLocation::at_line(lineno),
format!("CLK record has {} fields, need ≥ 2", parts.len()),
));
}
continue;
}
let sat = parts[0].to_string();
let bias_s = parse_f64(parts[1], lineno, "bias", mode)?;
product.clocks.push(OrbexClockEntry {
sat,
epoch_mjd: current_epoch_mjd,
bias_s,
});
}
Section::Att => {
if parts.len() < 5 {
if mode == ParseMode::Strict {
return Err(FormatError::located(
"ORBEX §3.5",
FileLocation::at_line(lineno),
format!("ATT record has {} fields, need ≥ 5", parts.len()),
));
}
continue;
}
let sat = parts[0].to_string();
let w = parse_f64(parts[1], lineno, "q_w", mode)?;
let x = parse_f64(parts[2], lineno, "q_x", mode)?;
let y = parse_f64(parts[3], lineno, "q_y", mode)?;
let z = parse_f64(parts[4], lineno, "q_z", mode)?;
product.attitudes.push(OrbexAttitudeEntry {
sat,
epoch_mjd: current_epoch_mjd,
quaternion: [w, x, y, z],
});
}
Section::None => {}
}
}
Ok(product)
}
fn parse_f64(s: &str, lineno: usize, what: &str, mode: ParseMode) -> Result<f64, FormatError> {
s.parse::<f64>().map_err(|_| {
if mode == ParseMode::Strict {
FormatError::located(
"ORBEX §3",
FileLocation::at_line(lineno),
format!("cannot parse {what}: {s:?}"),
)
} else {
FormatError::Format(format!("ORBEX line {lineno}: cannot parse {what}: {s:?}"))
}
})
}
fn orbex_epoch_to_mjd(year: i32, doy: u32, sod: f64) -> f64 {
let epoch = match chrono::NaiveDate::from_yo_opt(year, doy.max(1)) {
Some(d) => d,
None => return 0.0,
};
let mjd_ref = chrono::NaiveDate::from_ymd_opt(1858, 11, 17).expect("valid MJD epoch");
let days = (epoch - mjd_ref).num_days() as f64;
days + sod / 86400.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formats::ParseMode;
fn orb_only_text() -> String {
[
"%=ORBEX 0.09",
"%%",
"+EPHEMERIS/DATA",
"#ORB",
"## 2024 21 21600.000000000",
" G01 1.0E+07 2.0E+06 3.0E+06",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n")
}
#[test]
fn read_orbex_orb_entry() {
let prod = read_orbex(orb_only_text().as_bytes(), ParseMode::Strict).unwrap();
assert_eq!(prod.orbits.len(), 1);
assert_eq!(prod.orbits[0].sat, "G01");
assert_eq!(prod.orbits[0].pos_m[0], 1.0e7);
assert!(prod.orbits[0].vel_m_s.is_none());
}
#[test]
fn read_orbex_orb_with_velocity() {
let text = [
"%=ORBEX 0.09",
"%%",
"+EPHEMERIS/DATA",
"#ORB",
"## 2024 21 0.0",
" G02 1.0E+07 0.0 0.0 100.0 200.0 300.0",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
let prod = read_orbex(text.as_bytes(), ParseMode::Strict).unwrap();
assert_eq!(prod.orbits.len(), 1);
let vel = prod.orbits[0].vel_m_s.unwrap();
assert_eq!(vel[0], 100.0);
assert_eq!(vel[1], 200.0);
assert_eq!(vel[2], 300.0);
}
#[test]
fn read_orbex_clk_entry() {
let text = [
"%=ORBEX 0.09",
"%%",
"+EPHEMERIS/DATA",
"#CLK",
"## 2024 21 0.0",
" G01 1.5E-7",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
let prod = read_orbex(text.as_bytes(), ParseMode::Strict).unwrap();
assert_eq!(prod.clocks.len(), 1);
assert_eq!(prod.clocks[0].sat, "G01");
assert!((prod.clocks[0].bias_s - 1.5e-7).abs() < 1e-15);
}
#[test]
fn read_orbex_att_entry() {
let text = [
"%=ORBEX 0.09",
"%%",
"+EPHEMERIS/DATA",
"#ATT",
"## 2024 21 0.0",
" G01 1.0 0.0 0.0 0.0",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
let prod = read_orbex(text.as_bytes(), ParseMode::Strict).unwrap();
assert_eq!(prod.attitudes.len(), 1);
assert_eq!(prod.attitudes[0].quaternion[0], 1.0);
}
#[test]
fn read_orbex_short_record_strict_is_error() {
let text = [
"%=ORBEX 0.09",
"+EPHEMERIS/DATA",
"#ORB",
"## 2024 21 0.0",
" G01 1.0E+07",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
assert!(read_orbex(text.as_bytes(), ParseMode::Strict).is_err());
}
#[test]
fn read_orbex_short_record_permissive_skips() {
let text = [
"%=ORBEX 0.09",
"+EPHEMERIS/DATA",
"#ORB",
"## 2024 21 0.0",
" G01 1.0E+07",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
let prod = read_orbex(text.as_bytes(), ParseMode::Permissive).unwrap();
assert!(prod.orbits.is_empty());
}
#[test]
fn read_orbex_unknown_section_sets_none() {
let text = [
"%=ORBEX 0.09",
"+EPHEMERIS/DATA",
"#FOO",
"## 2024 21 0.0",
" G01 1.0E+07 2.0 3.0",
"-EPHEMERIS/DATA",
"%ENDORBEX",
]
.join("\n");
let prod = read_orbex(text.as_bytes(), ParseMode::Permissive).unwrap();
assert!(prod.orbits.is_empty());
assert!(prod.clocks.is_empty());
assert!(prod.attitudes.is_empty());
}
#[test]
fn read_orbex_version_parsed() {
let prod = read_orbex(orb_only_text().as_bytes(), ParseMode::Permissive).unwrap();
assert_eq!(prod.version, "0.09");
}
}