Skip to content

Commit 9b8d4b7

Browse files
committed
Add execution timeouts for simulations
1 parent 714a659 commit 9b8d4b7

4 files changed

Lines changed: 240 additions & 25 deletions

File tree

book/RBPF.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -460,7 +460,7 @@ fn test_rbpf_closed_loop_on_real_data() {
460460
let stream = build_event_stream(&records, &cfg);
461461

462462
// Run closed-loop
463-
let results = run_closed_loop(&mut rbpf, stream, None)
463+
let results = run_closed_loop(&mut rbpf, stream, None, None)
464464
.expect("RBPF closed-loop should complete");
465465

466466
// Compute error metrics

core/src/sim.rs

Lines changed: 162 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ use log::{debug, info, warn};
4444
use std::fmt::{Debug, Display};
4545
use std::io::{self, Read, Write};
4646
use std::path::Path;
47+
use std::time::{Duration as StdDuration, Instant};
4748

4849
use anyhow::{Result, bail};
4950
use chrono::{DateTime, Duration, Utc};
@@ -60,9 +61,11 @@ use crate::messages::{Event, EventStream, GnssFaultModel, GnssScheduler};
6061

6162
use crate::{IMUData, StrapdownState, forward};
6263
use health::HealthMonitor;
64+
use execution::ExecutionMonitor;
6365

6466
// Re-export HealthLimits for easier access in tests and external users
6567
pub use health::HealthLimits;
68+
pub use execution::{ExecutionLimits, ExecutionMonitor};
6669

6770
pub const DEFAULT_PROCESS_NOISE: [f64; 15] = [
6871
// Default process noise if not provided
@@ -83,6 +86,10 @@ pub const DEFAULT_PROCESS_NOISE: [f64; 15] = [
8386
1e-8, // gyro bias z noise
8487
];
8588

89+
pub const DEFAULT_MAX_WALL_CLOCK_RATIO: f64 = 0.25;
90+
pub const DEFAULT_MAX_WALL_CLOCK_S: f64 = 1200.0;
91+
pub const DEFAULT_MAX_NO_PROGRESS_S: f64 = 600.0;
92+
8693
fn de_f64_nan<'de, D>(deserializer: D) -> Result<f64, D::Error>
8794
where
8895
D: Deserializer<'de>,
@@ -1903,19 +1910,31 @@ pub fn dead_reckoning(records: &[TestDataRecord]) -> Vec<NavigationResult> {
19031910
/// * `filter` - Mutable reference to a type implementing NavigationFilter
19041911
/// * `stream` - Event stream containing IMU and measurement events
19051912
/// * `health_limits` - Optional health limits for monitoring
1913+
/// * `execution_limits` - Optional wall-clock and no-progress limits
19061914
///
19071915
/// # Returns
19081916
/// * `Vec<NavigationResult>` - A vector of navigation results
19091917
pub fn run_closed_loop<F: NavigationFilter>(
19101918
filter: &mut F,
19111919
stream: EventStream,
19121920
health_limits: Option<HealthLimits>,
1921+
execution_limits: Option<ExecutionLimits>,
19131922
) -> anyhow::Result<Vec<NavigationResult>> {
19141923
let start_time = stream.start_time;
19151924
let mut results: Vec<NavigationResult> = Vec::with_capacity(stream.events.len());
19161925
let total = stream.events.len();
19171926
let mut last_ts: Option<DateTime<Utc>> = None;
19181927
let mut monitor = HealthMonitor::new(health_limits.unwrap_or_default());
1928+
let sim_duration_s = stream
1929+
.events
1930+
.last()
1931+
.map(|event| match event {
1932+
Event::Imu { elapsed_s, .. } => *elapsed_s,
1933+
Event::Measurement { elapsed_s, .. } => *elapsed_s,
1934+
})
1935+
.unwrap_or(0.0);
1936+
let mut execution_monitor =
1937+
execution_limits.map(|limits| ExecutionMonitor::new(limits, sim_duration_s));
19191938

19201939
info!(
19211940
"Starting closed-loop navigation filter with {} events",
@@ -2005,6 +2024,10 @@ pub fn run_closed_loop<F: NavigationFilter>(
20052024
debug!("Filter state at {}: {:?}", ts, mean);
20062025
results.push(NavigationResult::from((&ts, &mean, &cov)));
20072026
}
2027+
2028+
if let Some(ref mut monitor) = execution_monitor {
2029+
monitor.check("closed-loop")?;
2030+
}
20082031
}
20092032
debug!("Closed-loop simulation complete");
20102033
Ok(results)
@@ -2525,6 +2548,125 @@ pub fn print_sim_status<F: NavigationFilter>(filter: &F) {
25252548
);
25262549
}
25272550

2551+
pub mod execution {
2552+
use super::*;
2553+
2554+
#[derive(Clone, Debug, Serialize, Deserialize)]
2555+
pub struct ExecutionLimits {
2556+
/// Max wall-clock time as a ratio of simulated duration (<= 0 disables).
2557+
#[serde(default = "default_max_wall_clock_ratio")]
2558+
pub max_wall_clock_ratio: f64,
2559+
/// Max wall-clock time per trajectory in seconds (<= 0 disables).
2560+
#[serde(default = "default_max_wall_clock_s")]
2561+
pub max_wall_clock_s: f64,
2562+
/// Max wall-clock time without progress in seconds (<= 0 disables).
2563+
#[serde(default = "default_max_no_progress_s")]
2564+
pub max_no_progress_s: f64,
2565+
}
2566+
2567+
fn default_max_wall_clock_ratio() -> f64 {
2568+
DEFAULT_MAX_WALL_CLOCK_RATIO
2569+
}
2570+
2571+
fn default_max_wall_clock_s() -> f64 {
2572+
DEFAULT_MAX_WALL_CLOCK_S
2573+
}
2574+
2575+
fn default_max_no_progress_s() -> f64 {
2576+
DEFAULT_MAX_NO_PROGRESS_S
2577+
}
2578+
2579+
impl Default for ExecutionLimits {
2580+
fn default() -> Self {
2581+
Self {
2582+
max_wall_clock_ratio: default_max_wall_clock_ratio(),
2583+
max_wall_clock_s: default_max_wall_clock_s(),
2584+
max_no_progress_s: default_max_no_progress_s(),
2585+
}
2586+
}
2587+
}
2588+
2589+
#[derive(Clone, Debug)]
2590+
pub struct ExecutionMonitor {
2591+
start_time: Instant,
2592+
last_progress: Instant,
2593+
max_wall_clock: Option<StdDuration>,
2594+
max_no_progress: Option<StdDuration>,
2595+
}
2596+
2597+
impl ExecutionMonitor {
2598+
pub fn new(limits: ExecutionLimits, sim_duration_s: f64) -> Self {
2599+
let max_wall_clock = compute_max_wall_clock(
2600+
sim_duration_s,
2601+
limits.max_wall_clock_ratio,
2602+
limits.max_wall_clock_s,
2603+
);
2604+
let max_no_progress = if limits.max_no_progress_s > 0.0 {
2605+
Some(StdDuration::from_secs_f64(limits.max_no_progress_s))
2606+
} else {
2607+
None
2608+
};
2609+
let now = Instant::now();
2610+
2611+
Self {
2612+
start_time: now,
2613+
last_progress: now,
2614+
max_wall_clock,
2615+
max_no_progress,
2616+
}
2617+
}
2618+
2619+
pub fn check(&mut self, context: &str) -> Result<()> {
2620+
let now = Instant::now();
2621+
if let Some(max_wall_clock) = self.max_wall_clock {
2622+
if now.duration_since(self.start_time) > max_wall_clock {
2623+
bail!(
2624+
"Execution timeout ({context}): exceeded wall-clock limit of {:.2} s",
2625+
max_wall_clock.as_secs_f64()
2626+
);
2627+
}
2628+
}
2629+
if let Some(max_no_progress) = self.max_no_progress {
2630+
let since_progress = now.duration_since(self.last_progress);
2631+
if since_progress > max_no_progress {
2632+
bail!(
2633+
"Execution timeout ({context}): no progress for {:.2} s (limit {:.2} s)",
2634+
since_progress.as_secs_f64(),
2635+
max_no_progress.as_secs_f64()
2636+
);
2637+
}
2638+
}
2639+
self.last_progress = now;
2640+
Ok(())
2641+
}
2642+
}
2643+
2644+
fn compute_max_wall_clock(
2645+
sim_duration_s: f64,
2646+
max_ratio: f64,
2647+
max_wall_clock_s: f64,
2648+
) -> Option<StdDuration> {
2649+
let mut max_s = None;
2650+
if sim_duration_s > 0.0 && max_ratio > 0.0 {
2651+
max_s = Some(sim_duration_s * max_ratio);
2652+
}
2653+
if max_wall_clock_s > 0.0 {
2654+
max_s = Some(match max_s {
2655+
Some(current) => current.min(max_wall_clock_s),
2656+
None => max_wall_clock_s,
2657+
});
2658+
}
2659+
2660+
max_s.and_then(|s| {
2661+
if s > 0.0 {
2662+
Some(StdDuration::from_secs_f64(s))
2663+
} else {
2664+
None
2665+
}
2666+
})
2667+
}
2668+
}
2669+
25282670
pub mod health {
25292671
use super::*;
25302672

@@ -3004,6 +3146,9 @@ pub struct SimulationConfig {
30043146
/// Generate performance plot comparing navigation output to GPS measurements
30053147
#[serde(default)]
30063148
pub generate_plot: bool,
3149+
/// Execution time limits (wall-clock and no-progress)
3150+
#[serde(default)]
3151+
pub execution_limits: ExecutionLimits,
30073152
/// Logging configuration
30083153
#[serde(default)]
30093154
pub logging: LoggingConfig,
@@ -3034,6 +3179,7 @@ impl Default for SimulationConfig {
30343179
seed: default_seed(),
30353180
parallel: false,
30363181
generate_plot: false,
3182+
execution_limits: ExecutionLimits::default(),
30373183
logging: LoggingConfig::default(),
30383184
closed_loop: Some(ClosedLoopConfig::default()),
30393185
particle_filter: None,
@@ -3704,7 +3850,7 @@ mod tests {
37043850
events: vec![event],
37053851
};
37063852

3707-
let res = run_closed_loop(&mut ukf, stream, None);
3853+
let res = run_closed_loop(&mut ukf, stream, None, None);
37083854
assert!(!res.unwrap().is_empty());
37093855
}
37103856
#[test]
@@ -4084,6 +4230,20 @@ mod tests {
40844230
assert!(limits.cov_diag_max > 0.0);
40854231
}
40864232

4233+
#[test]
4234+
fn test_execution_monitor_no_progress_timeout() {
4235+
let limits = ExecutionLimits {
4236+
max_wall_clock_ratio: 0.0,
4237+
max_wall_clock_s: 0.0,
4238+
max_no_progress_s: 0.01,
4239+
};
4240+
let mut monitor = ExecutionMonitor::new(limits, 1.0);
4241+
4242+
std::thread::sleep(std::time::Duration::from_millis(20));
4243+
let result = monitor.check("test");
4244+
assert!(result.is_err());
4245+
}
4246+
40874247
#[test]
40884248
fn test_health_monitor_check_valid_state() {
40894249
let limits = HealthLimits::default();
@@ -4574,7 +4734,7 @@ mod tests {
45744734
};
45754735

45764736
let health_limits = HealthLimits::default();
4577-
let result = run_closed_loop(&mut ukf, stream, Some(health_limits));
4737+
let result = run_closed_loop(&mut ukf, stream, Some(health_limits), None);
45784738
assert!(result.is_ok());
45794739
}
45804740

core/tests/integration_tests.rs

Lines changed: 13 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -590,7 +590,7 @@ fn test_ukf_closed_loop_on_real_data() {
590590

591591
// Run closed-loop filter
592592
let results =
593-
run_closed_loop(&mut ukf, stream, None).expect("Closed-loop filter should complete");
593+
run_closed_loop(&mut ukf, stream, None, None).expect("Closed-loop filter should complete");
594594

595595
// Verify results
596596
assert!(
@@ -743,7 +743,7 @@ fn test_ukf_with_degraded_gnss() {
743743
let stream = build_event_stream(&records, &cfg);
744744

745745
// Run closed-loop filter
746-
let results = run_closed_loop(&mut ukf, stream, None)
746+
let results = run_closed_loop(&mut ukf, stream, None, None)
747747
.expect("Closed-loop filter with degraded GNSS should complete");
748748

749749
// Compute error metrics
@@ -838,7 +838,7 @@ fn test_ukf_outperforms_dead_reckoning() {
838838
};
839839
let stream = build_event_stream(&records, &cfg);
840840

841-
let ukf_results = run_closed_loop(&mut ukf, stream, None).expect("UKF should complete");
841+
let ukf_results = run_closed_loop(&mut ukf, stream, None, None).expect("UKF should complete");
842842
let ukf_stats = compute_error_metrics(&ukf_results, &records);
843843

844844
// Print comparison
@@ -918,7 +918,7 @@ fn test_ekf_closed_loop_on_real_data() {
918918

919919
// Run closed-loop filter
920920
let results =
921-
run_closed_loop(&mut ekf, stream, None).expect("Closed-loop EKF filter should complete");
921+
run_closed_loop(&mut ekf, stream, None, None).expect("Closed-loop EKF filter should complete");
922922

923923
// Verify results
924924
assert!(
@@ -1068,7 +1068,7 @@ fn test_ekf_with_degraded_gnss() {
10681068
let stream = build_event_stream(&records, &cfg);
10691069

10701070
// Run closed-loop filter
1071-
let results = run_closed_loop(&mut ekf, stream, None)
1071+
let results = run_closed_loop(&mut ekf, stream, None, None)
10721072
.expect("Closed-loop EKF filter with degraded GNSS should complete");
10731073

10741074
// Compute error metrics
@@ -1176,7 +1176,7 @@ fn test_ekf_outperforms_dead_reckoning() {
11761176
};
11771177
let stream = build_event_stream(&records, &cfg);
11781178

1179-
let ekf_results = run_closed_loop(&mut ekf, stream, None).expect("EKF should complete");
1179+
let ekf_results = run_closed_loop(&mut ekf, stream, None, None).expect("EKF should complete");
11801180
let ekf_stats = compute_error_metrics(&ekf_results, &records);
11811181

11821182
// Print comparison
@@ -1256,7 +1256,7 @@ fn test_eskf_closed_loop_on_real_data() {
12561256

12571257
// Run closed-loop filter
12581258
let results =
1259-
run_closed_loop(&mut eskf, stream, None).expect("Closed-loop ESKF filter should complete");
1259+
run_closed_loop(&mut eskf, stream, None, None).expect("Closed-loop ESKF filter should complete");
12601260

12611261
// Verify results
12621262
assert!(
@@ -1405,7 +1405,7 @@ fn test_eskf_with_degraded_gnss() {
14051405
let stream = build_event_stream(&records, &cfg);
14061406

14071407
// Run closed-loop filter
1408-
let results = run_closed_loop(&mut eskf, stream, None)
1408+
let results = run_closed_loop(&mut eskf, stream, None, None)
14091409
.expect("Closed-loop ESKF filter with degraded GNSS should complete");
14101410

14111411
// Compute error metrics
@@ -1505,7 +1505,7 @@ fn test_eskf_outperforms_dead_reckoning() {
15051505
};
15061506
let stream = build_event_stream(&records, &cfg);
15071507

1508-
let eskf_results = run_closed_loop(&mut eskf, stream, None).expect("ESKF should complete");
1508+
let eskf_results = run_closed_loop(&mut eskf, stream, None, None).expect("ESKF should complete");
15091509
let eskf_stats = compute_error_metrics(&eskf_results, &records);
15101510

15111511
// Print comparison
@@ -1596,7 +1596,7 @@ fn test_eskf_stability_high_dynamics() {
15961596

15971597
// Run closed-loop filter
15981598
let results =
1599-
run_closed_loop(&mut eskf, stream, None).expect("ESKF with high dynamics should complete");
1599+
run_closed_loop(&mut eskf, stream, None, None).expect("ESKF with high dynamics should complete");
16001600

16011601
// Verify all results are valid (no NaN or Inf)
16021602
for (i, result) in results.iter().enumerate() {
@@ -1709,7 +1709,7 @@ fn test_filter_comparison() {
17091709
0.0,
17101710
);
17111711
let stream_ukf = build_event_stream(&records, &cfg);
1712-
let ukf_results = run_closed_loop(&mut ukf, stream_ukf, None).expect("UKF should complete");
1712+
let ukf_results = run_closed_loop(&mut ukf, stream_ukf, None, None).expect("UKF should complete");
17131713
let ukf_stats = compute_error_metrics(&ukf_results, &records);
17141714

17151715
// Run EKF
@@ -1721,7 +1721,7 @@ fn test_filter_comparison() {
17211721
true,
17221722
);
17231723
let stream_ekf = build_event_stream(&records, &cfg);
1724-
let ekf_results = run_closed_loop(&mut ekf, stream_ekf, None).expect("EKF should complete");
1724+
let ekf_results = run_closed_loop(&mut ekf, stream_ekf, None, None).expect("EKF should complete");
17251725
let ekf_stats = compute_error_metrics(&ekf_results, &records);
17261726

17271727
// Run ESKF
@@ -1736,7 +1736,7 @@ fn test_filter_comparison() {
17361736
);
17371737

17381738
let stream_eskf = build_event_stream(&records, &cfg);
1739-
let eskf_results = run_closed_loop(&mut eskf, stream_eskf, None).expect("ESKF should complete");
1739+
let eskf_results = run_closed_loop(&mut eskf, stream_eskf, None, None).expect("ESKF should complete");
17401740
let eskf_stats = compute_error_metrics(&eskf_results, &records);
17411741

17421742
// Print comparison

0 commit comments

Comments
 (0)