|
1 | | -use clap::Parser; |
| 1 | +use clap::{Args, Parser, Subcommand, ValueEnum}; |
2 | 2 | use csv::ReaderBuilder; |
3 | 3 | use std::error::Error; |
4 | | -use std::path::PathBuf; |
| 4 | +use std::path::{Path, PathBuf}; |
| 5 | +use strapdown::filter::UKF; |
| 6 | +use strapdown::messages::{ |
| 7 | + GnssDegradationConfig, GnssFaultModel, GnssScheduler, build_event_stream, |
| 8 | +}; |
5 | 9 | use strapdown::sim::{ |
6 | 10 | NavigationResult, TestDataRecord, closed_loop, dead_reckoning, degrade_measurements, |
| 11 | + initialize_ukf, |
7 | 12 | }; |
8 | 13 |
|
| 14 | +const LONG_ABOUT: &str = "STRAPDOWN: A simulation and analysis tool for strapdown inertial navigation systems. |
| 15 | +
|
| 16 | +This program can operate in two modes: open-loop and closed-loop. In open loop mode, the system relies solely on inertial measurements (IMU) and an initial position estimate and performs simple dead reckoning. This mode is only particularly useful for high-accuracy low noise IMUs such as those for aerospace or marine applications (i.e. have a drift rate <=1nm per 24 hours). In closed-loop mode, the system incorporates GNSS measurements to correct for IMU drift and improve overall navigation accuracy. The closed-loop mode can simulate various GNSS degradation scenarios, including jamming (signal dropouts, reduced update rates, and measurement corruption) and a limited form of spoofing (bias introduction, signal hijack). |
| 17 | +
|
| 18 | +This program is designed to work with tabular comma-separated value datasets that contain IMU and GNSS measurements of the form: |
| 19 | +* time - ISO UTC timestamp of the form YYYY-MM-DD HH:mm:ss.ssss+HH:MM (where the +HH:MM is the timezone offset) |
| 20 | +* speed - Speed measurement in meters per second |
| 21 | +* bearing - Bearing measurement in degrees |
| 22 | +* altitude - Altitude measurement in meters |
| 23 | +* longitude - Longitude measurement in degrees |
| 24 | +* latitude - Latitude measurement in degrees |
| 25 | +* qz - Quaternion component |
| 26 | +* qy - Quaternion component |
| 27 | +* qx - Quaternion component |
| 28 | +* qw - Quaternion component |
| 29 | +* roll - Roll angle in degrees |
| 30 | +* pitch - Pitch angle in degrees |
| 31 | +* yaw - Yaw angle in degrees |
| 32 | +* acc_z - Acceleration in the Z direction (meters per second squared) |
| 33 | +* acc_y - Acceleration in the Y direction (meters per second squared) |
| 34 | +* acc_x - Acceleration in the X direction (meters per second squared) |
| 35 | +* gyro_z - Angular velocity around the Z axis (radians per second) |
| 36 | +* gyro_y - Angular velocity around the Y axis (radians per second) |
| 37 | +* gyro_x - Angular velocity around the X axis (radians per second) |
| 38 | +* mag_z - Magnetic field strength in the Z direction (micro teslas) |
| 39 | +* mag_y - Magnetic field strength in the Y direction (micro teslas) |
| 40 | +* mag_x - Magnetic field strength in the X direction (micro teslas) |
| 41 | +* relativeAltitude - Relative altitude measurement (meters) |
| 42 | +* pressure - Atmospheric pressure measurement (milli bar) |
| 43 | +* grav_z - Gravitational acceleration in the Z direction (meters per second squared) |
| 44 | +* grav_y - Gravitational acceleration in the Y direction (meters per second squared) |
| 45 | +* grav_x - Gravitational acceleration in the X direction (meters per second squared)"; |
| 46 | + |
9 | 47 | /// Command line arguments |
10 | | -#[derive(Parser, Debug)] |
11 | | -#[clap(author, version, about, long_about = None)] |
12 | | -struct Args { |
| 48 | +#[derive(Parser)] |
| 49 | +#[command(author, version, about, long_about = LONG_ABOUT)] |
| 50 | +struct Cli { |
13 | 51 | /// Mode of operation, either open-loop or closed-loop |
14 | | - #[clap( |
15 | | - short, |
16 | | - long, |
17 | | - value_parser, |
18 | | - default_value = "open-loop", |
19 | | - help = "Mode of operation: 'open-loop' or 'closed-loop'" |
20 | | - )] |
21 | | - mode: String, |
22 | | - /// Input CSV file path |
23 | | - #[clap( |
24 | | - short, |
25 | | - long, |
26 | | - value_parser, |
27 | | - help = "Path to the input CSV file containing IMU and (optionally) GPS data" |
28 | | - )] |
| 52 | + #[command(subcommand)] |
| 53 | + mode: SimMode, |
| 54 | + /// Input file path |
| 55 | + #[arg(short, long, value_parser)] |
29 | 56 | input: PathBuf, |
30 | | - /// Output CSV file path |
31 | | - #[clap( |
32 | | - short, |
33 | | - long, |
34 | | - value_parser, |
35 | | - help = "Path to the output CSV file for navigation results" |
36 | | - )] |
| 57 | + /// Output file path |
| 58 | + #[arg(short, long, value_parser)] |
37 | 59 | output: PathBuf, |
38 | | - /// Optional: GPS measurement interval in seconds (for simulating intermittent GPS outages) |
39 | | - #[clap( |
40 | | - long, |
41 | | - value_parser, |
42 | | - help = "Interval (in seconds) between GPS measurements, used to simulate GPS outages. Default is 0.0 which updates at every iteration." |
43 | | - )] |
44 | | - gps_interval: Option<f64>, |
45 | | - /// Optional: GPS degradation factor |
46 | | - #[clap( |
47 | | - long, |
48 | | - value_parser, |
49 | | - default_value = "1.0", |
50 | | - help = "Factor by which GPS accuracy is degraded. 1.0 is no degradation. >= 1.0 is a degradation factor. This factor is applied as a scalar multiplier to the recorded GPS accuracy." |
51 | | - )] |
52 | | - gps_degradation: Option<f64>, |
53 | | - /// Optional: GPS spoofing offset |
54 | | - #[clap( |
55 | | - long, |
56 | | - value_parser, |
57 | | - default_value = "0.0", |
58 | | - help = "Offset to apply to GPS coordinates (in meters)" |
59 | | - )] |
60 | | - gps_spoofing_offset: Option<f64>, |
61 | 60 | } |
62 | | -fn main() -> Result<(), Box<dyn Error>> { |
63 | | - let args = Args::parse(); |
64 | | - // Validate the mode |
65 | | - if args.mode != "open-loop" && args.mode != "closed-loop" { |
66 | | - return Err("Invalid mode specified. Use 'open-loop' or 'closed-loop'.".into()); |
| 61 | +#[derive(Subcommand, Clone)] |
| 62 | +enum SimMode { |
| 63 | + #[command(name = "open-loop", about = "Run the simulation in open-loop mode")] |
| 64 | + OpenLoop, |
| 65 | + #[command(name = "closed-loop", about = "Run the simulation in closed-loop mode")] |
| 66 | + ClosedLoop(ClosedLoopArgs), |
| 67 | +} |
| 68 | +/* -------------------- COMPARTMENTALIZED GROUPS -------------------- */ |
| 69 | +#[derive(Args, Clone, Debug)] |
| 70 | +struct ClosedLoopArgs { |
| 71 | + /// RNG seed (applies to any stochastic options) |
| 72 | + #[arg(long, default_value_t = 42)] |
| 73 | + seed: u64, |
| 74 | + |
| 75 | + /// Scheduler settings (dropouts / reduced rate) |
| 76 | + #[command(flatten)] |
| 77 | + scheduler: SchedulerArgs, |
| 78 | + |
| 79 | + /// Fault model settings (corrupt measurement content) |
| 80 | + #[command(flatten)] |
| 81 | + fault: FaultArgs, |
| 82 | +} |
| 83 | + |
| 84 | +// Scheduler group |
| 85 | +#[derive(Copy, Clone, Debug, ValueEnum)] |
| 86 | +enum SchedKind { |
| 87 | + Passthrough, |
| 88 | + Fixed, |
| 89 | + Duty, |
| 90 | +} |
| 91 | + |
| 92 | +#[derive(Args, Clone, Debug)] |
| 93 | +struct SchedulerArgs { |
| 94 | + /// Scheduler kind: passthrough | fixed | duty |
| 95 | + #[arg(long, value_enum, default_value_t = SchedKind::Passthrough)] |
| 96 | + sched: SchedKind, |
| 97 | + |
| 98 | + /// Fixed-interval seconds (sched=fixed) |
| 99 | + #[arg(long, default_value_t = 1.0)] |
| 100 | + interval_s: f64, |
| 101 | + |
| 102 | + /// Initial phase seconds (sched=fixed) |
| 103 | + #[arg(long, default_value_t = 0.0)] |
| 104 | + phase_s: f64, |
| 105 | + |
| 106 | + /// Duty-cycle ON seconds (sched=duty) |
| 107 | + #[arg(long, default_value_t = 10.0)] |
| 108 | + on_s: f64, |
| 109 | + |
| 110 | + /// Duty-cycle OFF seconds (sched=duty) |
| 111 | + #[arg(long, default_value_t = 10.0)] |
| 112 | + off_s: f64, |
| 113 | + |
| 114 | + /// Duty-cycle start phase seconds (sched=duty) |
| 115 | + #[arg(long, default_value_t = 0.0)] |
| 116 | + duty_phase_s: f64, |
| 117 | +} |
| 118 | +/// Fault group |
| 119 | +#[derive(Args, Clone, Debug)] |
| 120 | +struct FaultArgs { |
| 121 | + /// Fault kind: none | degraded | slowbias | hijack |
| 122 | + #[arg(long, value_enum, default_value_t = FaultKind::None)] |
| 123 | + fault: FaultKind, |
| 124 | + |
| 125 | + /// Degraded (AR(1)) |
| 126 | + #[arg(long, default_value_t = 0.99)] |
| 127 | + rho_pos: f64, |
| 128 | + #[arg(long, default_value_t = 3.0)] |
| 129 | + sigma_pos_m: f64, |
| 130 | + #[arg(long, default_value_t = 0.95)] |
| 131 | + rho_vel: f64, |
| 132 | + #[arg(long, default_value_t = 0.3)] |
| 133 | + sigma_vel_mps: f64, |
| 134 | + #[arg(long, default_value_t = 5.0)] |
| 135 | + r_scale: f64, |
| 136 | + |
| 137 | + /// Slow bias |
| 138 | + #[arg(long, default_value_t = 0.02)] |
| 139 | + drift_n_mps: f64, |
| 140 | + #[arg(long, default_value_t = 0.0)] |
| 141 | + drift_e_mps: f64, |
| 142 | + #[arg(long, default_value_t = 1e-6)] |
| 143 | + q_bias: f64, |
| 144 | + #[arg(long, default_value_t = 0.0)] |
| 145 | + rotate_omega_rps: f64, |
| 146 | + |
| 147 | + /// Hijack |
| 148 | + #[arg(long, default_value_t = 50.0)] |
| 149 | + hijack_offset_n_m: f64, |
| 150 | + #[arg(long, default_value_t = 0.0)] |
| 151 | + hijack_offset_e_m: f64, |
| 152 | + #[arg(long, default_value_t = 120.0)] |
| 153 | + hijack_start_s: f64, |
| 154 | + #[arg(long, default_value_t = 60.0)] |
| 155 | + hijack_duration_s: f64, |
| 156 | +} |
| 157 | + |
| 158 | +#[derive(Copy, Clone, Debug, ValueEnum)] |
| 159 | +enum FaultKind { |
| 160 | + None, |
| 161 | + Degraded, |
| 162 | + Slowbias, |
| 163 | + Hijack, |
| 164 | +} |
| 165 | + |
| 166 | +/* -------------------- BUILDERS FROM GROUPS -------------------- */ |
| 167 | + |
| 168 | +fn build_scheduler(a: &SchedulerArgs) -> GnssScheduler { |
| 169 | + match a.sched { |
| 170 | + SchedKind::Passthrough => GnssScheduler::PassThrough, |
| 171 | + SchedKind::Fixed => GnssScheduler::FixedInterval { |
| 172 | + interval_s: a.interval_s, |
| 173 | + phase_s: a.phase_s, |
| 174 | + }, |
| 175 | + SchedKind::Duty => GnssScheduler::DutyCycle { |
| 176 | + on_s: a.on_s, |
| 177 | + off_s: a.off_s, |
| 178 | + start_phase_s: a.duty_phase_s, |
| 179 | + }, |
67 | 180 | } |
68 | | - // Validate the GPS measurement interval is positive |
69 | | - match args.gps_interval { |
70 | | - Some(interval) if interval < 0.0 => { |
71 | | - return Err("GPS measurement interval must be a non-negative value.".into()); |
72 | | - } |
73 | | - _ => {} |
| 181 | +} |
| 182 | + |
| 183 | +fn build_fault(a: &FaultArgs) -> GnssFaultModel { |
| 184 | + match a.fault { |
| 185 | + FaultKind::None => GnssFaultModel::None, |
| 186 | + FaultKind::Degraded => GnssFaultModel::Degraded { |
| 187 | + rho_pos: a.rho_pos, |
| 188 | + sigma_pos_m: a.sigma_pos_m, |
| 189 | + rho_vel: a.rho_vel, |
| 190 | + sigma_vel_mps: a.sigma_vel_mps, |
| 191 | + r_scale: a.r_scale, |
| 192 | + }, |
| 193 | + FaultKind::Slowbias => GnssFaultModel::SlowBias { |
| 194 | + drift_n_mps: a.drift_n_mps, |
| 195 | + drift_e_mps: a.drift_e_mps, |
| 196 | + q_bias: a.q_bias, |
| 197 | + rotate_omega_rps: a.rotate_omega_rps, |
| 198 | + }, |
| 199 | + FaultKind::Hijack => GnssFaultModel::Hijack { |
| 200 | + offset_n_m: a.hijack_offset_n_m, |
| 201 | + offset_e_m: a.hijack_offset_e_m, |
| 202 | + start_s: a.hijack_start_s, |
| 203 | + duration_s: a.hijack_duration_s, |
| 204 | + }, |
74 | 205 | } |
| 206 | +} |
| 207 | + |
| 208 | +fn main() -> Result<(), Box<dyn Error>> { |
| 209 | + let cli = Cli::parse(); |
| 210 | + // Validate the mode |
| 211 | + //if args.mode != "open-loop" && args.mode != "closed-loop" { |
| 212 | + // return Err("Invalid mode specified. Use 'open-loop' or 'closed-loop'.".into()); |
| 213 | + //} |
75 | 214 | // Read the input CSV file |
76 | 215 | // Validate that the input file exists and is readable |
77 | | - if !args.input.exists() { |
78 | | - return Err(format!("Input file '{}' does not exist.", args.input.display()).into()); |
| 216 | + if !cli.input.exists() { |
| 217 | + return Err(format!("Input file '{}' does not exist.", cli.input.display()).into()); |
79 | 218 | } |
80 | | - if !args.input.is_file() { |
81 | | - return Err(format!("Input path '{}' is not a file.", args.input.display()).into()); |
| 219 | + if !cli.input.is_file() { |
| 220 | + return Err(format!("Input path '{}' is not a file.", cli.input.display()).into()); |
82 | 221 | } |
83 | 222 | // Validate that the output file is writable |
84 | | - if let Some(parent) = args.output.parent() { |
| 223 | + if let Some(parent) = cli.output.parent() { |
85 | 224 | if !parent.exists() && parent.is_dir() { |
86 | 225 | return Err(format!("Output directory '{}' does not exist.", parent.display()).into()); |
87 | 226 | } |
88 | | - // if !parent.is_dir() { |
89 | | - // return Err(format!("Output path '{}' is not a directory.", parent.display()).into()); |
90 | | - // } |
91 | 227 | } |
92 | | - let mut rdr = ReaderBuilder::new().from_path(&args.input)?; |
93 | | - let mut records: Vec<TestDataRecord> = rdr.deserialize().collect::<Result<_, _>>()?; |
94 | | - println!( |
95 | | - "Read {} records from {}", |
96 | | - records.len(), |
97 | | - &args.input.display() |
98 | | - ); |
99 | | - records = match args.gps_degradation { |
100 | | - Some(value) => degrade_measurements(records, value), |
101 | | - None => records, |
102 | | - }; |
103 | | - let results: Vec<NavigationResult>; |
104 | | - if args.mode == "closed-loop" { |
105 | | - println!( |
106 | | - "Running in closed-loop mode with GPS interval: {:?}", |
107 | | - args.gps_interval |
108 | | - ); |
109 | | - results = closed_loop(&records, args.gps_interval); |
110 | | - //match write_results_to_csv(&results, &args.output) { |
111 | | - match NavigationResult::to_csv(&results, &args.output) { |
112 | | - Ok(_) => println!("Results written to {}", args.output.display()), |
113 | | - Err(e) => eprintln!("Error writing results: {}", e), |
114 | | - }; |
115 | | - } else if args.mode == "open-loop" { |
116 | | - results = dead_reckoning(&records); |
117 | | - // match write_results_to_csv(&results, &args.output) { |
118 | | - match NavigationResult::to_csv(&results, &args.output) { |
119 | | - Ok(_) => println!("Results written to {}", args.output.display()), |
120 | | - Err(e) => eprintln!("Error writing results: {}", e), |
121 | | - }; |
122 | | - } else { |
123 | | - return Err("Invalid mode specified. Use 'open-loop' or 'closed-loop'.".into()); |
| 228 | + match cli.mode { |
| 229 | + SimMode::OpenLoop => println!("Running in open-loop mode"), |
| 230 | + SimMode::ClosedLoop(ref args) => { |
| 231 | + let mut rdr = ReaderBuilder::new().from_path(&cli.input)?; |
| 232 | + let records: Vec<TestDataRecord> = rdr.deserialize().collect::<Result<_, _>>()?; |
| 233 | + println!( |
| 234 | + "Read {} records from {}", |
| 235 | + records.len(), |
| 236 | + &cli.input.display() |
| 237 | + ); |
| 238 | + let cfg = GnssDegradationConfig { |
| 239 | + scheduler: build_scheduler(&args.scheduler), |
| 240 | + fault: build_fault(&args.fault), |
| 241 | + seed: args.seed, |
| 242 | + }; |
| 243 | + |
| 244 | + let events = build_event_stream(&records, &cfg); |
| 245 | + let ukf = initialize_ukf(records[0].clone(), None, None); |
| 246 | + // let results = closed_loop_events(ukf, events); |
| 247 | + // sim::write_results_csv(&cli.output, &results)?; |
| 248 | + } |
124 | 249 | } |
| 250 | + |
| 251 | + // records = match args.gps_degradation { |
| 252 | + // Some(value) => degrade_measurements(records, value), |
| 253 | + // None => records, |
| 254 | + // }; |
| 255 | + // let results: Vec<NavigationResult>; |
| 256 | + // if args.mode == "closed-loop" { |
| 257 | + // println!( |
| 258 | + // "Running in closed-loop mode with GPS interval: {:?}", |
| 259 | + // args.gps_interval |
| 260 | + // ); |
| 261 | + // results = closed_loop(&records, args.gps_interval); |
| 262 | + // //match write_results_to_csv(&results, &args.output) { |
| 263 | + // match NavigationResult::to_csv(&results, &args.output) { |
| 264 | + // Ok(_) => println!("Results written to {}", args.output.display()), |
| 265 | + // Err(e) => eprintln!("Error writing results: {}", e), |
| 266 | + // }; |
| 267 | + // } else if args.mode == "open-loop" { |
| 268 | + // results = dead_reckoning(&records); |
| 269 | + // // match write_results_to_csv(&results, &args.output) { |
| 270 | + // match NavigationResult::to_csv(&results, &args.output) { |
| 271 | + // Ok(_) => println!("Results written to {}", args.output.display()), |
| 272 | + // Err(e) => eprintln!("Error writing results: {}", e), |
| 273 | + // }; |
| 274 | + // } else { |
| 275 | + // return Err("Invalid mode specified. Use 'open-loop' or 'closed-loop'.".into()); |
| 276 | + // } |
125 | 277 | Ok(()) |
126 | 278 | } |
0 commit comments