use super::FormatError;
use std::io::{BufRead, BufReader, Read, Write};
#[derive(Debug, Clone, PartialEq)]
pub struct CartesianState {
pub epoch: String,
pub position_km: [f64; 3],
pub velocity_km_s: [f64; 3],
}
#[derive(Debug, Clone, PartialEq)]
pub struct KeplerianElements {
pub semi_major_axis_km: f64,
pub eccentricity: f64,
pub inclination_deg: f64,
pub ra_of_asc_node_deg: f64,
pub arg_of_pericenter_deg: f64,
pub true_anomaly_deg: f64,
}
#[derive(Debug, Clone)]
pub struct OpmMetadata {
pub object_name: String,
pub object_id: String,
pub center_name: String,
pub ref_frame: String,
pub time_system: String,
}
#[derive(Debug, Clone)]
pub struct OpmMessage {
pub metadata: OpmMetadata,
pub state: CartesianState,
pub keplerian: Option<KeplerianElements>,
}
pub fn read_opm<R: Read>(reader: R) -> Result<OpmMessage, FormatError> {
let buf = BufReader::new(reader);
let mut kv: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for result in buf.lines() {
let line = result.map_err(FormatError::Io)?;
let line = line.trim().to_string();
if line.is_empty()
|| line.starts_with("COMMENT")
|| line == "META_START"
|| line == "META_STOP"
{
continue;
}
if let Some(eq) = line.find('=') {
let key = line[..eq].trim().to_string();
let val = line[eq + 1..].trim().to_string();
kv.insert(key, val);
}
}
let get = |k: &str| -> Result<String, FormatError> {
kv.get(k)
.cloned()
.ok_or_else(|| FormatError::Format(format!("OPM: missing field {k}")))
};
let getf = |k: &str| -> Result<f64, FormatError> {
let v = kv
.get(k)
.ok_or_else(|| FormatError::Format(format!("OPM: missing field {k}")))?;
v.parse::<f64>()
.map_err(|_| FormatError::Format(format!("OPM: cannot parse {k}={v:?}")))
};
let metadata = OpmMetadata {
object_name: get("OBJECT_NAME")?,
object_id: get("OBJECT_ID").unwrap_or_default(),
center_name: get("CENTER_NAME").unwrap_or_else(|_| "EARTH".to_string()),
ref_frame: get("REF_FRAME").unwrap_or_else(|_| "EME2000".to_string()),
time_system: get("TIME_SYSTEM").unwrap_or_else(|_| "UTC".to_string()),
};
let epoch = get("EPOCH")?;
let state = CartesianState {
epoch,
position_km: [getf("X")?, getf("Y")?, getf("Z")?],
velocity_km_s: [getf("X_DOT")?, getf("Y_DOT")?, getf("Z_DOT")?],
};
let keplerian = if kv.contains_key("SEMI_MAJOR_AXIS") {
Some(KeplerianElements {
semi_major_axis_km: getf("SEMI_MAJOR_AXIS")?,
eccentricity: getf("ECCENTRICITY")?,
inclination_deg: getf("INCLINATION")?,
ra_of_asc_node_deg: getf("RA_OF_ASC_NODE")?,
arg_of_pericenter_deg: getf("ARG_OF_PERICENTER")?,
true_anomaly_deg: getf("TRUE_ANOMALY")?,
})
} else {
None
};
Ok(OpmMessage {
metadata,
state,
keplerian,
})
}
pub fn write_opm<W: Write>(w: &mut W, msg: &OpmMessage) -> Result<(), FormatError> {
writeln!(w, "CCSDS_OPM_VERS = 2.0").map_err(FormatError::Io)?;
writeln!(w, "META_START").map_err(FormatError::Io)?;
writeln!(w, "OBJECT_NAME = {}", msg.metadata.object_name).map_err(FormatError::Io)?;
writeln!(w, "OBJECT_ID = {}", msg.metadata.object_id).map_err(FormatError::Io)?;
writeln!(w, "CENTER_NAME = {}", msg.metadata.center_name).map_err(FormatError::Io)?;
writeln!(w, "REF_FRAME = {}", msg.metadata.ref_frame).map_err(FormatError::Io)?;
writeln!(w, "TIME_SYSTEM = {}", msg.metadata.time_system).map_err(FormatError::Io)?;
writeln!(w, "META_STOP").map_err(FormatError::Io)?;
writeln!(w).map_err(FormatError::Io)?;
writeln!(w, "EPOCH = {}", msg.state.epoch).map_err(FormatError::Io)?;
writeln!(w, "X = {:.6}", msg.state.position_km[0])
.map_err(FormatError::Io)?;
writeln!(w, "Y = {:.6}", msg.state.position_km[1])
.map_err(FormatError::Io)?;
writeln!(w, "Z = {:.6}", msg.state.position_km[2])
.map_err(FormatError::Io)?;
writeln!(
w,
"X_DOT = {:.6}",
msg.state.velocity_km_s[0]
)
.map_err(FormatError::Io)?;
writeln!(
w,
"Y_DOT = {:.6}",
msg.state.velocity_km_s[1]
)
.map_err(FormatError::Io)?;
writeln!(
w,
"Z_DOT = {:.6}",
msg.state.velocity_km_s[2]
)
.map_err(FormatError::Io)?;
if let Some(k) = &msg.keplerian {
writeln!(w).map_err(FormatError::Io)?;
writeln!(w, "SEMI_MAJOR_AXIS = {:.6}", k.semi_major_axis_km)
.map_err(FormatError::Io)?;
writeln!(w, "ECCENTRICITY = {:.7}", k.eccentricity).map_err(FormatError::Io)?;
writeln!(w, "INCLINATION = {:.4}", k.inclination_deg).map_err(FormatError::Io)?;
writeln!(w, "RA_OF_ASC_NODE = {:.4}", k.ra_of_asc_node_deg)
.map_err(FormatError::Io)?;
writeln!(w, "ARG_OF_PERICENTER = {:.4}", k.arg_of_pericenter_deg)
.map_err(FormatError::Io)?;
writeln!(w, "TRUE_ANOMALY = {:.4}", k.true_anomaly_deg).map_err(FormatError::Io)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn make_meta() -> OpmMetadata {
OpmMetadata {
object_name: "SAT".to_string(),
object_id: "2024-001A".to_string(),
center_name: "EARTH".to_string(),
ref_frame: "EME2000".to_string(),
time_system: "UTC".to_string(),
}
}
fn make_state() -> CartesianState {
CartesianState {
epoch: "2024-001T12:00:00.000".to_string(),
position_km: [7000.0, 0.0, 0.0],
velocity_km_s: [0.0, 7.5, 0.0],
}
}
fn base_opm_text() -> String {
"CCSDS_OPM_VERS = 2.0\n\
META_START\n\
OBJECT_NAME = SAT\n\
OBJECT_ID = 2024-001A\n\
CENTER_NAME = EARTH\n\
REF_FRAME = EME2000\n\
TIME_SYSTEM = UTC\n\
META_STOP\n\
EPOCH = 2024-001T12:00:00.000\n\
X = 7000.0\n\
Y = 0.0\n\
Z = 0.0\n\
X_DOT = 0.0\n\
Y_DOT = 7.5\n\
Z_DOT = 0.0\n"
.to_string()
}
#[test]
fn read_opm_cartesian_only() {
let msg = read_opm(base_opm_text().as_bytes()).unwrap();
assert_eq!(msg.metadata.object_name, "SAT");
assert_eq!(msg.state.position_km[0], 7000.0);
assert!(msg.keplerian.is_none());
}
#[test]
fn read_opm_with_keplerian_block() {
let text = format!(
"{}\
SEMI_MAJOR_AXIS = 7000.0\n\
ECCENTRICITY = 0.001\n\
INCLINATION = 51.6\n\
RA_OF_ASC_NODE = 247.0\n\
ARG_OF_PERICENTER = 130.0\n\
TRUE_ANOMALY = 325.0\n",
base_opm_text()
);
let msg = read_opm(text.as_bytes()).unwrap();
let kep = msg.keplerian.unwrap();
assert_eq!(kep.semi_major_axis_km, 7000.0);
assert_eq!(kep.eccentricity, 0.001);
assert_eq!(kep.inclination_deg, 51.6);
}
#[test]
fn read_opm_missing_object_name_is_error() {
let text = "CCSDS_OPM_VERS = 2.0\n\
META_START\n\
OBJECT_ID = 2024-001A\n\
META_STOP\n\
EPOCH = 2024-001T12:00:00.000\n\
X = 7000.0\nY = 0.0\nZ = 0.0\n\
X_DOT = 0.0\nY_DOT = 7.5\nZ_DOT = 0.0\n";
assert!(read_opm(text.as_bytes()).is_err());
}
#[test]
fn read_opm_bad_float_is_error() {
let text = base_opm_text().replace("X = 7000.0", "X = not_a_number");
assert!(read_opm(text.as_bytes()).is_err());
}
#[test]
fn write_opm_roundtrip_cartesian() {
let msg = OpmMessage {
metadata: make_meta(),
state: make_state(),
keplerian: None,
};
let mut buf = Vec::new();
write_opm(&mut buf, &msg).unwrap();
let parsed = read_opm(buf.as_slice()).unwrap();
assert_eq!(parsed.metadata.object_name, "SAT");
assert!((parsed.state.position_km[0] - 7000.0).abs() < 1e-4);
}
#[test]
fn write_opm_roundtrip_with_keplerian() {
let kep = KeplerianElements {
semi_major_axis_km: 7000.0,
eccentricity: 0.001,
inclination_deg: 51.6,
ra_of_asc_node_deg: 247.0,
arg_of_pericenter_deg: 130.0,
true_anomaly_deg: 325.0,
};
let msg = OpmMessage {
metadata: make_meta(),
state: make_state(),
keplerian: Some(kep),
};
let mut buf = Vec::new();
write_opm(&mut buf, &msg).unwrap();
let parsed = read_opm(buf.as_slice()).unwrap();
let k = parsed.keplerian.unwrap();
assert!((k.semi_major_axis_km - 7000.0).abs() < 1e-4);
assert!((k.inclination_deg - 51.6).abs() < 1e-4);
}
}