use crate::lunar::{
lunar_araim, mci_to_mcmf, selenographic_to_mcmf, Selenographic, LUNAR_SIGMA_URE_M,
MOON_GM_M3_S2, R_MOON_M,
};
use crate::orbit::Dop;
use crate::raim::IntegrityBudget;
use serde::{Deserialize, Serialize};
type Vec3 = [f64; 3];
fn dot(a: Vec3, b: Vec3) -> f64 {
a[0] * b[0] + a[1] * b[1] + a[2] * b[2]
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LunarSat {
pub sma_m: f64,
pub eccentricity: f64,
pub inc_deg: f64,
pub raan_deg: f64,
pub argp_deg: f64,
pub mean_anom_deg: f64,
}
impl LunarSat {
pub fn position_mci(&self, t_s: f64) -> Vec3 {
let n = (MOON_GM_M3_S2 / self.sma_m.powi(3)).sqrt();
let e = self.eccentricity;
let m = self.mean_anom_deg.to_radians() + n * t_s;
let mut ea = m;
if e != 0.0 {
for _ in 0..40 {
let d = (ea - e * ea.sin() - m) / (1.0 - e * ea.cos());
ea -= d;
if d.abs() < 1e-13 {
break;
}
}
}
let r = self.sma_m * (1.0 - e * ea.cos());
let nu =
2.0 * ((1.0 + e).sqrt() * (ea * 0.5).sin()).atan2((1.0 - e).sqrt() * (ea * 0.5).cos());
let u = self.argp_deg.to_radians() + nu;
let (su, cu) = u.sin_cos();
let (si, ci) = self.inc_deg.to_radians().sin_cos();
let (sraan, craan) = self.raan_deg.to_radians().sin_cos();
[
r * (craan * cu - sraan * ci * su),
r * (sraan * cu + craan * ci * su),
r * (si * su),
]
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct LunarConstellation {
pub sats: Vec<LunarSat>,
}
impl LunarConstellation {
pub fn new(sats: Vec<LunarSat>) -> Self {
Self { sats }
}
pub fn illustrative_lcns(n: usize) -> Self {
let n = n.clamp(1, 12);
let sma_m = R_MOON_M + 8_000_000.0;
let sats = (0..n)
.map(|k| LunarSat {
sma_m,
eccentricity: 0.6,
inc_deg: 57.7,
raan_deg: 360.0 * (k as f64) / (n as f64),
argp_deg: 90.0, mean_anom_deg: 360.0 * (k as f64) / (n as f64),
})
.collect();
Self { sats }
}
pub fn n_sats(&self) -> usize {
self.sats.len()
}
pub fn positions_mci(&self, t_s: f64) -> Vec<Vec3> {
self.sats.iter().map(|s| s.position_mci(t_s)).collect()
}
pub fn positions_mcmf(&self, t_s: f64) -> Vec<Vec3> {
self.sats
.iter()
.map(|s| mci_to_mcmf(s.position_mci(t_s), t_s))
.collect()
}
}
impl Default for LunarConstellation {
fn default() -> Self {
Self::illustrative_lcns(4)
}
}
pub fn visible_sats(user_mcmf: Vec3, sats_mcmf: &[Vec3], elev_mask_rad: f64) -> Vec<Vec3> {
let up = unit_or_zero(user_mcmf);
let sin_mask = elev_mask_rad.sin();
let mut out = Vec::new();
for &s in sats_mcmf {
let d = [
s[0] - user_mcmf[0],
s[1] - user_mcmf[1],
s[2] - user_mcmf[2],
];
let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
if n == 0.0 {
continue;
}
let e = [d[0] / n, d[1] / n, d[2] / n];
if dot(e, up) >= sin_mask {
out.push(e);
}
}
out
}
pub fn visible_sat_positions(user_mcmf: Vec3, sats_mcmf: &[Vec3], elev_mask_rad: f64) -> Vec<Vec3> {
let up = unit_or_zero(user_mcmf);
let sin_mask = elev_mask_rad.sin();
sats_mcmf
.iter()
.copied()
.filter(|&s| {
let d = [
s[0] - user_mcmf[0],
s[1] - user_mcmf[1],
s[2] - user_mcmf[2],
];
let n = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
n > 0.0 && {
let e = [d[0] / n, d[1] / n, d[2] / n];
dot(e, up) >= sin_mask
}
})
.collect()
}
fn unit_or_zero(v: Vec3) -> Vec3 {
let n = (v[0] * v[0] + v[1] * v[1] + v[2] * v[2]).sqrt();
if n == 0.0 {
[0.0, 0.0, 0.0]
} else {
[v[0] / n, v[1] / n, v[2] / n]
}
}
pub fn service_dop(user_mcmf: Vec3, sats_mcmf: &[Vec3], elev_mask_rad: f64) -> Option<Dop> {
let vis = visible_sat_positions(user_mcmf, sats_mcmf, elev_mask_rad);
crate::orbit::dop(user_mcmf, &vis)
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct CoverageStats {
pub n_samples: usize,
pub n_four_plus: usize,
pub n_available: usize,
pub coverage_fraction: f64,
pub min_sats: usize,
pub max_sats: usize,
pub pdop_min: Option<f64>,
pub pdop_mean: Option<f64>,
pub pdop_max: Option<f64>,
}
pub fn coverage(
constellation: &LunarConstellation,
grid_points_selenographic: &[Selenographic],
times_s: &[f64],
elev_mask_rad: f64,
pdop_threshold: f64,
) -> CoverageStats {
let users: Vec<Vec3> = grid_points_selenographic
.iter()
.map(|&s| selenographic_to_mcmf(s))
.collect();
let mut n_samples = 0usize;
let mut n_four_plus = 0usize;
let mut n_available = 0usize;
let mut min_sats = usize::MAX;
let mut max_sats = 0usize;
let mut pdop_min = f64::INFINITY;
let mut pdop_max = 0.0_f64;
let mut pdop_sum = 0.0_f64;
let mut pdop_n = 0usize;
for &t in times_s {
let sats = constellation.positions_mcmf(t);
for &user in &users {
n_samples += 1;
let vis = visible_sat_positions(user, &sats, elev_mask_rad);
let nv = vis.len();
min_sats = min_sats.min(nv);
max_sats = max_sats.max(nv);
if nv >= 4 {
n_four_plus += 1;
if let Some(d) = crate::orbit::dop(user, &vis) {
pdop_min = pdop_min.min(d.pdop);
pdop_max = pdop_max.max(d.pdop);
pdop_sum += d.pdop;
pdop_n += 1;
if d.pdop < pdop_threshold {
n_available += 1;
}
}
}
}
}
let coverage_fraction = if n_samples == 0 {
0.0
} else {
n_available as f64 / n_samples as f64
};
CoverageStats {
n_samples,
n_four_plus,
n_available,
coverage_fraction,
min_sats: if n_samples == 0 { 0 } else { min_sats },
max_sats,
pdop_min: (pdop_n > 0).then_some(pdop_min),
pdop_mean: (pdop_n > 0).then(|| pdop_sum / pdop_n as f64),
pdop_max: (pdop_n > 0).then_some(pdop_max),
}
}
pub fn lunar_protection_level(
user_selenographic: Selenographic,
sats_mcmf: &[Vec3],
budget: IntegrityBudget,
) -> Option<ProtLevel> {
let user = selenographic_to_mcmf(user_selenographic);
let resid = vec![0.0; sats_mcmf.len()];
lunar_araim(user, sats_mcmf, &resid, budget).map(|r| ProtLevel {
hpl_m: r.hpl_m,
vpl_m: r.vpl_m,
n_used: r.n_used,
sigma_ure_m: LUNAR_SIGMA_URE_M,
})
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize)]
pub struct ProtLevel {
pub hpl_m: f64,
pub vpl_m: f64,
pub n_used: usize,
pub sigma_ure_m: f64,
}
fn d_n_sats() -> usize {
8
}
fn d_sma_km() -> f64 {
R_MOON_M / 1000.0 + 8_000.0
}
fn d_ecc() -> f64 {
0.6
}
fn d_inc_deg() -> f64 {
57.7
}
fn d_argp_deg() -> f64 {
90.0
}
fn d_lat_min_deg() -> f64 {
-90.0
}
fn d_lat_max_deg() -> f64 {
-60.0
}
fn d_lat_step_deg() -> f64 {
10.0
}
fn d_lon_min_deg() -> f64 {
-180.0
}
fn d_lon_max_deg() -> f64 {
180.0
}
fn d_lon_step_deg() -> f64 {
60.0
}
fn d_horizon_hours() -> f64 {
12.0
}
fn d_step_min() -> f64 {
60.0
}
fn d_elev_mask_deg() -> f64 {
5.0
}
fn d_pdop_threshold() -> f64 {
6.0
}
fn d_alert_limit_m() -> f64 {
50.0
}
fn d_p_hmi() -> f64 {
1e-4
}
#[derive(Clone, Copy, Debug, Deserialize)]
pub struct LunarServiceScenario {
#[serde(default = "d_n_sats")]
pub n_sats: usize,
#[serde(default = "d_sma_km")]
pub sma_km: f64,
#[serde(default = "d_ecc")]
pub eccentricity: f64,
#[serde(default = "d_inc_deg")]
pub inc_deg: f64,
#[serde(default = "d_argp_deg")]
pub argp_deg: f64,
#[serde(default = "d_lat_min_deg")]
pub lat_min_deg: f64,
#[serde(default = "d_lat_max_deg")]
pub lat_max_deg: f64,
#[serde(default = "d_lat_step_deg")]
pub lat_step_deg: f64,
#[serde(default = "d_lon_min_deg")]
pub lon_min_deg: f64,
#[serde(default = "d_lon_max_deg")]
pub lon_max_deg: f64,
#[serde(default = "d_lon_step_deg")]
pub lon_step_deg: f64,
#[serde(default = "d_horizon_hours")]
pub horizon_hours: f64,
#[serde(default = "d_step_min")]
pub step_min: f64,
#[serde(default = "d_elev_mask_deg")]
pub elev_mask_deg: f64,
#[serde(default = "d_pdop_threshold")]
pub pdop_threshold: f64,
#[serde(default = "d_alert_limit_m")]
pub alert_limit_m: f64,
#[serde(default = "d_p_hmi")]
pub p_hmi: f64,
}
impl Default for LunarServiceScenario {
fn default() -> Self {
Self {
n_sats: d_n_sats(),
sma_km: d_sma_km(),
eccentricity: d_ecc(),
inc_deg: d_inc_deg(),
argp_deg: d_argp_deg(),
lat_min_deg: d_lat_min_deg(),
lat_max_deg: d_lat_max_deg(),
lat_step_deg: d_lat_step_deg(),
lon_min_deg: d_lon_min_deg(),
lon_max_deg: d_lon_max_deg(),
lon_step_deg: d_lon_step_deg(),
horizon_hours: d_horizon_hours(),
step_min: d_step_min(),
elev_mask_deg: d_elev_mask_deg(),
pdop_threshold: d_pdop_threshold(),
alert_limit_m: d_alert_limit_m(),
p_hmi: d_p_hmi(),
}
}
}
#[derive(Clone, Debug, Serialize)]
pub struct LunarServiceReport {
pub n_sats: usize,
pub n_grid_points: usize,
pub n_epochs: usize,
pub n_samples: usize,
pub elev_mask_deg: f64,
pub pdop_threshold: f64,
pub alert_limit_m: f64,
pub sigma_ure_m: f64,
pub coverage_pct: f64,
pub min_sats: usize,
pub max_sats: usize,
pub pdop_min: f64,
pub pdop_mean: f64,
pub pdop_max: f64,
pub hpl_min_m: f64,
pub hpl_max_m: f64,
pub vpl_min_m: f64,
pub vpl_max_m: f64,
pub n_pl_samples: usize,
pub pl_availability_pct: f64,
pub note: &'static str,
}
impl LunarServiceScenario {
fn grid(&self) -> Vec<Selenographic> {
let mut pts = Vec::new();
let mut lat = self.lat_min_deg;
let lat_step = if self.lat_step_deg.abs() < 1e-9 {
1.0
} else {
self.lat_step_deg.abs()
};
while lat <= self.lat_max_deg + 1e-9 {
let mut lon = self.lon_min_deg;
let lon_step = if self.lon_step_deg.abs() < 1e-9 {
1.0
} else {
self.lon_step_deg.abs()
};
let lon_hi = if (self.lon_max_deg - self.lon_min_deg - 360.0).abs() < 1e-6 {
self.lon_max_deg - lon_step + 1e-9
} else {
self.lon_max_deg + 1e-9
};
while lon <= lon_hi {
pts.push(Selenographic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: 0.0,
});
lon += lon_step;
}
lat += lat_step;
}
pts
}
fn times(&self) -> Vec<f64> {
let mut ts = Vec::new();
let horizon_s = self.horizon_hours * 3600.0;
let step_s = if self.step_min.abs() < 1e-9 {
3600.0
} else {
self.step_min.abs() * 60.0
};
let mut t = 0.0;
while t < horizon_s - 1e-6 {
ts.push(t);
t += step_s;
}
if ts.is_empty() {
ts.push(0.0);
}
ts
}
pub fn run(&self) -> LunarServiceReport {
let sma_m = self.sma_km * 1000.0;
let n = self.n_sats.clamp(1, 12);
let sats = (0..n)
.map(|k| LunarSat {
sma_m,
eccentricity: self.eccentricity,
inc_deg: self.inc_deg,
raan_deg: 360.0 * (k as f64) / (n as f64),
argp_deg: self.argp_deg,
mean_anom_deg: 360.0 * (k as f64) / (n as f64),
})
.collect();
let constellation = LunarConstellation::new(sats);
let grid = self.grid();
let times = self.times();
let elev_mask_rad = self.elev_mask_deg.to_radians();
let stats = coverage(
&constellation,
&grid,
×,
elev_mask_rad,
self.pdop_threshold,
);
let budget = IntegrityBudget {
p_hmi_vert: self.p_hmi,
p_hmi_horz: self.p_hmi,
p_fa: 1e-5,
};
let mut hpl_min = f64::INFINITY;
let mut hpl_max = 0.0_f64;
let mut vpl_min = f64::INFINITY;
let mut vpl_max = 0.0_f64;
let mut n_pl = 0usize;
let mut n_pl_avail = 0usize;
for &t in × {
let sats_mcmf = constellation.positions_mcmf(t);
for &g in &grid {
let user = selenographic_to_mcmf(g);
let vis = visible_sat_positions(user, &sats_mcmf, elev_mask_rad);
if let Some(pl) = lunar_protection_level(g, &vis, budget) {
hpl_min = hpl_min.min(pl.hpl_m);
hpl_max = hpl_max.max(pl.hpl_m);
vpl_min = vpl_min.min(pl.vpl_m);
vpl_max = vpl_max.max(pl.vpl_m);
n_pl += 1;
if pl.hpl_m <= self.alert_limit_m {
n_pl_avail += 1;
}
}
}
}
LunarServiceReport {
n_sats: n,
n_grid_points: grid.len(),
n_epochs: times.len(),
n_samples: stats.n_samples,
elev_mask_deg: self.elev_mask_deg,
pdop_threshold: self.pdop_threshold,
alert_limit_m: self.alert_limit_m,
sigma_ure_m: LUNAR_SIGMA_URE_M,
coverage_pct: stats.coverage_fraction * 100.0,
min_sats: stats.min_sats,
max_sats: stats.max_sats,
pdop_min: stats.pdop_min.unwrap_or(0.0),
pdop_mean: stats.pdop_mean.unwrap_or(0.0),
pdop_max: stats.pdop_max.unwrap_or(0.0),
hpl_min_m: if hpl_min.is_finite() { hpl_min } else { 0.0 },
hpl_max_m: hpl_max,
vpl_min_m: if vpl_min.is_finite() { vpl_min } else { 0.0 },
vpl_max_m: vpl_max,
n_pl_samples: n_pl,
pl_availability_pct: if n_pl == 0 {
0.0
} else {
n_pl_avail as f64 / n_pl as f64 * 100.0
},
note: "Illustrative, public-source LCNS-class constellation; not affiliated with ESA. \
DOP geometry reuses the gnss_lib_py-validated kernel; coverage/integrity MODELLED.",
}
}
}
pub fn lunar_service_svg(r: &LunarServiceReport) -> String {
let (w, h) = (820.0_f64, 360.0_f64);
let (ml, mr, mt, mb) = (70.0_f64, 20.0_f64, 36.0_f64, 50.0_f64);
let (pw, ph) = (w - ml - mr, h - mt - mb);
let vals = [
("PDOP min", r.pdop_min),
("PDOP mean", r.pdop_mean),
("PDOP max", r.pdop_max),
];
let y_max = (r.pdop_max * 1.2).max(r.pdop_threshold * 1.2).max(1.0);
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 service volume — {} sats, {} pts × {} epochs: {:.1}% coverage (PDOP<{:.1})</text>",
r.n_sats, r.n_grid_points, r.n_epochs, r.coverage_pct, r.pdop_threshold
));
svg.push_str(&format!(
"<text x=\"{ml:.0}\" y=\"32\" font-size=\"11\">sats {}–{} | HPL {:.0}–{:.0} m | VPL {:.0}–{:.0} m | PL avail {:.1}% (AL {:.0} m, σ_URE {:.0} m)</text>",
r.min_sats, r.max_sats, r.hpl_min_m, r.hpl_max_m, r.vpl_min_m, r.vpl_max_m, r.pl_availability_pct, 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\"/>",
ml,
yof(r.pdop_threshold),
ml + pw,
yof(r.pdop_threshold)
));
let bar_w = pw / (vals.len() as f64 * 2.0);
for (i, (label, v)) in vals.iter().enumerate() {
let x = ml + (i as f64 * 2.0 + 0.5) * bar_w;
let y = yof(*v);
let bh = (mt + ph) - y;
svg.push_str(&format!(
"<rect x=\"{x:.1}\" y=\"{y:.1}\" width=\"{bar_w:.1}\" height=\"{bh:.1}\" fill=\"#e0bd84\"/>"
));
svg.push_str(&format!(
"<text x=\"{:.1}\" y=\"{:.1}\" font-size=\"11\" text-anchor=\"middle\">{} {:.2}</text>",
x + bar_w / 2.0,
(mt + ph) + 16.0,
label,
v
));
}
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::*;
use crate::lunar::{lunar_araim, lunar_sky_geometry};
use std::f64::consts::FRAC_PI_2;
fn budget() -> IntegrityBudget {
IntegrityBudget {
p_hmi_vert: 1e-4,
p_hmi_horz: 1e-4,
p_fa: 1e-5,
}
}
#[test]
fn dop_reuses_validated_kernel() {
let user = [R_MOON_M, 0.0, 0.0];
let azels = [
(0.0, 75.0),
(60.0, 60.0),
(120.0, 50.0),
(200.0, 65.0),
(270.0, 55.0),
(320.0, 70.0),
];
let sats = lunar_sky_geometry(user, 8.0e6, &azels);
let mask = 5.0_f64.to_radians();
let via_service = service_dop(user, &sats, mask).expect("≥4 visible");
let vis = visible_sat_positions(user, &sats, mask);
let direct = crate::orbit::dop(user, &vis).expect("≥4 visible");
assert_eq!(
via_service, direct,
"service_dop must be the validated kernel"
);
}
#[test]
fn pl_reduces_to_south_pole_case() {
let sp = Selenographic {
lat_rad: -FRAC_PI_2,
lon_rad: 0.0,
alt_m: 0.0,
};
let user = selenographic_to_mcmf(sp);
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 sats = lunar_sky_geometry(user, 6.0e6, &base);
let resid = vec![0.0; sats.len()];
let reference = lunar_araim(user, &sats, &resid, budget()).expect("ref PL");
let generalised = lunar_protection_level(sp, &sats, budget()).expect("gen PL");
assert!(
(generalised.hpl_m - reference.hpl_m).abs() < 1e-9,
"HPL {} vs reference {}",
generalised.hpl_m,
reference.hpl_m
);
assert!(
(generalised.vpl_m - reference.vpl_m).abs() < 1e-9,
"VPL {} vs reference {}",
generalised.vpl_m,
reference.vpl_m
);
assert_eq!(generalised.n_used, reference.n_used);
}
#[test]
fn coverage_monotone_in_constellation_size() {
let grid: Vec<Selenographic> = [-90.0_f64, -80.0, -70.0]
.iter()
.flat_map(|&lat| {
[-120.0_f64, 0.0, 120.0]
.iter()
.map(move |&lon| Selenographic {
lat_rad: lat.to_radians(),
lon_rad: lon.to_radians(),
alt_m: 0.0,
})
})
.collect();
let times: Vec<f64> = (0..6).map(|k| k as f64 * 3600.0).collect();
let mask = 5.0_f64.to_radians();
let small = LunarConstellation::illustrative_lcns(4);
let large = LunarConstellation::illustrative_lcns(8);
let cs = coverage(&small, &grid, ×, mask, 6.0);
let cl = coverage(&large, &grid, ×, mask, 6.0);
assert!(
cl.coverage_fraction >= cs.coverage_fraction - 1e-12,
"coverage must be non-decreasing in constellation size: small {} large {}",
cs.coverage_fraction,
cl.coverage_fraction
);
assert!(cl.max_sats >= cs.max_sats);
}
#[test]
fn visibility_respects_mask() {
let user = [R_MOON_M, 0.0, 0.0];
let low = lunar_sky_geometry(user, 5.0e6, &[(0.0, 3.0)]);
let high = lunar_sky_geometry(user, 5.0e6, &[(0.0, 20.0)]);
let mask = 5.0_f64.to_radians();
assert_eq!(
visible_sats(user, &low, mask).len(),
0,
"a 3° satellite must be below a 5° mask"
);
assert_eq!(
visible_sats(user, &high, mask).len(),
1,
"a 20° satellite must clear a 5° mask"
);
let v = visible_sats(user, &high, mask);
let n = (v[0][0] * v[0][0] + v[0][1] * v[0][1] + v[0][2] * v[0][2]).sqrt();
assert!((n - 1.0).abs() < 1e-12);
}
#[test]
fn service_dop_none_below_four() {
let user = [R_MOON_M, 0.0, 0.0];
let sats = lunar_sky_geometry(user, 5.0e6, &[(0.0, 70.0), (90.0, 60.0), (180.0, 50.0)]);
assert!(service_dop(user, &sats, 5.0_f64.to_radians()).is_none());
}
#[test]
fn elliptical_orbit_radius_varies_between_peri_and_apo() {
let c = LunarConstellation::default();
let s = c.sats[0];
let a = s.sma_m;
let e = s.eccentricity;
let peri = a * (1.0 - e);
let apo = a * (1.0 + e);
let n = (MOON_GM_M3_S2 / a.powi(3)).sqrt();
let period = std::f64::consts::TAU / n;
let mut rmin = f64::INFINITY;
let mut rmax = 0.0_f64;
for k in 0..50 {
let t = period * k as f64 / 49.0;
let p = s.position_mci(t);
let r = (p[0] * p[0] + p[1] * p[1] + p[2] * p[2]).sqrt();
rmin = rmin.min(r);
rmax = rmax.max(r);
}
assert!(rmin >= peri - 1.0 && rmax <= apo + 1.0);
assert!(
rmax - rmin > 0.5 * (apo - peri),
"should sample a real spread"
);
}
#[test]
fn scenario_is_deterministic() {
let scn = LunarServiceScenario::default();
let a = scn.run();
let b = scn.run();
assert_eq!(
serde_json::to_string(&a).unwrap(),
serde_json::to_string(&b).unwrap()
);
}
#[test]
fn scenario_report_self_consistent() {
let scn = LunarServiceScenario::default();
let r = scn.run();
assert_eq!(r.n_samples, r.n_grid_points * r.n_epochs);
assert!(r.n_grid_points > 0 && r.n_epochs > 0);
assert!(r.coverage_pct >= 0.0 && r.coverage_pct <= 100.0);
assert!(r.pl_availability_pct >= 0.0 && r.pl_availability_pct <= 100.0);
assert!(r.max_sats >= r.min_sats);
assert!((r.sigma_ure_m - LUNAR_SIGMA_URE_M).abs() < 1e-9);
let svg = lunar_service_svg(&r);
assert!(svg.starts_with("<svg") && svg.ends_with("</svg>"));
let json = serde_json::to_string(&r).unwrap();
assert!(json.contains("not affiliated with ESA"));
assert!(json.contains("MODELLED"));
}
}