Skip to content

Commit 3daa0ff

Browse files
authored
Merge pull request #246 from jbrodovsky/copilot/sub-pr-245
Address PR #245 review: add documentation, fix initialization bug, expand test coverage
2 parents 526eb96 + 1b302aa commit 3daa0ff

3 files changed

Lines changed: 138 additions & 11 deletions

File tree

core/src/sim.rs

Lines changed: 125 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1944,7 +1944,7 @@ pub fn run_closed_loop<F: NavigationFilter>(
19441944
let cov = filter.get_certainty();
19451945
results.push(NavigationResult::from((&start_time, &mean, &cov)));
19461946
debug!("Initial filter state at {}: {:?}", start_time, mean);
1947-
let mut last_ts = start_time;
1947+
let mut last_ts = Some(start_time);
19481948

19491949
for (i, event) in stream.events.into_iter().enumerate() {
19501950
// Print detailed progress every 100 iterations or at key milestones
@@ -2034,7 +2034,7 @@ pub fn run_closed_loop<F: NavigationFilter>(
20342034
let cov = filter.get_certainty();
20352035
results.push(NavigationResult::from((&ts, &mean, &cov)));
20362036
debug!("Filter state at {}: {:?}", ts, mean);
2037-
last_ts = ts;
2037+
last_ts = Some(ts);
20382038
}
20392039
}
20402040
debug!("Closed-loop simulation complete");
@@ -2576,6 +2576,17 @@ pub fn print_sim_status<F: NavigationFilter>(filter: &F) {
25762576
pub mod execution {
25772577
use super::*;
25782578

2579+
/// Configuration for execution timeout limits in simulations.
2580+
///
2581+
/// This struct provides multiple timeout mechanisms to prevent runaway computations
2582+
/// and detect performance issues during simulation execution:
2583+
///
2584+
/// - **Wall-clock ratio timeout**: Limits execution time relative to simulation duration
2585+
/// - **Absolute wall-clock timeout**: Enforces a hard limit in real-world seconds
2586+
/// - **No-progress timeout**: Detects when simulation makes no progress (possible hang)
2587+
///
2588+
/// All timeout values <= 0 are treated as disabled. The most restrictive active timeout
2589+
/// will trigger first. These limits are enforced by [`ExecutionMonitor`] during simulation.
25792590
#[derive(Clone, Debug, Serialize, Deserialize)]
25802591
pub struct ExecutionLimits {
25812592
/// Max wall-clock time as a ratio of simulated duration (<= 0 disables).
@@ -2611,6 +2622,17 @@ pub mod execution {
26112622
}
26122623
}
26132624

2625+
/// Monitors execution time and progress during simulation runs.
2626+
///
2627+
/// This struct tracks wall-clock time and detects stalled simulations by monitoring
2628+
/// when progress was last reported. It enforces timeout limits specified in [`ExecutionLimits`].
2629+
///
2630+
/// The monitor maintains two types of timeouts:
2631+
/// - **Wall-clock timeout**: Maximum total execution time (computed from simulation duration and limits)
2632+
/// - **No-progress timeout**: Maximum time without calling `mark_progress()`
2633+
///
2634+
/// Use [`check`](ExecutionMonitor::check) periodically during simulation to verify execution
2635+
/// stays within limits, and call `mark_progress()` after each significant simulation step.
26142636
#[derive(Clone, Debug)]
26152637
pub struct ExecutionMonitor {
26162638
start_time: Instant,
@@ -2620,6 +2642,17 @@ pub mod execution {
26202642
}
26212643

26222644
impl ExecutionMonitor {
2645+
/// Creates a new execution monitor with the specified limits.
2646+
///
2647+
/// # Arguments
2648+
///
2649+
/// * `limits` - Timeout configuration including wall-clock and no-progress limits
2650+
/// * `sim_duration_s` - Expected simulation duration in seconds, used to compute
2651+
/// wall-clock timeout when `max_wall_clock_ratio` is enabled
2652+
///
2653+
/// # Returns
2654+
///
2655+
/// A new `ExecutionMonitor` instance initialized with the current time.
26232656
pub fn new(limits: ExecutionLimits, sim_duration_s: f64) -> Self {
26242657
let max_wall_clock = compute_max_wall_clock(
26252658
sim_duration_s,
@@ -2641,6 +2674,33 @@ pub mod execution {
26412674
}
26422675
}
26432676

2677+
/// Checks if execution is within timeout limits.
2678+
///
2679+
/// This method verifies that the simulation has not exceeded wall-clock or no-progress
2680+
/// timeout limits. It should be called periodically during simulation execution.
2681+
///
2682+
/// # Arguments
2683+
///
2684+
/// * `context` - A string describing the current execution context, included in error messages
2685+
///
2686+
/// # Returns
2687+
///
2688+
/// * `Ok(())` if execution is within limits
2689+
/// * `Err(...)` with a descriptive message if any timeout has been exceeded
2690+
///
2691+
/// # Example
2692+
///
2693+
/// ```no_run
2694+
/// # use strapdown_core::sim::{ExecutionMonitor, ExecutionLimits};
2695+
/// let limits = ExecutionLimits::default();
2696+
/// let mut monitor = ExecutionMonitor::new(limits, 100.0);
2697+
///
2698+
/// // Check timeout before processing
2699+
/// monitor.check("data processing")?;
2700+
/// // ... do work ...
2701+
/// monitor.mark_progress();
2702+
/// # Ok::<(), anyhow::Error>(())
2703+
/// ```
26442704
pub fn check(&mut self, context: &str) -> Result<()> {
26452705
let now = Instant::now();
26462706
if let Some(max_wall_clock) = self.max_wall_clock
@@ -4303,6 +4363,69 @@ mod tests {
43034363
assert!(result.is_err());
43044364
}
43054365

4366+
#[test]
4367+
fn test_execution_monitor_wall_clock_timeout() {
4368+
let limits = ExecutionLimits {
4369+
max_wall_clock_ratio: 0.0,
4370+
max_wall_clock_s: 0.01,
4371+
max_no_progress_s: 0.0,
4372+
};
4373+
let mut monitor = ExecutionMonitor::new(limits, 1.0);
4374+
4375+
std::thread::sleep(std::time::Duration::from_millis(20));
4376+
let result = monitor.check("test");
4377+
assert!(result.is_err());
4378+
assert!(result.unwrap_err().to_string().contains("wall-clock limit"));
4379+
}
4380+
4381+
#[test]
4382+
fn test_execution_monitor_successful_execution_with_progress() {
4383+
let limits = ExecutionLimits {
4384+
max_wall_clock_ratio: 0.0,
4385+
max_wall_clock_s: 1.0,
4386+
max_no_progress_s: 0.05,
4387+
};
4388+
let mut monitor = ExecutionMonitor::new(limits, 1.0);
4389+
4390+
// Simulate work with regular progress updates
4391+
for _ in 0..5 {
4392+
std::thread::sleep(std::time::Duration::from_millis(10));
4393+
let result = monitor.check("test");
4394+
assert!(result.is_ok());
4395+
monitor.mark_progress();
4396+
}
4397+
}
4398+
4399+
#[test]
4400+
fn test_execution_monitor_zero_timeout_disabled() {
4401+
let limits = ExecutionLimits {
4402+
max_wall_clock_ratio: 0.0,
4403+
max_wall_clock_s: 0.0,
4404+
max_no_progress_s: 0.0,
4405+
};
4406+
let mut monitor = ExecutionMonitor::new(limits, 1.0);
4407+
4408+
// With all timeouts disabled, sleep and check should succeed
4409+
std::thread::sleep(std::time::Duration::from_millis(50));
4410+
let result = monitor.check("test");
4411+
assert!(result.is_ok());
4412+
}
4413+
4414+
#[test]
4415+
fn test_execution_monitor_negative_timeout_disabled() {
4416+
let limits = ExecutionLimits {
4417+
max_wall_clock_ratio: -1.0,
4418+
max_wall_clock_s: -1.0,
4419+
max_no_progress_s: -1.0,
4420+
};
4421+
let mut monitor = ExecutionMonitor::new(limits, 1.0);
4422+
4423+
// With negative timeouts (disabled), sleep and check should succeed
4424+
std::thread::sleep(std::time::Duration::from_millis(50));
4425+
let result = monitor.check("test");
4426+
assert!(result.is_ok());
4427+
}
4428+
43064429
#[test]
43074430
fn test_health_monitor_check_valid_state() {
43084431
let limits = HealthLimits::default();

core/tests/integration_tests.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1977,7 +1977,7 @@ fn test_filter_output_length_matches_input() {
19771977
);
19781978

19791979
let event_stream = build_event_stream(&records, &degradation);
1980-
let ukf_results = run_closed_loop(&mut ukf, event_stream, None)
1980+
let ukf_results = run_closed_loop(&mut ukf, event_stream, None, None)
19811981
.expect("UKF closed loop should complete successfully");
19821982

19831983
assert_eq!(
@@ -2003,7 +2003,7 @@ fn test_filter_output_length_matches_input() {
20032003
);
20042004

20052005
let event_stream = build_event_stream(&records, &degradation);
2006-
let ekf_results = run_closed_loop(&mut ekf, event_stream, None)
2006+
let ekf_results = run_closed_loop(&mut ekf, event_stream, None, None)
20072007
.expect("EKF closed loop should complete successfully");
20082008

20092009
assert_eq!(
@@ -2024,7 +2024,7 @@ fn test_filter_output_length_matches_input() {
20242024
ErrorStateKalmanFilter::new(initial_state, imu_biases, initial_covariance, process_noise);
20252025

20262026
let event_stream = build_event_stream(&records, &degradation);
2027-
let eskf_results = run_closed_loop(&mut eskf, event_stream, None)
2027+
let eskf_results = run_closed_loop(&mut eskf, event_stream, None, None)
20282028
.expect("ESKF closed loop should complete successfully");
20292029

20302030
assert_eq!(

sim/src/main.rs

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -56,8 +56,9 @@ use strapdown::sim::health::HealthMonitor;
5656
use strapdown::sim::{DEFAULT_PROCESS_NOISE, GeoResolution};
5757
use strapdown::sim::{
5858
ExecutionLimits, ExecutionMonitor, FaultArgs, FilterType, NavigationResult, ParticleFilterType,
59-
SchedulerArgs, SimulationConfig, SimulationMode, TestDataRecord, build_fault, build_scheduler,
60-
dead_reckoning, initialize_ekf, initialize_eskf, initialize_ukf, run_closed_loop,
59+
SchedulerArgs, SimulationConfig, SimulationMode, TestDataRecord, UkfConfig, build_fault,
60+
build_scheduler, dead_reckoning, initialize_ekf, initialize_eskf, initialize_ukf,
61+
run_closed_loop,
6162
};
6263

6364
const LONG_ABOUT: &str =
@@ -416,7 +417,7 @@ fn process_file(
416417
let results = match filter_config.filter {
417418
FilterType::Ukf => {
418419
let mut ukf =
419-
initialize_ukf(records[0].clone(), None, None, None, None, None, None);
420+
initialize_ukf(records[0].clone(), UkfConfig::default());
420421
info!("Initialized UKF");
421422
run_closed_loop(&mut ukf, event_stream, None, Some(execution_limits.clone()))
422423
}
@@ -635,7 +636,7 @@ fn process_file(
635636
Event::Imu { dt_s, imu, .. } => {
636637
rbpf.predict(&imu, dt_s);
637638
}
638-
Event::Measurement { mut meas, .. } => {
639+
Event::Measurement { meas, .. } => {
639640
#[cfg(feature = "geonav")]
640641
if gravity_map.is_some() || magnetic_map.is_some() {
641642
let (mean, _) = rbpf.estimate();
@@ -807,6 +808,7 @@ fn run_from_config(
807808
/// This is a helper function that extracts the common logic for running closed-loop simulations
808809
/// with either UKF or EKF filters. It handles event stream creation, filter initialization,
809810
/// simulation execution, and results writing.
811+
#[allow(clippy::too_many_arguments)]
810812
fn run_single_closed_loop_simulation(
811813
filter_type: FilterType,
812814
records: &[TestDataRecord],
@@ -1135,7 +1137,9 @@ fn run_geo_closed_loop_cli(args: &ClosedLoopSimArgs) -> Result<(), Box<dyn Error
11351137
// Get all CSV files to process
11361138
let csv_files = get_csv_files(&args.sim.input)?;
11371139
let is_multiple = csv_files.len() > 1;
1138-
let _execution_limits = execution_limits_from_args(&args.sim);
1140+
// NOTE: Execution limits are not yet applied in geophysical closed-loop simulations.
1141+
// We still parse the arguments here to validate them and keep CLI behavior consistent.
1142+
let _ = execution_limits_from_args(&args.sim);
11391143

11401144
if is_multiple {
11411145
info!("Processing {} CSV files from directory", csv_files.len());
@@ -1543,7 +1547,7 @@ fn run_particle_filter(args: &ParticleFilterSimArgs) -> Result<(), Box<dyn Error
15431547
Event::Imu { dt_s, imu, .. } => {
15441548
rbpf.predict(&imu, dt_s);
15451549
}
1546-
Event::Measurement { mut meas, .. } => {
1550+
Event::Measurement { meas, .. } => {
15471551
#[cfg(feature = "geonav")]
15481552
if args.geo.geo {
15491553
let (mean, _) = rbpf.estimate();

0 commit comments

Comments
 (0)