use crate::raim::{araim_raim, AraimResult, FaultPriors, IntegrityBudget};
use serde::{Deserialize, Serialize};
use std::f64::consts::{FRAC_PI_2, TAU};
pub const R_MOON_M: f64 = 1_737_400.0;
pub const LUNAR_SIGMA_URE_M: f64 = 30.0;
pub const LUNAR_P_SAT: f64 = 1.0e-4;
pub const LUNAR_SIDEREAL_DAY_S: f64 = 27.321_661 * 86_400.0;
type Vec3 = [f64; 3];
fn norm(v: Vec3) -> f64 {
(v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt()
}
fn cross(a: Vec3, b: Vec3) -> Vec3 {
[
a[1] * b[2] - a[2] * b[1],
a[2] * b[0] - a[0] * b[2],
a[0] * b[1] - a[1] * b[0],
]
}
fn unit(v: Vec3) -> Vec3 {
let n = norm(v);
[v[0] / n, v[1] / n, v[2] / n]
}
pub fn spherical_enu(pos: Vec3) -> (Vec3, Vec3, Vec3) {
let up = unit(pos);
let seed = if up[2].abs() < 0.99 {
[0.0, 0.0, 1.0]
} else {
[1.0, 0.0, 0.0]
};
let east = unit(cross(seed, up));
let north = cross(up, east);
(east, north, up)
}
pub fn lunar_sky_geometry(user: Vec3, range_m: f64, azels_deg: &[(f64, f64)]) -> Vec<Vec3> {
let (east, north, up) = spherical_enu(user);
azels_deg
.iter()
.map(|&(az, el)| {
let (azr, elr) = (az.to_radians(), el.to_radians());
let de = elr.cos() * azr.sin();
let dn = elr.cos() * azr.cos();
let du = elr.sin();
[
user[0] + range_m * (de * east[0] + dn * north[0] + du * up[0]),
user[1] + range_m * (de * east[1] + dn * north[1] + du * up[1]),
user[2] + range_m * (de * east[2] + dn * north[2] + du * up[2]),
]
})
.collect()
}
pub fn lunar_rotation_angle(seconds: f64) -> f64 {
(TAU / LUNAR_SIDEREAL_DAY_S * seconds).rem_euclid(TAU)
}
fn rot3(r: Vec3, theta: f64) -> Vec3 {
let (s, c) = theta.sin_cos();
[c * r[0] + s * r[1], -s * r[0] + c * r[1], r[2]]
}
pub fn mci_to_mcmf(r_mci: Vec3, seconds: f64) -> Vec3 {
rot3(r_mci, lunar_rotation_angle(seconds))
}
pub fn mcmf_to_mci(r_mcmf: Vec3, seconds: f64) -> Vec3 {
rot3(r_mcmf, -lunar_rotation_angle(seconds))
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct Selenographic {
pub lat_rad: f64,
pub lon_rad: f64,
pub alt_m: f64,
}
pub fn mcmf_to_selenographic(r_mcmf: Vec3) -> Selenographic {
let rad = norm(r_mcmf);
let lon = r_mcmf[1].atan2(r_mcmf[0]);
let lat = if rad > 0.0 {
(r_mcmf[2] / rad).asin()
} else {
0.0
};
Selenographic {
lat_rad: lat,
lon_rad: lon,
alt_m: rad - R_MOON_M,
}
}
pub fn selenographic_to_mcmf(s: Selenographic) -> Vec3 {
let r = R_MOON_M + s.alt_m;
let (sla, cla) = s.lat_rad.sin_cos();
let (slo, clo) = s.lon_rad.sin_cos();
[r * cla * clo, r * cla * slo, r * sla]
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct LunarPassPoint {
pub t_s: f64,
pub hpl_m: f64,
pub vpl_m: f64,
pub available: bool,
}
pub fn south_pole_hpl_pass(
step_s: f64,
duration_s: f64,
alert_limit_m: f64,
budget: IntegrityBudget,
) -> Vec<LunarPassPoint> {
let user = selenographic_to_mcmf(Selenographic {
lat_rad: -FRAC_PI_2,
lon_rad: 0.0,
alt_m: 0.0,
});
let base: [(f64, f64); 6] = [
(10.0, 70.0),
(70.0, 35.0),
(140.0, 55.0),
(210.0, 28.0),
(280.0, 60.0),
(330.0, 40.0),
];
let az_rate = [3.0, 5.5, 4.0, 6.5, 4.8, 5.2];
let mut out = Vec::new();
let mut t = 0.0;
while t < duration_s - 1e-6 {
let hours = t / 3600.0;
let azels: Vec<(f64, f64)> = base
.iter()
.enumerate()
.map(|(i, &(az, el))| {
let a = (az + az_rate[i] * hours).rem_euclid(360.0);
let e = (el + 8.0 * (0.3 * hours + i as f64).sin()).clamp(10.0, 88.0);
(a, e)
})
.collect();
let sats = lunar_sky_geometry(user, 6.0e6, &azels);
let resid = vec![0.0; sats.len()];
if let Some(r) = lunar_araim(user, &sats, &resid, budget) {
out.push(LunarPassPoint {
t_s: t,
hpl_m: r.hpl_m,
vpl_m: r.vpl_m,
available: r.hpl_m <= alert_limit_m,
});
}
t += step_s;
}
out
}
pub fn lunar_araim(
user: Vec3,
sats: &[Vec3],
range_residual_m: &[f64],
budget: IntegrityBudget,
) -> Option<AraimResult> {
araim_raim(
user,
sats,
range_residual_m,
LUNAR_SIGMA_URE_M,
FaultPriors { p_sat: LUNAR_P_SAT },
budget,
)
}
fn default_step_s() -> f64 {
3600.0
}
fn default_duration_s() -> f64 {
86_400.0
}
fn default_alert_m() -> f64 {
50.0
}
fn default_p_hmi() -> f64 {
1e-4
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct LunarScenario {
#[serde(default = "default_step_s")]
pub step_s: f64,
#[serde(default = "default_duration_s")]
pub duration_s: f64,
#[serde(default = "default_alert_m")]
pub alert_limit_m: f64,
#[serde(default = "default_p_hmi")]
pub p_hmi: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct LunarReport {
pub alert_limit_m: f64,
pub sigma_ure_m: f64,
pub samples_total: usize,
pub samples_available: usize,
pub min_hpl_m: f64,
pub max_hpl_m: f64,
pub pass: Vec<LunarPassPoint>,
}
impl LunarReport {
pub fn availability(&self) -> f64 {
if self.samples_total == 0 {
0.0
} else {
self.samples_available as f64 / self.samples_total as f64
}
}
}
impl LunarScenario {
pub fn run(&self) -> LunarReport {
let budget = IntegrityBudget {
p_hmi_vert: self.p_hmi,
p_hmi_horz: self.p_hmi,
p_fa: 1e-5,
};
let pass = south_pole_hpl_pass(self.step_s, self.duration_s, self.alert_limit_m, budget);
let available = pass.iter().filter(|p| p.available).count();
let min_hpl = pass.iter().map(|p| p.hpl_m).fold(f64::INFINITY, f64::min);
let max_hpl = pass.iter().map(|p| p.hpl_m).fold(0.0_f64, f64::max);
LunarReport {
alert_limit_m: self.alert_limit_m,
sigma_ure_m: LUNAR_SIGMA_URE_M,
samples_total: pass.len(),
samples_available: available,
min_hpl_m: if min_hpl.is_finite() { min_hpl } else { 0.0 },
max_hpl_m: max_hpl,
pass,
}
}
}
pub fn lunar_report_svg(r: &LunarReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 30.0_f64, 50.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let t_max = r.pass.iter().map(|p| p.t_s).fold(1.0_f64, f64::max);
let y_max = (r.max_hpl_m.max(r.alert_limit_m) * 1.15).max(1.0);
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |v: f64| mt + ph - (v.min(y_max) / y_max) * ph;
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(&format!(
"<text x=\"{ml:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Lunar south-pole HPL ({:.0}% available, AL {:.0} m, σ_URE {:.0} m)</text>",
r.availability() * 100.0,
r.alert_limit_m,
r.sigma_ure_m
));
svg.push_str(&format!(
"<line x1=\"{:.1}\" y1=\"{:.1}\" x2=\"{:.1}\" y2=\"{:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"4 3\"/>",
xof(0.0),
yof(r.alert_limit_m),
xof(t_max),
yof(r.alert_limit_m)
));
let pts: Vec<String> = r
.pass
.iter()
.map(|p| format!("{:.1},{:.1}", xof(p.t_s), yof(p.hpl_m)))
.collect();
if pts.len() > 1 {
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#e0bd84\" points=\"{}\"/>",
pts.join(" ")
));
}
let axis_y = mt + ph;
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>"
));
svg.push_str(&format!(
"<line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>",
ml + pw
));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
fn setup() -> (Vec3, Vec<Vec3>, Vec<f64>, IntegrityBudget) {
let user = [R_MOON_M, 0.0, 0.0];
let azels = [
(0.0, 75.0),
(60.0, 30.0),
(120.0, 50.0),
(200.0, 25.0),
(270.0, 55.0),
(320.0, 35.0),
];
let sats = lunar_sky_geometry(user, 5.0e6, &azels);
let resid = vec![0.0; sats.len()];
let budget = IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
};
(user, sats, resid, budget)
}
#[test]
fn spherical_enu_is_orthonormal() {
let (e, n, u) = spherical_enu([R_MOON_M, 2.0e5, -3.0e5]);
assert!(
(norm(e) - 1.0).abs() < 1e-12
&& (norm(n) - 1.0).abs() < 1e-12
&& (norm(u) - 1.0).abs() < 1e-12
);
let dot = |a: Vec3, b: Vec3| a[0] * b[0] + a[1] * b[1] + a[2] * b[2];
assert!(dot(e, n).abs() < 1e-12 && dot(e, u).abs() < 1e-12 && dot(n, u).abs() < 1e-12);
}
#[test]
fn geometry_places_satellites_at_the_slant_range() {
let user = [R_MOON_M, 0.0, 0.0];
let sats = lunar_sky_geometry(user, 5.0e6, &[(0.0, 90.0)]);
let d = norm([
sats[0][0] - user[0],
sats[0][1] - user[1],
sats[0][2] - user[2],
]);
assert!((d - 5.0e6).abs() < 1e-3, "slant = {d}");
}
#[test]
fn selenographic_round_trips_and_cardinal_points() {
let eq = selenographic_to_mcmf(Selenographic {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 0.0,
});
assert!((eq[0] - R_MOON_M).abs() < 1e-6 && eq[1].abs() < 1e-6 && eq[2].abs() < 1e-6);
let sp = selenographic_to_mcmf(Selenographic {
lat_rad: -std::f64::consts::FRAC_PI_2,
lon_rad: 0.0,
alt_m: 0.0,
});
assert!(sp[0].abs() < 1e-6 && sp[1].abs() < 1e-6 && (sp[2] + R_MOON_M).abs() < 1e-6);
for &(lat, lon, alt) in &[
(12.0_f64, 45.0_f64, 0.0_f64),
(-89.0, -120.0, 1500.0),
(60.0, 175.0, 30000.0),
] {
let s = Selenographic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: alt,
};
let back = mcmf_to_selenographic(selenographic_to_mcmf(s));
assert!((back.lat_rad - s.lat_rad).abs() < 1e-12, "lat {lat}");
assert!((back.lon_rad - s.lon_rad).abs() < 1e-12, "lon {lon}");
assert!((back.alt_m - s.alt_m).abs() < 1e-6, "alt {alt}");
}
}
#[test]
fn mci_mcmf_rotation_is_identity_at_epoch_and_period() {
let r = [1.2e6, -8.0e5, 4.0e5];
let at0 = mci_to_mcmf(r, 0.0);
for k in 0..3 {
assert!((at0[k] - r[k]).abs() < 1e-6, "t=0 component {k}");
}
let at_period = mci_to_mcmf(r, LUNAR_SIDEREAL_DAY_S);
for k in 0..3 {
assert!((at_period[k] - r[k]).abs() < 1e-3, "t=T component {k}");
}
let t = 0.37 * LUNAR_SIDEREAL_DAY_S;
let back = mcmf_to_mci(mci_to_mcmf(r, t), t);
for k in 0..3 {
assert!((back[k] - r[k]).abs() < 1e-6, "round-trip {k}");
}
assert!((norm(mci_to_mcmf(r, t)) - norm(r)).abs() < 1e-6);
let deg = lunar_rotation_angle(86_400.0).to_degrees();
assert!((deg - 13.176_358).abs() < 1e-3, "1-day rotation = {deg}°");
}
#[test]
fn south_pole_pass_shows_the_lunar_integrity_gap() {
let budget = IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
};
let pass = south_pole_hpl_pass(3600.0, 86_400.0, 50.0, budget);
assert_eq!(pass.len(), 24, "24 hourly samples");
assert!(
pass.iter().all(|p| p.hpl_m.is_finite() && p.hpl_m > 0.0),
"every epoch yields a finite protection level"
);
let hmin = pass.iter().map(|p| p.hpl_m).fold(f64::INFINITY, f64::min);
let hmax = pass.iter().map(|p| p.hpl_m).fold(0.0_f64, f64::max);
assert!(hmax > hmin, "HPL should vary across the pass");
assert!(
pass.iter().all(|p| !p.available && p.hpl_m > 50.0),
"30 m LANS σ_URE cannot meet a 50 m alert limit"
);
}
#[test]
fn lunar_protection_levels_are_finite_and_scale_with_sigma_ure() {
let (user, sats, resid, budget) = setup();
let lunar = lunar_araim(user, &sats, &resid, budget).expect("lunar araim runs");
assert!(
lunar.hpl_m.is_finite() && lunar.hpl_m > 0.0,
"HPL {}",
lunar.hpl_m
);
assert!(
lunar.vpl_m.is_finite() && lunar.vpl_m > 0.0,
"VPL {}",
lunar.vpl_m
);
let ref_06 = araim_raim(
user,
&sats,
&resid,
0.6,
FaultPriors { p_sat: LUNAR_P_SAT },
budget,
)
.expect("reference araim runs");
let ratio = lunar.hpl_m / ref_06.hpl_m;
assert!(
(ratio - 50.0).abs() < 0.5,
"HPL ratio = {ratio} (want ≈ 50)"
);
}
}