strapdown/messages.rs
1// gnss_degrader.rs
2use chrono::{DateTime, Utc};
3use nalgebra::Vector3;
4use rand::SeedableRng;
5use rand_distr::{Distribution, Normal};
6
7use crate::IMUData;
8use crate::earth::{meters_ned_to_dlat_dlon, METERS_TO_DEGREES};
9use crate::filter::{
10 GPSPositionAndVelocityMeasurement, GravityAnomalyMeasurement, MagneticAnomalyMeasurement,
11 RelativeAltitudeMeasurement,
12};
13use crate::sim::TestDataRecord;
14/// Scheduler for controlling when GNSS measurements are emitted into the simulation.
15///
16/// This models denial- or jamming-like effects that reduce the *rate* of
17/// available GNSS updates, independent of their content (which is handled by
18/// [`GnssFaultModel`]).
19///
20/// By separating scheduling from corruption, you can experiment with outages,
21/// degraded update rates, or duty-cycled availability while keeping the
22/// measurement noise model orthogonal.
23///
24/// ## Usage
25/// - `PassThrough` → GNSS data is delivered at its native logging rate.
26/// - `FixedInterval` → Downsample the GNSS stream to a constant interval,
27/// simulating jamming that allows only low-rate fixes.
28/// - `DutyCycle` → Alternate between ON and OFF windows of fixed length,
29/// simulating periodic outages.
30///
31/// See also [`GnssDegradationConfig`] for how this is combined with a
32/// [`GnssFaultModel`] and a random seed.
33///
34/// ## Examples
35///
36/// ```
37/// use strapdown::messages::GnssScheduler;
38///
39/// // Keep all GNSS fixes (no scheduling)
40/// let sched = GnssScheduler::PassThrough;
41///
42/// // Deliver a GNSS fix every 10 seconds, starting at t=0
43/// let sched = GnssScheduler::FixedInterval { interval_s: 10.0, phase_s: 0.0 };
44///
45/// // Alternate 5 s ON, 15 s OFF, starting in ON state at t=0
46/// let sched = GnssScheduler::DutyCycle { on_s: 5.0, off_s: 15.0, start_phase_s: 0.0 };
47/// ```
48#[derive(Clone, Debug)]
49pub enum GnssScheduler {
50 /// Pass every GNSS fix through to the filter with no rate reduction.
51 ///
52 /// Useful as a baseline when you want to test only fault injection without
53 /// simulating outages or reduced update rates.
54 PassThrough,
55 /// Emit GNSS measurements at a fixed interval, discarding those in between.
56 ///
57 /// This simulates reduced-rate operation under jamming or low-power conditions.
58 ///
59 /// * `interval_s` — Desired interval between emitted GNSS fixes, in seconds.
60 /// * `phase_s` — Initial time offset before the first emission, in seconds.
61 FixedInterval {
62 /// Desired interval between GNSS fixes (seconds).
63 interval_s: f64,
64 /// Initial phase offset before the first emitted fix (seconds).
65 phase_s: f64,
66 },
67 /// Alternate between ON and OFF windows to create duty-cycled outages.
68 ///
69 /// This simulates conditions like periodic GNSS denial or environments
70 /// where reception is available only intermittently (e.g., urban canyon).
71 ///
72 /// * `on_s` — Duration of each ON window (seconds).
73 /// * `off_s` — Duration of each OFF window (seconds).
74 /// * `start_phase_s` — Initial time offset before the first toggle (seconds).
75 DutyCycle {
76 /// Duration of each ON window (seconds).
77 on_s: f64,
78 /// Duration of each OFF window (seconds).
79 off_s: f64,
80 /// Initial phase offset before the first ON/OFF toggle (seconds).
81 start_phase_s: f64,
82 },
83}
84/// Models how GNSS measurement *content* is corrupted before it reaches the filter.
85///
86/// This is complementary to [`GnssScheduler`], which decides *when* GNSS
87/// updates are delivered. `GnssFaultModel` decides *what* corruption to apply
88/// to each delivered measurement. Together, they allow you to simulate a wide
89/// range of denial, jamming, or spoofing conditions.
90///
91/// Typical usage is to wrap a "truth-like" GNSS fix (from your dataset) with
92/// one of these variants before passing it to the UKF update step.
93///
94/// ## Variants
95///
96/// - `None`: deliver the fix unchanged.
97/// - `Degraded`: add AR(1)-correlated noise to position and velocity, and
98/// inflate the advertised covariance. Simulates low-SNR or multipath conditions.
99/// - `SlowBias`: apply a slowly drifting offset in N/E position and velocity.
100/// Simulates soft spoofing where the trajectory is nudged gradually away
101/// from truth.
102/// - `Hijack`: apply a hard constant offset in N/E position during a fixed time
103/// window. Simulates hard spoofing where the solution is forced onto a
104/// parallel displaced track.
105/// - `Combo`: apply several fault models in sequence (output of one feeds into
106/// the next), allowing composition of multiple effects.
107///
108/// ## Examples
109///
110/// ```
111/// use strapdown::messages::GnssFaultModel;
112///
113/// // No corruption (baseline)
114/// let fault = GnssFaultModel::None;
115///
116/// // Degraded accuracy: ~3 m wander, ~0.3 m/s vel wander, 5x inflated R
117/// let fault = GnssFaultModel::Degraded {
118/// rho_pos: 0.99,
119/// sigma_pos_m: 3.0,
120/// rho_vel: 0.95,
121/// sigma_vel_mps: 0.3,
122/// r_scale: 5.0,
123/// };
124///
125/// // Slow bias drifting north at 2 cm/s
126/// let fault = GnssFaultModel::SlowBias {
127/// drift_n_mps: 0.02,
128/// drift_e_mps: 0.0,
129/// q_bias: 1e-6,
130/// rotate_omega_rps: 0.0,
131/// };
132///
133/// // Hijack: apply 50 m north offset between 120–180 s
134/// let fault = GnssFaultModel::Hijack {
135/// offset_n_m: 50.0,
136/// offset_e_m: 0.0,
137/// start_s: 120.0,
138/// duration_s: 60.0,
139/// };
140///
141/// // Combo: first drift slowly, then add hijack window
142/// let fault = GnssFaultModel::Combo(vec![
143/// GnssFaultModel::SlowBias { drift_n_mps: 0.02, drift_e_mps: 0.0, q_bias: 1e-6, rotate_omega_rps: 0.0 },
144/// GnssFaultModel::Hijack { offset_n_m: 50.0, offset_e_m: 0.0, start_s: 120.0, duration_s: 60.0 },
145/// ]);
146/// ```
147#[derive(Clone, Debug)]
148pub enum GnssFaultModel {
149 /// No corruption; GNSS fixes are passed through unchanged.
150 None,
151
152 /// (2) Degraded accuracy: AR(1)-correlated noise on position and velocity,
153 /// plus inflated advertised covariance. Models low-SNR or multipath cases.
154 Degraded {
155 /// AR(1) correlation coefficient for position error (close to 1.0).
156 rho_pos: f64,
157 /// AR(1) innovation standard deviation for position error (meters).
158 sigma_pos_m: f64,
159 /// AR(1) correlation coefficient for velocity error.
160 rho_vel: f64,
161 /// AR(1) innovation standard deviation for velocity error (m/s).
162 sigma_vel_mps: f64,
163 /// Scale factor for inflating the advertised measurement noise covariance.
164 r_scale: f64,
165 },
166
167 /// (5) Slow drifting bias (soft spoof), applied in N/E meters and velocity.
168 ///
169 /// Models gradual displacement of the navigation solution that appears
170 /// plausible to the filter.
171 SlowBias {
172 /// Northward drift rate (m/s).
173 drift_n_mps: f64,
174 /// Eastward drift rate (m/s).
175 drift_e_mps: f64,
176 /// Random walk PSD (m²/s³) for adding a small stochastic component to the bias.
177 q_bias: f64,
178 /// Optional slow rotation of drift direction (rad/s).
179 rotate_omega_rps: f64,
180 },
181
182 /// (6) Hard spoof window: apply a constant N/E offset for a fixed time window.
183 ///
184 /// Simulates abrupt hijacking of the trajectory.
185 Hijack {
186 /// North offset in meters.
187 offset_n_m: f64,
188 /// East offset in meters.
189 offset_e_m: f64,
190 /// Start time of spoofing window (s).
191 start_s: f64,
192 /// Duration of spoofing window (s).
193 duration_s: f64,
194 },
195
196 /// Compose multiple effects by chaining models together.
197 ///
198 /// The output of one model is fed as the input to the next. This allows
199 /// combining e.g. `SlowBias` with a `Hijack` to simulate multi-stage spoofing.
200 Combo(Vec<GnssFaultModel>),
201}
202
203/// Configuration container for GNSS degradation in simulation.
204///
205/// This ties together a [`GnssScheduler`] (which controls *when* GNSS fixes
206/// are delivered), a [`GnssFaultModel`] (which controls *what* corruption is
207/// applied to each fix), and a random seed for reproducibility.
208///
209/// By keeping scheduling and fault injection separate but bundled here, you can
210/// easily swap in different scenarios or repeat experiments deterministically.
211///
212/// ## Fields
213///
214/// - `scheduler`: Controls emission rate / outage pattern (e.g. pass-through,
215/// fixed-interval, duty-cycled).
216/// - `fault`: Corrupts measurement content (e.g. degraded AR(1) wander, slow
217/// bias, hijack).
218/// - `seed`: Seed for the internal random number generator, ensuring runs are
219/// reproducible for debugging and A/B comparisons.
220///
221/// ## Example
222///
223/// ```
224/// use strapdown::messages::{GnssDegradationConfig, GnssScheduler, GnssFaultModel};
225///
226/// // Deliver GNSS every 10 seconds, with AR(1)-degraded accuracy.
227/// let cfg = GnssDegradationConfig {
228/// scheduler: GnssScheduler::FixedInterval { interval_s: 10.0, phase_s: 0.0 },
229/// fault: GnssFaultModel::Degraded {
230/// rho_pos: 0.99,
231/// sigma_pos_m: 3.0,
232/// rho_vel: 0.95,
233/// sigma_vel_mps: 0.3,
234/// r_scale: 5.0,
235/// },
236/// seed: 42,
237/// };
238/// ```
239#[derive(Clone, Debug)]
240pub struct GnssDegradationConfig {
241 /// Scheduler that determines when GNSS measurements are emitted
242 /// (e.g., pass-through, fixed interval, or duty-cycled).
243 pub scheduler: GnssScheduler,
244
245 /// Fault model that corrupts the contents of each emitted GNSS measurement
246 /// (e.g., degraded wander, slow bias drift, hijack).
247 pub fault: GnssFaultModel,
248
249 /// Random number generator seed for deterministic tests and reproducibility.
250 ///
251 /// Use the same seed to repeat scenarios exactly; change it to get a new
252 /// realization of stochastic processes such as AR(1) degradation.
253 pub seed: u64,
254}
255
256/// A simulation event delivered to the filter in time order.
257///
258/// Events represent sensor updates or other observations that occur during
259/// playback of recorded data. The event stream is built by combining raw
260/// logged records with a [`GnssDegradationConfig`] (for GNSS scheduling and
261/// fault injection), and then fed to the UKF loop.
262///
263/// Each variant bundles both the measurement itself and the elapsed simulation
264/// time when it occurred. This allows the filter to advance its state correctly
265/// and to process updates at realistic intervals.
266///
267/// ## Variants
268///
269/// - `Imu`: An inertial measurement unit (IMU) step, including the time delta
270/// since the previous step. Drives the prediction step.
271/// - `Gnss`: A GNSS position/velocity fix (possibly degraded or spoofed).
272/// - `Altitude`: A relative altitude or barometric measurement, constraining
273/// vertical drift.
274/// - `GravityAnomaly`: A gravity anomaly measurement, derived from accelerometer
275/// or dedicated gravimeter data and matched against a gravity anomaly map.
276/// - `MagneticAnomaly`: A magnetic anomaly measurement, derived from
277/// magnetometer data (magnitude or components) and matched against a magnetic
278/// anomaly map.
279///
280/// ## Extensibility
281///
282/// You can add further variants (e.g., `Pressure`, `SonarDepth`, `StarTracker`)
283/// in the same style if more sensors are to be fused.
284///
285/// ## Example
286///
287/// ```
288/// use strapdown::messages::Event;
289/// use strapdown::IMUData;
290/// use strapdown::filter::{GPSPositionAndVelocityMeasurement, GravityAnomalyMeasurement, MagneticAnomalyMeasurement};
291/// use nalgebra::Vector3;
292
293/// // An IMU event with 0.01 s timestep
294/// let imu_event = Event::Imu {
295/// dt_s: 0.01,
296/// imu: IMUData { accel: Vector3::new(0.0, 0.1, -9.8),
297/// gyro: Vector3::new(0.001, 0.0, 0.0) },
298/// elapsed_s: 1.23,
299/// };
300///
301/// // A GNSS event
302/// let gnss_meas = GPSPositionAndVelocityMeasurement {
303/// latitude: 39.95,
304/// longitude: -75.16,
305/// altitude: 30.0,
306/// northward_velocity: 0.1,
307/// eastward_velocity: -0.2,
308/// horizontal_noise_std: 5.0,
309/// vertical_noise_std: 10.0,
310/// velocity_noise_std: 0.2,
311/// };
312/// let gnss_event = Event::Gnss { meas: gnss_meas, elapsed_s: 2.0 };
313/// ```
314#[derive(Clone, Debug)]
315pub enum Event {
316 /// IMU prediction step.
317 ///
318 /// - `dt_s`: Time delta since the previous event (seconds).
319 /// - `imu`: Inertial data record (accelerometer, gyroscope, etc.).
320 /// - `elapsed_s`: Elapsed simulation time at this event (seconds).
321 Imu {
322 dt_s: f64,
323 imu: IMUData,
324 elapsed_s: f64,
325 },
326
327 /// GNSS position/velocity measurement (possibly degraded or spoofed).
328 ///
329 /// - `meas`: GNSS measurement structure.
330 /// - `elapsed_s`: Elapsed simulation time at this fix (seconds).
331 Gnss {
332 meas: GPSPositionAndVelocityMeasurement,
333 elapsed_s: f64,
334 },
335
336 /// Relative altitude or barometric measurement.
337 ///
338 /// - `meas`: Altitude measurement structure.
339 /// - `elapsed_s`: Elapsed simulation time at this measurement (seconds).
340 Altitude {
341 meas: RelativeAltitudeMeasurement,
342 elapsed_s: f64,
343 },
344
345 /// Gravity anomaly measurement, used for map matching against known gravity anomalies.
346 ///
347 /// Typically expressed in milligals (mGal).
348 ///
349 /// - `meas`: Gravity anomaly measurement structure.
350 /// - `elapsed_s`: Elapsed simulation time (seconds).
351 GravityAnomaly {
352 meas: GravityAnomalyMeasurement,
353 elapsed_s: f64,
354 },
355
356 /// Magnetic anomaly measurement, used for map matching against magnetic anomaly maps.
357 ///
358 /// Typically expressed in nanoteslas (nT).
359 ///
360 /// - `meas`: Magnetic anomaly measurement structure.
361 /// - `elapsed_s`: Elapsed simulation time (seconds).
362 MagneticAnomaly {
363 meas: MagneticAnomalyMeasurement,
364 elapsed_s: f64,
365 },
366}
367pub struct EventStream {
368 pub start_time: DateTime<Utc>,
369 pub events: Vec<Event>,
370}
371// -------- internal state for AR(1) and bias integration --------
372/// Internal state used to realize stochastic GNSS fault models.
373///
374/// `FaultState` holds the evolving error terms for [`GnssFaultModel`] variants
375/// that require memory across timesteps, such as:
376///
377/// - **Degraded (AR(1))**: maintains correlated error states for position and
378/// velocity, updated each epoch with an autoregressive process.
379/// - **SlowBias**: integrates a slow, possibly rotating bias in N/E position,
380/// with optional random walk.
381/// - **Hijack**: does not need state, but still shares the RNG.
382///
383/// This struct is not exposed outside the degradation machinery. It is created
384/// once at the beginning of a run (using a deterministic RNG seed) and then
385/// updated as each measurement is processed.
386///
387/// ## Fields
388///
389/// - `e_n_m`, `e_e_m`, `e_u_m`: AR(1) position error states in the N/E/U
390/// directions (meters).
391/// - `ev_n_mps`, `ev_e_mps`, `ev_u_mps`: AR(1) velocity error states in the
392/// N/E/U directions (m/s).
393/// - `b_n_m`, `b_e_m`: integrated bias terms for slow-bias models (meters).
394/// - `rng`: deterministic random number generator used for injecting noise
395/// (seeded from [`FaultState::new`]).
396///
397/// ## Example
398///
399/// ```ignore
400/// use strapdown::messages::FaultState;
401///
402/// // Create a new state with a fixed seed for reproducibility
403/// let mut st = FaultState::new(42);
404///
405/// // At each timestep, the AR(1) and bias states are updated by the
406/// // degradation logic (not shown here).
407/// ```
408#[derive(Clone, Debug)]
409struct FaultState {
410 /// AR(1) position error state (north, meters).
411 e_n_m: f64,
412 /// AR(1) position error state (east, meters).
413 e_e_m: f64,
414 /// AR(1) position error state (up, meters).
415 e_u_m: f64,
416 /// AR(1) velocity error state (north, m/s).
417 ev_n_mps: f64,
418 /// AR(1) velocity error state (east, m/s).
419 ev_e_mps: f64,
420 /// AR(1) velocity error state (up, m/s).
421 ev_u_mps: f64,
422 /// Integrated slow bias (north, meters).
423 b_n_m: f64,
424 /// Integrated slow bias (east, meters).
425 b_e_m: f64,
426 /// Deterministic RNG for generating noise realizations.
427 rng: rand::rngs::StdRng,
428}
429impl FaultState {
430 /// Construct a new `FaultState` with all error terms initialized to zero.
431 ///
432 /// The random number generator is seeded from the provided `seed`, so
433 /// repeated runs with the same seed yield identical noise realizations.
434 fn new(seed: u64) -> Self {
435 Self {
436 e_n_m: 0.0,
437 e_e_m: 0.0,
438 e_u_m: 0.0,
439 ev_n_mps: 0.0,
440 ev_e_mps: 0.0,
441 ev_u_mps: 0.0,
442 b_n_m: 0.0,
443 b_e_m: 0.0,
444 rng: rand::rngs::StdRng::seed_from_u64(seed),
445 }
446 }
447}
448// -------- helpers --------
449/// Advance an AR(1) (autoregressive) process by one timestep.
450///
451/// Updates the error state `x` according to
452///
453/// ```text
454/// x_t = ρ · x_{t-1} + σ · w_t
455/// ```
456///
457/// where:
458/// - `rho` (`ρ`) is the correlation coefficient, typically close to 1.0
459/// (e.g., 0.95–0.995 for GNSS error wander).
460/// - `sigma` (`σ`) is the innovation standard deviation.
461/// - `w_t` is white Gaussian noise, sampled here from `N(0, σ²)`.
462///
463/// This is used to model time-correlated measurement errors, such as degraded
464/// GNSS position/velocity noise, in contrast to purely white (independent) noise.
465///
466/// ## Arguments
467/// - `x`: Mutable reference to the AR(1) state to be updated.
468/// - `rho`: Correlation coefficient.
469/// - `sigma`: Innovation standard deviation.
470/// - `rng`: Deterministic random number generator used for noise sampling.
471///
472/// ## Example
473///
474/// ```ignore
475/// use rand::SeedableRng;
476///
477/// let mut x = 0.0;
478/// let mut rng = SeedableRng::seed_from_u64(42);
479/// for _ in 0..5 {
480/// ar1_step(&mut x, 0.99, 3.0, &mut rng);
481/// println!("New error state: {x}");
482/// }
483/// ```
484fn ar1_step(x: &mut f64, rho: f64, sigma: f64, rng: &mut rand::rngs::StdRng) {
485 let n = Normal::new(0.0, sigma.max(0.0)).unwrap();
486 *x = rho * *x + n.sample(rng);
487}
488/// Apply a GNSS fault model to a truth-like GNSS fix, producing a corrupted measurement.
489///
490/// This function implements the *content* transformation for GNSS faults
491/// (complementing the *rate/outage* control handled by the scheduler).
492/// Given the current time `t`, time step `dt`, and an input GNSS
493/// position/velocity plus advertised standard deviations, it returns a new
494/// (possibly corrupted) measurement in the same units.
495///
496/// The stochastic components use and update the internal [`FaultState`]
497/// (AR(1) states, slow-bias integrators, and RNG), so repeated calls with the
498/// same seed are reproducible.
499///
500/// # Arguments
501/// - `fault`: The [`GnssFaultModel`] variant describing which corruption to apply.
502/// - `st`: Mutable internal state for AR(1) and slow-bias models (updated in place).
503/// - `t`: Elapsed time of this measurement (seconds).
504/// - `dt`: Time since the previous step (seconds).
505/// - `lat_deg`, `lon_deg`: Input geodetic latitude/longitude **in degrees** (truth-like).
506/// - `alt_m`: Altitude above the ellipsoid (meters).
507/// - `vn_mps`, `ve_mps`: N/E components of velocity (m/s).
508/// - `horiz_std_m`, `vert_std_m`, `vel_std_mps`: Advertised 1σ standard deviations
509/// for horizontal position, vertical position, and velocity, respectively.
510/// These may be scaled by some fault modes to reflect degraded confidence.
511///
512/// # Returns
513/// A 7-tuple:
514/// `(lat_deg, lon_deg, alt_m, vn_mps, ve_mps, horiz_std_m, vel_std_mps)`
515/// representing the *corrupted* latitude, longitude (degrees), altitude (m),
516/// N/E velocities (m/s), horizontal position std (m), and velocity std (m/s).
517///
518/// > **Note:** The current implementation passes `vert_std_m` through unchanged.
519/// > If you also want to degrade vertical accuracy, extend the relevant branches
520/// > to scale it and return it (and update the function signature/uses accordingly).
521///
522/// # Behavior by variant
523/// - **`GnssFaultModel::None`**
524/// Returns inputs unchanged (baseline).
525///
526/// - **`GnssFaultModel::Degraded`**
527/// Adds AR(1)-correlated errors to position (N/E/U, meters) and velocity
528/// (N/E, m/s). The position error is mapped to Δlat/Δlon via an ellipsoidal
529/// small-offset conversion. The advertised horizontal/velocity standard
530/// deviations are multiplied by `r_scale`.
531///
532/// - **`GnssFaultModel::SlowBias`**
533/// Integrates a slowly drifting N/E bias (m) with optional slow rotation of
534/// drift direction and small random-walk perturbation. A small consistent
535/// velocity bias (N/E, m/s) is also applied to keep the corruption plausible.
536///
537/// - **`GnssFaultModel::Hijack`**
538/// Applies a constant N/E offset (meters) within a time window
539/// `[start_s, start_s + duration_s]`, mapping it to Δlat/Δlon. Outside the
540/// window, measurements pass through unchanged.
541///
542/// - **`GnssFaultModel::Combo`**
543/// Intended to compose multiple effects by feeding the output of one model as
544/// the input to the next. (Wire up the call loop to `apply_fault` for each
545/// sub-model if composition is desired.)
546///
547/// # Units & conventions
548/// - Inputs/outputs for latitude and longitude are **degrees**; internal small-angle
549/// calculations convert to **radians** and back.
550/// - Altitude is in meters; velocities are in m/s (N/E components).
551/// - Standard deviations are 1σ values (not variances).
552///
553/// # Numerical notes
554/// - Conversion from N/E meter offsets to Δlat/Δlon uses WGS-84 principal radii
555/// with a small `cos(lat)` clamp near the poles to avoid singularities.
556/// - AR(1) updates use a Normal(0, σ) innovation each step; see [`ar1_step`].
557///
558/// # Examples
559/// ```ignore
560/// // Degraded GNSS with AR(1) wander and inflated R
561/// let mut st = FaultState::new(42);
562/// let (lat, lon, alt, vn, ve, hstd, vstd) = apply_fault(
563/// &GnssFaultModel::Degraded {
564/// rho_pos: 0.99, sigma_pos_m: 3.0,
565/// rho_vel: 0.95, sigma_vel_mps: 0.3,
566/// r_scale: 5.0,
567/// },
568/// &mut st,
569/// t, dt,
570/// lat_deg, lon_deg, alt_m,
571/// vn_mps, ve_mps,
572/// horiz_std_m, vert_std_m, vel_std_mps,
573/// );
574/// ```
575fn apply_fault(
576 fault: &GnssFaultModel,
577 st: &mut FaultState,
578 t: f64,
579 dt: f64,
580 lat_deg: f64,
581 lon_deg: f64,
582 alt_m: f64,
583 vn_mps: f64,
584 ve_mps: f64,
585 horiz_std_m: f64,
586 vert_std_m: f64,
587 vel_std_mps: f64,
588) -> (f64, f64, f64, f64, f64, f64, f64) /* lat, lon, alt, vn, ve, horiz_std, vel_std */ {
589 match fault {
590 GnssFaultModel::None => (
591 lat_deg,
592 lon_deg,
593 alt_m,
594 vn_mps,
595 ve_mps,
596 horiz_std_m,
597 vel_std_mps,
598 ),
599
600 GnssFaultModel::Degraded {
601 rho_pos,
602 sigma_pos_m,
603 rho_vel,
604 sigma_vel_mps,
605 r_scale,
606 } => {
607 ar1_step(&mut st.e_n_m, *rho_pos, *sigma_pos_m, &mut st.rng);
608 ar1_step(&mut st.e_e_m, *rho_pos, *sigma_pos_m, &mut st.rng);
609 ar1_step(&mut st.e_u_m, *rho_pos, *sigma_pos_m, &mut st.rng);
610 ar1_step(&mut st.ev_n_mps, *rho_vel, *sigma_vel_mps, &mut st.rng);
611 ar1_step(&mut st.ev_e_mps, *rho_vel, *sigma_vel_mps, &mut st.rng);
612
613 let (dlat, dlon) =
614 meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, st.e_n_m, st.e_e_m);
615 let lat_c = lat_deg + dlat.to_degrees();
616 let lon_c = lon_deg + dlon.to_degrees();
617 let alt_c = alt_m + st.e_u_m;
618
619 let vn_c = vn_mps + st.ev_n_mps;
620 let ve_c = ve_mps + st.ev_e_mps;
621
622 (
623 lat_c,
624 lon_c,
625 alt_c,
626 vn_c,
627 ve_c,
628 horiz_std_m * r_scale,
629 vel_std_mps * r_scale,
630 )
631 }
632
633 GnssFaultModel::SlowBias {
634 drift_n_mps,
635 drift_e_mps,
636 q_bias,
637 rotate_omega_rps,
638 } => {
639 // integrate bias with optional slow rotation
640 let (mut bn_dot, mut be_dot) = (*drift_n_mps, *drift_e_mps);
641 if *rotate_omega_rps != 0.0 {
642 let th = rotate_omega_rps * t;
643 let c = th.cos();
644 let s = th.sin();
645 let (n0, e0) = (*drift_n_mps, *drift_e_mps);
646 bn_dot = c * n0 - s * e0;
647 be_dot = s * n0 + c * e0;
648 }
649 st.b_n_m += bn_dot * dt;
650 st.b_e_m += be_dot * dt;
651 if *q_bias > 0.0 {
652 ar1_step(&mut st.b_n_m, 1.0, (q_bias * dt).sqrt(), &mut st.rng);
653 ar1_step(&mut st.b_e_m, 1.0, (q_bias * dt).sqrt(), &mut st.rng);
654 }
655 let (dlat, dlon) =
656 meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, st.b_n_m, st.b_e_m);
657 let lat_c = lat_deg + dlat.to_degrees();
658 let lon_c = lon_deg + dlon.to_degrees();
659 let vn_c = vn_mps + bn_dot;
660 let ve_c = ve_mps + be_dot;
661
662 (lat_c, lon_c, alt_m, vn_c, ve_c, horiz_std_m, vel_std_mps)
663 }
664
665 GnssFaultModel::Hijack {
666 offset_n_m,
667 offset_e_m,
668 start_s,
669 duration_s,
670 } => {
671 if t >= *start_s && t <= (start_s + duration_s) {
672 let (dlat, dlon) =
673 meters_ned_to_dlat_dlon(lat_deg.to_radians(), alt_m, *offset_n_m, *offset_e_m);
674 let lat_c = lat_deg + dlat.to_degrees();
675 let lon_c = lon_deg + dlon.to_degrees();
676 (
677 lat_c,
678 lon_c,
679 alt_m,
680 vn_mps,
681 ve_mps,
682 horiz_std_m,
683 vel_std_mps,
684 )
685 } else {
686 (
687 lat_deg,
688 lon_deg,
689 alt_m,
690 vn_mps,
691 ve_mps,
692 horiz_std_m,
693 vel_std_mps,
694 )
695 }
696 }
697
698 GnssFaultModel::Combo(models) => {
699 let mut out = (
700 lat_deg,
701 lon_deg,
702 alt_m,
703 vn_mps,
704 ve_mps,
705 horiz_std_m,
706 vel_std_mps,
707 );
708 for m in models {
709 // out = apply_fault(m, st, t, dt, out.0, out.1, out.2, out.3, out.4, out.5, out.6);
710 }
711 out
712 }
713 }
714}
715// --------------------------- public API ---------------------------
716/// Build a time-ordered event stream from recorded data and a GNSS degradation
717/// configuration.
718///
719/// This function converts raw `records` into a vector of [`Event`]s suitable
720/// for an event-driven filter loop. It:
721///
722/// 1. Normalizes the record timestamps to **elapsed seconds** from the first sample.
723/// 2. Emits an [`Event::Imu`] at each step with `dt_s = t[i] - t[i-1]`.
724/// 3. Uses the provided [`GnssDegradationConfig`] to decide *when* to emit GNSS
725/// (via the [`GnssScheduler`]) and *how* to corrupt that GNSS fix
726/// (via the [`GnssFaultModel`], applied by [`apply_fault`]).
727/// 4. Appends each emitted GNSS fix as an [`Event::Gnss`] with the same `elapsed_s`
728/// as the IMU step.
729///
730/// The resulting event stream cleanly separates simulation policy (scheduling
731/// and corruption) from the filter loop, enabling reproducible scenario testing.
732///
733/// # Arguments
734/// - `records`: Source telemetry, ordered by time, providing IMU and GNSS-like
735/// fields (lat/lon/alt/speed/bearing/accuracies).
736/// - `cfg`: GNSS degradation configuration combining a scheduler (*when*) and a
737/// fault model (*what*), plus a seed for deterministic noise.
738///
739/// # Returns
740/// A `EventStream` containing an interleaved sequence of IMU and (optionally
741/// downsampled/corrupted) GNSS events, ordered by `elapsed_s`.
742///
743/// # Scheduling semantics
744/// - [`GnssScheduler::PassThrough`]: emit a GNSS event at every record step.
745/// - [`GnssScheduler::FixedInterval`]: emit when `elapsed_s >= next_emit_time`,
746/// then advance `next_emit_time += interval_s` (with initial `phase_s`).
747/// - [`GnssScheduler::DutyCycle`]: toggle ON/OFF windows of lengths `on_s`/`off_s`
748/// starting at `start_phase_s`; emit only at the boundary into the ON window.
749///
750/// # Corruption semantics
751/// The truth-like GNSS (lat/lon/alt + velocity derived from `speed`/`bearing`)
752/// is transformed by [`apply_fault`] according to `cfg.fault`:
753/// - `None`: unchanged.
754/// - `Degraded`: AR(1) wander on position/velocity; advertised horizontal and
755/// velocity sigmas scaled by `r_scale`.
756/// - `SlowBias`: integrates a drifting N/E bias (with optional rotation/random walk).
757/// - `Hijack`: applies a constant N/E offset within a time window.
758/// - `Combo`: intended for sequential composition (hook up as needed).
759///
760/// > **Note:** The current implementation passes `vertical_noise_std` through
761/// > unchanged. If you also want to degrade vertical accuracy, extend the
762/// > `apply_fault` branch and adjust the GNSS measurement construction.
763///
764/// # Units & conventions
765/// - Elapsed time is in **seconds** from the first record.
766/// - `lat_deg`, `lon_deg` are **degrees**; small-offset conversions use radians internally.
767/// - Altitude (m), velocities (m/s), standard deviations are **1σ** (not variances).
768///
769/// # Preconditions & caveats
770/// - `records.len() >= 2` and timestamps are monotonically increasing.
771/// - If your `horizontal_accuracy`/`vertical_accuracy` fields are *variances*,
772/// adjust the `.sqrt()` usage accordingly.
773/// - The event vector capacity is sized roughly to `2 * records.len()` (IMU + GNSS).
774///
775/// # Example
776/// ```ignore
777/// let cfg = GnssDegradationConfig {
778/// scheduler: GnssScheduler::FixedInterval { interval_s: 10.0, phase_s: 0.0 },
779/// fault: GnssFaultModel::Degraded {
780/// rho_pos: 0.99, sigma_pos_m: 3.0,
781/// rho_vel: 0.95, sigma_vel_mps: 0.3,
782/// r_scale: 5.0,
783/// },
784/// seed: 42,
785/// };
786/// let events = build_event_stream(&records, &cfg);
787/// // feed into your event-driven filter loop
788/// ```
789pub fn build_event_stream(records: &[TestDataRecord], cfg: &GnssDegradationConfig) -> EventStream {
790 let start_time = records[0].time;
791 let records_with_elapsed: Vec<(f64, &TestDataRecord)> = records
792 .iter()
793 .map(|r| ((r.time - start_time).num_milliseconds() as f64 / 1000.0, r))
794 .collect();
795 let mut events = Vec::with_capacity(records_with_elapsed.len() * 2);
796 let mut st = FaultState::new(cfg.seed);
797
798 // Scheduler state
799 let mut next_emit_time = match cfg.scheduler {
800 GnssScheduler::PassThrough => 0.0,
801 GnssScheduler::FixedInterval {
802 interval_s,
803 phase_s,
804 } => phase_s,
805 GnssScheduler::DutyCycle { start_phase_s, .. } => start_phase_s,
806 };
807 let mut duty_on = true;
808
809 for w in records_with_elapsed.windows(2) {
810 let (t0, r0) = (&w[0].0, &w[0].1);
811 let (t1, r1) = (&w[1].0, &w[1].1);
812 let dt = t1 - t0;
813
814 // Build IMU event at t1
815 let imu = IMUData {
816 accel: Vector3::new(r1.acc_x, r1.acc_y, r1.acc_z),
817 gyro: Vector3::new(r1.gyro_x, r1.gyro_y, r1.gyro_z),
818 // add other fields as your UKF expects
819 };
820 events.push(Event::Imu {
821 dt_s: dt,
822 imu,
823 elapsed_s: *t1,
824 });
825
826 // Decide if GNSS should be emitted at t1
827 let should_emit = match cfg.scheduler {
828 GnssScheduler::PassThrough => true,
829 GnssScheduler::FixedInterval { interval_s, .. } => {
830 if *t1 + 1e-9 >= next_emit_time {
831 next_emit_time += interval_s;
832 true
833 } else {
834 false
835 }
836 }
837 GnssScheduler::DutyCycle { on_s, off_s, .. } => {
838 let window = if duty_on { on_s } else { off_s };
839 if *t1 + 1e-9 >= next_emit_time {
840 duty_on = !duty_on;
841 next_emit_time += window;
842 duty_on // only emit when toggling into ON
843 } else {
844 false
845 }
846 }
847 };
848
849 if should_emit {
850 // Truth-like GNSS from r1
851 let lat = r1.latitude;
852 let lon = r1.longitude;
853 let alt = r1.altitude;
854 let bearing_rad = r1.bearing.to_radians();
855 let vn = r1.speed * bearing_rad.cos();
856 let ve = r1.speed * bearing_rad.sin();
857
858 // Use your provided accuracies (adjust if these are variances vs std)
859 let horiz_std = r1.horizontal_accuracy.max(1e-3);
860 let vert_std = r1.vertical_accuracy.max(1e-3);
861 let vel_std = r1.speed_accuracy.max(0.1);
862
863 let (lat_c, lon_c, alt_c, vn_c, ve_c, horiz_c, vel_c) = apply_fault(
864 &cfg.fault, &mut st, *t1, dt, lat, lon, alt, vn, ve, horiz_std, vert_std, vel_std,
865 );
866
867 let meas = GPSPositionAndVelocityMeasurement {
868 latitude: lat_c,
869 longitude: lon_c,
870 altitude: alt_c,
871 northward_velocity: vn_c,
872 eastward_velocity: ve_c,
873 horizontal_noise_std: horiz_c,
874 vertical_noise_std: vert_std, // pass-through here; you can also degrade it if desired
875 velocity_noise_std: vel_c,
876 };
877 events.push(Event::Gnss {
878 meas,
879 elapsed_s: *t1,
880 });
881 }
882 }
883 EventStream { start_time, events }
884}
885
886#[cfg(test)]
887mod tests {
888 use super::*;
889 use chrono::{TimeZone, Utc};
890 use assert_approx_eq::assert_approx_eq;
891
892 fn create_test_records(count: usize, interval_secs: f64) -> Vec<TestDataRecord> {
893 let base_time = Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap();
894 let mut records = Vec::with_capacity(count);
895
896 for i in 0..count {
897 let time_ms = (i as f64 * interval_secs * 1000.0) as i64;
898 let time = base_time + chrono::Duration::milliseconds(time_ms);
899 let record = TestDataRecord {
900 time,
901 latitude: 37.0,
902 longitude: -122.0,
903 altitude: 100.0,
904 bearing: 45.0,
905 speed: 5.0,
906 acc_x: 0.0,
907 acc_y: 0.0,
908 acc_z: 9.81,
909 gyro_x: 0.0,
910 gyro_y: 0.0,
911 gyro_z: 0.01,
912 qx: 0.0,
913 qy: 0.0,
914 qz: 0.0,
915 qw: 1.0,
916 roll: 0.0,
917 pitch: 0.0,
918 yaw: 0.0,
919 mag_x: 0.0,
920 mag_y: 0.0,
921 mag_z: 0.0,
922 relative_altitude: 0.0,
923 pressure: 1013.25,
924 grav_x: 0.0,
925 grav_y: 0.0,
926 grav_z: 9.81,
927 horizontal_accuracy: 2.0,
928 vertical_accuracy: 4.0,
929 speed_accuracy: 0.5,
930 bearing_accuracy: 1.0,
931 };
932 records.push(record);
933 }
934 records
935 }
936 #[test]
937 fn test_passthrough_scheduler() {
938 let records = create_test_records(10, 0.1); // 10 records, 0.1s apart
939 let config = GnssDegradationConfig {
940 scheduler: GnssScheduler::PassThrough,
941 fault: GnssFaultModel::None,
942 seed: 42,
943 };
944
945 let events = build_event_stream(&records, &config);
946
947 // We expect IMU events for each record except the first,
948 // and GNSS events for each record except the first
949 assert_eq!(events.events.len(), 18); // 9 IMU + 9 GNSS
950
951 // Count IMU and GNSS events
952 let imu_count = events
953 .events
954 .iter()
955 .filter(|e| matches!(e, Event::Imu { .. }))
956 .count();
957 let gnss_count = events
958 .events
959 .iter()
960 .filter(|e| matches!(e, Event::Gnss { .. }))
961 .count();
962 assert_eq!(imu_count, 9);
963 assert_eq!(gnss_count, 9);
964 }
965 #[test]
966 fn test_fixed_interval_scheduler() {
967 let records = create_test_records(20, 0.1); // 20 records, 0.1s apart
968 let config = GnssDegradationConfig {
969 scheduler: GnssScheduler::FixedInterval {
970 interval_s: 0.5,
971 phase_s: 0.0,
972 },
973 fault: GnssFaultModel::None,
974 seed: 42,
975 };
976
977 let events = build_event_stream(&records, &config);
978
979 // We expect IMU events for each record except the first,
980 // and GNSS events every 0.5s (so at records 5, 10, 15...)
981 let imu_count = events
982 .events
983 .iter()
984 .filter(|e| matches!(e, Event::Imu { .. }))
985 .count();
986 let gnss_count = events
987 .events
988 .iter()
989 .filter(|e| matches!(e, Event::Gnss { .. }))
990 .count();
991 assert_eq!(imu_count, 19);
992 assert_eq!(gnss_count, 4); // At 0.5s, 1.0s, 1.5s, and 1.9s
993 }
994 #[test]
995 fn test_duty_cycle_scheduler() {
996 let records = create_test_records(60, 1.0); // 60 records, 1s apart, 60 seconds total
997 assert!(records.len() == 60, "{}", format!("Expected 60 records, found: {}", records.len()));
998
999 let config = GnssDegradationConfig {
1000 scheduler: GnssScheduler::DutyCycle {
1001 on_s: 1.0,
1002 off_s: 1.0,
1003 start_phase_s: 0.0,
1004 },
1005 fault: GnssFaultModel::None,
1006 seed: 42,
1007 };
1008 //
1009 let events = build_event_stream(&records, &config);
1010 //assert!(events.len() == 60, "{}", format!("Expected 60 events, found: {}", events.len()));
1011 // We should only have GNSS events when turning ON
1012 // (so at 0.0s, 2.0s, 4.0s, ...)
1013 let gnss_events: Vec<&Event> = events
1014 .events
1015 .iter()
1016 .filter(|e| matches!(e, Event::Gnss { .. }))
1017 .collect();
1018 // We initialize off of the first event and only get GNSS updates every two seconds starting from 1.0
1019 // (60 - 1) // 2 = 29
1020 assert!(gnss_events.len() >= 2);
1021 assert!(gnss_events.len() == 29, "{}", format!("Expected 29 GNSS events, found: {}", gnss_events.len()));
1022
1023 // Extract elapsed_s from GNSS events and verify they occur at expected times
1024 let gnss_times: Vec<f64> = gnss_events
1025 .iter()
1026 .map(|e| match e {
1027 Event::Gnss { elapsed_s, .. } => *elapsed_s,
1028 _ => unreachable!(),
1029 })
1030 .collect();
1031
1032 // Check first few expected GNSS times
1033 // They should be close to 0.0, 2.0, 4.0, etc.
1034 for i in 0..gnss_times.len().min(6) {
1035 let expected_time = (i + 1) as f64 * 2.0;
1036 // let closest_time = gnss_times
1037 // .iter()
1038 // .min_by(|&&a, &&b| {
1039 // (a - expected_time)
1040 // .abs()
1041 // .partial_cmp(&(b - expected_time).abs())
1042 // .unwrap()
1043 // })
1044 // .unwrap();
1045 assert_approx_eq!(expected_time, gnss_times[i], 0.1); // Allow for small timing differences
1046 }
1047 }
1048 #[test]
1049 fn test_degraded_fault_model() {
1050 let records = create_test_records(10, 0.1);
1051 let config = GnssDegradationConfig {
1052 scheduler: GnssScheduler::PassThrough,
1053 fault: GnssFaultModel::Degraded {
1054 rho_pos: 0.99,
1055 sigma_pos_m: 3.0,
1056 rho_vel: 0.95,
1057 sigma_vel_mps: 0.3,
1058 r_scale: 5.0,
1059 },
1060 seed: 500,
1061 };
1062
1063 let events = build_event_stream(&records, &config);
1064
1065 // Find GNSS events
1066 let gnss_events: Vec<&Event> = events
1067 .events
1068 .iter()
1069 .filter(|e| matches!(e, Event::Gnss { .. }))
1070 .collect();
1071
1072 // Check R-scaling is applied
1073 for event in &gnss_events {
1074 if let Event::Gnss { meas, .. } = event {
1075 // Original horizontal accuracy is 2.0
1076 assert_approx_eq!(meas.horizontal_noise_std, 9.0, 2.0); // 2.0 * 5.0 = 10.0 (with some numerical variation)
1077 // Original velocity accuracy is 0.5
1078 assert_approx_eq!(meas.velocity_noise_std, 2.5, 0.1); // 0.5 * 5.0 = 2.5 (with some numerical variation)
1079 }
1080 }
1081
1082 // Check that positions are perturbed
1083 let original_lat = 37.0;
1084 let original_lon = -122.0;
1085
1086 let mut all_same = true;
1087 let mut prev_lat = None;
1088 let mut prev_lon = None;
1089
1090 for event in &gnss_events {
1091 if let Event::Gnss { meas, .. } = event {
1092 // Positions should be perturbed from original
1093 assert_approx_eq!(meas.latitude, original_lat, 1e-3);
1094 assert_approx_eq!(meas.longitude, original_lon, 1e-3);
1095
1096 // Check if positions vary between measurements
1097 if let Some(prev_lat) = prev_lat {
1098 if (meas.latitude - prev_lat as f64).abs() > 1e-10 {
1099 all_same = false;
1100 }
1101 }
1102 prev_lat = Some(meas.latitude);
1103
1104 if let Some(prev_lon) = prev_lon {
1105 if (meas.longitude - prev_lon as f64).abs() > 1e-10 {
1106 all_same = false;
1107 }
1108 }
1109 prev_lon = Some(meas.longitude);
1110 }
1111 }
1112
1113 // Positions should vary between measurements due to AR(1) process
1114 assert!(!all_same);
1115 }
1116
1117 #[test]
1118 fn slow_bias_adds_velocity_bias() {
1119 let mut st = FaultState::new(123);
1120 let fault = GnssFaultModel::SlowBias {
1121 drift_n_mps: 0.02,
1122 drift_e_mps: -0.01,
1123 q_bias: 0.0,
1124 rotate_omega_rps: 0.0,
1125 };
1126
1127 // zero “truth” velocity
1128 let (_lat, _lon, _alt, vn_c, ve_c, _hstd, _vstd) = apply_fault(
1129 &fault, &mut st,
1130 /*t*/ 10.0, /*dt*/ 1.0,
1131 /*lat_deg*/ 40.0, /*lon_deg*/ -75.0, /*alt_m*/ 0.0,
1132 /*vn_mps*/ 0.0, /*ve_mps*/ 0.0,
1133 /*horiz_std_m*/ 3.0, /*vert_std_m*/ 5.0, /*vel_std_mps*/ 0.2,
1134 );
1135
1136 assert_approx_eq!(vn_c, 0.02, 0.001);
1137 assert_approx_eq!(ve_c, -0.01, 0.001);
1138 }
1139
1140
1141 #[test]
1142 fn test_hijack_fault_model() {
1143 let records = create_test_records(30, 0.1); // 30 records, 0.1s apart, total 3.0 seconds
1144 let offset_n = 50.0;
1145 let offset_e = 30.0;
1146
1147 let config = GnssDegradationConfig {
1148 scheduler: GnssScheduler::PassThrough,
1149 fault: GnssFaultModel::Hijack {
1150 offset_n_m: offset_n,
1151 offset_e_m: offset_e,
1152 start_s: 1.0,
1153 duration_s: 1.0, // Hijack from 1.0s to 2.0s
1154 },
1155 seed: 42,
1156 };
1157
1158 let events = build_event_stream(&records, &config);
1159
1160 // Find GNSS events and group by time
1161 let mut gnss_by_time: Vec<(f64, &GPSPositionAndVelocityMeasurement)> = Vec::new();
1162 for event in &events.events {
1163 if let Event::Gnss { meas, elapsed_s } = event {
1164 gnss_by_time.push((*elapsed_s, meas));
1165 }
1166 }
1167 gnss_by_time.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
1168
1169 // Original position
1170 let original_lat = 37.0;
1171 let original_lon = -122.0;
1172
1173 // Check measurements before, during, and after hijack
1174 for (time, meas) in gnss_by_time {
1175 if time < 1.0 - 1e-6 || time > 2.0 + 1e-6 {
1176 // Before hijack or after hijack: positions should be near original
1177 assert!((meas.latitude - original_lat).abs() < 1e-6);
1178 assert!((meas.longitude - original_lon).abs() < 1e-6);
1179 } else {
1180 // During hijack: positions should be offset
1181 assert!((meas.latitude - original_lat).abs() > 1e-6);
1182 assert!((meas.longitude - original_lon).abs() > 1e-6);
1183 }
1184 }
1185 }
1186
1187
1188
1189 #[test]
1190 fn test_combo_fault_model() {
1191 // Test that the combo fault model functionality exists
1192 // Note: Due to commented code in apply_fault for Combo, this is a minimal test
1193 let records = create_test_records(10, 0.1);
1194
1195 let config = GnssDegradationConfig {
1196 scheduler: GnssScheduler::PassThrough,
1197 fault: GnssFaultModel::Combo(vec![GnssFaultModel::None, GnssFaultModel::None]),
1198 seed: 42,
1199 };
1200
1201 // This should at least not crash
1202 let events = build_event_stream(&records, &config);
1203 assert!(events.events.len() > 0);
1204 }
1205
1206 #[test]
1207 fn test_ar1_step() {
1208 // Test the AR(1) process step function
1209 let mut rng = rand::rngs::StdRng::seed_from_u64(42);
1210
1211 // For rho=0, value should be replaced by Gaussian noise
1212 let mut x = 10.0;
1213 ar1_step(&mut x, 0.0, 1.0, &mut rng);
1214 assert!(x != 10.0); // Should have changed
1215 assert!(x.abs() < 5.0); // Should be reasonably close to 0 (5-sigma event very unlikely)
1216
1217 // For rho=1, sigma=0, value should remain unchanged
1218 let mut x = 10.0;
1219 ar1_step(&mut x, 1.0, 0.0, &mut rng);
1220 assert_eq!(x, 10.0);
1221
1222 // For negative sigma, should be treated as 0
1223 let mut x = 10.0;
1224 ar1_step(&mut x, 0.5, -1.0, &mut rng);
1225 assert_eq!(x, 5.0); // 0.5 * 10.0 + 0.0
1226 }
1227}