use crate::rinex::EpochUtc;
use serde::Serialize;
#[derive(Clone, Debug, Serialize)]
pub struct OemMetadata {
pub object_name: String,
pub object_id: String,
pub center_name: String,
pub ref_frame: String,
pub time_system: String,
pub start: EpochUtc,
pub stop: EpochUtc,
}
#[derive(Clone, Debug, PartialEq, Serialize)]
pub struct OemStateLine {
pub epoch: EpochUtc,
pub pos_km: [f64; 3],
pub vel_km_s: [f64; 3],
}
#[derive(Clone, Debug, Serialize)]
pub struct OemSegment {
pub meta: OemMetadata,
pub states: Vec<OemStateLine>,
}
#[derive(Clone, Debug, Serialize)]
pub struct OemFile {
pub version: String,
pub creation_date: EpochUtc,
pub originator: String,
pub segments: Vec<OemSegment>,
}
impl OemFile {
pub fn from_propagators(
ids: &[String],
sats: &[crate::orbit::Propagator],
start: EpochUtc,
step_s: f64,
num_epochs: usize,
creation_date: EpochUtc,
) -> Self {
let day_jd0 = crate::timescales::julian_date(start.year, start.month, start.day, 0, 0, 0.0);
let base_sod = start.hour as f64 * 3600.0 + start.minute as f64 * 60.0 + start.second;
let epoch_at = |i: usize| -> EpochUtc {
let total = base_sod + i as f64 * step_s;
let day_add = (total / 86_400.0).floor();
let mut sod = total - day_add * 86_400.0; let date = crate::timescales::civil_from_jd(day_jd0 + day_add);
let hour = (sod / 3600.0).floor();
sod -= hour * 3600.0;
let minute = (sod / 60.0).floor();
sod -= minute * 60.0;
EpochUtc {
year: date.year,
month: date.month,
day: date.day,
hour: hour as u32,
minute: minute as u32,
second: sod,
}
};
let last = num_epochs.saturating_sub(1);
let mut segments = Vec::with_capacity(sats.len());
for (id, sat) in ids.iter().zip(sats.iter()) {
let mut states = Vec::with_capacity(num_epochs);
for i in 0..num_epochs {
let t = i as f64 * step_s;
let s = sat.state_eci(t);
states.push(OemStateLine {
epoch: epoch_at(i),
pos_km: [s.r_m[0] / 1000.0, s.r_m[1] / 1000.0, s.r_m[2] / 1000.0],
vel_km_s: [
s.v_m_s[0] / 1000.0,
s.v_m_s[1] / 1000.0,
s.v_m_s[2] / 1000.0,
],
});
}
segments.push(OemSegment {
meta: OemMetadata {
object_name: id.clone(),
object_id: id.clone(),
center_name: "EARTH".to_string(),
ref_frame: "TEME".to_string(),
time_system: "GPS".to_string(),
start: epoch_at(0),
stop: epoch_at(last),
},
states,
});
}
OemFile {
version: "2.0".to_string(),
creation_date,
originator: "KSHANA".to_string(),
segments,
}
}
pub fn to_oem_string(&self) -> String {
let mut out = String::new();
out.push_str(&format!("CCSDS_OEM_VERS = {}\n", self.version));
out.push_str(&format!(
"CREATION_DATE = {}\n",
iso8601(&self.creation_date)
));
out.push_str(&format!("ORIGINATOR = {}\n", self.originator));
for seg in &self.segments {
out.push('\n');
out.push_str("META_START\n");
out.push_str(&format!("OBJECT_NAME = {}\n", seg.meta.object_name));
out.push_str(&format!("OBJECT_ID = {}\n", seg.meta.object_id));
out.push_str(&format!("CENTER_NAME = {}\n", seg.meta.center_name));
out.push_str(&format!("REF_FRAME = {}\n", seg.meta.ref_frame));
out.push_str(&format!("TIME_SYSTEM = {}\n", seg.meta.time_system));
out.push_str(&format!("START_TIME = {}\n", iso8601(&seg.meta.start)));
out.push_str(&format!("STOP_TIME = {}\n", iso8601(&seg.meta.stop)));
out.push_str("META_STOP\n");
out.push('\n');
for st in &seg.states {
out.push_str(&format!(
"{} {:.6} {:.6} {:.6} {:.9} {:.9} {:.9}\n",
iso8601(&st.epoch),
st.pos_km[0],
st.pos_km[1],
st.pos_km[2],
st.vel_km_s[0],
st.vel_km_s[1],
st.vel_km_s[2],
));
}
}
out
}
}
fn iso8601(e: &EpochUtc) -> String {
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:09.6}",
e.year, e.month, e.day, e.hour, e.minute, e.second
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::orbit::{Orbit, Propagator};
fn start_epoch() -> EpochUtc {
EpochUtc {
year: 2023,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
}
}
fn ephemeris_lines(text: &str) -> Vec<(String, [f64; 6])> {
let mut rows = Vec::new();
for line in text.lines() {
let toks: Vec<&str> = line.split_whitespace().collect();
if toks.len() == 7 && toks[0].len() >= 10 && toks[0].as_bytes()[4] == b'-' {
let mut v = [0.0f64; 6];
let mut ok = true;
for (k, t) in toks[1..].iter().enumerate() {
match t.parse::<f64>() {
Ok(x) => v[k] = x,
Err(_) => {
ok = false;
break;
}
}
}
if ok {
rows.push((toks[0].to_string(), v));
}
}
}
rows
}
#[test]
fn iso8601_formats_a_padded_calendar_time() {
let e = EpochUtc {
year: 2023,
month: 1,
day: 2,
hour: 3,
minute: 4,
second: 5.5,
};
assert_eq!(iso8601(&e), "2023-01-02T03:04:05.500000");
assert_eq!(iso8601(&start_epoch()), "2023-01-01T00:00:00.000000");
}
#[test]
fn header_and_segment_structure_is_valid_oem() {
let a = 26_560_000.0;
let sats = vec![Propagator::Kepler(Orbit::new(a, 0.96, 0.0, 0.0))];
let ids = vec!["G01".to_string()];
let f = OemFile::from_propagators(&ids, &sats, start_epoch(), 900.0, 4, start_epoch());
let text = f.to_oem_string();
assert!(text.starts_with("CCSDS_OEM_VERS = 2.0\n"));
assert!(text.contains("CREATION_DATE = 2023-01-01T00:00:00.000000\n"));
assert!(text.contains("ORIGINATOR = KSHANA\n"));
assert_eq!(text.matches("META_START").count(), 1);
assert_eq!(text.matches("META_STOP").count(), 1);
for kw in [
"OBJECT_NAME = G01",
"OBJECT_ID = G01",
"CENTER_NAME = EARTH",
"REF_FRAME = TEME",
"TIME_SYSTEM = GPS",
"START_TIME = 2023-01-01T00:00:00.000000",
"STOP_TIME = 2023-01-01T00:45:00.000000",
] {
assert!(text.contains(kw), "missing metadata keyword: {kw}");
}
assert_eq!(ephemeris_lines(&text).len(), 4);
}
#[test]
fn ephemeris_values_match_the_propagator_state() {
let a = 26_560_000.0;
let orbit = Orbit::keplerian(a, 0.01, 0.9, 0.3, 0.2, 0.4);
let sats = vec![Propagator::Kepler(orbit)];
let ids = vec!["G01".to_string()];
let f = OemFile::from_propagators(&ids, &sats, start_epoch(), 900.0, 5, start_epoch());
let rows = ephemeris_lines(&f.to_oem_string());
assert_eq!(rows.len(), 5);
for (i, (_epoch, vals)) in rows.iter().enumerate() {
let s = Propagator::Kepler(orbit).state_eci(i as f64 * 900.0);
for k in 0..3 {
assert!(
(vals[k] - s.r_m[k] / 1000.0).abs() < 1e-3,
"epoch {i} pos axis {k}: wrote {} km",
vals[k]
);
assert!(
(vals[k + 3] - s.v_m_s[k] / 1000.0).abs() < 1e-6,
"epoch {i} vel axis {k}: wrote {} km/s",
vals[k + 3]
);
}
}
let (_e0, v0) = &rows[0];
let r = (v0[0].powi(2) + v0[1].powi(2) + v0[2].powi(2)).sqrt();
let speed = (v0[3].powi(2) + v0[4].powi(2) + v0[5].powi(2)).sqrt();
assert!((r - a / 1000.0).abs() < 400.0, "radius {r:.1} km");
assert!((3.0..4.5).contains(&speed), "speed {speed:.3} km/s");
}
#[test]
fn each_satellite_becomes_its_own_segment() {
let a = 26_560_000.0;
let sats = vec![
Propagator::Kepler(Orbit::new(a, 0.96, 0.0, 0.0)),
Propagator::Kepler(Orbit::new(a, 0.96, std::f64::consts::PI, 1.0)),
];
let ids = vec!["G01".to_string(), "G02".to_string()];
let f = OemFile::from_propagators(&ids, &sats, start_epoch(), 900.0, 3, start_epoch());
assert_eq!(f.segments.len(), 2);
let text = f.to_oem_string();
assert_eq!(text.matches("META_START").count(), 2);
assert!(text.contains("OBJECT_ID = G01"));
assert!(text.contains("OBJECT_ID = G02"));
assert_eq!(ephemeris_lines(&text).len(), 6);
assert!(text.contains("STOP_TIME = 2023-01-01T00:30:00.000000"));
}
#[test]
fn creation_date_is_caller_supplied_not_wall_clock() {
let a = 26_560_000.0;
let sats = vec![Propagator::Kepler(Orbit::new(a, 0.96, 0.0, 0.0))];
let ids = vec!["G01".to_string()];
let made = EpochUtc {
year: 2026,
month: 6,
day: 3,
hour: 12,
minute: 0,
second: 0.0,
};
let f1 = OemFile::from_propagators(&ids, &sats, start_epoch(), 900.0, 4, made);
let f2 = OemFile::from_propagators(&ids, &sats, start_epoch(), 900.0, 4, made);
let t1 = f1.to_oem_string();
assert_eq!(t1, f2.to_oem_string(), "output must be deterministic");
assert!(t1.contains("CREATION_DATE = 2026-06-03T12:00:00.000000\n"));
}
}