use crate::frames::{geodetic_to_ecef, is_visible, look_angles, teme_to_ecef, Geodetic, Vec3};
use crate::scenario::{GnssState, TimeCfg};
use crate::walker::{walker_epoch_jd, WalkerSgp4};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::f64::consts::PI;
pub const C_M_PER_S: f64 = 299_792_458.0;
pub const L1_HZ: f64 = 1_575_420_000.0;
pub const CA_CHIP_RATE_HZ: f64 = 1_023_000.0;
pub const BOLTZMANN_J_PER_K: f64 = 1.380_649e-23;
pub const DEFAULT_TEMP_K: f64 = 290.0;
pub const DEFAULT_SIGNAL_POWER_DBW: f64 = -158.5;
pub const DEFAULT_TRACKING_THRESHOLD_DBHZ: f64 = 25.0;
pub const DEFAULT_DEGRADED_MARGIN_DB: f64 = 6.0;
fn default_mask_deg() -> f64 {
5.0
}
fn default_threshold() -> f64 {
DEFAULT_TRACKING_THRESHOLD_DBHZ
}
fn default_degraded_margin() -> f64 {
DEFAULT_DEGRADED_MARGIN_DB
}
fn default_signal_power() -> f64 {
DEFAULT_SIGNAL_POWER_DBW
}
fn default_temp_k() -> f64 {
DEFAULT_TEMP_K
}
fn default_freq() -> f64 {
L1_HZ
}
fn default_chip_rate() -> f64 {
CA_CHIP_RATE_HZ
}
fn default_jammer_type() -> String {
"broadband".to_string()
}
pub fn free_space_path_loss_db(d_m: f64, f_hz: f64) -> f64 {
let d = d_m.max(1e-3);
20.0 * d.log10() + 20.0 * f_hz.log10() + 20.0 * (4.0 * PI / C_M_PER_S).log10()
}
#[allow(clippy::too_many_arguments)]
pub fn j_over_s_db(
jammer_power_dbw: f64,
jammer_gain_dbi: f64,
rx_gain_toward_jammer_db: f64,
distance_m: f64,
f_hz: f64,
signal_power_dbw: f64,
rx_gain_toward_sat_db: f64,
) -> f64 {
let jammer_rx = jammer_power_dbw + jammer_gain_dbi + rx_gain_toward_jammer_db
- free_space_path_loss_db(distance_m, f_hz);
let signal_rx = signal_power_dbw + rx_gain_toward_sat_db;
jammer_rx - signal_rx
}
pub fn noise_density_dbw_per_hz(temp_k: f64) -> f64 {
10.0 * (BOLTZMANN_J_PER_K * temp_k).log10()
}
pub fn nominal_cn0_dbhz(signal_power_dbw: f64, antenna_gain_db: f64, temp_k: f64) -> f64 {
signal_power_dbw + antenna_gain_db - noise_density_dbw_per_hz(temp_k)
}
pub fn effective_cn0_dbhz(cn0_nominal_dbhz: f64, js_db: f64, q: f64, chip_rate_hz: f64) -> f64 {
let cn0_lin = 10f64.powf(cn0_nominal_dbhz / 10.0);
let js_lin = 10f64.powf(js_db / 10.0);
let denom = 1.0 / cn0_lin + js_lin / (q.max(1e-9) * chip_rate_hz);
-10.0 * denom.log10()
}
pub fn q_factor(jammer_type: &str, q_override: Option<f64>) -> f64 {
if let Some(q) = q_override {
return q.max(1e-9);
}
match jammer_type {
"broadband" => 1.0,
"swept" => 1.0,
"narrowband" | "cw" => 1.5,
_ => 1.0,
}
}
pub fn rx_antenna_gain_db(el_rad: f64) -> f64 {
let el = el_rad.clamp(0.0, PI / 2.0);
-4.0 * (1.0 - el / (PI / 2.0))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)]
pub enum LockStatus {
Locked,
Degraded,
Lost,
}
impl LockStatus {
fn label(self) -> &'static str {
match self {
LockStatus::Locked => "LOCKED",
LockStatus::Degraded => "DEGRADED",
LockStatus::Lost => "LOST",
}
}
}
pub fn lock_status(cn0_eff_dbhz: f64, threshold_dbhz: f64, degraded_margin_db: f64) -> LockStatus {
if cn0_eff_dbhz < threshold_dbhz {
LockStatus::Lost
} else if cn0_eff_dbhz < threshold_dbhz + degraded_margin_db {
LockStatus::Degraded
} else {
LockStatus::Locked
}
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JammerCfg {
pub position_ecef_m: Vec3,
pub power_dbw: f64,
#[serde(default)]
pub gain_dbi: f64,
#[serde(default = "default_jammer_type")]
pub jammer_type: String,
#[serde(default)]
pub bandwidth_mhz: Option<f64>,
#[serde(default)]
pub q_override: Option<f64>,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReceiverCfg {
pub lat_deg: f64,
pub lon_deg: f64,
#[serde(default)]
pub alt_m: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct JammingScenario {
pub seed: u64,
pub time: TimeCfg,
pub receiver: ReceiverCfg,
pub constellation: WalkerSgp4,
#[serde(default)]
pub jammer: Option<JammerCfg>,
#[serde(default = "default_mask_deg")]
pub mask_deg: f64,
#[serde(default = "default_threshold")]
pub tracking_threshold_dbhz: f64,
#[serde(default = "default_degraded_margin")]
pub degraded_margin_db: f64,
#[serde(default = "default_signal_power")]
pub signal_power_dbw: f64,
#[serde(default = "default_temp_k")]
pub temp_k: f64,
#[serde(default = "default_freq")]
pub freq_hz: f64,
#[serde(default = "default_chip_rate")]
pub chip_rate_hz: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct SatLink {
pub prn: usize,
pub el_deg: f64,
pub js_db: f64,
pub cn0_nominal_dbhz: f64,
pub cn0_effective_dbhz: f64,
pub status: String,
}
#[derive(Clone, Debug, Serialize)]
pub struct JammingEpoch {
pub t: f64,
pub visible: usize,
pub tracking: usize,
pub gnss_state: GnssState,
pub sats: Vec<SatLink>,
}
#[derive(Clone, Debug, Serialize)]
pub struct JammingFoM {
pub availability_under_jamming: f64,
pub availability_nominal: f64,
pub min_tracking: usize,
pub mean_js_db: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct JammingResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub seed: u64,
pub jammer_present: bool,
pub fom: JammingFoM,
pub epochs: Vec<JammingEpoch>,
}
fn hash_scenario(scn: &JammingScenario) -> String {
let c = serde_json::to_string(scn).expect("scenario serializes");
let mut h = Sha256::new();
h.update(c.as_bytes());
hex::encode(h.finalize())
}
pub fn run_jamming(scn: &JammingScenario) -> JammingResult {
let station = Geodetic {
lat_rad: scn.receiver.lat_deg.to_radians(),
lon_rad: scn.receiver.lon_deg.to_radians(),
alt_m: scn.receiver.alt_m,
};
let station_ecef = geodetic_to_ecef(station);
let sats = scn.constellation.satellites();
let jammer_geom = scn.jammer.as_ref().map(|j| {
let d = dist(station_ecef, j.position_ecef_m);
let el = look_angles(station, j.position_ecef_m).el_rad;
(j, d, rx_antenna_gain_db(el))
});
let dt = scn.time.step_s;
let n = (scn.time.duration_s / dt).round() as usize;
let mut epochs = Vec::with_capacity(n + 1);
let (mut avail_jam, mut avail_nom, mut min_tracking) = (0usize, 0usize, usize::MAX);
let (mut js_sum, mut js_count) = (0.0f64, 0usize);
for i in 0..=n {
let t = i as f64 * dt;
let jd = walker_epoch_jd() + t / 86_400.0;
let mut links = Vec::new();
for (prn, p) in sats.iter().enumerate() {
let sat_ecef = teme_to_ecef(p.position_eci(t), jd);
if !is_visible(station, sat_ecef, scn.mask_deg) {
continue;
}
let look = look_angles(station, sat_ecef);
let gain = rx_antenna_gain_db(look.el_rad);
let cn0_nom = nominal_cn0_dbhz(scn.signal_power_dbw, gain, scn.temp_k);
let (js_db, cn0_eff) = match &jammer_geom {
Some((j, d, rx_gain_jammer)) => {
let js = j_over_s_db(
j.power_dbw,
j.gain_dbi,
*rx_gain_jammer,
*d,
scn.freq_hz,
scn.signal_power_dbw,
gain,
);
let q = q_factor(&j.jammer_type, j.q_override);
js_sum += js;
js_count += 1;
(js, effective_cn0_dbhz(cn0_nom, js, q, scn.chip_rate_hz))
}
None => (f64::NEG_INFINITY, cn0_nom),
};
let status = lock_status(cn0_eff, scn.tracking_threshold_dbhz, scn.degraded_margin_db);
links.push(SatLink {
prn,
el_deg: look.el_rad.to_degrees(),
js_db,
cn0_nominal_dbhz: cn0_nom,
cn0_effective_dbhz: cn0_eff,
status: status.label().into(),
});
}
let visible = links.len();
let tracking = links.iter().filter(|l| l.status != "LOST").count();
if visible >= 4 {
avail_nom += 1;
}
if tracking >= 4 {
avail_jam += 1;
}
min_tracking = min_tracking.min(tracking);
epochs.push(JammingEpoch {
t,
visible,
tracking,
gnss_state: gnss_state_of(tracking),
sats: links,
});
}
let denom = (n + 1) as f64;
JammingResult {
schema_version: "0.7".into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: hash_scenario(scn),
seed: scn.seed,
jammer_present: scn.jammer.is_some(),
fom: JammingFoM {
availability_under_jamming: avail_jam as f64 / denom,
availability_nominal: avail_nom as f64 / denom,
min_tracking: if min_tracking == usize::MAX {
0
} else {
min_tracking
},
mean_js_db: if js_count > 0 {
js_sum / js_count as f64
} else {
f64::NAN
},
},
epochs,
}
}
fn dist(a: Vec3, b: Vec3) -> f64 {
((a[0] - b[0]).powi(2) + (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2)).sqrt()
}
fn gnss_state_of(tracking: usize) -> GnssState {
match tracking {
0 => GnssState::Denied,
1..=3 => GnssState::Degraded,
_ => GnssState::Nominal,
}
}
pub fn to_svg(result: &JammingResult) -> String {
let (w, h) = (820.0_f64, 420.0_f64);
let (ml, mr, mt, mb) = (60.0_f64, 20.0_f64, 30.0_f64, 50.0_f64);
let pw = w - ml - mr;
let ph = h - mt - mb;
let ep = &result.epochs;
let t_max = ep.iter().map(|e| e.t).fold(1.0_f64, f64::max);
let y_max = ep.iter().map(|e| e.visible).max().unwrap_or(8).max(4) as f64 + 1.0;
let xof = |t: f64| ml + (t / t_max) * pw;
let yof = |v: f64| mt + ph - (v / y_max) * ph;
let poly = |sel: &dyn Fn(&JammingEpoch) -> f64| {
ep.iter()
.map(|e| format!("{:.1},{:.1}", xof(e.t), yof(sel(e))))
.collect::<Vec<_>>()
.join(" ")
};
let axis_y = mt + 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=\"#cdd6e0\"><rect width=\"{w:.0}\" height=\"{h:.0}\" fill=\"#0e131b\"/>"));
svg.push_str(&format!("<text x=\"{ml:.0}\" y=\"18\" font-size=\"15\" font-weight=\"bold\">Satellites visible vs. tracking under jamming</text>"));
svg.push_str(&crate::chart::y_axis(ml, mt, pw, ph, y_max, "satellites"));
svg.push_str(&format!("<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#3a4757\"/><line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#3a4757\"/>", ml + pw));
let four_y = yof(4.0);
svg.push_str(&format!("<line x1=\"{ml:.0}\" y1=\"{four_y:.1}\" x2=\"{:.0}\" y2=\"{four_y:.1}\" stroke=\"#d33\" stroke-dasharray=\"6 4\"/><text x=\"{:.0}\" y=\"{:.1}\" fill=\"#d33\">4-SV fix</text>", ml + pw, ml + 4.0, four_y - 4.0));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#5a6b7a\" stroke-width=\"2\" points=\"{}\"/>",
poly(&|e| e.visible as f64)
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#27ae60\" stroke-width=\"2\" points=\"{}\"/>",
poly(&|e| e.tracking as f64)
));
svg.push_str(&format!("<text x=\"{:.0}\" y=\"44\" fill=\"#5a6b7a\">visible</text><text x=\"{:.0}\" y=\"60\" fill=\"#27ae60\">tracking</text>", ml + 10.0, ml + 10.0));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn free_space_path_loss_matches_the_textbook_formula() {
let fspl = free_space_path_loss_db(1000.0, L1_HZ);
assert!((fspl - 96.395).abs() < 0.01, "FSPL = {fspl}");
let fspl_100km = free_space_path_loss_db(100_000.0, L1_HZ);
assert!((fspl_100km - fspl - 40.0).abs() < 1e-6);
}
#[test]
fn j_over_s_and_effective_cn0_match_hand_computation() {
let js = j_over_s_db(10.0, 0.0, 0.0, 1000.0, L1_HZ, DEFAULT_SIGNAL_POWER_DBW, 0.0);
assert!((js - 72.105).abs() < 0.01, "J/S = {js}");
let cn0 = nominal_cn0_dbhz(DEFAULT_SIGNAL_POWER_DBW, 0.0, DEFAULT_TEMP_K);
assert!((cn0 - 45.475).abs() < 0.01, "C/N0 = {cn0}");
let eff = effective_cn0_dbhz(cn0, js, 1.0, CA_CHIP_RATE_HZ);
assert!((eff - (-12.0)).abs() < 0.1, "eff C/N0 = {eff}");
assert_eq!(
lock_status(
eff,
DEFAULT_TRACKING_THRESHOLD_DBHZ,
DEFAULT_DEGRADED_MARGIN_DB
),
LockStatus::Lost
);
}
#[test]
fn distant_jammer_leaves_a_healthy_link() {
let js = j_over_s_db(
10.0,
0.0,
0.0,
100_000.0,
L1_HZ,
DEFAULT_SIGNAL_POWER_DBW,
0.0,
);
assert!((js - 32.105).abs() < 0.01, "J/S = {js}");
let cn0 = nominal_cn0_dbhz(DEFAULT_SIGNAL_POWER_DBW, 0.0, DEFAULT_TEMP_K);
let eff = effective_cn0_dbhz(cn0, js, 1.0, CA_CHIP_RATE_HZ);
assert!((eff - 27.9).abs() < 0.1, "eff C/N0 = {eff}");
assert_eq!(
lock_status(
eff,
DEFAULT_TRACKING_THRESHOLD_DBHZ,
DEFAULT_DEGRADED_MARGIN_DB
),
LockStatus::Degraded
);
assert!(eff >= DEFAULT_TRACKING_THRESHOLD_DBHZ, "must not lose lock");
}
fn gps_like() -> WalkerSgp4 {
WalkerSgp4 {
altitude_km: 20_200.0,
inclination_deg: 55.0,
planes: 6,
sats_per_plane: 4,
phasing_f: 1.0,
}
}
fn scenario(jammer: Option<JammerCfg>) -> JammingScenario {
JammingScenario {
seed: 1,
time: TimeCfg {
step_s: 5.0,
duration_s: 30.0,
},
receiver: ReceiverCfg {
lat_deg: 52.0,
lon_deg: 4.0,
alt_m: 0.0,
},
constellation: gps_like(),
jammer,
mask_deg: 5.0,
tracking_threshold_dbhz: DEFAULT_TRACKING_THRESHOLD_DBHZ,
degraded_margin_db: DEFAULT_DEGRADED_MARGIN_DB,
signal_power_dbw: DEFAULT_SIGNAL_POWER_DBW,
temp_k: DEFAULT_TEMP_K,
freq_hz: L1_HZ,
chip_rate_hz: CA_CHIP_RATE_HZ,
}
}
fn jammer_at(scn: &JammingScenario, distance_m: f64, power_dbw: f64) -> JammerCfg {
let station = Geodetic {
lat_rad: scn.receiver.lat_deg.to_radians(),
lon_rad: scn.receiver.lon_deg.to_radians(),
alt_m: scn.receiver.alt_m,
};
let s = geodetic_to_ecef(station);
JammerCfg {
position_ecef_m: [s[0] + distance_m, s[1], s[2]],
power_dbw,
gain_dbi: 0.0,
jammer_type: "broadband".into(),
bandwidth_mhz: Some(20.0),
q_override: None,
}
}
#[test]
fn near_jammer_denies_at_least_two_satellites_within_30s() {
let base = scenario(None);
let j = jammer_at(&base, 1000.0, 10.0); let scn = scenario(Some(j));
let r = run_jamming(&scn);
let nom = run_jamming(&base);
assert!(
nom.epochs.iter().all(|e| e.visible >= 4),
"baseline geometry should keep ≥4 visible"
);
let max_lost = scn_lost_within(&r, 30.0);
assert!(
max_lost >= 2,
"expected ≥2 satellites to lose lock; max lost was {max_lost}"
);
assert_eq!(r.fom.availability_under_jamming, 0.0);
assert!(r.fom.availability_nominal > 0.0);
}
#[test]
fn distant_jammer_causes_no_lock_loss() {
let base = scenario(None);
let j = jammer_at(&base, 100_000.0, 10.0); let scn = scenario(Some(j));
let r = run_jamming(&scn);
for e in &r.epochs {
assert_eq!(
e.tracking, e.visible,
"no lock loss expected at 100 km (t={})",
e.t
);
assert!(
e.sats.iter().all(|s| s.status != "LOST"),
"no satellite should be LOST at 100 km"
);
}
assert_eq!(r.fom.availability_under_jamming, r.fom.availability_nominal);
}
#[test]
fn no_jammer_is_a_clean_sky_baseline_and_run_is_deterministic() {
let scn = scenario(None);
let r = run_jamming(&scn);
assert!(!r.jammer_present);
assert_eq!(r.fom.availability_under_jamming, r.fom.availability_nominal);
assert!(r.fom.mean_js_db.is_nan());
for e in &r.epochs {
assert_eq!(e.tracking, e.visible);
assert!(e.sats.iter().all(|s| s.status == "LOCKED"));
}
let a = serde_json::to_string(&run_jamming(&scn)).unwrap();
let b = serde_json::to_string(&run_jamming(&scn)).unwrap();
assert_eq!(a, b);
}
fn scn_lost_within(r: &JammingResult, t_limit: f64) -> usize {
r.epochs
.iter()
.filter(|e| e.t <= t_limit + 1e-9)
.map(|e| e.sats.iter().filter(|s| s.status == "LOST").count())
.max()
.unwrap_or(0)
}
}