use crate::frames::{geodetic_to_ecef, is_visible, look_angles, teme_to_ecef, Geodetic, Vec3};
use crate::scenario::TimeCfg;
use crate::walker::{walker_epoch_jd, WalkerSgp4};
use rand::SeedableRng;
use rand_chacha::ChaCha8Rng;
use rand_distr::{Distribution, Normal};
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;
#[derive(Clone, Copy, Debug)]
pub struct KlobucharCoeffs {
pub alpha: [f64; 4],
pub beta: [f64; 4],
}
impl Default for KlobucharCoeffs {
fn default() -> Self {
Self {
alpha: [3.82e-8, 1.49e-8, -1.79e-7, 0.0],
beta: [1.43e5, 0.0, -3.28e5, 1.13e5],
}
}
}
pub fn klobuchar_delay_m(
coeffs: &KlobucharCoeffs,
lat_rad: f64,
lon_rad: f64,
el_rad: f64,
az_rad: f64,
gps_sod: f64,
) -> f64 {
let phi_u = lat_rad / PI;
let lambda_u = lon_rad / PI;
let e = el_rad / PI;
let a = az_rad;
let psi = 0.0137 / (e + 0.11) - 0.022;
let mut phi_i = phi_u + psi * a.cos();
phi_i = phi_i.clamp(-0.416, 0.416);
let lambda_i = lambda_u + psi * a.sin() / (phi_i * PI).cos();
let phi_m = phi_i + 0.064 * ((lambda_i - 1.617) * PI).cos();
let mut t = 43_200.0 * lambda_i + gps_sod;
t = t.rem_euclid(86_400.0);
let f = 1.0 + 16.0 * (0.53 - e).powi(3);
let mut amp = coeffs.alpha[0]
+ coeffs.alpha[1] * phi_m
+ coeffs.alpha[2] * phi_m * phi_m
+ coeffs.alpha[3] * phi_m.powi(3);
if amp < 0.0 {
amp = 0.0;
}
let mut per = coeffs.beta[0]
+ coeffs.beta[1] * phi_m
+ coeffs.beta[2] * phi_m * phi_m
+ coeffs.beta[3] * phi_m.powi(3);
if per < 72_000.0 {
per = 72_000.0;
}
let x = 2.0 * PI * (t - 50_400.0) / per;
let t_iono = if x.abs() < 1.57 {
f * (5e-9 + amp * (1.0 - x * x / 2.0 + x.powi(4) / 24.0))
} else {
f * 5e-9
};
t_iono * C_M_PER_S
}
#[derive(Clone, Copy, Debug)]
pub struct Meteo {
pub pressure_hpa: f64,
pub temp_k: f64,
pub humidity: f64,
}
impl Default for Meteo {
fn default() -> Self {
Self {
pressure_hpa: 1013.25,
temp_k: 288.15,
humidity: 0.5,
}
}
}
fn water_vapour_pressure_hpa(meteo: &Meteo) -> f64 {
let tc = meteo.temp_k - 273.15;
let es = 6.108 * (17.15 * tc / (234.7 + tc)).exp();
meteo.humidity * es
}
pub fn saastamoinen_zenith_m(meteo: &Meteo, lat_rad: f64, h_m: f64) -> (f64, f64) {
let denom = 1.0 - 0.00266 * (2.0 * lat_rad).cos() - 0.00028 * (h_m / 1000.0);
let zhd = 0.0022768 * meteo.pressure_hpa / denom;
let e = water_vapour_pressure_hpa(meteo);
let zwd = 0.0022768 * (1255.0 / meteo.temp_k + 0.05) * e / denom;
(zhd, zwd)
}
fn marini_mapping(sin_e: f64, a: f64, b: f64, c: f64) -> f64 {
let num = 1.0 + a / (1.0 + b / (1.0 + c));
let den = sin_e + a / (sin_e + b / (sin_e + c));
num / den
}
fn niell_interp(table: &[f64; 5], lat_deg_abs: f64) -> f64 {
let lats = [15.0, 30.0, 45.0, 60.0, 75.0];
if lat_deg_abs <= lats[0] {
return table[0];
}
if lat_deg_abs >= lats[4] {
return table[4];
}
let i = lats.iter().position(|&l| lat_deg_abs < l).unwrap();
let f = (lat_deg_abs - lats[i - 1]) / (lats[i] - lats[i - 1]);
table[i - 1] + f * (table[i] - table[i - 1])
}
pub fn niell_hydrostatic(el_rad: f64, lat_rad: f64, h_m: f64, doy: f64) -> f64 {
const AVG_A: [f64; 5] = [
1.2769934e-3,
1.2683230e-3,
1.2465397e-3,
1.2196049e-3,
1.2045996e-3,
];
const AVG_B: [f64; 5] = [
2.9153695e-3,
2.9152299e-3,
2.9288445e-3,
2.9022565e-3,
2.9024912e-3,
];
const AVG_C: [f64; 5] = [
62.610505e-3,
62.837393e-3,
63.721774e-3,
63.824265e-3,
64.258455e-3,
];
const AMP_A: [f64; 5] = [0.0, 1.2709626e-5, 2.6523662e-5, 3.4000452e-5, 4.1202191e-5];
const AMP_B: [f64; 5] = [0.0, 2.1414979e-5, 3.0160779e-5, 7.2562722e-5, 11.723375e-5];
const AMP_C: [f64; 5] = [0.0, 9.0128400e-5, 4.3497037e-5, 84.795348e-5, 170.37206e-5];
const A_HT: f64 = 2.53e-5;
const B_HT: f64 = 5.49e-3;
const C_HT: f64 = 1.14e-3;
let lat_abs = lat_rad.to_degrees().abs();
let doy_adj = if lat_rad < 0.0 { doy + 182.625 } else { doy };
let season = (2.0 * PI * (doy_adj - 28.0) / 365.25).cos();
let coef = |avg: &[f64; 5], amp: &[f64; 5]| {
niell_interp(avg, lat_abs) - niell_interp(amp, lat_abs) * season
};
let (a, b, c) = (
coef(&AVG_A, &_A),
coef(&AVG_B, &_B),
coef(&AVG_C, &_C),
);
let sin_e = el_rad.sin();
let m = marini_mapping(sin_e, a, b, c);
let ht = 1.0 / sin_e - marini_mapping(sin_e, A_HT, B_HT, C_HT);
m + ht * (h_m / 1000.0)
}
pub fn niell_wet(el_rad: f64, lat_rad: f64) -> f64 {
const A: [f64; 5] = [
5.8021897e-4,
5.6794847e-4,
5.8118019e-4,
5.9727542e-4,
6.1641693e-4,
];
const B: [f64; 5] = [
1.4275268e-3,
1.5138625e-3,
1.4572752e-3,
1.5007428e-3,
1.7599082e-3,
];
const C: [f64; 5] = [
4.3472961e-2,
4.6729510e-2,
4.3908931e-2,
4.4626982e-2,
5.4736038e-2,
];
let lat_abs = lat_rad.to_degrees().abs();
let (a, b, c) = (
niell_interp(&A, lat_abs),
niell_interp(&B, lat_abs),
niell_interp(&C, lat_abs),
);
marini_mapping(el_rad.sin(), a, b, c)
}
pub fn tropo_delay_m(meteo: &Meteo, lat_rad: f64, h_m: f64, el_rad: f64, doy: f64) -> f64 {
let (zhd, zwd) = saastamoinen_zenith_m(meteo, lat_rad, h_m);
zhd * niell_hydrostatic(el_rad, lat_rad, h_m, doy) + zwd * niell_wet(el_rad, lat_rad)
}
pub fn geometric_range_m(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 default_mask_deg() -> f64 {
5.0
}
fn default_p_fa() -> f64 {
1e-5
}
fn default_p_md() -> f64 {
1e-3
}
fn default_uere() -> f64 {
3.0
}
fn default_alert_h() -> f64 {
40.0
}
fn default_alert_v() -> f64 {
50.0
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct ReceiverCfg {
pub lat_deg: f64,
pub lon_deg: f64,
#[serde(default)]
pub alt_m: f64,
#[serde(default)]
pub clock_bias_m: f64,
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct IonoCfg {
pub alpha: [f64; 4],
pub beta: [f64; 4],
#[serde(default = "default_gps_sod")]
pub gps_seconds_of_day: f64,
}
fn default_gps_sod() -> f64 {
50_400.0
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct TropoCfg {
#[serde(default = "default_pressure")]
pub pressure_hpa: f64,
#[serde(default = "default_temp")]
pub temp_k: f64,
#[serde(default = "default_humidity")]
pub humidity: f64,
#[serde(default = "default_doy")]
pub day_of_year: f64,
}
fn default_pressure() -> f64 {
1013.25
}
fn default_temp() -> f64 {
288.15
}
fn default_humidity() -> f64 {
0.5
}
fn default_doy() -> f64 {
180.0
}
#[derive(Clone, Debug, Deserialize, Serialize)]
pub struct GnssSimScenario {
pub seed: u64,
pub time: TimeCfg,
pub receiver: ReceiverCfg,
pub constellation: WalkerSgp4,
#[serde(default)]
pub iono: Option<IonoCfg>,
#[serde(default)]
pub tropo: Option<TropoCfg>,
#[serde(default = "default_mask_deg")]
pub mask_deg: f64,
#[serde(default)]
pub noise_sigma_m: f64,
#[serde(default)]
pub multipath_m: f64,
#[serde(default)]
pub sat_clock_rms_m: f64,
#[serde(default = "default_uere")]
pub uere_m: f64,
#[serde(default = "default_p_fa")]
pub p_fa: f64,
#[serde(default = "default_p_md")]
pub p_md: f64,
#[serde(default = "default_alert_h")]
pub alert_limit_h_m: f64,
#[serde(default = "default_alert_v")]
pub alert_limit_v_m: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct SatMeasurement {
pub prn: usize,
pub el_deg: f64,
pub az_deg: f64,
pub pseudorange_m: f64,
pub doppler_hz: f64,
pub cn0_dbhz: f64,
pub iono_correction_m: f64,
pub tropo_correction_m: f64,
pub sat_clock_m: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct RaimEpoch {
pub n_used: usize,
pub fault_detected: bool,
pub hpl_m: f64,
pub vpl_m: f64,
pub test_statistic: f64,
pub threshold: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct GnssSimEpoch {
pub t: f64,
pub n_visible: usize,
pub raim: Option<RaimEpoch>,
pub measurements: Vec<SatMeasurement>,
}
#[derive(Clone, Debug, Serialize)]
pub struct GnssSimFoM {
pub raim_availability: f64,
pub mean_hpl_m: f64,
pub mean_vpl_m: f64,
pub fault_rate: f64,
pub mean_iono_m: f64,
pub mean_tropo_m: f64,
}
#[derive(Clone, Debug, Serialize)]
pub struct GnssSimResult {
pub schema_version: String,
pub engine_version: String,
pub scenario_hash: String,
pub seed: u64,
pub fom: GnssSimFoM,
pub gnss_measurements: Vec<GnssSimEpoch>,
}
fn cn0_dbhz(el_rad: f64) -> f64 {
40.0 + 8.0 * el_rad.sin()
}
fn sat_clock_m(prn: usize, rms: f64) -> f64 {
let frac = (prn as f64 * 0.61803398875).fract();
rms * (2.0 * frac - 1.0)
}
fn hash_scenario(scn: &GnssSimScenario) -> 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_gnss_sim(scn: &GnssSimScenario) -> GnssSimResult {
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 lambda_l1 = C_M_PER_S / L1_HZ;
let iono = scn.iono.as_ref().map(|c| {
(
KlobucharCoeffs {
alpha: c.alpha,
beta: c.beta,
},
c.gps_seconds_of_day,
)
});
let (meteo, doy) = match &scn.tropo {
Some(t) => (
Some(Meteo {
pressure_hpa: t.pressure_hpa,
temp_k: t.temp_k,
humidity: t.humidity,
}),
t.day_of_year,
),
None => (None, 0.0),
};
let dt = scn.time.step_s;
let n = (scn.time.duration_s / dt).round() as usize;
let mut rng = ChaCha8Rng::seed_from_u64(scn.seed);
let noise = Normal::new(0.0, scn.noise_sigma_m.max(0.0).max(1e-12)).unwrap();
let sat_ecef_at = |i: usize, t: f64| -> Vec3 {
teme_to_ecef(sats[i].position_eci(t), walker_epoch_jd() + t / 86_400.0)
};
let mut epochs = Vec::with_capacity(n + 1);
let (mut avail, mut faults, mut hpl_sum, mut vpl_sum, mut hvpl_count) =
(0usize, 0usize, 0.0, 0.0, 0usize);
let (mut iono_sum, mut tropo_sum, mut meas_count) = (0.0, 0.0, 0usize);
for k in 0..=n {
let t = k as f64 * dt;
let mut measurements = Vec::new();
let mut raim_sats: Vec<Vec3> = Vec::new();
let mut raim_resid: Vec<f64> = Vec::new();
for (prn, _) in sats.iter().enumerate() {
let sat = sat_ecef_at(prn, t);
if !is_visible(station, sat, scn.mask_deg) {
continue;
}
let look = look_angles(station, sat);
let (el, az) = (look.el_rad, look.az_rad);
let r_geom = geometric_range_m(station_ecef, sat);
let i_delay = iono
.as_ref()
.map(|(c, sod)| {
klobuchar_delay_m(c, station.lat_rad, station.lon_rad, el, az, sod + t)
})
.unwrap_or(0.0);
let t_delay = meteo
.as_ref()
.map(|m| tropo_delay_m(m, station.lat_rad, station.alt_m, el, doy))
.unwrap_or(0.0);
let sclk = sat_clock_m(prn, scn.sat_clock_rms_m);
let multipath = scn.multipath_m * (1.0 - el.sin());
let eps = if scn.noise_sigma_m > 0.0 {
noise.sample(&mut rng)
} else {
0.0
};
let pr =
r_geom + scn.receiver.clock_bias_m - sclk + i_delay + t_delay + multipath + eps;
let d = 0.5_f64.min(dt.max(1e-3) * 0.5);
let r_plus = geometric_range_m(station_ecef, sat_ecef_at(prn, t + d));
let r_minus = geometric_range_m(station_ecef, sat_ecef_at(prn, (t - d).max(0.0)));
let range_rate = (r_plus - r_minus) / (2.0 * d);
let doppler_hz = -range_rate / lambda_l1;
let predicted = r_geom + scn.receiver.clock_bias_m - sclk + i_delay + t_delay;
raim_sats.push(sat);
raim_resid.push(pr - predicted);
iono_sum += i_delay;
tropo_sum += t_delay;
meas_count += 1;
measurements.push(SatMeasurement {
prn,
el_deg: el.to_degrees(),
az_deg: az.to_degrees(),
pseudorange_m: pr,
doppler_hz,
cn0_dbhz: cn0_dbhz(el),
iono_correction_m: i_delay,
tropo_correction_m: t_delay,
sat_clock_m: sclk,
});
}
let n_visible = measurements.len();
let raim = crate::raim::snapshot_raim(
station_ecef,
&raim_sats,
&raim_resid,
scn.uere_m,
scn.p_fa,
scn.p_md,
)
.map(|r| RaimEpoch {
n_used: r.n_used,
fault_detected: r.fault_detected,
hpl_m: r.hpl_m,
vpl_m: r.vpl_m,
test_statistic: r.test_statistic,
threshold: r.threshold,
});
if let Some(r) = &raim {
hpl_sum += r.hpl_m;
vpl_sum += r.vpl_m;
hvpl_count += 1;
if r.fault_detected {
faults += 1;
}
if r.hpl_m <= scn.alert_limit_h_m && r.vpl_m <= scn.alert_limit_v_m {
avail += 1;
}
}
epochs.push(GnssSimEpoch {
t,
n_visible,
raim,
measurements,
});
}
let denom = (n + 1) as f64;
let hv = hvpl_count.max(1) as f64;
let mc = meas_count.max(1) as f64;
GnssSimResult {
schema_version: crate::interchange::SCHEMA_VERSION.into(),
engine_version: env!("CARGO_PKG_VERSION").into(),
scenario_hash: hash_scenario(scn),
seed: scn.seed,
fom: GnssSimFoM {
raim_availability: avail as f64 / denom,
mean_hpl_m: hpl_sum / hv,
mean_vpl_m: vpl_sum / hv,
fault_rate: faults as f64 / denom,
mean_iono_m: iono_sum / mc,
mean_tropo_m: tropo_sum / mc,
},
gnss_measurements: epochs,
}
}
pub fn to_svg(result: &GnssSimResult, alert_h_m: f64, alert_v_m: f64) -> 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.gnss_measurements;
let t_max = ep.iter().map(|e| e.t).fold(1.0_f64, f64::max);
let mut y_max = alert_v_m.max(alert_h_m);
for e in ep {
if let Some(r) = &e.raim {
y_max = y_max.max(r.hpl_m).max(r.vpl_m);
}
}
y_max *= 1.15;
if y_max <= 0.0 {
y_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 poly = |sel: &dyn Fn(&RaimEpoch) -> f64| {
ep.iter()
.filter_map(|e| {
e.raim
.as_ref()
.map(|r| format!("{:.1},{:.1}", xof(e.t), yof(sel(r))))
})
.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=\"#bcb3a3\"><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\">RAIM protection levels vs alert limits</text>"));
svg.push_str(&crate::chart::y_axis(
ml,
mt,
pw,
ph,
y_max,
"protection level (m)",
));
svg.push_str(&format!("<line x1=\"{ml:.0}\" y1=\"{mt:.0}\" x2=\"{ml:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/><line x1=\"{ml:.0}\" y1=\"{axis_y:.0}\" x2=\"{:.0}\" y2=\"{axis_y:.0}\" stroke=\"#342c21\"/>", ml + pw));
let right = ml + pw;
let val_y = yof(alert_v_m);
let val_label_x = ml + 4.0;
let val_label_y = val_y - 4.0;
svg.push_str(&format!("<line x1=\"{ml:.0}\" y1=\"{val_y:.1}\" x2=\"{right:.0}\" y2=\"{val_y:.1}\" stroke=\"#e5645a\" stroke-dasharray=\"6 4\"/><text x=\"{val_label_x:.0}\" y=\"{val_label_y:.1}\" fill=\"#e5645a\">VAL {alert_v_m:.0} m</text>"));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#e0bd84\" stroke-width=\"2\" points=\"{}\"/>",
poly(&|r| r.hpl_m)
));
svg.push_str(&format!(
"<polyline fill=\"none\" stroke=\"#c79e63\" stroke-width=\"2\" points=\"{}\"/>",
poly(&|r| r.vpl_m)
));
svg.push_str(&format!("<text x=\"{:.0}\" y=\"44\" fill=\"#e0bd84\">HPL</text><text x=\"{:.0}\" y=\"60\" fill=\"#c79e63\">VPL</text>", ml + 10.0, ml + 10.0));
svg.push_str("</svg>");
svg
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn klobuchar_is_positive_and_largest_at_low_elevation() {
let c = KlobucharCoeffs::default();
let (lat, lon) = (40f64.to_radians(), -100f64.to_radians());
let high = klobuchar_delay_m(&c, lat, lon, 80f64.to_radians(), 0.0, 50_400.0);
let low = klobuchar_delay_m(&c, lat, lon, 10f64.to_radians(), 0.0, 50_400.0);
assert!(high > 0.0, "delay should be positive, got {high}");
assert!(
low > high * 1.5,
"low-el {low} should exceed high-el {high} by ≥1.5×"
);
assert!(
high > 0.5 && high < 30.0,
"zenith-ish delay {high} m out of range"
);
}
#[test]
fn klobuchar_night_floor_is_five_ns() {
let c = KlobucharCoeffs::default();
let d = klobuchar_delay_m(&c, 0.0, 0.0, 90f64.to_radians(), 0.0, 7_200.0);
let night_m = 5e-9 * C_M_PER_S; assert!(
(d - night_m).abs() < 0.2,
"night delay {d} should be ≈ {night_m}"
);
}
#[test]
fn saastamoinen_zenith_is_about_2_3_m_at_sea_level() {
let m = Meteo::default();
let (zhd, zwd) = saastamoinen_zenith_m(&m, 45f64.to_radians(), 0.0);
assert!((zhd - 2.30).abs() < 0.05, "ZHD {zhd}");
assert!(zwd > 0.05 && zwd < 0.30, "ZWD {zwd}");
}
#[test]
fn niell_mapping_is_unity_at_zenith_and_grows_toward_the_horizon() {
let lat = 45f64.to_radians();
assert!((niell_hydrostatic(PI / 2.0, lat, 0.0, 100.0) - 1.0).abs() < 1e-9);
assert!((niell_wet(PI / 2.0, lat) - 1.0).abs() < 1e-9);
let m30 = niell_hydrostatic(30f64.to_radians(), lat, 0.0, 100.0);
let m5 = niell_hydrostatic(5f64.to_radians(), lat, 0.0, 100.0);
assert!(m30 > 1.9 && m30 < 2.1, "m(30°)={m30}");
assert!(m5 > 9.0 && m5 < 12.0, "m(5°)={m5}");
assert!(m5 > m30);
}
#[test]
fn total_tropo_at_30_deg_is_about_twice_the_zenith() {
let meteo = Meteo::default();
let lat = 45f64.to_radians();
let (zhd, zwd) = saastamoinen_zenith_m(&meteo, lat, 0.0);
let zenith_total = zhd + zwd;
let slant30 = tropo_delay_m(&meteo, lat, 0.0, 30f64.to_radians(), 100.0);
assert!(
(slant30 / zenith_total - 2.0).abs() < 0.05,
"slant/zenith = {}",
slant30 / zenith_total
);
}
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 sim_scenario(noise_sigma_m: f64) -> GnssSimScenario {
GnssSimScenario {
seed: 42,
time: TimeCfg {
step_s: 30.0,
duration_s: 300.0,
},
receiver: ReceiverCfg {
lat_deg: 40.0,
lon_deg: -3.0,
alt_m: 600.0,
clock_bias_m: 1234.5,
},
constellation: gps_like(),
iono: Some(IonoCfg {
alpha: [3.82e-8, 1.49e-8, -1.79e-7, 0.0],
beta: [1.43e5, 0.0, -3.28e5, 1.13e5],
gps_seconds_of_day: 50_400.0,
}),
tropo: Some(TropoCfg {
pressure_hpa: 1013.25,
temp_k: 288.15,
humidity: 0.5,
day_of_year: 180.0,
}),
mask_deg: 5.0,
noise_sigma_m,
multipath_m: 0.0,
sat_clock_rms_m: 30.0,
uere_m: 3.0,
p_fa: 1e-5,
p_md: 1e-3,
alert_limit_h_m: 40.0,
alert_limit_v_m: 50.0,
}
}
#[test]
fn zero_noise_pseudorange_matches_geometry_plus_corrections_to_a_millimetre() {
let r = run_gnss_sim(&sim_scenario(0.0));
let mut checked = 0;
for e in &r.gnss_measurements {
for m in &e.measurements {
assert!(
m.pseudorange_m > 1.9e7,
"pseudorange {} implausible",
m.pseudorange_m
);
checked += 1;
}
if let Some(raim) = &e.raim {
assert!(
raim.test_statistic < 1e-6,
"zero-noise RAIM statistic should be ~0, got {}",
raim.test_statistic
);
assert!(!raim.fault_detected);
}
}
assert!(checked > 20, "expected many measurements, got {checked}");
}
#[test]
fn corrections_are_reported_and_physically_sized() {
let r = run_gnss_sim(&sim_scenario(0.0));
let mut saw = false;
for e in &r.gnss_measurements {
for m in &e.measurements {
assert!(m.iono_correction_m > 0.0 && m.iono_correction_m < 60.0);
assert!(m.tropo_correction_m > 2.0 && m.tropo_correction_m < 30.0);
assert!(m.cn0_dbhz > 39.0 && m.cn0_dbhz < 49.0);
assert!(m.doppler_hz.abs() < 6000.0, "doppler {}", m.doppler_hz);
saw = true;
}
}
assert!(saw);
assert!(r.fom.mean_iono_m > 0.0 && r.fom.mean_tropo_m > 2.0);
}
#[test]
fn raim_produces_protection_levels_and_run_is_deterministic() {
let r = run_gnss_sim(&sim_scenario(1.0));
let with_raim = r
.gnss_measurements
.iter()
.filter(|e| e.raim.is_some())
.count();
assert!(
with_raim > 0,
"RAIM should be available with a full constellation"
);
for e in &r.gnss_measurements {
if let Some(raim) = &e.raim {
assert!(raim.hpl_m > 0.0 && raim.vpl_m > 0.0);
assert!(raim.n_used >= 5);
}
}
let a = serde_json::to_string(&run_gnss_sim(&sim_scenario(1.0))).unwrap();
let b = serde_json::to_string(&run_gnss_sim(&sim_scenario(1.0))).unwrap();
assert_eq!(a, b);
}
#[test]
fn disabling_the_atmosphere_zeroes_the_corrections() {
let mut scn = sim_scenario(0.0);
scn.iono = None;
scn.tropo = None;
let r = run_gnss_sim(&scn);
for e in &r.gnss_measurements {
for m in &e.measurements {
assert_eq!(m.iono_correction_m, 0.0);
assert_eq!(m.tropo_correction_m, 0.0);
}
}
}
}