use chrono::{DateTime, Datelike, Utc};
use nalgebra::Vector3;
use rand::SeedableRng;
use rand_distr::{Distribution, Normal};
use serde::{Deserialize, Serialize};
use std::fs::File;
use std::io::{self, Read, Write};
use std::path::Path;
use crate::IMUData;
use crate::earth::meters_ned_to_dlat_dlon;
use crate::measurements::{
GPSPositionAndVelocityMeasurement, MAG_YAW_NOISE, MagnetometerYawMeasurement, MeasurementModel,
RelativeAltitudeMeasurement,
};
use crate::sim::TestDataRecord;
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GnssScheduler {
#[default]
PassThrough,
FixedInterval {
interval_s: f64,
phase_s: f64,
},
DutyCycle {
on_s: f64,
off_s: f64,
start_phase_s: f64,
},
}
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum GnssFaultModel {
#[default]
None,
Degraded {
rho_pos: f64,
sigma_pos_m: f64,
rho_vel: f64,
sigma_vel_mps: f64,
r_scale: f64,
},
SlowBias {
drift_n_mps: f64,
drift_e_mps: f64,
q_bias: f64,
rotate_omega_rps: f64,
},
Hijack {
offset_n_m: f64,
offset_e_m: f64,
start_s: f64,
duration_s: f64,
},
Combo(Vec<GnssFaultModel>),
}
fn default_seed() -> u64 {
42
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct GnssDegradationConfig {
#[serde(default)]
pub scheduler: GnssScheduler,
#[serde(default)]
pub fault: GnssFaultModel,
#[serde(default = "default_seed")]
pub seed: u64,
}
impl Default for GnssDegradationConfig {
fn default() -> Self {
GnssDegradationConfig {
scheduler: GnssScheduler::default(),
fault: GnssFaultModel::default(),
seed: default_seed(),
}
}
}
impl GnssDegradationConfig {
pub fn to_json<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let file = File::create(path)?;
serde_json::to_writer_pretty(file, self).map_err(io::Error::other)
}
pub fn from_json<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let file = File::open(path)?;
serde_json::from_reader(file).map_err(io::Error::other)
}
pub fn to_yaml<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let mut file = File::create(path)?;
let s = serde_yaml::to_string(self).map_err(io::Error::other)?;
file.write_all(s.as_bytes())
}
pub fn from_yaml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let file = File::open(path)?;
serde_yaml::from_reader(file).map_err(io::Error::other)
}
pub fn to_toml<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let mut file = File::create(path)?;
let s = toml::to_string(self).map_err(io::Error::other)?;
file.write_all(s.as_bytes())
}
pub fn from_toml<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let mut s = String::new();
let mut file = File::open(path)?;
file.read_to_string(&mut s)?;
toml::from_str(&s).map_err(io::Error::other)
}
pub fn to_file<P: AsRef<Path>>(&self, path: P) -> io::Result<()> {
let p = path.as_ref();
let ext = p
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
match ext.as_deref() {
Some("json") => self.to_json(p),
Some("yaml") | Some("yml") => self.to_yaml(p),
Some("toml") => self.to_toml(p),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported file extension",
)),
}
}
pub fn from_file<P: AsRef<Path>>(path: P) -> io::Result<Self> {
let p = path.as_ref();
let ext = p
.extension()
.and_then(|s| s.to_str())
.map(|s| s.to_lowercase());
match ext.as_deref() {
Some("json") => Self::from_json(p),
Some("yaml") | Some("yml") => Self::from_yaml(p),
Some("toml") => Self::from_toml(p),
_ => Err(io::Error::new(
io::ErrorKind::InvalidInput,
"unsupported file extension",
)),
}
}
}
pub enum Event {
Imu {
dt_s: f64,
imu: IMUData,
elapsed_s: f64,
},
Measurement {
meas: Box<dyn MeasurementModel>, elapsed_s: f64,
},
}
pub struct EventStream {
pub start_time: DateTime<Utc>,
pub events: Vec<Event>,
}
#[derive(Clone, Debug)]
pub struct FaultState {
e_n_m: f64,
e_e_m: f64,
e_u_m: f64,
ev_n_mps: f64,
ev_e_mps: f64,
b_n_m: f64,
b_e_m: f64,
rng: rand::rngs::StdRng,
}
impl FaultState {
pub fn new(seed: u64) -> Self {
Self {
e_n_m: 0.0,
e_e_m: 0.0,
e_u_m: 0.0,
ev_n_mps: 0.0,
ev_e_mps: 0.0,
b_n_m: 0.0,
b_e_m: 0.0,
rng: rand::rngs::StdRng::seed_from_u64(seed),
}
}
}
fn ar1_step(x: &mut f64, rho: f64, sigma: f64, rng: &mut rand::rngs::StdRng) {
let n = Normal::new(0.0, sigma.max(0.0)).unwrap();
*x = rho * *x + n.sample(rng);
}
#[allow(clippy::too_many_arguments)]
pub fn apply_fault(
fault: &GnssFaultModel,
st: &mut FaultState,
t: f64,
dt: f64,
lat_deg: f64,
lon_deg: f64,
alt_m: f64,
vn_mps: f64,
ve_mps: f64,
horiz_std_m: f64,
vel_std_mps: f64,
) -> (f64, f64, f64, f64, f64, f64, f64) {
match fault {
GnssFaultModel::None => (
lat_deg,
lon_deg,
alt_m,
vn_mps,
ve_mps,
horiz_std_m,
vel_std_mps,
),
GnssFaultModel::Degraded {
rho_pos,
sigma_pos_m,
rho_vel,
sigma_vel_mps,
r_scale,
} => {
ar1_step(&mut st.e_n_m, *rho_pos, *sigma_pos_m, &mut st.rng);
ar1_step(&mut st.e_e_m, *rho_pos, *sigma_pos_m, &mut st.rng);
ar1_step(&mut st.e_u_m, *rho_pos, *sigma_pos_m, &mut st.rng);
ar1_step(&mut st.ev_n_mps, *rho_vel, *sigma_vel_mps, &mut st.rng);
ar1_step(&mut st.ev_e_mps, *rho_vel, *sigma_vel_mps, &mut st.rng);
let (dlat, dlon) =
meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, st.e_n_m, st.e_e_m);
let lat_c = lat_deg + dlat.to_degrees();
let lon_c = lon_deg + dlon.to_degrees();
let alt_c = alt_m + st.e_u_m;
let vn_c = vn_mps + st.ev_n_mps;
let ve_c = ve_mps + st.ev_e_mps;
(
lat_c,
lon_c,
alt_c,
vn_c,
ve_c,
horiz_std_m * r_scale,
vel_std_mps * r_scale,
)
}
GnssFaultModel::SlowBias {
drift_n_mps,
drift_e_mps,
q_bias,
rotate_omega_rps,
} => {
let (mut bn_dot, mut be_dot) = (*drift_n_mps, *drift_e_mps);
if *rotate_omega_rps != 0.0 {
let th = rotate_omega_rps * t;
let c = th.cos();
let s = th.sin();
let (n0, e0) = (*drift_n_mps, *drift_e_mps);
bn_dot = c * n0 - s * e0;
be_dot = s * n0 + c * e0;
}
st.b_n_m += bn_dot * dt;
st.b_e_m += be_dot * dt;
if *q_bias > 0.0 {
ar1_step(&mut st.b_n_m, 1.0, (q_bias * dt).sqrt(), &mut st.rng);
ar1_step(&mut st.b_e_m, 1.0, (q_bias * dt).sqrt(), &mut st.rng);
}
let (dlat, dlon) =
meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, st.b_n_m, st.b_e_m);
let lat_c = lat_deg + dlat.to_degrees();
let lon_c = lon_deg + dlon.to_degrees();
let vn_c = vn_mps + bn_dot;
let ve_c = ve_mps + be_dot;
(lat_c, lon_c, alt_m, vn_c, ve_c, horiz_std_m, vel_std_mps)
}
GnssFaultModel::Hijack {
offset_n_m,
offset_e_m,
start_s,
duration_s,
} => {
if t >= *start_s && t <= (start_s + duration_s) {
let (dlat, dlon) =
meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, *offset_n_m, *offset_e_m);
let lat_c = lat_deg + dlat.to_degrees();
let lon_c = lon_deg + dlon.to_degrees();
(
lat_c,
lon_c,
alt_m,
vn_mps,
ve_mps,
horiz_std_m,
vel_std_mps,
)
} else {
(
lat_deg,
lon_deg,
alt_m,
vn_mps,
ve_mps,
horiz_std_m,
vel_std_mps,
)
}
}
GnssFaultModel::Combo(models) => {
let mut out = (
lat_deg,
lon_deg,
alt_m,
vn_mps,
ve_mps,
horiz_std_m,
vel_std_mps,
);
for m in models {
out = apply_fault(
m, st, t, dt, out.0, out.1, out.2, out.3, out.4, out.5, out.6,
);
}
out
}
}
}
pub fn build_event_stream(records: &[TestDataRecord], cfg: &GnssDegradationConfig) -> EventStream {
let start_time = records[0].time;
let records_with_elapsed: Vec<(f64, &TestDataRecord)> = records
.iter()
.map(|r| ((r.time - start_time).num_milliseconds() as f64 / 1000.0, r))
.collect();
let mut events = Vec::with_capacity(records_with_elapsed.len() * 2);
let mut st = FaultState::new(cfg.seed);
let mut next_emit_time = match cfg.scheduler {
GnssScheduler::PassThrough => 0.0,
GnssScheduler::FixedInterval { phase_s, .. } => phase_s,
GnssScheduler::DutyCycle { start_phase_s, .. } => start_phase_s,
};
let mut duty_on = true;
let reference_altitude = records[0].altitude;
for w in records_with_elapsed.windows(2) {
let (t0, _) = (&w[0].0, &w[0].1);
let (t1, r1) = (&w[1].0, &w[1].1);
let dt = t1 - t0;
let imu_components = [
r1.acc_x, r1.acc_y, r1.acc_z, r1.gyro_x, r1.gyro_y, r1.gyro_z,
];
let imu_present = imu_components.iter().all(|v| !v.is_nan());
if imu_present {
let imu = IMUData {
accel: Vector3::new(r1.acc_x, r1.acc_y, r1.acc_z),
gyro: Vector3::new(r1.gyro_x, r1.gyro_y, r1.gyro_z),
};
events.push(Event::Imu {
dt_s: dt,
imu,
elapsed_s: *t1,
});
}
let should_emit = match cfg.scheduler {
GnssScheduler::PassThrough => true,
GnssScheduler::FixedInterval { interval_s, .. } => {
if *t1 + 1e-9 >= next_emit_time {
next_emit_time += interval_s;
true
} else {
false
}
}
GnssScheduler::DutyCycle { on_s, off_s, .. } => {
let window = if duty_on { on_s } else { off_s };
if *t1 + 1e-9 >= next_emit_time {
duty_on = !duty_on;
next_emit_time += window;
duty_on } else {
false
}
}
};
if should_emit {
let gnss_required = [r1.latitude, r1.longitude, r1.altitude, r1.speed, r1.bearing];
let gnss_present = gnss_required.iter().all(|v| !v.is_nan());
if gnss_present {
let lat = r1.latitude;
let lon = r1.longitude;
let alt = r1.altitude;
let bearing_rad = r1.bearing.to_radians();
let vn = r1.speed * bearing_rad.cos();
let ve = r1.speed * bearing_rad.sin();
let horiz_std = if r1.horizontal_accuracy.is_nan() {
15.0
} else {
r1.horizontal_accuracy.max(1e-3)
};
let vert_std = if r1.vertical_accuracy.is_nan() {
1000.0
} else {
r1.vertical_accuracy.max(1e-3)
};
let vel_std = if r1.speed_accuracy.is_nan() {
100.0
} else {
r1.speed_accuracy.max(0.1)
};
let (lat_c, lon_c, alt_c, vn_c, ve_c, horiz_c, vel_c) = apply_fault(
&cfg.fault, &mut st, *t1, dt, lat, lon, alt, vn, ve, horiz_std, vel_std,
);
let meas = GPSPositionAndVelocityMeasurement {
latitude: lat_c,
longitude: lon_c,
altitude: alt_c,
northward_velocity: vn_c,
eastward_velocity: ve_c,
horizontal_noise_std: horiz_c,
vertical_noise_std: vert_std, velocity_noise_std: vel_c,
};
events.push(Event::Measurement {
meas: Box::new(meas),
elapsed_s: *t1,
});
}
}
if !r1.relative_altitude.is_nan() {
let baro: RelativeAltitudeMeasurement = RelativeAltitudeMeasurement {
relative_altitude: r1.relative_altitude,
reference_altitude,
};
events.push(Event::Measurement {
meas: Box::new(baro),
elapsed_s: *t1,
});
}
if [r1.mag_x, r1.mag_y, r1.mag_z].iter().all(|v| !v.is_nan()) {
let mag_meas = MagnetometerYawMeasurement {
mag_x: r1.mag_x,
mag_y: r1.mag_y,
mag_z: r1.mag_z,
noise_std: MAG_YAW_NOISE, apply_declination: true,
year: r1.time.year(),
day_of_year: r1.time.day() as u16,
};
events.push(Event::Measurement {
meas: Box::new(mag_meas),
elapsed_s: *t1,
});
}
}
EventStream { start_time, events }
}
#[cfg(test)]
mod tests {
use super::*;
use assert_approx_eq::assert_approx_eq;
use chrono::{TimeZone, Utc};
fn create_test_records(count: usize, interval_secs: f64) -> Vec<TestDataRecord> {
let base_time = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let mut records = Vec::with_capacity(count);
for i in 0..count {
let time_ms = (i as f64 * interval_secs * 1000.0) as i64;
let time = base_time + chrono::Duration::milliseconds(time_ms);
let record = TestDataRecord {
time,
latitude: 37.0,
longitude: -122.0,
altitude: 100.0,
bearing: 45.0,
speed: 5.0,
acc_x: 0.0,
acc_y: 0.0,
acc_z: 9.81,
gyro_x: 0.0,
gyro_y: 0.0,
gyro_z: 0.01,
qx: 0.0,
qy: 0.0,
qz: 0.0,
qw: 1.0,
roll: 0.0,
pitch: 0.0,
yaw: 0.0,
mag_x: 0.0,
mag_y: 0.0,
mag_z: 0.0,
relative_altitude: 0.0,
pressure: 1013.25,
grav_x: 0.0,
grav_y: 0.0,
grav_z: 9.81,
horizontal_accuracy: 2.0,
vertical_accuracy: 4.0,
speed_accuracy: 0.5,
bearing_accuracy: 1.0,
};
records.push(record);
}
records
}
#[test]
fn test_passthrough_scheduler() {
let records = create_test_records(10, 0.1); let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let events = build_event_stream(&records, &config);
assert_eq!(events.events.len(), 36);
let imu_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Imu { .. }))
.count();
let gnss_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.filter(|e| {
if let Event::Measurement { meas, .. } = e {
meas.as_any().is::<GPSPositionAndVelocityMeasurement>()
} else {
false
}
})
.count();
assert_eq!(imu_count, 9);
assert_eq!(gnss_count, 9);
}
#[test]
fn test_fixed_interval_scheduler() {
let records = create_test_records(20, 0.1); let config = GnssDegradationConfig {
scheduler: GnssScheduler::FixedInterval {
interval_s: 0.5,
phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let events = build_event_stream(&records, &config);
let imu_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Imu { .. }))
.count();
let measurements = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.count();
assert_eq!(imu_count, 19);
assert_eq!(measurements, 42); }
#[test]
fn test_duty_cycle_scheduler() {
let records = create_test_records(60, 1.0); assert!(
records.len() == 60,
"{}",
format!("Expected 60 records, found: {}", records.len())
);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::DutyCycle {
on_s: 1.0,
off_s: 1.0,
start_phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let events = build_event_stream(&records, &config);
let measurements: Vec<&Event> = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.collect();
assert!(measurements.len() >= 2);
assert!(
measurements.len() == 147,
"{}",
format!("Expected 147 GNSS events, found: {}", measurements.len())
);
}
#[test]
fn test_degraded_fault_model() {
let records = create_test_records(10, 0.1);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::Degraded {
rho_pos: 0.99,
sigma_pos_m: 3.0,
rho_vel: 0.95,
sigma_vel_mps: 0.3,
r_scale: 5.0,
},
seed: 500,
};
let events = build_event_stream(&records, &config);
let measurements: Vec<&Event> = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.collect();
let gnss_events: Vec<&Event> = measurements
.into_iter()
.filter(|e| {
if let Event::Measurement { meas, .. } = e {
meas.as_any().is::<GPSPositionAndVelocityMeasurement>()
} else {
false
}
})
.collect();
for event in &gnss_events {
let meas = if let Event::Measurement { meas, .. } = event {
meas.as_any()
.downcast_ref::<GPSPositionAndVelocityMeasurement>()
} else {
None
};
let Some(meas) = meas else {
continue;
};
assert_approx_eq!(meas.horizontal_noise_std, 9.0, 2.0);
assert_approx_eq!(meas.velocity_noise_std, 2.5, 0.1);
}
let original_lat = 37.0;
let original_lon = -122.0;
let mut all_same = true;
let mut prev_lat: Option<f64> = None;
let mut prev_lon: Option<f64> = None;
for event in &gnss_events {
if let Event::Measurement { meas, .. } = event {
let meas = meas
.as_any()
.downcast_ref::<GPSPositionAndVelocityMeasurement>()
.unwrap();
assert_approx_eq!(meas.latitude, original_lat, 1e-3);
assert_approx_eq!(meas.longitude, original_lon, 1e-3);
if let Some(prev_lat) = prev_lat
&& (meas.latitude - prev_lat).abs() > 1e-10
{
all_same = false;
}
prev_lat = Some(meas.latitude);
if let Some(prev_lon) = prev_lon
&& (meas.longitude - prev_lon).abs() > 1e-10
{
all_same = false;
}
prev_lon = Some(meas.longitude);
}
}
assert!(!all_same);
}
#[test]
fn slow_bias_adds_velocity_bias() {
let mut st = FaultState::new(123);
let fault = GnssFaultModel::SlowBias {
drift_n_mps: 0.02,
drift_e_mps: -0.01,
q_bias: 0.0,
rotate_omega_rps: 0.0,
};
let (_lat, _lon, _alt, vn_c, ve_c, _hstd, _vstd) = apply_fault(
&fault, &mut st, 10.0, 1.0, 40.0,
-75.0, 0.0, 0.0, 0.0,
3.0, 0.2,
);
assert_approx_eq!(vn_c, 0.02, 0.001);
assert_approx_eq!(ve_c, -0.01, 0.001);
}
#[test]
fn test_hijack_fault_model() {
let records = create_test_records(30, 0.1); let offset_n = 50.0;
let offset_e = 30.0;
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::Hijack {
offset_n_m: offset_n,
offset_e_m: offset_e,
start_s: 1.0,
duration_s: 1.0, },
..Default::default()
};
let events = build_event_stream(&records, &config);
let mut gnss_by_time: Vec<(f64, &GPSPositionAndVelocityMeasurement)> = Vec::new();
for event in &events.events {
if let Event::Measurement { meas, elapsed_s } = event
&& let Some(gps) = meas
.as_any()
.downcast_ref::<GPSPositionAndVelocityMeasurement>()
{
gnss_by_time.push((*elapsed_s, gps));
}
}
gnss_by_time.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let original_lat = 37.0;
let original_lon = -122.0;
for (time, meas) in gnss_by_time {
if !(1.0 - 1e-6..=2.0 + 1e-6).contains(&time) {
assert!((meas.latitude - original_lat).abs() < 1e-6);
assert!((meas.longitude - original_lon).abs() < 1e-6);
} else {
assert!((meas.latitude - original_lat).abs() > 1e-6);
assert!((meas.longitude - original_lon).abs() > 1e-6);
}
}
}
#[test]
fn test_combo_fault_model() {
let records = create_test_records(10, 0.1);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::Combo(vec![GnssFaultModel::None, GnssFaultModel::None]),
..Default::default()
};
let events = build_event_stream(&records, &config);
assert!(!events.events.is_empty());
}
#[test]
fn test_ar1_step() {
let mut rng = rand::rngs::StdRng::seed_from_u64(42);
let mut x = 10.0;
ar1_step(&mut x, 0.0, 1.0, &mut rng);
assert!(x != 10.0); assert!(x.abs() < 5.0);
let mut x = 10.0;
ar1_step(&mut x, 1.0, 0.0, &mut rng);
assert_eq!(x, 10.0);
let mut x = 10.0;
ar1_step(&mut x, 0.5, -1.0, &mut rng);
assert_eq!(x, 5.0); }
#[test]
fn test_slow_bias_fault_with_rotation() {
let records = create_test_records(10, 0.1);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::SlowBias {
drift_n_mps: 1.0,
drift_e_mps: 0.5,
rotate_omega_rps: 0.1,
q_bias: 0.0,
},
..Default::default()
};
let events = build_event_stream(&records, &config);
assert!(!events.events.is_empty());
let gnss_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.count();
assert!(gnss_count > 0);
}
#[test]
fn test_slow_bias_fault_with_q_bias() {
let records = create_test_records(10, 0.1);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::SlowBias {
drift_n_mps: 0.0,
drift_e_mps: 0.0,
rotate_omega_rps: 0.0,
q_bias: 1.0,
},
..Default::default()
};
let events = build_event_stream(&records, &config);
assert!(!events.events.is_empty());
let gnss_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.count();
assert!(gnss_count > 0);
}
#[test]
fn test_nan_accuracy_handling() {
let base_time = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
let records = vec![
TestDataRecord {
time: base_time,
latitude: 37.0,
longitude: -122.0,
altitude: 100.0,
bearing: 45.0,
speed: 5.0,
acc_x: 0.0,
acc_y: 0.0,
acc_z: 9.81,
gyro_x: 0.0,
gyro_y: 0.0,
gyro_z: 0.01,
qx: 0.0,
qy: 0.0,
qz: 0.0,
qw: 1.0,
roll: 0.0,
pitch: 0.0,
yaw: 0.0,
mag_x: 0.0,
mag_y: 0.0,
mag_z: 0.0,
relative_altitude: 0.0,
pressure: 1013.25,
grav_x: 0.0,
grav_y: 0.0,
grav_z: 9.81,
horizontal_accuracy: 2.0,
vertical_accuracy: 4.0,
speed_accuracy: 0.5,
bearing_accuracy: 1.0,
},
TestDataRecord {
time: base_time + chrono::Duration::milliseconds(100),
latitude: 37.0,
longitude: -122.0,
altitude: 100.0,
bearing: 45.0,
speed: 5.0,
acc_x: 0.0,
acc_y: 0.0,
acc_z: 9.81,
gyro_x: 0.0,
gyro_y: 0.0,
gyro_z: 0.01,
qx: 0.0,
qy: 0.0,
qz: 0.0,
qw: 1.0,
roll: 0.0,
pitch: 0.0,
yaw: 0.0,
mag_x: 0.0,
mag_y: 0.0,
mag_z: 0.0,
relative_altitude: 0.0,
pressure: 1013.25,
grav_x: 0.0,
grav_y: 0.0,
grav_z: 9.81,
horizontal_accuracy: f64::NAN,
vertical_accuracy: f64::NAN,
speed_accuracy: f64::NAN,
bearing_accuracy: 1.0,
},
];
let config = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let events = build_event_stream(&records, &config);
assert!(!events.events.is_empty());
let gnss_count = events
.events
.iter()
.filter(|e| matches!(e, Event::Measurement { .. }))
.count();
assert!(gnss_count > 0);
}
#[test]
fn test_duty_cycle_scheduler_toggles() {
let records = create_test_records(20, 0.1);
let config = GnssDegradationConfig {
scheduler: GnssScheduler::DutyCycle {
on_s: 0.5,
off_s: 0.5,
start_phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let events = build_event_stream(&records, &config);
assert!(!events.events.is_empty());
}
}
#[cfg(test)]
mod serialization_tests {
use super::*;
use tempfile::NamedTempFile;
fn sample_cfg() -> GnssDegradationConfig {
GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::Degraded {
rho_pos: 0.99,
sigma_pos_m: 3.0,
rho_vel: 0.95,
sigma_vel_mps: 0.3,
r_scale: 5.0,
},
..Default::default()
}
}
#[test]
fn json_roundtrip() {
let cfg = sample_cfg();
let f = NamedTempFile::new().unwrap();
let path = f.path().with_extension("json");
cfg.to_json(&path).unwrap();
let loaded = GnssDegradationConfig::from_json(&path).unwrap();
assert_eq!(cfg.seed, loaded.seed);
}
#[test]
fn yaml_roundtrip() {
let cfg = sample_cfg();
let f = NamedTempFile::new().unwrap();
let path = f.path().with_extension("yaml");
cfg.to_yaml(&path).unwrap();
let loaded = GnssDegradationConfig::from_yaml(&path).unwrap();
assert_eq!(cfg.seed, loaded.seed);
}
#[test]
fn toml_roundtrip() {
let cfg = sample_cfg();
let f = NamedTempFile::new().unwrap();
let path = f.path().with_extension("toml");
cfg.to_toml(&path).unwrap();
let loaded = GnssDegradationConfig::from_toml(&path).unwrap();
assert_eq!(cfg.seed, loaded.seed);
}
#[test]
fn generic_dispatch_roundtrip() {
let cfg = sample_cfg();
let f = NamedTempFile::new().unwrap();
let path = f.path().with_extension("json");
cfg.to_file(&path).unwrap();
let loaded = GnssDegradationConfig::from_file(&path).unwrap();
assert_eq!(cfg.seed, loaded.seed);
}
}