use crate::detection::{analytic_pd, detection_boundary};
use crate::lunar::{selenographic_to_mcmf, Selenographic};
use crate::lunar_service::visible_sat_positions;
use crate::spoof_monitors::{combine_power_dbm, AgcMonitor};
pub fn horizon_ground_range_m(radius_m: f64, height_m: f64) -> f64 {
radius_m * (radius_m / (radius_m + height_m)).acos()
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SurfaceMonitor {
pub lat_rad: f64,
pub lon_rad: f64,
pub alt_m: f64,
pub floor_dbm: f64,
pub power_sigma_db: f64,
}
impl SurfaceMonitor {
pub fn new(
lat_rad: f64,
lon_rad: f64,
alt_m: f64,
floor_dbm: f64,
power_sigma_db: f64,
) -> Self {
SurfaceMonitor {
lat_rad,
lon_rad,
alt_m,
floor_dbm,
power_sigma_db,
}
}
pub fn agc(&self) -> AgcMonitor {
AgcMonitor::new(self.floor_dbm)
}
pub fn selenographic(&self) -> Selenographic {
Selenographic {
lat_rad: self.lat_rad,
lon_rad: self.lon_rad,
alt_m: self.alt_m,
}
}
pub fn position_mcmf(&self) -> [f64; 3] {
selenographic_to_mcmf(self.selenographic())
}
pub fn detection_statistic(&self, spoof_rx_dbm: f64) -> (f64, f64) {
let agc = self.agc();
let measured_dbm = combine_power_dbm(&[self.floor_dbm, spoof_rx_dbm]);
(agc.excess_db(measured_dbm), self.power_sigma_db)
}
pub fn detection_power(&self, spoof_rx_dbm: f64, p_fa: f64) -> f64 {
let (mu, sigma) = self.detection_statistic(spoof_rx_dbm);
let gamma = detection_boundary(sigma, p_fa);
analytic_pd(mu, sigma, gamma)
}
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct SpoofEvent {
pub lat_rad: f64,
pub lon_rad: f64,
pub alt_m: f64,
pub eirp_dbm: f64,
}
impl SpoofEvent {
pub fn selenographic(&self) -> Selenographic {
Selenographic {
lat_rad: self.lat_rad,
lon_rad: self.lon_rad,
alt_m: self.alt_m,
}
}
pub fn position_mcmf(&self) -> [f64; 3] {
selenographic_to_mcmf(self.selenographic())
}
}
pub fn free_space_path_loss_db(range_m: f64) -> f64 {
if range_m <= 1.0 {
0.0
} else {
20.0 * range_m.log10()
}
}
fn slant_range_m(a: [f64; 3], b: [f64; 3]) -> f64 {
let d = [a[0] - b[0], a[1] - b[1], a[2] - b[2]];
(d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt()
}
pub fn spoof_power_at_monitor(event: &SpoofEvent, monitor: &SurfaceMonitor) -> f64 {
let range_m = slant_range_m(event.position_mcmf(), monitor.position_mcmf());
event.eirp_dbm - free_space_path_loss_db(range_m)
}
pub fn monitor_observes_event(
monitor: &SurfaceMonitor,
event: &SpoofEvent,
elev_mask_rad: f64,
) -> bool {
let user = monitor.position_mcmf();
let emitter = [event.position_mcmf()];
!visible_sat_positions(user, &emitter, elev_mask_rad).is_empty()
}
pub fn network_detection_probability(per_monitor_pd: &[f64]) -> f64 {
let miss: f64 = per_monitor_pd
.iter()
.map(|&p| 1.0 - p.clamp(0.0, 1.0))
.product();
(1.0 - miss).clamp(0.0, 1.0)
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct NetworkDetectionPoint {
pub n_monitors: usize,
pub n_observing: usize,
pub network_pd: f64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct NetworkDetectionResult {
pub p_fa: f64,
pub elev_mask_rad: f64,
pub event: SpoofEvent,
pub curve: Vec<NetworkDetectionPoint>,
pub observing_pd: Vec<f64>,
pub spoof_rx_dbm: Vec<f64>,
}
pub fn detection_probability_vs_n(
monitors: &[SurfaceMonitor],
event: &SpoofEvent,
p_fa: f64,
elev_mask_rad: f64,
) -> NetworkDetectionResult {
let mut spoof_rx_dbm = Vec::with_capacity(monitors.len());
let mut observing_pd = Vec::new();
let mut curve = Vec::with_capacity(monitors.len());
for (i, m) in monitors.iter().enumerate() {
let rx_dbm = spoof_power_at_monitor(event, m);
spoof_rx_dbm.push(rx_dbm);
if monitor_observes_event(m, event, elev_mask_rad) {
observing_pd.push(m.detection_power(rx_dbm, p_fa));
}
let network_pd = network_detection_probability(&observing_pd);
curve.push(NetworkDetectionPoint {
n_monitors: i + 1,
n_observing: observing_pd.len(),
network_pd,
});
}
NetworkDetectionResult {
p_fa,
elev_mask_rad,
event: *event,
curve,
observing_pd,
spoof_rx_dbm,
}
}
pub fn monitors_to_reach(result: &NetworkDetectionResult, target_pd: f64) -> Option<usize> {
result
.curve
.iter()
.find(|p| p.network_pd >= target_pd)
.map(|p| p.n_monitors)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::detection::{analytic_pd, detection_boundary};
use crate::lunar::{selenographic_to_mcmf, R_MOON_M};
use crate::lunar_service::visible_sat_positions;
use crate::spoof_monitors::{combine_power_dbm, AgcMonitor};
fn nominal_floor_dbm() -> f64 {
combine_power_dbm(&[-130.0; 8])
}
#[test]
fn per_monitor_mu_is_the_agc_power_excess_statistic() {
let floor = nominal_floor_dbm();
let m = SurfaceMonitor::new(0.0, 0.0, 0.0, floor, 1.5);
let spoof_rx_dbm = -118.0;
let (mu, sigma) = m.detection_statistic(spoof_rx_dbm);
let agc = AgcMonitor::new(floor);
let measured = combine_power_dbm(&[floor, spoof_rx_dbm]);
let mu_oracle = agc.excess_db(measured);
assert!(
(mu - mu_oracle).abs() < 1e-15,
"mu {mu} vs oracle {mu_oracle}"
);
assert_eq!(sigma, 1.5);
assert!(mu > 0.0, "spoof above floor must raise power: mu = {mu}");
let (mu_weak, _) = m.detection_statistic(floor - 40.0);
assert!(mu_weak.abs() < 0.1, "weak spoof mu = {mu_weak}");
}
#[test]
fn per_monitor_pd_matches_detection_oracle() {
let floor = nominal_floor_dbm();
let sigma = 1.0;
let p_fa = 1e-3;
let m = SurfaceMonitor::new(0.0, 0.0, 0.0, floor, sigma);
let spoof_rx_dbm = -119.0;
let (mu, s) = m.detection_statistic(spoof_rx_dbm);
let gamma = detection_boundary(s, p_fa);
let oracle = analytic_pd(mu, s, gamma);
assert!((m.detection_power(spoof_rx_dbm, p_fa) - oracle).abs() < 1e-15);
let stronger = m.detection_power(spoof_rx_dbm + 6.0, p_fa);
assert!(stronger >= m.detection_power(spoof_rx_dbm, p_fa) - 1e-15);
}
#[test]
fn network_combination_hand_values() {
assert!((network_detection_probability(&[0.5, 0.5]) - 0.75).abs() < 1e-15);
assert!((network_detection_probability(&[0.9, 0.8]) - 0.98).abs() < 1e-15);
assert!((network_detection_probability(&[0.5, 0.5, 0.5]) - 0.875).abs() < 1e-15);
assert_eq!(network_detection_probability(&[]), 0.0);
assert!((network_detection_probability(&[1.0]) - 1.0).abs() < 1e-15);
}
#[test]
fn free_space_path_loss_is_inverse_square() {
assert_eq!(free_space_path_loss_db(1.0), 0.0);
assert_eq!(free_space_path_loss_db(0.5), 0.0); assert!((free_space_path_loss_db(10.0) - 20.0).abs() < 1e-12);
assert!((free_space_path_loss_db(100.0) - 40.0).abs() < 1e-12);
let d1 = free_space_path_loss_db(1000.0);
let d2 = free_space_path_loss_db(2000.0);
assert!((d2 - d1 - 20.0 * 2.0_f64.log10()).abs() < 1e-12);
}
#[test]
fn visibility_gate_is_the_lunar_service_geometry() {
let event = SpoofEvent {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 2.0e6, eirp_dbm: 30.0,
};
let mask = 5.0_f64.to_radians();
let near = SurfaceMonitor::new(0.0, 0.0, 0.0, nominal_floor_dbm(), 1.0);
assert!(monitor_observes_event(&near, &event, mask));
let far = SurfaceMonitor::new(0.0, std::f64::consts::PI, 0.0, nominal_floor_dbm(), 1.0);
assert!(!monitor_observes_event(&far, &event, mask));
let emitter = [selenographic_to_mcmf(event.selenographic())];
assert!(!visible_sat_positions(near.position_mcmf(), &emitter, mask).is_empty());
assert!(visible_sat_positions(far.position_mcmf(), &emitter, mask).is_empty());
let up = {
let u = near.position_mcmf();
let n = (u[0] * u[0] + u[1] * u[1] + u[2] * u[2]).sqrt();
[u[0] / n, u[1] / n, u[2] / n]
};
let d = {
let e = emitter[0];
let u = near.position_mcmf();
let dv = [e[0] - u[0], e[1] - u[1], e[2] - u[2]];
let n = (dv[0] * dv[0] + dv[1] * dv[1] + dv[2] * dv[2]).sqrt();
[dv[0] / n, dv[1] / n, dv[2] / n]
};
let sin_el = d[0] * up[0] + d[1] * up[1] + d[2] * up[2];
assert!(
(sin_el - 1.0).abs() < 1e-9,
"emitter straight up ⇒ el = 90°"
);
}
#[test]
fn vs_n_curve_is_sized_and_monotone() {
let p_fa = 1e-3;
let mask = 5.0_f64.to_radians();
let floor = nominal_floor_dbm();
let event = SpoofEvent {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 2.0e6,
eirp_dbm: 60.0,
};
let small = 1.0e4 / R_MOON_M; let on_event = |k: usize| SurfaceMonitor::new(0.0, k as f64 * small, 0.0, floor, 1.0);
let monitors = vec![
on_event(0),
on_event(1),
on_event(2),
SurfaceMonitor::new(0.0, std::f64::consts::PI, 0.0, floor, 1.0),
];
let res = detection_probability_vs_n(&monitors, &event, p_fa, mask);
let pds: Vec<f64> = monitors[..3]
.iter()
.map(|m| {
let rx = spoof_power_at_monitor(&event, m);
let (mu, sigma) = m.detection_statistic(rx);
let gamma = detection_boundary(sigma, p_fa);
analytic_pd(mu, sigma, gamma)
})
.collect();
assert_eq!(res.curve[0].n_observing, 1);
assert!((res.curve[0].network_pd - pds[0]).abs() < 1e-12);
let expect3 = 1.0 - pds.iter().map(|p| 1.0 - p).product::<f64>();
assert_eq!(res.curve[2].n_observing, 3);
assert!((res.curve[2].network_pd - expect3).abs() < 1e-12);
assert_eq!(res.curve[3].n_observing, 3);
assert!((res.curve[3].network_pd - expect3).abs() < 1e-12);
for w in res.curve.windows(2) {
assert!(w[1].network_pd >= w[0].network_pd - 1e-15);
}
assert_eq!(monitors_to_reach(&res, pds[0] - 1e-9), Some(1));
assert_eq!(monitors_to_reach(&res, 1.0 + 1e-6), None);
}
#[test]
fn farther_station_sees_weaker_spoof_and_lower_pd() {
let floor = nominal_floor_dbm();
let p_fa = 1e-3;
let event = SpoofEvent {
lat_rad: 0.0,
lon_rad: 0.0,
alt_m: 1.0e5,
eirp_dbm: 90.0,
};
let near = SurfaceMonitor::new(0.0, 0.0, 0.0, floor, 1.0);
let far = SurfaceMonitor::new(0.0, 2.0e5 / R_MOON_M, 0.0, floor, 1.0);
let rx_near = spoof_power_at_monitor(&event, &near);
let rx_far = spoof_power_at_monitor(&event, &far);
assert!(rx_far < rx_near, "farther station must see weaker spoof");
let (mu_near, _) = near.detection_statistic(rx_near);
let (mu_far, _) = far.detection_statistic(rx_far);
assert!(mu_far < mu_near, "weaker spoof ⇒ smaller AGC excess");
let pd_near = near.detection_power(rx_near, p_fa);
let pd_far = far.detection_power(rx_far, p_fa);
assert!(
pd_far <= pd_near + 1e-15,
"weaker spoof ⇒ lower Pd: near {pd_near} far {pd_far}"
);
}
}