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}