use std::path::Path;
use strapdown::StrapdownState;
use strapdown::earth::haversine_distance;
use strapdown::kalman::{
ErrorStateKalmanFilter, ExtendedKalmanFilter, InitialState, UnscentedKalmanFilter,
};
use strapdown::messages::{
Event, GnssDegradationConfig, GnssFaultModel, GnssScheduler, build_event_stream,
};
use strapdown::rbpf::{RaoBlackwellizedParticleFilter, RbpfConfig};
use strapdown::sim::{NavigationResult, TestDataRecord, dead_reckoning, run_closed_loop};
use nalgebra::{DMatrix, DVector, Quaternion, Rotation3, UnitQuaternion};
const DEFAULT_PROCESS_NOISE: [f64; 15] = [
1e-6, 1e-6, 1e-6, 1e-3, 1e-3, 1e-3, 1e-5, 1e-5, 1e-5, 1e-6, 1e-6, 1e-6, 1e-8, 1e-8, 1e-8, ];
const DEFAULT_INITIAL_COVARIANCE: [f64; 15] = [
1e-6, 1e-6, 1.0, 0.1, 0.1, 0.1, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.001, 0.001, 0.001, ];
const MIN_DRIFT_FOR_COMPARISON: f64 = 5.0;
const ESKF_PROCESS_NOISE: [f64; 15] = [
8e-6, 8e-6, 8e-6, 8e-3, 8e-3, 8e-3, 8e-5, 8e-5, 8e-5, 8e-6, 8e-6, 8e-6, 8e-8, 8e-8, 8e-8, ];
const ESKF_INITIAL_COVARIANCE: [f64; 15] = [
8e-6, 8e-6, 8.0, 0.8, 0.8, 0.8, 0.08, 0.08, 0.08, 0.08, 0.08, 0.08, 0.008, 0.008, 0.008, ];
#[derive(Debug, Clone)]
struct ErrorStats {
mean_horizontal_error: f64,
min_horizontal_error: f64,
median_horizontal_error: f64,
max_horizontal_error: f64,
rms_horizontal_error: f64,
mean_altitude_error: f64,
min_altitude_error: f64,
median_altitude_error: f64,
max_altitude_error: f64,
rms_altitude_error: f64,
mean_velocity_north_error: f64,
mean_velocity_east_error: f64,
mean_velocity_vertical_error: f64,
}
impl ErrorStats {
fn new() -> Self {
Self {
mean_horizontal_error: 0.0,
min_horizontal_error: 0.0,
median_horizontal_error: 0.0,
max_horizontal_error: 0.0,
rms_horizontal_error: 0.0,
mean_altitude_error: 0.0,
min_altitude_error: 0.0,
median_altitude_error: 0.0,
max_altitude_error: 0.0,
rms_altitude_error: 0.0,
mean_velocity_north_error: 0.0,
mean_velocity_east_error: 0.0,
mean_velocity_vertical_error: 0.0,
}
}
}
fn compute_error_metrics(results: &[NavigationResult], records: &[TestDataRecord]) -> ErrorStats {
let mut horizontal_errors = Vec::new();
let mut altitude_errors = Vec::new();
let mut velocity_north_errors = Vec::new();
let mut velocity_east_errors = Vec::new();
let mut velocity_vertical_errors = Vec::new();
for (i, result) in results.iter().enumerate() {
if let Some(record) = records.iter().find(|r| r.time == result.timestamp) {
if record.latitude.is_nan()
|| record.longitude.is_nan()
|| record.altitude.is_nan()
|| record.horizontal_accuracy.is_nan()
{
continue;
}
if i < 3 {
println!(
"Record {}: result.lat={:.6}, result.lon={:.6}, result.alt={:.2}",
i, result.latitude, result.longitude, result.altitude
);
println!(
"Record {}: record.lat={:.6}, record.lon={:.6}, record.alt={:.2}",
i, record.latitude, record.longitude, record.altitude
);
}
let horizontal_error = haversine_distance(
result.latitude.to_radians(),
result.longitude.to_radians(),
record.latitude.to_radians(),
record.longitude.to_radians(),
);
if i < 3 {
println!("Record {}: horizontal_error={:.2}m", i, horizontal_error);
}
if !horizontal_error.is_finite() {
if i < 10 || horizontal_errors.len() < 10 {
println!(
"WARNING: Skipping non-finite horizontal_error at index {}",
i
);
}
continue;
}
horizontal_errors.push(horizontal_error);
let altitude_error = (result.altitude - record.altitude).abs();
if altitude_error.is_finite() {
altitude_errors.push(altitude_error);
}
let gnss_vel_north = record.speed * record.bearing.to_radians().cos();
let gnss_vel_east = record.speed * record.bearing.to_radians().sin();
let vn_err = (result.velocity_north - gnss_vel_north).abs();
let ve_err = (result.velocity_east - gnss_vel_east).abs();
let vd_err = result.velocity_vertical.abs();
if vn_err.is_finite() {
velocity_north_errors.push(vn_err);
}
if ve_err.is_finite() {
velocity_east_errors.push(ve_err);
}
if vd_err.is_finite() {
velocity_vertical_errors.push(vd_err);
}
}
}
let mut stats = ErrorStats::new();
println!("Collected {} horizontal errors", horizontal_errors.len());
if horizontal_errors.len() > 10 {
println!(
"Last 10 horizontal errors: {:?}",
&horizontal_errors[horizontal_errors.len() - 10..]
);
}
if !horizontal_errors.is_empty() {
stats.mean_horizontal_error =
horizontal_errors.iter().sum::<f64>() / horizontal_errors.len() as f64;
stats.min_horizontal_error = horizontal_errors
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
stats.max_horizontal_error = horizontal_errors
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
stats.rms_horizontal_error = (horizontal_errors.iter().map(|e| e.powi(2)).sum::<f64>()
/ horizontal_errors.len() as f64)
.sqrt();
let mut sorted_horizontal = horizontal_errors.clone();
sorted_horizontal.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = sorted_horizontal.len() / 2;
stats.median_horizontal_error = if sorted_horizontal.len() % 2 == 0 {
(sorted_horizontal[mid - 1] + sorted_horizontal[mid]) / 2.0
} else {
sorted_horizontal[mid]
};
}
if !altitude_errors.is_empty() {
stats.mean_altitude_error =
altitude_errors.iter().sum::<f64>() / altitude_errors.len() as f64;
stats.min_altitude_error = altitude_errors
.iter()
.cloned()
.fold(f64::INFINITY, f64::min);
stats.max_altitude_error = altitude_errors
.iter()
.cloned()
.fold(f64::NEG_INFINITY, f64::max);
stats.rms_altitude_error = (altitude_errors.iter().map(|e| e.powi(2)).sum::<f64>()
/ altitude_errors.len() as f64)
.sqrt();
let mut sorted_altitude = altitude_errors.clone();
sorted_altitude.sort_by(|a, b| a.partial_cmp(b).unwrap());
let mid = sorted_altitude.len() / 2;
stats.median_altitude_error = if sorted_altitude.len() % 2 == 0 {
(sorted_altitude[mid - 1] + sorted_altitude[mid]) / 2.0
} else {
sorted_altitude[mid]
};
}
if !velocity_north_errors.is_empty() {
stats.mean_velocity_north_error =
velocity_north_errors.iter().sum::<f64>() / velocity_north_errors.len() as f64;
stats.mean_velocity_east_error =
velocity_east_errors.iter().sum::<f64>() / velocity_east_errors.len() as f64;
stats.mean_velocity_vertical_error =
velocity_vertical_errors.iter().sum::<f64>() / velocity_vertical_errors.len() as f64;
}
stats
}
fn load_test_data(path: &Path) -> Vec<TestDataRecord> {
TestDataRecord::from_csv(path)
.unwrap_or_else(|_| panic!("Failed to load test data from CSV: {}", path.display()))
}
fn create_initial_state(first_record: &TestDataRecord) -> InitialState {
use nalgebra::{Quaternion, Rotation3, UnitQuaternion};
let quat = UnitQuaternion::from_quaternion(Quaternion::new(
first_record.qw,
first_record.qx,
first_record.qy,
first_record.qz,
));
let rot: Rotation3<f64> = quat.into();
let (roll, pitch, yaw) = rot.euler_angles();
InitialState {
latitude: first_record.latitude.to_radians(),
longitude: first_record.longitude.to_radians(),
altitude: first_record.altitude,
northward_velocity: first_record.speed * first_record.bearing.to_radians().cos(),
eastward_velocity: first_record.speed * first_record.bearing.to_radians().sin(),
vertical_velocity: 0.0,
roll,
pitch,
yaw,
in_degrees: false, is_enu: true,
}
}
fn create_nominal_state(first_record: &TestDataRecord) -> StrapdownState {
let quat = UnitQuaternion::from_quaternion(Quaternion::new(
first_record.qw,
first_record.qx,
first_record.qy,
first_record.qz,
));
let rot: Rotation3<f64> = quat.into();
let (roll, pitch, yaw) = rot.euler_angles();
StrapdownState {
latitude: first_record.latitude.to_radians(),
longitude: first_record.longitude.to_radians(),
altitude: first_record.altitude,
velocity_north: first_record.speed * first_record.bearing.to_radians().cos(),
velocity_east: first_record.speed * first_record.bearing.to_radians().sin(),
velocity_vertical: 0.0,
attitude: Rotation3::from_euler_angles(roll, pitch, yaw),
is_enu: true,
}
}
fn run_rbpf_with_cfg(
records: &[TestDataRecord],
cfg: &GnssDegradationConfig,
) -> Vec<NavigationResult> {
let stream = build_event_stream(records, cfg);
let nominal = create_nominal_state(&records[0]);
let mut rbpf = RaoBlackwellizedParticleFilter::new(
nominal,
RbpfConfig {
num_particles: 5000,
seed: 42,
..RbpfConfig::default()
},
);
let start_time = stream.start_time;
let mut results: Vec<NavigationResult> = Vec::with_capacity(stream.events.len());
let mut last_ts: Option<chrono::DateTime<chrono::Utc>> = None;
let stream_events_len = stream.events.len();
for (i, event) in stream.events.into_iter().enumerate() {
let elapsed_s = match &event {
Event::Imu { elapsed_s, .. } => *elapsed_s,
Event::Measurement { elapsed_s, .. } => *elapsed_s,
};
let ts = start_time + chrono::Duration::milliseconds((elapsed_s * 1000.0).round() as i64);
match event {
Event::Imu { dt_s, imu, .. } => rbpf.predict(&imu, dt_s),
Event::Measurement { meas, .. } => rbpf.update(meas.as_ref()),
}
if Some(ts) != last_ts {
if let Some(prev_ts) = last_ts {
let (mean, cov) = rbpf.estimate();
results.push(NavigationResult::from_particle_filter(
&prev_ts, &mean, &cov,
));
}
last_ts = Some(ts);
}
if i + 1 == stream_events_len {
let (mean, cov) = rbpf.estimate();
results.push(NavigationResult::from_particle_filter(&ts, &mean, &cov));
}
}
results
}
fn run_rbpf(records: &[TestDataRecord]) -> Vec<NavigationResult> {
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
run_rbpf_with_cfg(records, &cfg)
}
#[test]
fn test_dead_reckoning_on_real_data() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let results = dead_reckoning(&records);
assert_eq!(
results.len(),
records.len(),
"Dead reckoning should produce one result per input record"
);
let stats = compute_error_metrics(&results, &records);
println!("\n=== Dead Reckoning Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
println!(
"Velocity Error: N={:.3}m/s, E={:.3}m/s, D={:.3}m/s",
stats.mean_velocity_north_error,
stats.mean_velocity_east_error,
stats.mean_velocity_vertical_error
);
for result in &results {
assert!(
result.latitude.is_finite(),
"Latitude should be finite: {}",
result.latitude
);
assert!(
result.longitude.is_finite(),
"Longitude should be finite: {}",
result.longitude
);
assert!(
result.altitude.is_finite(),
"Altitude should be finite: {}",
result.altitude
);
}
}
#[test]
fn test_ukf_closed_loop_on_real_data() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let imu_biases = vec![0.0; 6]; let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ukf = UnscentedKalmanFilter::new(
initial_state,
imu_biases,
None, initial_covariance,
process_noise,
1e-3, 2.0, 0.0, );
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results =
run_closed_loop(&mut ukf, stream, None, None).expect("Closed-loop filter should complete");
assert!(
!results.is_empty(),
"Closed-loop filter should produce results"
);
let stats = compute_error_metrics(&results, &records);
println!("\n=== UKF Closed-Loop Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
println!(
"Velocity Error: N={:.3}m/s, E={:.3}m/s, D={:.3}m/s",
stats.mean_velocity_north_error,
stats.mean_velocity_east_error,
stats.mean_velocity_vertical_error
);
let rms_horizontal_limit = 25.0;
let max_horizontal_limit = 39.0;
let rms_altitude_limit = 50.0;
let max_altitude_limit = 250.0;
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_horizontal_limit,
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < max_horizontal_limit,
"Maximum horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_horizontal_limit,
stats.max_horizontal_error
);
assert!(
stats.rms_altitude_error < rms_altitude_limit,
"RMS altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_altitude_limit,
stats.rms_altitude_error
);
assert!(
stats.max_altitude_error < max_altitude_limit,
"Maximum altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_altitude_limit,
stats.max_altitude_error
);
for result in &results {
assert!(
result.latitude.is_finite(),
"Latitude should be finite: {}",
result.latitude
);
assert!(
result.longitude.is_finite(),
"Longitude should be finite: {}",
result.longitude
);
assert!(
result.altitude.is_finite(),
"Altitude should be finite: {}",
result.altitude
);
assert!(
result.velocity_north.is_finite(),
"Velocity north should be finite: {}",
result.velocity_north
);
assert!(
result.velocity_east.is_finite(),
"Velocity east should be finite: {}",
result.velocity_east
);
assert!(
result.velocity_vertical.is_finite(),
"Velocity down should be finite: {}",
result.velocity_vertical
);
}
}
#[test]
fn test_ukf_with_degraded_gnss() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let imu_biases = vec![0.0; 6];
let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ukf = UnscentedKalmanFilter::new(
initial_state,
imu_biases,
None,
initial_covariance,
process_noise,
1e-3, 2.0, 0.0, );
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::FixedInterval {
interval_s: 5.0,
phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut ukf, stream, None, None)
.expect("Closed-loop filter with degraded GNSS should complete");
let stats = compute_error_metrics(&results, &records);
println!("\n=== UKF with Degraded GNSS (5s updates) Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
assert!(
stats.rms_horizontal_error < 50.0,
"RMS horizontal error with degraded GNSS should be less than 50m, got {:.2}m",
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < 400.0,
"Maximum horizontal error with degraded GNSS should be less than 600m, got {:.2}m",
stats.max_horizontal_error
);
for result in &results {
assert!(result.latitude.is_finite());
assert!(result.longitude.is_finite());
assert!(result.altitude.is_finite());
}
}
#[test]
fn test_ukf_outperforms_dead_reckoning() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let dr_results = dead_reckoning(&records);
let dr_stats = compute_error_metrics(&dr_results, &records);
let initial_state = create_initial_state(&records[0]);
let imu_biases = vec![0.0; 6];
let initial_covariance = vec![
1e-6, 1e-6, 1.0, 0.1, 0.1, 0.1, 0.01, 0.01, 0.01, 0.01, 0.01, 0.01, 0.001, 0.001, 0.001,
];
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ukf = UnscentedKalmanFilter::new(
initial_state,
imu_biases,
None,
initial_covariance,
process_noise,
1e-3,
2.0,
0.0,
);
let scheduler = GnssScheduler::PassThrough;
let fault_model = GnssFaultModel::None;
let cfg = GnssDegradationConfig {
scheduler,
fault: fault_model,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let ukf_results = run_closed_loop(&mut ukf, stream, None, None).expect("UKF should complete");
let ukf_stats = compute_error_metrics(&ukf_results, &records);
println!("\n=== Performance Comparison ===");
println!(
"Dead Reckoning RMS Horizontal Error: {:.2}m",
dr_stats.rms_horizontal_error
);
println!(
"UKF RMS Horizontal Error: {:.2}m",
ukf_stats.rms_horizontal_error
);
println!(
"Improvement: {:.1}%",
(1.0 - ukf_stats.rms_horizontal_error / dr_stats.rms_horizontal_error) * 100.0
);
if dr_stats.rms_horizontal_error > MIN_DRIFT_FOR_COMPARISON {
assert!(
ukf_stats.rms_horizontal_error < dr_stats.rms_horizontal_error,
"UKF should have lower RMS horizontal error than dead reckoning. UKF: {:.2}m, DR: {:.2}m",
ukf_stats.rms_horizontal_error,
dr_stats.rms_horizontal_error
);
}
}
#[test]
fn test_ekf_closed_loop_on_real_data() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ekf = ExtendedKalmanFilter::new(
initial_state,
vec![0.0; 6], initial_covariance,
process_noise,
true, );
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut ekf, stream, None, None)
.expect("Closed-loop EKF filter should complete");
assert!(
!results.is_empty(),
"Closed-loop EKF filter should produce results"
);
let stats = compute_error_metrics(&results, &records);
println!("\n=== EKF Closed-Loop Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
println!(
"Velocity Error: N={:.3}m/s, E={:.3}m/s, D={:.3}m/s",
stats.mean_velocity_north_error,
stats.mean_velocity_east_error,
stats.mean_velocity_vertical_error
);
let rms_horizontal_limit = 35.0;
let max_horizontal_limit = 145.0;
let rms_altitude_limit = 150.0;
let max_altitude_limit = 1230.0;
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_horizontal_limit,
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < max_horizontal_limit,
"Maximum horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_horizontal_limit,
stats.max_horizontal_error
);
assert!(
stats.rms_altitude_error < rms_altitude_limit,
"RMS altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_altitude_limit,
stats.rms_altitude_error
);
assert!(
stats.max_altitude_error < max_altitude_limit,
"Maximum altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_altitude_limit,
stats.max_altitude_error
);
for result in &results {
assert!(
result.latitude.is_finite(),
"Latitude should be finite: {}",
result.latitude
);
assert!(
result.longitude.is_finite(),
"Longitude should be finite: {}",
result.longitude
);
assert!(
result.altitude.is_finite(),
"Altitude should be finite: {}",
result.altitude
);
assert!(
result.velocity_north.is_finite(),
"Velocity north should be finite: {}",
result.velocity_north
);
assert!(
result.velocity_east.is_finite(),
"Velocity east should be finite: {}",
result.velocity_east
);
assert!(
result.velocity_vertical.is_finite(),
"Velocity vertical should be finite: {}",
result.velocity_vertical
);
}
}
#[test]
fn test_ekf_with_degraded_gnss() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ekf = ExtendedKalmanFilter::new(
initial_state,
vec![0.0; 6],
initial_covariance,
process_noise,
true, );
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::FixedInterval {
interval_s: 5.0,
phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut ekf, stream, None, None)
.expect("Closed-loop EKF filter with degraded GNSS should complete");
let stats = compute_error_metrics(&results, &records);
println!("\n=== EKF with Degraded GNSS (5s updates) Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
let rms_horizontal_limit = 125.0;
let max_horizontal_limit = 1700.0;
let rms_altitude_limit = 140.0;
let max_altitude_limit = 2000.0;
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_horizontal_limit,
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < max_horizontal_limit,
"Maximum horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_horizontal_limit,
stats.max_horizontal_error
);
assert!(
stats.rms_altitude_error < rms_altitude_limit,
"RMS altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_altitude_limit,
stats.rms_altitude_error
);
assert!(
stats.max_altitude_error < max_altitude_limit,
"Maximum altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_altitude_limit,
stats.max_altitude_error
);
for result in &results {
assert!(result.latitude.is_finite());
assert!(result.longitude.is_finite());
assert!(result.altitude.is_finite());
}
}
#[test]
fn test_ekf_outperforms_dead_reckoning() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let dr_results = dead_reckoning(&records);
let dr_stats = compute_error_metrics(&dr_results, &records);
let initial_state = create_initial_state(&records[0]);
let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ekf = ExtendedKalmanFilter::new(
initial_state,
vec![0.0; 6],
initial_covariance,
process_noise,
true, );
let scheduler = GnssScheduler::PassThrough;
let fault_model = GnssFaultModel::None;
let cfg = GnssDegradationConfig {
scheduler,
fault: fault_model,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let ekf_results = run_closed_loop(&mut ekf, stream, None, None).expect("EKF should complete");
let ekf_stats = compute_error_metrics(&ekf_results, &records);
println!("\n=== Performance Comparison (EKF vs Dead Reckoning) ===");
println!(
"Dead Reckoning RMS Horizontal Error: {:.2}m",
dr_stats.rms_horizontal_error
);
println!(
"EKF RMS Horizontal Error: {:.2}m",
ekf_stats.rms_horizontal_error
);
println!(
"Improvement: {:.1}%",
(1.0 - ekf_stats.rms_horizontal_error / dr_stats.rms_horizontal_error) * 100.0
);
if dr_stats.rms_horizontal_error > MIN_DRIFT_FOR_COMPARISON {
assert!(
ekf_stats.rms_horizontal_error < dr_stats.rms_horizontal_error,
"EKF should have lower RMS horizontal error than dead reckoning. EKF: {:.2}m, DR: {:.2}m",
ekf_stats.rms_horizontal_error,
dr_stats.rms_horizontal_error
);
}
}
#[test]
fn test_eskf_closed_loop_on_real_data() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let initial_error_covariance = ESKF_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(ESKF_PROCESS_NOISE.to_vec()));
let mut eskf = ErrorStateKalmanFilter::new(
initial_state,
vec![0.0; 6], initial_error_covariance,
process_noise,
);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut eskf, stream, None, None)
.expect("Closed-loop ESKF filter should complete");
assert!(
!results.is_empty(),
"Closed-loop ESKF filter should produce results"
);
let stats = compute_error_metrics(&results, &records);
println!("\n=== ESKF Closed-Loop Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
println!(
"Velocity Error: N={:.3}m/s, E={:.3}m/s, D={:.3}m/s",
stats.mean_velocity_north_error,
stats.mean_velocity_east_error,
stats.mean_velocity_vertical_error
);
let rms_horizontal_limit = 2000.0;
let max_horizontal_limit = 2500.0;
let rms_altitude_limit = 135.0;
let max_altitude_limit = 509.0;
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_horizontal_limit,
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < max_horizontal_limit,
"Maximum horizontal error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_horizontal_limit,
stats.max_horizontal_error
);
assert!(
stats.rms_altitude_error < rms_altitude_limit,
"RMS altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
rms_altitude_limit,
stats.rms_altitude_error
);
assert!(
stats.max_altitude_error < max_altitude_limit,
"Maximum altitude error with degraded GNSS should be less than {:.2}m, got {:.2}m",
max_altitude_limit,
stats.max_altitude_error
);
for result in &results {
assert!(
result.latitude.is_finite(),
"Latitude should be finite: {}",
result.latitude
);
assert!(
result.longitude.is_finite(),
"Longitude should be finite: {}",
result.longitude
);
assert!(
result.altitude.is_finite(),
"Altitude should be finite: {}",
result.altitude
);
assert!(
result.velocity_north.is_finite(),
"Velocity north should be finite: {}",
result.velocity_north
);
assert!(
result.velocity_east.is_finite(),
"Velocity east should be finite: {}",
result.velocity_east
);
assert!(
result.velocity_vertical.is_finite(),
"Velocity vertical should be finite: {}",
result.velocity_vertical
);
}
}
#[test]
#[ignore = "ESKF with degraded GNSS has altitude divergence and health monitor aborts due to out of range"]
fn test_eskf_with_degraded_gnss() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let initial_error_covariance = ESKF_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(ESKF_PROCESS_NOISE.to_vec()));
let mut eskf = ErrorStateKalmanFilter::new(
initial_state,
vec![0.0; 6],
initial_error_covariance,
process_noise,
);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::FixedInterval {
interval_s: 2.0,
phase_s: 0.0,
},
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut eskf, stream, None, None)
.expect("Closed-loop ESKF filter with degraded GNSS should complete");
let stats = compute_error_metrics(&results, &records);
println!("\n=== ESKF with Degraded GNSS (5s updates) Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
assert!(
stats.rms_horizontal_error < 1000.0,
"RMS horizontal error with degraded GNSS should be less than 1000m, got {:.2}m",
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < 3500.0,
"Maximum horizontal error with degraded GNSS should be less than 3500m, got {:.2}m",
stats.max_horizontal_error
);
assert!(
stats.rms_altitude_error < 400.0,
"RMS altitude error with degraded GNSS should be less than 400m, got {:.2}m",
stats.rms_altitude_error
);
assert!(
stats.max_altitude_error < 3000.0,
"Maximum altitude error with degraded GNSS should be less than 3000m, got {:.2}m",
stats.max_altitude_error
);
for result in &results {
assert!(result.latitude.is_finite());
assert!(result.longitude.is_finite());
assert!(result.altitude.is_finite());
}
}
#[test]
fn test_eskf_outperforms_dead_reckoning() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let dr_results = dead_reckoning(&records);
let dr_stats = compute_error_metrics(&dr_results, &records);
let initial_state = create_initial_state(&records[0]);
let initial_error_covariance = ESKF_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(ESKF_PROCESS_NOISE.to_vec()));
let mut eskf = ErrorStateKalmanFilter::new(
initial_state,
vec![0.0; 6], initial_error_covariance,
process_noise,
);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let eskf_results =
run_closed_loop(&mut eskf, stream, None, None).expect("ESKF should complete");
let eskf_stats = compute_error_metrics(&eskf_results, &records);
println!("\n=== Performance Comparison (ESKF vs Dead Reckoning) ===");
println!(
"Dead Reckoning RMS Horizontal Error: {:.2}m",
dr_stats.rms_horizontal_error
);
println!(
"ESKF RMS Horizontal Error: {:.2}m",
eskf_stats.rms_horizontal_error
);
println!(
"Improvement: {:.1}%",
(1.0 - eskf_stats.rms_horizontal_error / dr_stats.rms_horizontal_error) * 100.0
);
if dr_stats.rms_horizontal_error > MIN_DRIFT_FOR_COMPARISON {
assert!(
eskf_stats.rms_horizontal_error < dr_stats.rms_horizontal_error,
"ESKF should have lower RMS horizontal error than dead reckoning. ESKF: {:.2}m, DR: {:.2}m",
eskf_stats.rms_horizontal_error,
dr_stats.rms_horizontal_error
);
}
}
#[test]
fn test_eskf_stability_high_dynamics() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let _initial_error_covariance = vec![
1e-6, 1e-6, 1.0, 0.5, 0.5, 0.5, 0.05, 0.05, 0.05, 0.05, 0.05, 0.05, 0.005, 0.005, 0.005, ];
let _process_noise_values = vec![
1e-5, 1e-5, 1e-5, 1e-2, 1e-2, 1e-2, 1e-4, 1e-4, 1e-4, 1e-5, 1e-5, 1e-5, 1e-7, 1e-7, 1e-7, ];
let initial_error_covariance = ESKF_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(ESKF_PROCESS_NOISE.to_vec()));
let mut eskf = ErrorStateKalmanFilter::new(
initial_state,
vec![0.0; 6], initial_error_covariance,
process_noise,
);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let stream = build_event_stream(&records, &cfg);
let results = run_closed_loop(&mut eskf, stream, None, None)
.expect("ESKF with high dynamics should complete");
for (i, result) in results.iter().enumerate() {
assert!(
result.latitude.is_finite(),
"Latitude should be finite at step {}: {}",
i,
result.latitude
);
assert!(
result.longitude.is_finite(),
"Longitude should be finite at step {}: {}",
i,
result.longitude
);
assert!(
result.altitude.is_finite(),
"Altitude should be finite at step {}: {}",
i,
result.altitude
);
assert!(
result.velocity_north.is_finite(),
"Velocity north should be finite at step {}: {}",
i,
result.velocity_north
);
assert!(
result.velocity_east.is_finite(),
"Velocity east should be finite at step {}: {}",
i,
result.velocity_east
);
assert!(
result.velocity_vertical.is_finite(),
"Velocity vertical should be finite at step {}: {}",
i,
result.velocity_vertical
);
}
let stats = compute_error_metrics(&results, &records);
println!("\n=== ESKF High Dynamics Stability Test ===");
println!(
"RMS Horizontal Error: {:.2}m, Max: {:.2}m",
stats.rms_horizontal_error, stats.max_horizontal_error
);
println!(
"RMS Altitude Error: {:.2}m, Max: {:.2}m",
stats.rms_altitude_error, stats.max_altitude_error
);
let rms_horizontal_limit = 1905.0;
let max_horizontal_limit = 2494.0;
assert!(
stats.rms_horizontal_error < rms_horizontal_limit,
"RMS horizontal error should remain bounded with high dynamics, expected and error less than {:.2}m, got {:.2}m",
rms_horizontal_limit,
stats.rms_horizontal_error
);
assert!(
stats.max_horizontal_error < max_horizontal_limit,
"Maximum horizontal error should remain bounded with high dynamics, expected less than {:.2}m, got {:.2}m",
max_horizontal_limit,
stats.max_horizontal_error
);
}
#[test]
fn test_filter_comparison() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let initial_state = create_initial_state(&records[0]);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::PassThrough,
fault: GnssFaultModel::None,
..Default::default()
};
let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let mut ukf = UnscentedKalmanFilter::new(
initial_state.clone(),
vec![0.0; 6],
None,
initial_covariance.clone(),
process_noise.clone(),
1e-3,
2.0,
0.0,
);
let stream_ukf = build_event_stream(&records, &cfg);
let ukf_results =
run_closed_loop(&mut ukf, stream_ukf, None, None).expect("UKF should complete");
let ukf_stats = compute_error_metrics(&ukf_results, &records);
let mut ekf = ExtendedKalmanFilter::new(
initial_state.clone(),
vec![0.0; 6],
initial_covariance.clone(),
process_noise.clone(),
true,
);
let stream_ekf = build_event_stream(&records, &cfg);
let ekf_results =
run_closed_loop(&mut ekf, stream_ekf, None, None).expect("EKF should complete");
let ekf_stats = compute_error_metrics(&ekf_results, &records);
let initial_error_covariance = ESKF_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(ESKF_PROCESS_NOISE.to_vec()));
let mut eskf = ErrorStateKalmanFilter::new(
initial_state,
vec![0.0; 6], initial_error_covariance,
process_noise,
);
let stream_eskf = build_event_stream(&records, &cfg);
let eskf_results =
run_closed_loop(&mut eskf, stream_eskf, None, None).expect("ESKF should complete");
let eskf_stats = compute_error_metrics(&eskf_results, &records);
println!("\n=== Filter Performance Comparison ===");
println!(
"UKF - RMS Horizontal: {:.2}m, RMS Altitude: {:.2}m, Max Horizontal: {:.2}m",
ukf_stats.rms_horizontal_error,
ukf_stats.rms_altitude_error,
ukf_stats.max_horizontal_error
);
println!(
"EKF - RMS Horizontal: {:.2}m, RMS Altitude: {:.2}m, Max Horizontal: {:.2}m",
ekf_stats.rms_horizontal_error,
ekf_stats.rms_altitude_error,
ekf_stats.max_horizontal_error
);
println!(
"ESKF - RMS Horizontal: {:.2}m, RMS Altitude: {:.2}m, Max Horizontal: {:.2}m",
eskf_stats.rms_horizontal_error,
eskf_stats.rms_altitude_error,
eskf_stats.max_horizontal_error
);
assert!(
ukf_stats.rms_horizontal_error < 25.0,
"UKF RMS horizontal error should be reasonable"
);
assert!(
ekf_stats.rms_horizontal_error < 30.0,
"EKF RMS horizontal error should be reasonable"
);
assert!(
eskf_stats.rms_horizontal_error < 1905.0,
"ESKF RMS horizontal error should be reasonable"
);
assert_eq!(ukf_results.len(), ekf_results.len());
assert_eq!(ekf_results.len(), eskf_results.len());
}
#[test]
fn test_rbpf_closed_loop_on_real_data() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let results = run_rbpf(&records);
assert!(!results.is_empty(), "RBPF should produce results");
let stats = compute_error_metrics(&results, &records);
println!("\n=== RBPF Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
println!(
"Velocity Error: N={:.3}m/s, E={:.3}m/s, D={:.3}m/s",
stats.mean_velocity_north_error,
stats.mean_velocity_east_error,
stats.mean_velocity_vertical_error
);
assert!(
stats.rms_horizontal_error < 2200.0,
"RBPF RMS horizontal error should be less than 150m, got {:.2}m",
stats.rms_horizontal_error
);
assert!(
stats.median_horizontal_error < 210.0,
"RBPF median horizontal error should be less than 210m, got {:.2}m",
stats.median_horizontal_error
);
assert!(
stats.rms_altitude_error < 100.0,
"RBPF RMS altitude error should be less than 100m, got {:.2}m",
stats.rms_altitude_error
);
for result in &results {
assert!(result.latitude.is_finite());
assert!(result.longitude.is_finite());
assert!(result.altitude.is_finite());
}
}
#[test]
fn test_rbpf_with_degraded_gnss() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let cfg = GnssDegradationConfig {
scheduler: GnssScheduler::FixedInterval {
interval_s: 5.0,
phase_s: 0.0,
},
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()
};
let results = run_rbpf_with_cfg(&records, &cfg);
assert!(!results.is_empty(), "RBPF should produce results");
let stats = compute_error_metrics(&results, &records);
println!("\n=== RBPF Degraded GNSS Error Statistics ===");
println!(
"Horizontal Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_horizontal_error,
stats.min_horizontal_error,
stats.median_horizontal_error,
stats.max_horizontal_error,
stats.rms_horizontal_error
);
println!(
"Altitude Error: mean={:.2}m, min={:.2}m, median={:.2}m, max={:.2}m, rms={:.2}m",
stats.mean_altitude_error,
stats.min_altitude_error,
stats.median_altitude_error,
stats.max_altitude_error,
stats.rms_altitude_error
);
assert!(
stats.rms_horizontal_error < 2200.0,
"RBPF RMS horizontal error with degraded GNSS should be less than 250m, got {:.2}m",
stats.rms_horizontal_error
);
assert!(
stats.median_horizontal_error < 250.0,
"RBPF median horizontal error with degraded GNSS should be less than 300m, got {:.2}m",
stats.median_horizontal_error
);
assert!(
stats.rms_altitude_error < 150.0,
"RBPF RMS altitude error with degraded GNSS should be less than 150m, got {:.2}m",
stats.rms_altitude_error
);
for result in &results {
assert!(result.latitude.is_finite());
assert!(result.longitude.is_finite());
assert!(result.altitude.is_finite());
}
}
#[test]
fn test_filter_output_length_matches_input() {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let test_data_path = Path::new(manifest_dir).join("tests/test_data.csv");
let records = load_test_data(&test_data_path);
assert!(
!records.is_empty(),
"Test data should contain at least one record"
);
let input_length = records.len();
println!("Testing with {} input records", input_length);
let dr_results = dead_reckoning(&records);
assert_eq!(
dr_results.len(),
input_length,
"Dead reckoning output length {} should match input length {}",
dr_results.len(),
input_length
);
println!(
"✓ Dead reckoning: {} outputs for {} inputs",
dr_results.len(),
input_length
);
let initial_state = create_initial_state(&records[0]);
let imu_biases = vec![0.0; 6]; let initial_covariance = DEFAULT_INITIAL_COVARIANCE.to_vec();
let process_noise = DMatrix::from_diagonal(&DVector::from_vec(DEFAULT_PROCESS_NOISE.to_vec()));
let degradation = GnssDegradationConfig::default();
let mut ukf = UnscentedKalmanFilter::new(
initial_state.clone(),
imu_biases.clone(),
None, initial_covariance.clone(),
process_noise.clone(),
1e-3, 2.0, 0.0, );
let event_stream = build_event_stream(&records, °radation);
let ukf_results = run_closed_loop(&mut ukf, event_stream, None, None)
.expect("UKF closed loop should complete successfully");
assert_eq!(
ukf_results.len(),
input_length,
"UKF output length {} should match input length {}",
ukf_results.len(),
input_length
);
println!(
"✓ UKF: {} outputs for {} inputs",
ukf_results.len(),
input_length
);
let mut ekf = ExtendedKalmanFilter::new(
initial_state.clone(),
imu_biases.clone(),
initial_covariance.clone(),
process_noise.clone(),
true,
);
let event_stream = build_event_stream(&records, °radation);
let ekf_results = run_closed_loop(&mut ekf, event_stream, None, None)
.expect("EKF closed loop should complete successfully");
assert_eq!(
ekf_results.len(),
input_length,
"EKF output length {} should match input length {}",
ekf_results.len(),
input_length
);
println!(
"✓ EKF: {} outputs for {} inputs",
ekf_results.len(),
input_length
);
let mut eskf =
ErrorStateKalmanFilter::new(initial_state, imu_biases, initial_covariance, process_noise);
let event_stream = build_event_stream(&records, °radation);
let eskf_results = run_closed_loop(&mut eskf, event_stream, None, None)
.expect("ESKF closed loop should complete successfully");
assert_eq!(
eskf_results.len(),
input_length,
"ESKF output length {} should match input length {}",
eskf_results.len(),
input_length
);
println!(
"✓ ESKF: {} outputs for {} inputs",
eskf_results.len(),
input_length
);
println!(
"\n✅ All filters produce output length matching input length: {}",
input_length
);
}