use super::FormatError;
use std::io::Write;
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct OemState {
pub epoch_jd: f64,
pub position_km: [f64; 3],
pub velocity_km_s: [f64; 3],
}
impl OemState {
pub fn new(epoch_jd: f64, position_km: [f64; 3], velocity_km_s: [f64; 3]) -> Self {
Self {
epoch_jd,
position_km,
velocity_km_s,
}
}
}
#[derive(Debug, Clone)]
pub struct OemMetadata {
pub object_id: String,
pub object_name: String,
pub ref_frame: String,
pub time_system: String,
pub center_name: String,
}
pub fn write_oem<W: Write>(
w: &mut W,
meta: &OemMetadata,
states: &[OemState],
) -> Result<(), FormatError> {
if states.is_empty() {
return Err(FormatError::Format(
"write_oem: no states to write".to_string(),
));
}
writeln!(w, "CCSDS_OEM_VERS = 3.0")?;
writeln!(w, "CREATION_DATE = 2026-01-01T00:00:00")?;
writeln!(w, "ORIGINATOR = SIDERUST-POD")?;
writeln!(w)?;
writeln!(w, "META_START")?;
writeln!(w, "OBJECT_NAME = {}", meta.object_name)?;
writeln!(w, "OBJECT_ID = {}", meta.object_id)?;
writeln!(w, "CENTER_NAME = {}", meta.center_name)?;
writeln!(w, "REF_FRAME = {}", meta.ref_frame)?;
writeln!(w, "TIME_SYSTEM = {}", meta.time_system)?;
writeln!(
w,
"START_TIME = {}",
jd_to_iso8601(states[0].epoch_jd)
)?;
writeln!(
w,
"STOP_TIME = {}",
jd_to_iso8601(states.last().unwrap().epoch_jd)
)?;
writeln!(w, "META_STOP")?;
writeln!(w)?;
for s in states {
writeln!(
w,
"{} {:.6} {:.6} {:.6} {:.9} {:.9} {:.9}",
jd_to_iso8601(s.epoch_jd),
s.position_km[0],
s.position_km[1],
s.position_km[2],
s.velocity_km_s[0],
s.velocity_km_s[1],
s.velocity_km_s[2],
)?;
}
Ok(())
}
fn jd_to_iso8601(jd: f64) -> String {
let jd_int = (jd + 0.5).floor() as i64;
let frac = jd + 0.5 - jd_int as f64;
let a = jd_int + 32_044;
let b = (4 * a + 3) / 146_097;
let c = a - (146_097 * b) / 4;
let d = (4 * c + 3) / 1_461;
let e = c - (1_461 * d) / 4;
let m = (5 * e + 2) / 153;
let day = (e - (153 * m + 2) / 5 + 1) as u32;
let month = (m + 3 - 12 * (m / 10)) as u32;
let year = (100 * b + d - 4_800 + (m / 10)) as i32;
let total_seconds = frac * 86_400.0;
let hours = (total_seconds / 3600.0).floor() as u32 % 24;
let mins = ((total_seconds % 3600.0) / 60.0).floor() as u32;
let secs = total_seconds % 60.0;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:09.6}",
year, month, day, hours, mins, secs
)
}
#[derive(Debug, Clone)]
pub struct OemSegment {
pub metadata: OemMetadata,
pub states: Vec<OemState>,
}
#[derive(Debug, Clone)]
pub struct OemFile {
pub version: String,
pub creation_date: String,
pub originator: String,
pub segments: Vec<OemSegment>,
}
pub fn read_oem<R: std::io::Read>(mut r: R) -> Result<OemFile, FormatError> {
let mut text = String::new();
r.read_to_string(&mut text)
.map_err(|e| FormatError::Format(format!("read_oem: io error: {e}")))?;
let mut version: Option<String> = None;
let mut creation_date: Option<String> = None;
let mut originator: Option<String> = None;
let mut segments: Vec<OemSegment> = Vec::new();
enum State {
Header,
Meta(MetaBuilder),
Data(OemSegment),
SkipBlock,
}
#[derive(Default)]
struct MetaBuilder {
object_id: Option<String>,
object_name: Option<String>,
ref_frame: Option<String>,
time_system: Option<String>,
center_name: Option<String>,
}
let mut state = State::Header;
for (lineno, raw) in text.lines().enumerate() {
let line = raw.trim();
if line.is_empty() || line.starts_with("COMMENT") {
continue;
}
match &mut state {
State::Header => {
if line == "META_START" {
state = State::Meta(MetaBuilder::default());
continue;
}
let (k, v) = split_kv(line, lineno)?;
match k {
"CCSDS_OEM_VERS" => version = Some(v.into()),
"CREATION_DATE" => creation_date = Some(v.into()),
"ORIGINATOR" => originator = Some(v.into()),
_ => {}
}
}
State::Meta(mb) => {
if line == "META_STOP" {
let MetaBuilder {
object_id,
object_name,
ref_frame,
time_system,
center_name,
} = std::mem::take(mb);
let need = |o: Option<String>, name: &str| -> Result<String, FormatError> {
o.ok_or_else(|| {
FormatError::Format(format!(
"read_oem: META block missing required field {name}"
))
})
};
let metadata = OemMetadata {
object_id: need(object_id, "OBJECT_ID")?,
object_name: need(object_name, "OBJECT_NAME")?,
ref_frame: need(ref_frame, "REF_FRAME")?,
time_system: need(time_system, "TIME_SYSTEM")?,
center_name: need(center_name, "CENTER_NAME")?,
};
state = State::Data(OemSegment {
metadata,
states: Vec::new(),
});
continue;
}
let (k, v) = split_kv(line, lineno)?;
match k {
"OBJECT_ID" => mb.object_id = Some(v.into()),
"OBJECT_NAME" => mb.object_name = Some(v.into()),
"REF_FRAME" => mb.ref_frame = Some(v.into()),
"TIME_SYSTEM" => mb.time_system = Some(v.into()),
"CENTER_NAME" => mb.center_name = Some(v.into()),
_ => {}
}
}
State::Data(seg) => {
if line == "META_START" {
let done = std::mem::replace(
seg,
OemSegment {
metadata: seg.metadata.clone(),
states: Vec::new(),
},
);
segments.push(done);
state = State::Meta(MetaBuilder::default());
continue;
}
if line == "COVARIANCE_START" || line == "MAN_START" {
state = State::SkipBlock;
continue;
}
seg.states.push(parse_data_line(line, lineno)?);
}
State::SkipBlock => {
if line == "COVARIANCE_STOP" || line == "MAN_STOP" {
if let Some(last) = segments.last_mut() {
let resumed = std::mem::replace(
last,
OemSegment {
metadata: last.metadata.clone(),
states: Vec::new(),
},
);
state = State::Data(resumed);
} else {
state = State::Header;
}
}
}
}
}
if let State::Data(seg) = state {
segments.push(seg);
}
if segments.is_empty() {
return Err(FormatError::Format(
"read_oem: file contains no segments".into(),
));
}
Ok(OemFile {
version: version.unwrap_or_else(|| "unknown".into()),
creation_date: creation_date.unwrap_or_default(),
originator: originator.unwrap_or_default(),
segments,
})
}
fn split_kv(line: &str, lineno: usize) -> Result<(&str, &str), FormatError> {
let (k, v) = line.split_once('=').ok_or_else(|| {
FormatError::Format(format!(
"read_oem: line {}: expected KEY = VALUE",
lineno + 1
))
})?;
Ok((k.trim(), v.trim()))
}
fn parse_data_line(line: &str, lineno: usize) -> Result<OemState, FormatError> {
let mut it = line.split_whitespace();
let epoch_str = it.next().ok_or_else(|| {
FormatError::Format(format!("read_oem: line {}: empty data row", lineno + 1))
})?;
let mut next_f64 = |what: &str| -> Result<f64, FormatError> {
let tok = it.next().ok_or_else(|| {
FormatError::Format(format!("read_oem: line {}: missing {what}", lineno + 1))
})?;
let v: f64 = tok.parse().map_err(|e| {
FormatError::Format(format!(
"read_oem: line {}: cannot parse {what} ({tok}): {e}",
lineno + 1
))
})?;
if !v.is_finite() {
return Err(FormatError::Format(format!(
"read_oem: line {}: {what} not finite",
lineno + 1
)));
}
Ok(v)
};
let x = next_f64("X")?;
let y = next_f64("Y")?;
let z = next_f64("Z")?;
let vx = next_f64("VX")?;
let vy = next_f64("VY")?;
let vz = next_f64("VZ")?;
let jd = iso8601_to_jd(epoch_str).ok_or_else(|| {
FormatError::Format(format!(
"read_oem: line {}: cannot parse epoch {epoch_str}",
lineno + 1
))
})?;
Ok(OemState::new(jd, [x, y, z], [vx, vy, vz]))
}
fn iso8601_to_jd(s: &str) -> Option<f64> {
let (date, time) = s.split_once('T')?;
let mut date_parts = date.split('-');
let year: i32 = date_parts.next()?.parse().ok()?;
let month: u32 = date_parts.next()?.parse().ok()?;
let day: u32 = date_parts.next()?.parse().ok()?;
let mut time_parts = time.split(':');
let hour: u32 = time_parts.next()?.parse().ok()?;
let minute: u32 = time_parts.next()?.parse().ok()?;
let second: f64 = time_parts.next()?.parse().ok()?;
let m = month as i64;
let y = year as i64;
let a = (14 - m) / 12;
let yy = y + 4800 - a;
let mm = m + 12 * a - 3;
let jdn = day as i64 + (153 * mm + 2) / 5 + 365 * yy + yy / 4 - yy / 100 + yy / 400 - 32_045;
let day_frac = (hour as f64 * 3600.0 + minute as f64 * 60.0 + second) / 86_400.0;
Some(jdn as f64 - 0.5 + day_frac)
}
pub fn write_oem_xml<W: std::io::Write>(
w: &mut W,
meta: &OemMetadata,
states: &[OemState],
) -> Result<(), FormatError> {
if states.is_empty() {
return Err(FormatError::Format(
"write_oem_xml: no states to write".into(),
));
}
let start = jd_to_iso8601(states[0].epoch_jd);
let stop = jd_to_iso8601(states.last().unwrap().epoch_jd);
writeln!(w, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>")?;
writeln!(w, "<oem id=\"CCSDS_OEM_VERS\" version=\"3.0\">")?;
writeln!(w, " <header>")?;
writeln!(w, " <CREATION_DATE>2026-01-01T00:00:00</CREATION_DATE>")?;
writeln!(w, " <ORIGINATOR>SIDERUST-POD</ORIGINATOR>")?;
writeln!(w, " </header>")?;
writeln!(w, " <body>")?;
writeln!(w, " <segment>")?;
writeln!(w, " <metadata>")?;
writeln!(
w,
" <OBJECT_NAME>{}</OBJECT_NAME>",
xml_escape(&meta.object_name)
)?;
writeln!(
w,
" <OBJECT_ID>{}</OBJECT_ID>",
xml_escape(&meta.object_id)
)?;
writeln!(
w,
" <CENTER_NAME>{}</CENTER_NAME>",
xml_escape(&meta.center_name)
)?;
writeln!(
w,
" <REF_FRAME>{}</REF_FRAME>",
xml_escape(&meta.ref_frame)
)?;
writeln!(
w,
" <TIME_SYSTEM>{}</TIME_SYSTEM>",
xml_escape(&meta.time_system)
)?;
writeln!(w, " <START_TIME>{}</START_TIME>", start)?;
writeln!(w, " <STOP_TIME>{}</STOP_TIME>", stop)?;
writeln!(w, " </metadata>")?;
writeln!(w, " <data>")?;
for s in states {
writeln!(w, " <stateVector>")?;
writeln!(w, " <EPOCH>{}</EPOCH>", jd_to_iso8601(s.epoch_jd))?;
writeln!(w, " <X units=\"km\">{:.6}</X>", s.position_km[0])?;
writeln!(w, " <Y units=\"km\">{:.6}</Y>", s.position_km[1])?;
writeln!(w, " <Z units=\"km\">{:.6}</Z>", s.position_km[2])?;
writeln!(
w,
" <X_DOT units=\"km/s\">{:.9}</X_DOT>",
s.velocity_km_s[0]
)?;
writeln!(
w,
" <Y_DOT units=\"km/s\">{:.9}</Y_DOT>",
s.velocity_km_s[1]
)?;
writeln!(
w,
" <Z_DOT units=\"km/s\">{:.9}</Z_DOT>",
s.velocity_km_s[2]
)?;
writeln!(w, " </stateVector>")?;
}
writeln!(w, " </data>")?;
writeln!(w, " </segment>")?;
writeln!(w, " </body>")?;
writeln!(w, "</oem>")?;
Ok(())
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn read_oem_xml<R: std::io::Read>(mut r: R) -> Result<OemFile, FormatError> {
use quick_xml::escape::unescape;
use quick_xml::events::Event;
use quick_xml::Reader;
let mut text = String::new();
r.read_to_string(&mut text)
.map_err(|e| FormatError::Format(format!("read_oem_xml: io: {e}")))?;
let mut rdr = Reader::from_str(&text);
rdr.config_mut().trim_text(true);
#[derive(Default)]
struct StateBuilder {
epoch: Option<f64>,
x: Option<f64>,
y: Option<f64>,
z: Option<f64>,
vx: Option<f64>,
vy: Option<f64>,
vz: Option<f64>,
}
#[derive(Default)]
struct SegBuilder {
object_id: Option<String>,
object_name: Option<String>,
ref_frame: Option<String>,
time_system: Option<String>,
center_name: Option<String>,
states: Vec<OemState>,
cur: Option<StateBuilder>,
}
let mut version = "3.0".to_string();
let mut creation_date = String::new();
let mut originator = String::new();
let mut segments: Vec<OemSegment> = Vec::new();
let mut seg: Option<SegBuilder> = None;
let mut cur_tag: Option<String> = None;
let mut buf = Vec::new();
loop {
match rdr
.read_event_into(&mut buf)
.map_err(|e| FormatError::Format(format!("read_oem_xml: xml: {e}")))?
{
Event::Eof => break,
Event::Start(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let local = name.rsplit(':').next().unwrap_or(&name).to_string();
match local.as_str() {
"oem" => {
for attr in e.attributes().flatten() {
if attr.key.as_ref() == b"version" {
version = String::from_utf8_lossy(&attr.value).into_owned();
}
}
}
"segment" => seg = Some(SegBuilder::default()),
"stateVector" => {
if let Some(s) = seg.as_mut() {
s.cur = Some(StateBuilder::default());
}
}
_ => {}
}
cur_tag = Some(local);
}
Event::End(e) => {
let name = String::from_utf8_lossy(e.name().as_ref()).to_string();
let local = name.rsplit(':').next().unwrap_or(&name).to_string();
match local.as_str() {
"stateVector" => {
if let Some(s) = seg.as_mut() {
if let Some(b) = s.cur.take() {
let need = |o: Option<f64>, n: &str| -> Result<f64, FormatError> {
o.ok_or_else(|| {
FormatError::Format(format!(
"read_oem_xml: stateVector missing {n}"
))
})
};
let epoch_jd = need(b.epoch, "EPOCH")?;
let st = OemState::new(
epoch_jd,
[need(b.x, "X")?, need(b.y, "Y")?, need(b.z, "Z")?],
[
need(b.vx, "X_DOT")?,
need(b.vy, "Y_DOT")?,
need(b.vz, "Z_DOT")?,
],
);
s.states.push(st);
}
}
}
"segment" => {
if let Some(s) = seg.take() {
let need =
|o: Option<String>, n: &str| -> Result<String, FormatError> {
o.ok_or_else(|| {
FormatError::Format(format!(
"read_oem_xml: metadata missing {n}"
))
})
};
segments.push(OemSegment {
metadata: OemMetadata {
object_id: need(s.object_id, "OBJECT_ID")?,
object_name: need(s.object_name, "OBJECT_NAME")?,
ref_frame: need(s.ref_frame, "REF_FRAME")?,
time_system: need(s.time_system, "TIME_SYSTEM")?,
center_name: need(s.center_name, "CENTER_NAME")?,
},
states: s.states,
});
}
}
_ => {}
}
cur_tag = None;
}
Event::Text(t) => {
let txt =
unescape(&t.xml10_content().map_err(|e| {
FormatError::Format(format!("read_oem_xml: xml text: {e}"))
})?)
.map_err(|e| FormatError::Format(format!("read_oem_xml: xml text: {e}")))?
.to_string();
let tag = match &cur_tag {
Some(t) => t.as_str(),
None => continue,
};
if let Some(s) = seg.as_mut() {
if let Some(b) = s.cur.as_mut() {
match tag {
"EPOCH" => b.epoch = iso8601_to_jd(&txt),
"X" => b.x = txt.parse().ok(),
"Y" => b.y = txt.parse().ok(),
"Z" => b.z = txt.parse().ok(),
"X_DOT" => b.vx = txt.parse().ok(),
"Y_DOT" => b.vy = txt.parse().ok(),
"Z_DOT" => b.vz = txt.parse().ok(),
_ => {}
}
} else {
match tag {
"OBJECT_ID" => s.object_id = Some(txt),
"OBJECT_NAME" => s.object_name = Some(txt),
"REF_FRAME" => s.ref_frame = Some(txt),
"TIME_SYSTEM" => s.time_system = Some(txt),
"CENTER_NAME" => s.center_name = Some(txt),
_ => {}
}
}
} else {
match tag {
"CREATION_DATE" => creation_date = txt,
"ORIGINATOR" => originator = txt,
_ => {}
}
}
}
_ => {}
}
buf.clear();
}
if segments.is_empty() {
return Err(FormatError::Format(
"read_oem_xml: file contains no segments".into(),
));
}
Ok(OemFile {
version,
creation_date,
originator,
segments,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn writes_header_and_data_lines() {
let states = vec![
OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]),
OemState::new(
2_451_545.0 + 30.0 / 86_400.0,
[6999.0, 225.0, 0.0],
[-0.24, 7.49, 0.0],
),
];
let mut buf = Vec::new();
write_oem(
&mut buf,
&OemMetadata {
object_id: "1900-001A".into(),
object_name: "TEST-SAT".into(),
ref_frame: "EME2000".into(),
time_system: "TT".into(),
center_name: "EARTH".into(),
},
&states,
)
.unwrap();
let text = String::from_utf8(buf).unwrap();
assert!(text.contains("CCSDS_OEM_VERS"));
assert!(text.contains("META_START"));
assert!(text.contains("META_STOP"));
assert!(text.contains("EME2000"));
assert_eq!(text.lines().filter(|l| l.starts_with("2000-")).count(), 2);
}
#[test]
fn jd_2451545_is_j2000() {
let s = jd_to_iso8601(2_451_545.0);
assert!(s.starts_with("2000-01-01T12:00"), "{}", s);
}
#[test]
fn iso_jd_round_trip_at_j2000() {
let jd = 2_451_545.0;
let iso = jd_to_iso8601(jd);
let back = iso8601_to_jd(&iso).expect("parse iso");
assert!((back - jd).abs() < 1e-9, "{back} vs {jd}");
}
#[test]
fn write_then_read_round_trip() {
let states = vec![
OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]),
OemState::new(
2_451_545.0 + 30.0 / 86_400.0,
[6999.0, 225.0, 0.0],
[-0.24, 7.49, 0.0],
),
];
let meta = OemMetadata {
object_id: "1900-001A".into(),
object_name: "TEST-SAT".into(),
ref_frame: "EME2000".into(),
time_system: "TT".into(),
center_name: "EARTH".into(),
};
let mut buf = Vec::new();
write_oem(&mut buf, &meta, &states).unwrap();
let parsed = read_oem(&buf[..]).expect("parse OEM");
assert_eq!(parsed.version, "3.0");
assert_eq!(parsed.segments.len(), 1);
let seg = &parsed.segments[0];
assert_eq!(seg.metadata.object_id, "1900-001A");
assert_eq!(seg.metadata.ref_frame, "EME2000");
assert_eq!(seg.states.len(), 2);
for (a, b) in seg.states.iter().zip(states.iter()) {
assert!((a.position_km[0] - b.position_km[0]).abs() < 1e-3);
assert!((a.position_km[1] - b.position_km[1]).abs() < 1e-3);
assert!((a.position_km[2] - b.position_km[2]).abs() < 1e-3);
assert!((a.velocity_km_s[0] - b.velocity_km_s[0]).abs() < 1e-6);
assert!((a.velocity_km_s[1] - b.velocity_km_s[1]).abs() < 1e-6);
assert!((a.velocity_km_s[2] - b.velocity_km_s[2]).abs() < 1e-6);
}
}
#[test]
fn read_oem_rejects_missing_meta_field() {
let txt = "CCSDS_OEM_VERS = 3.0\n\
META_START\n\
OBJECT_NAME = X\n\
META_STOP\n";
let err = read_oem(txt.as_bytes()).unwrap_err();
match err {
FormatError::Format(msg) => assert!(msg.contains("OBJECT_ID"), "{msg}"),
other => panic!("expected Format error, got {other:?}"),
}
}
#[test]
fn xml_round_trip() {
let states = vec![
OemState::new(2_451_545.0, [7000.0, 0.0, 0.0], [0.0, 7.5, 0.0]),
OemState::new(
2_451_545.0 + 30.0 / 86_400.0,
[6999.0, 225.0, 0.0],
[-0.24, 7.49, 0.0],
),
];
let meta = OemMetadata {
object_id: "1900-001A".into(),
object_name: "TEST-SAT".into(),
ref_frame: "EME2000".into(),
time_system: "TT".into(),
center_name: "EARTH".into(),
};
let mut buf = Vec::new();
write_oem_xml(&mut buf, &meta, &states).unwrap();
let parsed = read_oem_xml(&buf[..]).expect("parse OEM XML");
assert_eq!(parsed.segments.len(), 1);
let seg = &parsed.segments[0];
assert_eq!(seg.metadata.ref_frame, "EME2000");
assert_eq!(seg.states.len(), 2);
for (a, b) in seg.states.iter().zip(states.iter()) {
assert!((a.position_km[0] - b.position_km[0]).abs() < 1e-3);
assert!((a.velocity_km_s[1] - b.velocity_km_s[1]).abs() < 1e-6);
}
}
}