use crate::interchange::Envelope;
use crate::lunar_service::LunarConstellation;
use crate::oem::{OemFile, OemMetadata, OemSegment, OemStateLine};
use crate::rinex::EpochUtc;
use serde::{Deserialize, Serialize};
pub const LUNAR_HONESTY_LABEL: &str = "MODELLED";
pub type Vec3 = [f64; 3];
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LunarFrameId {
MoonMe,
MoonPa,
}
impl LunarFrameId {
pub fn as_ccsds_str(self) -> &'static str {
match self {
LunarFrameId::MoonMe => "MOON_ME",
LunarFrameId::MoonPa => "MOON_PA",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum LunarTimeId {
Ltc,
Tcl,
Utc,
}
impl LunarTimeId {
pub fn as_ccsds_str(self) -> &'static str {
match self {
LunarTimeId::Ltc => "LTC",
LunarTimeId::Tcl => "TCL",
LunarTimeId::Utc => "UTC",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct EphemState {
pub t_s: f64,
pub pos_m: Vec3,
pub vel_m_s: Vec3,
}
const OEM_EPOCH: EpochUtc = EpochUtc {
year: 2030,
month: 1,
day: 1,
hour: 0,
minute: 0,
second: 0.0,
};
fn epoch_at(offset_s: f64) -> EpochUtc {
let day_jd0 =
crate::timescales::julian_date(OEM_EPOCH.year, OEM_EPOCH.month, OEM_EPOCH.day, 0, 0, 0.0);
let base_sod =
OEM_EPOCH.hour as f64 * 3600.0 + OEM_EPOCH.minute as f64 * 60.0 + OEM_EPOCH.second;
let total = base_sod + offset_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,
}
}
pub fn export_lunar_oem(
object: &str,
frame: LunarFrameId,
time_system: LunarTimeId,
states: &[EphemState],
) -> String {
let oem_states: Vec<OemStateLine> = states
.iter()
.map(|s| OemStateLine {
epoch: epoch_at(s.t_s),
pos_km: [
s.pos_m[0] / 1000.0,
s.pos_m[1] / 1000.0,
s.pos_m[2] / 1000.0,
],
vel_km_s: [
s.vel_m_s[0] / 1000.0,
s.vel_m_s[1] / 1000.0,
s.vel_m_s[2] / 1000.0,
],
})
.collect();
let (start, stop) = match (states.first(), states.last()) {
(Some(a), Some(b)) => (epoch_at(a.t_s), epoch_at(b.t_s)),
_ => (epoch_at(0.0), epoch_at(0.0)),
};
let file = OemFile {
version: "2.0".to_string(),
creation_date: OEM_EPOCH,
originator: "KSHANA".to_string(),
segments: vec![OemSegment {
meta: OemMetadata {
object_name: object.to_string(),
object_id: object.to_string(),
center_name: "MOON".to_string(),
ref_frame: frame.as_ccsds_str().to_string(),
time_system: time_system.as_ccsds_str().to_string(),
start,
stop,
},
states: oem_states,
}],
};
file.to_oem_string()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LunarTimeMeta {
pub scale_id: String,
pub rate_us_per_day: f64,
pub band_low_us_per_day: f64,
pub band_high_us_per_day: f64,
pub reference_surface: String,
pub honesty: String,
}
pub fn export_lunar_time_metadata(
rate_us_per_day: f64,
band: (f64, f64),
reference: &str,
) -> LunarTimeMeta {
LunarTimeMeta {
scale_id: LunarTimeId::Ltc.as_ccsds_str().to_string(),
rate_us_per_day,
band_low_us_per_day: band.0,
band_high_us_per_day: band.1,
reference_surface: reference.to_string(),
honesty: LUNAR_HONESTY_LABEL.to_string(),
}
}
pub fn parse_lunar_time_metadata(json: &str) -> Option<LunarTimeMeta> {
serde_json::from_str(json).ok()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LunarInteropArtifacts {
pub oem: String,
pub frame: String,
pub time_system: String,
pub time_metadata: LunarTimeMeta,
pub honesty: String,
}
pub fn export_kif_lunar(
object: &str,
frame: LunarFrameId,
time_system: LunarTimeId,
states: &[EphemState],
time_metadata: &LunarTimeMeta,
) -> String {
let artifacts = LunarInteropArtifacts {
oem: export_lunar_oem(object, frame, time_system, states),
frame: frame.as_ccsds_str().to_string(),
time_system: time_system.as_ccsds_str().to_string(),
time_metadata: time_metadata.clone(),
honesty: LUNAR_HONESTY_LABEL.to_string(),
};
Envelope::wrap("lunar-interop", &artifacts)
.expect("lunar interop artifacts serialise")
.to_json()
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct OemConformance {
pub pass: bool,
pub present_fields: Vec<String>,
pub missing_fields: Vec<String>,
pub ref_frame: Option<String>,
pub time_system: Option<String>,
pub data_lines: usize,
}
const REQUIRED_OEM_FIELDS: &[&str] = &[
"CCSDS_OEM_VERS",
"META_START",
"OBJECT_NAME",
"CENTER_NAME",
"REF_FRAME",
"TIME_SYSTEM",
"START_TIME",
"STOP_TIME",
"META_STOP",
];
pub fn oem_conformance(oem: &str) -> OemConformance {
let mut present = Vec::new();
let mut missing = Vec::new();
for &field in REQUIRED_OEM_FIELDS {
let found = if field == "META_START" || field == "META_STOP" {
oem.lines().any(|l| l.trim() == field)
} else {
oem.lines().any(|l| {
l.split_once('=')
.map(|(k, _)| k.trim() == field)
.unwrap_or(false)
})
};
if found {
present.push(field.to_string());
} else {
missing.push(field.to_string());
}
}
let value_of = |key: &str| -> Option<String> {
oem.lines().find_map(|l| {
l.split_once('=').and_then(|(k, v)| {
if k.trim() == key {
Some(v.trim().to_string())
} else {
None
}
})
})
};
let ref_frame = value_of("REF_FRAME");
let time_system = value_of("TIME_SYSTEM");
let data_lines = oem
.lines()
.filter(|l| {
let toks: Vec<&str> = l.split_whitespace().collect();
if toks.len() != 7 && toks.len() != 10 {
return false;
}
if !toks[0].contains('T') || !toks[0].contains('-') {
return false;
}
toks[1..7].iter().all(|t| t.parse::<f64>().is_ok())
})
.count();
let frame_is_lunar = ref_frame
.as_deref()
.map(|f| f.starts_with("MOON_"))
.unwrap_or(false);
let time_present = time_system
.as_deref()
.map(|t| !t.is_empty())
.unwrap_or(false);
let pass = missing.is_empty() && frame_is_lunar && time_present && data_lines >= 1;
OemConformance {
pass,
present_fields: present,
missing_fields: missing,
ref_frame,
time_system,
data_lines,
}
}
fn sample_lunar_states(n: usize, step_s: f64) -> Vec<EphemState> {
let sat = LunarConstellation::illustrative_lcns(1).sats[0];
let dt = 1.0_f64; (0..n)
.map(|i| {
let t = i as f64 * step_s;
let pos = sat.position_mci(t);
let p_plus = sat.position_mci(t + dt);
let p_minus = sat.position_mci(t - dt);
let vel = [
(p_plus[0] - p_minus[0]) / (2.0 * dt),
(p_plus[1] - p_minus[1]) / (2.0 * dt),
(p_plus[2] - p_minus[2]) / (2.0 * dt),
];
EphemState {
t_s: t,
pos_m: pos,
vel_m_s: vel,
}
})
.collect()
}
fn d_frame() -> String {
"MOON_ME".to_string()
}
fn d_time_system() -> String {
"LTC".to_string()
}
fn d_n_states() -> usize {
9
}
fn d_step_min() -> f64 {
30.0
}
fn d_object() -> String {
"LCNS-ILLUSTRATIVE-1".to_string()
}
#[derive(Clone, Debug, Deserialize)]
pub struct LunarInteropScenario {
#[serde(default = "d_frame")]
pub frame: String,
#[serde(default = "d_time_system")]
pub time_system: String,
#[serde(default = "d_n_states")]
pub n_states: usize,
#[serde(default)]
pub epoch: String,
#[serde(default = "d_step_min")]
pub step_min: f64,
#[serde(default = "d_object")]
pub object: String,
}
impl Default for LunarInteropScenario {
fn default() -> Self {
Self {
frame: d_frame(),
time_system: d_time_system(),
n_states: d_n_states(),
epoch: String::new(),
step_min: d_step_min(),
object: d_object(),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct LunarInteropReport {
pub frame: String,
pub time_system: String,
pub artifacts_emitted: Vec<String>,
pub n_states: usize,
pub oem_line_count: usize,
pub conformance: OemConformance,
pub oem_roundtrip_ok: bool,
pub time_metadata_roundtrip_ok: bool,
pub kif_bytes: usize,
pub honesty: String,
}
impl LunarInteropScenario {
fn frame_id(&self) -> LunarFrameId {
match self.frame.to_uppercase().as_str() {
"MOON_PA" => LunarFrameId::MoonPa,
_ => LunarFrameId::MoonMe,
}
}
fn time_id(&self) -> LunarTimeId {
match self.time_system.to_uppercase().as_str() {
"TCL" => LunarTimeId::Tcl,
"UTC" => LunarTimeId::Utc,
_ => LunarTimeId::Ltc,
}
}
pub fn run(&self) -> LunarInteropReport {
let frame = self.frame_id();
let time_system = self.time_id();
let n = self.n_states.max(1);
let step_s = self.step_min * 60.0;
let states = sample_lunar_states(n, step_s);
let oem = export_lunar_oem(&self.object, frame, time_system, &states);
let conformance = oem_conformance(&oem);
let oem_roundtrip_ok = match crate::oem::parse_oem(&oem) {
Ok(parsed) => {
parsed.segments.len() == 1
&& parsed.segments[0].meta.ref_frame == frame.as_ccsds_str()
&& parsed.segments[0].meta.time_system == time_system.as_ccsds_str()
&& parsed.segments[0].states.len() == n
}
Err(_) => false,
};
let breakdown = crate::lunar_time::lunar_rate_breakdown(0.0);
let meta = export_lunar_time_metadata(
breakdown.total_us_per_day,
(breakdown.band_low, breakdown.band_high),
"mean lunar surface (selenoid)",
);
let meta_json = serde_json::to_string(&meta).expect("time metadata serialises");
let time_metadata_roundtrip_ok =
parse_lunar_time_metadata(&meta_json) == Some(meta.clone());
let kif = export_kif_lunar(&self.object, frame, time_system, &states, &meta);
LunarInteropReport {
frame: frame.as_ccsds_str().to_string(),
time_system: time_system.as_ccsds_str().to_string(),
artifacts_emitted: vec![
"ccsds-oem".to_string(),
"lunar-time-metadata".to_string(),
"kif-envelope".to_string(),
],
n_states: n,
oem_line_count: oem.lines().count(),
conformance,
oem_roundtrip_ok,
time_metadata_roundtrip_ok,
kif_bytes: kif.len(),
honesty: LUNAR_HONESTY_LABEL.to_string(),
}
}
}
pub fn lunar_interop_svg(r: &LunarInteropReport) -> String {
let (w, h) = (820.0_f64, 220.0_f64);
let pass_colour = if r.conformance.pass {
"#7fd18a"
} else {
"#e5645a"
};
let mut svg = String::new();
svg.push_str(&format!(
"<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.0}\" height=\"{h:.0}\" font-family=\"sans-serif\" font-size=\"12\" fill=\"#bcb3a3\">"
));
svg.push_str(&format!(
"<rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0c0b08\"/>"
));
svg.push_str(
"<text x=\"24\" y=\"30\" font-size=\"15\" font-weight=\"bold\" fill=\"#e0bd84\">\
Lunar interoperability export — CCSDS OEM + KIF (LunaNet/IOAG-aligned)</text>",
);
svg.push_str(&format!(
"<text x=\"24\" y=\"58\">REF_FRAME = {} | TIME_SYSTEM = {} | {} states | OEM {} lines</text>",
r.frame, r.time_system, r.n_states, r.oem_line_count
));
svg.push_str(&format!(
"<text x=\"24\" y=\"82\">artifacts: {}</text>",
r.artifacts_emitted.join(", ")
));
svg.push_str(&format!(
"<text x=\"24\" y=\"110\" fill=\"{pass_colour}\" font-weight=\"bold\">field conformance: {} ({} present, {} missing, {} data lines)</text>",
if r.conformance.pass { "PASS" } else { "FAIL" },
r.conformance.present_fields.len(),
r.conformance.missing_fields.len(),
r.conformance.data_lines,
));
svg.push_str(&format!(
"<text x=\"24\" y=\"134\">OEM round-trip: {} | time-metadata round-trip: {} | KIF {} bytes</text>",
if r.oem_roundtrip_ok { "OK" } else { "FAIL" },
if r.time_metadata_roundtrip_ok { "OK" } else { "FAIL" },
r.kif_bytes,
));
svg.push_str(
"<text x=\"24\" y=\"170\" font-size=\"11\" fill=\"#9a9080\">Round-trip / field conformance vs \
CCSDS OEM + published LunaNet/IOAG field semantics. MODELLED — not a certified \
interoperability conformance test; no agency endorsement.</text>",
);
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oem_carries_lunar_frame_and_time() {
let states = sample_lunar_states(5, 600.0);
let oem = export_lunar_oem("LCNS-1", LunarFrameId::MoonMe, LunarTimeId::Ltc, &states);
assert!(
oem.contains("REF_FRAME = MOON_ME"),
"OEM missing lunar REF_FRAME line:\n{oem}"
);
assert!(
oem.contains("TIME_SYSTEM = LTC"),
"OEM missing lunar TIME_SYSTEM line:\n{oem}"
);
assert!(oem.contains("CENTER_NAME = MOON"));
assert!(oem.contains("OBJECT_NAME = LCNS-1"));
assert!(oem.contains("META_START") && oem.contains("META_STOP"));
let has_data = oem.lines().any(|l| {
let toks: Vec<&str> = l.split_whitespace().collect();
toks.len() == 7 && toks[0].contains('T')
});
assert!(has_data, "OEM has no well-formed data line:\n{oem}");
let oem_pa = export_lunar_oem("LCNS-1", LunarFrameId::MoonPa, LunarTimeId::Tcl, &states);
assert!(oem_pa.contains("REF_FRAME = MOON_PA"));
assert!(oem_pa.contains("TIME_SYSTEM = TCL"));
}
#[test]
fn time_metadata_roundtrips() {
let meta = export_lunar_time_metadata(57.5, (56.0, 59.0), "mean lunar surface (selenoid)");
let json = serde_json::to_string(&meta).unwrap();
let back = parse_lunar_time_metadata(&json).expect("round-trips");
assert_eq!(back, meta);
assert_eq!(back.rate_us_per_day, 57.5);
assert_eq!(back.band_low_us_per_day, 56.0);
assert_eq!(back.band_high_us_per_day, 59.0);
assert_eq!(back.reference_surface, "mean lunar surface (selenoid)");
assert_eq!(back.honesty, LUNAR_HONESTY_LABEL);
assert!(parse_lunar_time_metadata("not json").is_none());
}
#[test]
fn kif_envelope_roundtrips_or_validates() {
let states = sample_lunar_states(4, 600.0);
let meta = export_lunar_time_metadata(57.5, (56.0, 59.0), "selenoid");
let kif = export_kif_lunar(
"LCNS-1",
LunarFrameId::MoonMe,
LunarTimeId::Ltc,
&states,
&meta,
);
let env = Envelope::parse(&kif).expect("KIF parses");
assert_eq!(env.kind, "lunar-interop");
let artifacts: LunarInteropArtifacts = env.payload_as().expect("payload deserialises");
assert_eq!(artifacts.frame, "MOON_ME");
assert_eq!(artifacts.time_system, "LTC");
assert_eq!(artifacts.time_metadata, meta);
assert_eq!(artifacts.honesty, LUNAR_HONESTY_LABEL);
assert!(kif.contains("MODELLED"));
assert!(artifacts.oem.contains("REF_FRAME = MOON_ME"));
}
#[test]
fn conformance_flags_missing_fields() {
let states = sample_lunar_states(3, 600.0);
let good = export_lunar_oem("LCNS-1", LunarFrameId::MoonMe, LunarTimeId::Ltc, &states);
let ok = oem_conformance(&good);
assert!(ok.pass, "well-formed lunar OEM should pass: {ok:?}");
assert!(ok.missing_fields.is_empty());
assert_eq!(ok.ref_frame.as_deref(), Some("MOON_ME"));
assert_eq!(ok.time_system.as_deref(), Some("LTC"));
assert!(ok.data_lines >= 1);
let broken: String = good
.lines()
.filter(|l| !l.trim_start().starts_with("TIME_SYSTEM"))
.collect::<Vec<_>>()
.join("\n");
let bad = oem_conformance(&broken);
assert!(!bad.pass, "OEM missing TIME_SYSTEM must fail conformance");
assert!(
bad.missing_fields.iter().any(|f| f == "TIME_SYSTEM"),
"TIME_SYSTEM should be reported missing: {bad:?}"
);
}
#[test]
fn scenario_runs_through_dispatch_shape() {
let scn = LunarInteropScenario::default();
let report = scn.run();
assert!(report.conformance.pass);
assert!(report.oem_roundtrip_ok);
assert!(report.time_metadata_roundtrip_ok);
assert_eq!(report.frame, "MOON_ME");
assert_eq!(report.time_system, "LTC");
assert_eq!(report.honesty, LUNAR_HONESTY_LABEL);
assert!(report.kif_bytes > 0);
assert_eq!(report.artifacts_emitted.len(), 3);
let json = serde_json::to_string(&report).unwrap();
assert!(json.starts_with('{'));
assert!(lunar_interop_svg(&report).starts_with("<svg"));
}
#[test]
fn sample_states_velocity_matches_orbit_speed() {
let states = sample_lunar_states(2, 600.0);
let v = states[0].vel_m_s;
let speed = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
assert!(
(200.0..5000.0).contains(&speed),
"lunar-orbit speed out of expected range: {speed} m/s"
);
}
}