Skip to main content

sidereon_core/fusion/
timesync.rs

1//! Time alignment for loosely coupled GNSS/INS updates.
2//!
3//! The caller supplies all IMU and GNSS epochs in one GNSS time scale. The
4//! routines here keep the filter sans-IO: they retain enough recent IMU and
5//! filter history to apply a late GNSS fix at its measurement epoch, then replay
6//! retained inertial samples and later GNSS fixes.
7
8use std::collections::VecDeque;
9
10use crate::astro::math::vec3::{add3, scale3, sub3};
11use crate::inertial::{ImuSample, ImuSampleKind};
12
13use super::loose::{FusionUpdate, GnssFixMeasurement, InertialFilter};
14use super::state::{invalid_input, validate_positive, FusionError, InsFilterState};
15use super::tight::{TightFilterSnapshot, TightFusionState, TightGnssEpoch};
16
17/// Default number of retained IMU samples for time-sync replay.
18pub const DEFAULT_TIME_SYNC_IMU_CAPACITY: usize = 256;
19/// Default number of retained GNSS checkpoints for time-sync replay.
20pub const DEFAULT_TIME_SYNC_CHECKPOINT_CAPACITY: usize = 64;
21
22/// Retained history limits for bounded-latency time synchronization.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct TimeSyncHistoryConfig {
25    /// Number of recent IMU samples retained for fractional replay.
26    pub imu_capacity: usize,
27    /// Number of recent filter checkpoints retained at GNSS epochs.
28    pub checkpoint_capacity: usize,
29}
30
31impl Default for TimeSyncHistoryConfig {
32    fn default() -> Self {
33        Self {
34            imu_capacity: DEFAULT_TIME_SYNC_IMU_CAPACITY,
35            checkpoint_capacity: DEFAULT_TIME_SYNC_CHECKPOINT_CAPACITY,
36        }
37    }
38}
39
40impl TimeSyncHistoryConfig {
41    /// Build retained-history limits for time-synchronized replay.
42    pub const fn new(imu_capacity: usize, checkpoint_capacity: usize) -> Self {
43        Self {
44            imu_capacity,
45            checkpoint_capacity,
46        }
47    }
48
49    /// Validate that both retained-history capacities are nonzero.
50    pub fn validate(&self) -> Result<(), FusionError> {
51        validate_capacity(self.imu_capacity, "imu_capacity")?;
52        validate_capacity(self.checkpoint_capacity, "checkpoint_capacity")
53    }
54}
55
56/// Snapshot of a closed-loop inertial filter at one epoch.
57#[derive(Debug, Clone, PartialEq)]
58pub struct InertialFilterSnapshot {
59    /// Closed-loop INS state and covariance.
60    pub state: InsFilterState,
61    /// Last propagated body angular rate relative to ECEF, resolved in body axes.
62    pub last_body_rate_wrt_ecef_rps: [f64; 3],
63    /// Tight receiver-clock augmentation and full augmented covariance.
64    pub tight: TightFilterSnapshot,
65}
66
67/// Current retained-history occupancy for time synchronization.
68#[derive(Debug, Clone, Copy, PartialEq)]
69pub struct TimeSyncHistoryStatus {
70    /// Configured IMU sample capacity.
71    pub imu_capacity: usize,
72    /// Number of retained IMU samples.
73    pub imu_len: usize,
74    /// Configured checkpoint capacity.
75    pub checkpoint_capacity: usize,
76    /// Number of retained filter checkpoints.
77    pub checkpoint_len: usize,
78    /// Oldest retained IMU sample end epoch, if any.
79    pub oldest_imu_epoch_j2000_s: Option<f64>,
80    /// Newest retained IMU sample end epoch, if any.
81    pub newest_imu_epoch_j2000_s: Option<f64>,
82    /// Oldest retained checkpoint epoch, if any.
83    pub oldest_checkpoint_epoch_j2000_s: Option<f64>,
84    /// Newest retained checkpoint epoch, if any.
85    pub newest_checkpoint_epoch_j2000_s: Option<f64>,
86}
87
88/// Result of a time-synchronized loose GNSS update.
89#[derive(Debug, Clone, PartialEq)]
90pub struct TimeSyncUpdate {
91    /// Loose-coupled EKF update report for the newly supplied measurement.
92    pub update: FusionUpdate,
93    /// Whether the measurement epoch was older than the current propagated epoch.
94    pub late_measurement: bool,
95    /// Number of IMU segments replayed while applying the measurement.
96    pub replayed_imu_segments: usize,
97    /// Checkpoint epoch used as the replay start.
98    pub restored_checkpoint_epoch_j2000_s: f64,
99    /// Filter epoch after any required replay is complete.
100    pub current_epoch_j2000_s: f64,
101}
102
103#[derive(Debug, Clone, PartialEq)]
104pub(super) struct TimeSyncHistory {
105    config: TimeSyncHistoryConfig,
106    imu_samples: VecDeque<StoredImuSample>,
107    checkpoints: VecDeque<StoredCheckpoint>,
108    measurements: VecDeque<StoredGnssMeasurement>,
109}
110
111impl TimeSyncHistory {
112    pub(super) fn from_initial(state: &InsFilterState, tight: &TightFusionState) -> Self {
113        let mut history = Self {
114            config: TimeSyncHistoryConfig::default(),
115            imu_samples: VecDeque::new(),
116            checkpoints: VecDeque::new(),
117            measurements: VecDeque::new(),
118        };
119        history.push_checkpoint(InertialFilterSnapshot {
120            state: state.clone(),
121            last_body_rate_wrt_ecef_rps: [0.0; 3],
122            tight: tight.snapshot(),
123        });
124        history
125    }
126
127    pub(super) fn validate_next_imu(
128        &self,
129        previous_t_j2000_s: f64,
130        sample: ImuSample,
131    ) -> Result<(), FusionError> {
132        validate_epoch(previous_t_j2000_s, "previous_t_j2000_s")?;
133        validate_epoch(sample.t_j2000_s, "t_j2000_s")?;
134        if sample.t_j2000_s <= previous_t_j2000_s {
135            return Err(invalid_input(
136                "imu_samples",
137                "must be strictly ordered by epoch",
138            ));
139        }
140        if let Some(last) = self.imu_samples.back() {
141            if sample.t_j2000_s <= last.sample.t_j2000_s {
142                return Err(invalid_input(
143                    "imu_samples",
144                    "must be strictly ordered by epoch",
145                ));
146            }
147        }
148        Ok(())
149    }
150
151    pub(super) fn push_imu(&mut self, previous_t_j2000_s: f64, sample: ImuSample) {
152        let previous_rate = self.imu_samples.back().and_then(|stored| {
153            rate_payload(stored.sample).map(|payload| RateEndpoint {
154                t_j2000_s: stored.sample.t_j2000_s,
155                specific_force_mps2: payload.specific_force_mps2,
156                angular_rate_rps: payload.angular_rate_rps,
157            })
158        });
159        bounded_push(
160            &mut self.imu_samples,
161            self.config.imu_capacity,
162            StoredImuSample {
163                previous_t_j2000_s,
164                sample,
165                previous_rate,
166            },
167        );
168    }
169
170    /// Epoch of the most recently accepted GNSS measurement, if any.
171    pub(super) fn last_measurement_t_j2000_s(&self) -> Option<f64> {
172        self.measurements.back().map(StoredGnssMeasurement::epoch)
173    }
174
175    pub(super) fn push_loose_measurement_and_checkpoint(
176        &mut self,
177        measurement: GnssFixMeasurement,
178        snapshot: InertialFilterSnapshot,
179    ) {
180        bounded_push(
181            &mut self.measurements,
182            self.config.checkpoint_capacity,
183            StoredGnssMeasurement::Loose(measurement),
184        );
185        self.push_checkpoint(snapshot);
186    }
187
188    pub(super) fn push_tight_measurement_and_checkpoint(
189        &mut self,
190        measurement: TightGnssEpoch,
191        snapshot: InertialFilterSnapshot,
192    ) {
193        bounded_push(
194            &mut self.measurements,
195            self.config.checkpoint_capacity,
196            StoredGnssMeasurement::Tight(measurement),
197        );
198        self.push_checkpoint(snapshot);
199    }
200
201    fn push_checkpoint(&mut self, snapshot: InertialFilterSnapshot) {
202        bounded_push(
203            &mut self.checkpoints,
204            self.config.checkpoint_capacity,
205            StoredCheckpoint {
206                t_j2000_s: snapshot.state.nominal.t_j2000_s,
207                snapshot,
208            },
209        );
210    }
211
212    fn push_stored_measurement_and_checkpoint(
213        &mut self,
214        measurement: StoredGnssMeasurement,
215        snapshot: InertialFilterSnapshot,
216    ) {
217        bounded_push(
218            &mut self.measurements,
219            self.config.checkpoint_capacity,
220            measurement,
221        );
222        self.push_checkpoint(snapshot);
223    }
224
225    pub(super) fn restore_to_snapshot(&mut self, snapshot: InertialFilterSnapshot) {
226        let restored_epoch_j2000_s = snapshot.state.nominal.t_j2000_s;
227        while self
228            .imu_samples
229            .back()
230            .is_some_and(|stored| stored.sample.t_j2000_s > restored_epoch_j2000_s)
231        {
232            self.imu_samples.pop_back();
233        }
234        while self
235            .checkpoints
236            .back()
237            .is_some_and(|checkpoint| checkpoint.t_j2000_s > restored_epoch_j2000_s)
238        {
239            self.checkpoints.pop_back();
240        }
241        while self
242            .measurements
243            .back()
244            .is_some_and(|measurement| measurement.epoch() > restored_epoch_j2000_s)
245        {
246            self.measurements.pop_back();
247        }
248        if let Some(checkpoint) = self.checkpoints.back_mut() {
249            if checkpoint.t_j2000_s == restored_epoch_j2000_s {
250                checkpoint.snapshot = snapshot;
251                return;
252            }
253        }
254        self.push_checkpoint(snapshot);
255    }
256
257    fn rebase_through_checkpoint(&self, checkpoint_epoch_j2000_s: f64) -> Self {
258        let mut history = Self {
259            config: self.config,
260            imu_samples: self.imu_samples.clone(),
261            checkpoints: VecDeque::new(),
262            measurements: VecDeque::new(),
263        };
264        for checkpoint in &self.checkpoints {
265            if checkpoint.t_j2000_s <= checkpoint_epoch_j2000_s {
266                history.checkpoints.push_back(checkpoint.clone());
267            }
268        }
269        for measurement in &self.measurements {
270            if measurement.epoch() <= checkpoint_epoch_j2000_s {
271                history.measurements.push_back(measurement.clone());
272            }
273        }
274        history
275    }
276
277    fn checkpoint_at_or_before(&self, t_j2000_s: f64) -> Option<&StoredCheckpoint> {
278        self.checkpoints
279            .iter()
280            .rev()
281            .find(|checkpoint| checkpoint.t_j2000_s <= t_j2000_s)
282    }
283
284    fn measurements_after(&self, t_j2000_s: f64) -> Vec<ReplayMeasurement> {
285        self.measurements
286            .iter()
287            .enumerate()
288            .filter(|(_, measurement)| measurement.epoch() > t_j2000_s)
289            .map(|(order, measurement)| ReplayMeasurement {
290                measurement: measurement.clone(),
291                order,
292                is_new: false,
293            })
294            .collect()
295    }
296
297    fn sample_covering(&self, t_j2000_s: f64) -> Option<&StoredImuSample> {
298        self.imu_samples.iter().find(|stored| {
299            stored.previous_t_j2000_s <= t_j2000_s && t_j2000_s < stored.sample.t_j2000_s
300        })
301    }
302
303    fn set_config(&mut self, config: TimeSyncHistoryConfig) {
304        self.config = config;
305        truncate_front(&mut self.imu_samples, config.imu_capacity);
306        truncate_front(&mut self.checkpoints, config.checkpoint_capacity);
307        truncate_front(&mut self.measurements, config.checkpoint_capacity);
308    }
309
310    fn status(&self) -> TimeSyncHistoryStatus {
311        TimeSyncHistoryStatus {
312            imu_capacity: self.config.imu_capacity,
313            imu_len: self.imu_samples.len(),
314            checkpoint_capacity: self.config.checkpoint_capacity,
315            checkpoint_len: self.checkpoints.len(),
316            oldest_imu_epoch_j2000_s: self
317                .imu_samples
318                .front()
319                .map(|stored| stored.sample.t_j2000_s),
320            newest_imu_epoch_j2000_s: self
321                .imu_samples
322                .back()
323                .map(|stored| stored.sample.t_j2000_s),
324            oldest_checkpoint_epoch_j2000_s: self
325                .checkpoints
326                .front()
327                .map(|checkpoint| checkpoint.t_j2000_s),
328            newest_checkpoint_epoch_j2000_s: self
329                .checkpoints
330                .back()
331                .map(|checkpoint| checkpoint.t_j2000_s),
332        }
333    }
334}
335
336/// Validate strictly increasing IMU sample epochs.
337pub fn validate_time_sync_imu_order(samples: &[ImuSample]) -> Result<(), FusionError> {
338    let mut previous_t_j2000_s = None;
339    for sample in samples {
340        validate_epoch(sample.t_j2000_s, "imu_samples")?;
341        if let Some(previous) = previous_t_j2000_s {
342            if sample.t_j2000_s <= previous {
343                return Err(invalid_input(
344                    "imu_samples",
345                    "must be strictly ordered by epoch",
346                ));
347            }
348        }
349        previous_t_j2000_s = Some(sample.t_j2000_s);
350    }
351    Ok(())
352}
353
354/// Validate strictly increasing GNSS measurement epochs.
355pub fn validate_time_sync_gnss_order(
356    measurements: &[GnssFixMeasurement],
357) -> Result<(), FusionError> {
358    let mut previous_t_j2000_s = None;
359    for measurement in measurements {
360        measurement.validate()?;
361        if let Some(previous) = previous_t_j2000_s {
362            if measurement.t_j2000_s <= previous {
363                return Err(invalid_input(
364                    "gnss_measurements",
365                    "must be strictly ordered by epoch",
366                ));
367            }
368        }
369        previous_t_j2000_s = Some(measurement.t_j2000_s);
370    }
371    Ok(())
372}
373
374impl InertialFilter {
375    /// Return a copy of the filter state needed for time-sync checkpoint replay.
376    pub fn snapshot(&self) -> InertialFilterSnapshot {
377        InertialFilterSnapshot {
378            state: self.state.clone(),
379            last_body_rate_wrt_ecef_rps: self.last_body_rate_wrt_ecef_rps,
380            tight: self.tight.snapshot(),
381        }
382    }
383
384    /// Restore the filter state from a snapshot.
385    pub fn restore_snapshot(
386        &mut self,
387        snapshot: &InertialFilterSnapshot,
388    ) -> Result<(), FusionError> {
389        snapshot.state.validate()?;
390        validate_vec3(
391            snapshot.last_body_rate_wrt_ecef_rps,
392            "last_body_rate_wrt_ecef_rps",
393        )?;
394        let restored = snapshot.clone();
395        self.state = restored.state.clone();
396        self.last_body_rate_wrt_ecef_rps = restored.last_body_rate_wrt_ecef_rps;
397        self.tight
398            .restore(&restored.tight, restored.state.dimension())?;
399        self.time_sync.restore_to_snapshot(restored);
400        Ok(())
401    }
402
403    /// Replace retained-history capacities for later time-sync replay.
404    pub fn configure_time_sync_history(
405        &mut self,
406        config: TimeSyncHistoryConfig,
407    ) -> Result<(), FusionError> {
408        config.validate()?;
409        self.time_sync.set_config(config);
410        Ok(())
411    }
412
413    /// Return current retained-history capacity and occupancy.
414    pub fn time_sync_history_status(&self) -> TimeSyncHistoryStatus {
415        self.time_sync.status()
416    }
417
418    /// Apply a loose GNSS update at the measurement epoch, replaying history if needed.
419    pub fn update_loose_time_sync(
420        &mut self,
421        measurement: &GnssFixMeasurement,
422    ) -> Result<TimeSyncUpdate, FusionError> {
423        measurement.validate()?;
424        let target_t_j2000_s = measurement.t_j2000_s;
425        let current_t_j2000_s = self.state.nominal.t_j2000_s;
426        if target_t_j2000_s > current_t_j2000_s {
427            return Err(invalid_input(
428                "t_j2000_s",
429                "must not exceed current inertial epoch",
430            ));
431        }
432
433        if target_t_j2000_s == current_t_j2000_s {
434            let update = self.update_loose(measurement)?;
435            return Ok(TimeSyncUpdate {
436                update,
437                late_measurement: false,
438                replayed_imu_segments: 0,
439                restored_checkpoint_epoch_j2000_s: current_t_j2000_s,
440                current_epoch_j2000_s: self.state.nominal.t_j2000_s,
441            });
442        }
443
444        self.apply_late_loose_update(measurement, current_t_j2000_s)
445    }
446
447    /// Apply a tight raw GNSS update at the measurement epoch, replaying history if needed.
448    pub fn update_tight_time_sync(
449        &mut self,
450        source: &dyn crate::observables::ObservableEphemerisSource,
451        epoch: &TightGnssEpoch,
452    ) -> Result<TimeSyncUpdate, FusionError> {
453        epoch.validate()?;
454        let target_t_j2000_s = epoch.t_j2000_s;
455        let current_t_j2000_s = self.state.nominal.t_j2000_s;
456        if target_t_j2000_s > current_t_j2000_s {
457            return Err(invalid_input(
458                "t_j2000_s",
459                "must not exceed current inertial epoch",
460            ));
461        }
462
463        if target_t_j2000_s == current_t_j2000_s {
464            let update = self.update_tight(source, epoch)?;
465            return Ok(TimeSyncUpdate {
466                update,
467                late_measurement: false,
468                replayed_imu_segments: 0,
469                restored_checkpoint_epoch_j2000_s: current_t_j2000_s,
470                current_epoch_j2000_s: self.state.nominal.t_j2000_s,
471            });
472        }
473
474        self.apply_late_tight_update(source, epoch, current_t_j2000_s)
475    }
476
477    fn apply_late_loose_update(
478        &mut self,
479        measurement: &GnssFixMeasurement,
480        original_current_t_j2000_s: f64,
481    ) -> Result<TimeSyncUpdate, FusionError> {
482        let original_history = self.time_sync.clone();
483        let checkpoint = original_history
484            .checkpoint_at_or_before(measurement.t_j2000_s)
485            .ok_or_else(|| invalid_input("t_j2000_s", "outside retained checkpoint history"))?
486            .clone();
487        let mut replay_measurements = original_history.measurements_after(checkpoint.t_j2000_s);
488        if replay_measurements
489            .iter()
490            .any(|r| r.measurement.epoch() == measurement.t_j2000_s)
491        {
492            return Err(invalid_input(
493                "t_j2000_s",
494                "duplicate GNSS measurement epoch in late replay",
495            ));
496        }
497        let new_order = replay_measurements.len();
498        replay_measurements.push(ReplayMeasurement {
499            measurement: StoredGnssMeasurement::Loose(measurement.clone()),
500            order: new_order,
501            is_new: true,
502        });
503        replay_measurements.sort_by(|a, b| {
504            a.measurement
505                .epoch()
506                .total_cmp(&b.measurement.epoch())
507                .then_with(|| a.order.cmp(&b.order))
508                .then_with(|| a.is_new.cmp(&b.is_new))
509        });
510
511        let mut working = self.clone();
512        working.restore_snapshot(&checkpoint.snapshot)?;
513        working.time_sync = original_history.rebase_through_checkpoint(checkpoint.t_j2000_s);
514
515        let mut replayed_imu_segments = 0usize;
516        let mut supplied_update = None;
517        for replay in replay_measurements {
518            replayed_imu_segments +=
519                working.replay_imu_to_epoch(replay.measurement.epoch(), &original_history)?;
520            let update = match &replay.measurement {
521                StoredGnssMeasurement::Loose(measurement) => {
522                    working.update_loose_core(measurement)?
523                }
524                StoredGnssMeasurement::Tight(_) => {
525                    return Err(invalid_input(
526                        "gnss_measurements",
527                        "tight replay needs update_tight_time_sync",
528                    ));
529                }
530            };
531            let snapshot = working.snapshot();
532            working
533                .time_sync
534                .push_stored_measurement_and_checkpoint(replay.measurement.clone(), snapshot);
535            if replay.is_new {
536                supplied_update = Some(update);
537            }
538        }
539        replayed_imu_segments +=
540            working.replay_imu_to_epoch(original_current_t_j2000_s, &original_history)?;
541        let update = supplied_update.ok_or_else(|| {
542            invalid_input("gnss_measurements", "supplied measurement was not replayed")
543        })?;
544        let restored_checkpoint_epoch_j2000_s = checkpoint.t_j2000_s;
545        let current_epoch_j2000_s = working.state.nominal.t_j2000_s;
546        *self = working;
547        Ok(TimeSyncUpdate {
548            update,
549            late_measurement: true,
550            replayed_imu_segments,
551            restored_checkpoint_epoch_j2000_s,
552            current_epoch_j2000_s,
553        })
554    }
555
556    fn apply_late_tight_update(
557        &mut self,
558        source: &dyn crate::observables::ObservableEphemerisSource,
559        epoch: &TightGnssEpoch,
560        original_current_t_j2000_s: f64,
561    ) -> Result<TimeSyncUpdate, FusionError> {
562        let original_history = self.time_sync.clone();
563        let checkpoint = original_history
564            .checkpoint_at_or_before(epoch.t_j2000_s)
565            .ok_or_else(|| invalid_input("t_j2000_s", "outside retained checkpoint history"))?
566            .clone();
567        let mut replay_measurements = original_history.measurements_after(checkpoint.t_j2000_s);
568        if replay_measurements
569            .iter()
570            .any(|r| r.measurement.epoch() == epoch.t_j2000_s)
571        {
572            return Err(invalid_input(
573                "t_j2000_s",
574                "duplicate GNSS measurement epoch in late replay",
575            ));
576        }
577        let new_order = replay_measurements.len();
578        replay_measurements.push(ReplayMeasurement {
579            measurement: StoredGnssMeasurement::Tight(epoch.clone()),
580            order: new_order,
581            is_new: true,
582        });
583        replay_measurements.sort_by(|a, b| {
584            a.measurement
585                .epoch()
586                .total_cmp(&b.measurement.epoch())
587                .then_with(|| a.order.cmp(&b.order))
588                .then_with(|| a.is_new.cmp(&b.is_new))
589        });
590
591        let mut working = self.clone();
592        working.restore_snapshot(&checkpoint.snapshot)?;
593        working.time_sync = original_history.rebase_through_checkpoint(checkpoint.t_j2000_s);
594
595        let mut replayed_imu_segments = 0usize;
596        let mut supplied_update = None;
597        for replay in replay_measurements {
598            replayed_imu_segments +=
599                working.replay_imu_to_epoch(replay.measurement.epoch(), &original_history)?;
600            let update = match &replay.measurement {
601                StoredGnssMeasurement::Loose(measurement) => {
602                    working.update_loose_core(measurement)?
603                }
604                StoredGnssMeasurement::Tight(measurement) => {
605                    working.update_tight_core(source, measurement)?
606                }
607            };
608            let snapshot = working.snapshot();
609            working
610                .time_sync
611                .push_stored_measurement_and_checkpoint(replay.measurement.clone(), snapshot);
612            if replay.is_new {
613                supplied_update = Some(update);
614            }
615        }
616        replayed_imu_segments +=
617            working.replay_imu_to_epoch(original_current_t_j2000_s, &original_history)?;
618        let update = supplied_update.ok_or_else(|| {
619            invalid_input("gnss_measurements", "supplied measurement was not replayed")
620        })?;
621        let restored_checkpoint_epoch_j2000_s = checkpoint.t_j2000_s;
622        let current_epoch_j2000_s = working.state.nominal.t_j2000_s;
623        *self = working;
624        Ok(TimeSyncUpdate {
625            update,
626            late_measurement: true,
627            replayed_imu_segments,
628            restored_checkpoint_epoch_j2000_s,
629            current_epoch_j2000_s,
630        })
631    }
632
633    fn replay_imu_to_epoch(
634        &mut self,
635        target_t_j2000_s: f64,
636        source: &TimeSyncHistory,
637    ) -> Result<usize, FusionError> {
638        validate_epoch(target_t_j2000_s, "target_t_j2000_s")?;
639        let mut segments = 0usize;
640        loop {
641            let current_t_j2000_s = self.state.nominal.t_j2000_s;
642            if current_t_j2000_s == target_t_j2000_s {
643                return Ok(segments);
644            }
645            if current_t_j2000_s > target_t_j2000_s {
646                return Err(invalid_input(
647                    "target_t_j2000_s",
648                    "must not be older than the restored epoch",
649                ));
650            }
651            let stored = source.sample_covering(current_t_j2000_s).ok_or_else(|| {
652                invalid_input("imu_samples", "target epoch outside retained IMU history")
653            })?;
654            let segment_end_t_j2000_s = stored.sample.t_j2000_s.min(target_t_j2000_s);
655            let sample = stored.segment_sample(current_t_j2000_s, segment_end_t_j2000_s)?;
656            self.propagate_core(sample)?;
657            segments += 1;
658        }
659    }
660}
661
662#[derive(Debug, Clone, Copy, PartialEq)]
663struct StoredImuSample {
664    previous_t_j2000_s: f64,
665    sample: ImuSample,
666    previous_rate: Option<RateEndpoint>,
667}
668
669impl StoredImuSample {
670    fn segment_sample(
671        &self,
672        segment_start_t_j2000_s: f64,
673        segment_end_t_j2000_s: f64,
674    ) -> Result<ImuSample, FusionError> {
675        validate_epoch(segment_start_t_j2000_s, "segment_start_t_j2000_s")?;
676        validate_epoch(segment_end_t_j2000_s, "segment_end_t_j2000_s")?;
677        if segment_start_t_j2000_s < self.previous_t_j2000_s
678            || segment_start_t_j2000_s >= segment_end_t_j2000_s
679            || segment_end_t_j2000_s > self.sample.t_j2000_s
680        {
681            return Err(invalid_input(
682                "imu_samples",
683                "segment outside retained sample",
684            ));
685        }
686        if segment_start_t_j2000_s == self.previous_t_j2000_s
687            && segment_end_t_j2000_s == self.sample.t_j2000_s
688        {
689            return Ok(self.sample);
690        }
691        let dt_s = segment_end_t_j2000_s - segment_start_t_j2000_s;
692        match self.sample.kind {
693            ImuSampleKind::Rate {
694                specific_force_mps2,
695                angular_rate_rps,
696            } => {
697                let current = RateEndpoint {
698                    t_j2000_s: self.sample.t_j2000_s,
699                    specific_force_mps2,
700                    angular_rate_rps,
701                };
702                let previous = self.previous_rate.ok_or_else(|| {
703                    invalid_input("imu_samples", "fractional rate segment needs prior rate")
704                })?;
705                if previous.t_j2000_s >= current.t_j2000_s {
706                    return Err(invalid_input(
707                        "imu_samples",
708                        "fractional rate segment needs ordered rate endpoints",
709                    ));
710                }
711                let start = interpolate_rate(previous, current, segment_start_t_j2000_s);
712                let end = interpolate_rate(previous, current, segment_end_t_j2000_s);
713                Ok(ImuSample::rate(
714                    segment_end_t_j2000_s,
715                    scale3(
716                        add3(start.specific_force_mps2, end.specific_force_mps2),
717                        0.5,
718                    ),
719                    scale3(add3(start.angular_rate_rps, end.angular_rate_rps), 0.5),
720                ))
721            }
722            ImuSampleKind::Increment {
723                delta_velocity_mps,
724                delta_theta_rad,
725                dt_s: sample_dt_s,
726            } => {
727                validate_positive(sample_dt_s, "dt_s")?;
728                let sample_interval_s = self.sample.t_j2000_s - self.previous_t_j2000_s;
729                validate_positive(sample_interval_s, "imu_samples")?;
730                let fraction = dt_s / sample_interval_s;
731                Ok(ImuSample::increment(
732                    segment_end_t_j2000_s,
733                    scale3(delta_velocity_mps, fraction),
734                    scale3(delta_theta_rad, fraction),
735                    dt_s,
736                ))
737            }
738        }
739    }
740}
741
742#[derive(Debug, Clone, PartialEq)]
743struct StoredCheckpoint {
744    t_j2000_s: f64,
745    snapshot: InertialFilterSnapshot,
746}
747
748#[derive(Debug, Clone, PartialEq)]
749enum StoredGnssMeasurement {
750    Loose(GnssFixMeasurement),
751    Tight(TightGnssEpoch),
752}
753
754impl StoredGnssMeasurement {
755    fn epoch(&self) -> f64 {
756        match self {
757            Self::Loose(measurement) => measurement.t_j2000_s,
758            Self::Tight(epoch) => epoch.t_j2000_s,
759        }
760    }
761}
762
763#[derive(Debug, Clone, PartialEq)]
764struct ReplayMeasurement {
765    measurement: StoredGnssMeasurement,
766    order: usize,
767    is_new: bool,
768}
769
770#[derive(Debug, Clone, Copy, PartialEq)]
771struct RateEndpoint {
772    t_j2000_s: f64,
773    specific_force_mps2: [f64; 3],
774    angular_rate_rps: [f64; 3],
775}
776
777#[derive(Debug, Clone, Copy, PartialEq)]
778struct RatePayload {
779    specific_force_mps2: [f64; 3],
780    angular_rate_rps: [f64; 3],
781}
782
783fn rate_payload(sample: ImuSample) -> Option<RatePayload> {
784    match sample.kind {
785        ImuSampleKind::Rate {
786            specific_force_mps2,
787            angular_rate_rps,
788        } => Some(RatePayload {
789            specific_force_mps2,
790            angular_rate_rps,
791        }),
792        ImuSampleKind::Increment { .. } => None,
793    }
794}
795
796fn interpolate_rate(start: RateEndpoint, end: RateEndpoint, t_j2000_s: f64) -> RateEndpoint {
797    let alpha = (t_j2000_s - start.t_j2000_s) / (end.t_j2000_s - start.t_j2000_s);
798    RateEndpoint {
799        t_j2000_s,
800        specific_force_mps2: add3(
801            start.specific_force_mps2,
802            scale3(
803                sub3(end.specific_force_mps2, start.specific_force_mps2),
804                alpha,
805            ),
806        ),
807        angular_rate_rps: add3(
808            start.angular_rate_rps,
809            scale3(sub3(end.angular_rate_rps, start.angular_rate_rps), alpha),
810        ),
811    }
812}
813
814fn bounded_push<T>(items: &mut VecDeque<T>, capacity: usize, item: T) {
815    if items.len() == capacity {
816        items.pop_front();
817    }
818    items.push_back(item);
819}
820
821fn truncate_front<T>(items: &mut VecDeque<T>, capacity: usize) {
822    while items.len() > capacity {
823        items.pop_front();
824    }
825}
826
827fn validate_capacity(capacity: usize, field: &'static str) -> Result<(), FusionError> {
828    if capacity == 0 {
829        Err(invalid_input(field, "must be positive"))
830    } else {
831        Ok(())
832    }
833}
834
835fn validate_epoch(value: f64, field: &'static str) -> Result<(), FusionError> {
836    if value.is_finite() {
837        Ok(())
838    } else {
839        Err(invalid_input(field, "must be finite"))
840    }
841}
842
843fn validate_vec3(value: [f64; 3], field: &'static str) -> Result<(), FusionError> {
844    for component in value {
845        validate_epoch(component, field)?;
846    }
847    Ok(())
848}
849
850#[cfg(test)]
851mod tests {
852    //! Provenance: time synchronization follows the Kalman implementation issue
853    //! described by Groves, Principles of GNSS, Inertial, and Multisensor
854    //! Integrated Navigation Systems, 2nd ed., Section 3.3.4. The fractional-increment
855    //! tests use synthetic constant integral IMU samples, so the oracle is the
856    //! same ordered sequence of split increments at the GNSS epoch. Late-update
857    //! parity compares the replayed path against direct in-order processing
858    //! with bit equality.
859
860    use super::*;
861    use crate::astro::constants::earth::{OMEGA_E_DOT_RAD_S, WGS84_A_M};
862    use crate::fusion::state::{
863        ErrorStateLayout, ERROR_POSITION_INDEX, ERROR_STATE_DIMENSION_15, ERROR_VELOCITY_INDEX,
864    };
865    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
866    use crate::inertial::state::mat3_identity;
867    use crate::inertial::{ImuSpec, NavState};
868    use nalgebra::DMatrix;
869
870    fn filter_at(t_j2000_s: f64) -> InertialFilter {
871        let nominal = NavState::new(
872            t_j2000_s,
873            [WGS84_A_M, 0.0, 0.0],
874            [0.0, 0.0, 0.0],
875            mat3_identity(),
876        )
877        .expect("nominal");
878        let diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
879        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
880            .expect("state");
881        let spec = ImuSpec::datasheet(
882            0.0,
883            0.0,
884            0.0,
885            0.0,
886            RANDOM_WALK_BIAS_TAU_S,
887            RANDOM_WALK_BIAS_TAU_S,
888            None,
889            None,
890        );
891        InertialFilter::new(state, spec).expect("filter")
892    }
893
894    fn noisy_filter_at(t_j2000_s: f64) -> InertialFilter {
895        let nominal = NavState::new(
896            t_j2000_s,
897            [WGS84_A_M, 0.0, 0.0],
898            [0.0, 0.0, 0.0],
899            mat3_identity(),
900        )
901        .expect("nominal");
902        let diagonal = vec![1.0e-6; ERROR_STATE_DIMENSION_15];
903        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
904            .expect("state");
905        let spec = ImuSpec::datasheet(0.02, 0.001, 0.004, 2.0e-4, 300.0, 300.0, None, None);
906        InertialFilter::new(state, spec).expect("filter")
907    }
908
909    fn increment(t_j2000_s: f64, dt_s: f64) -> ImuSample {
910        ImuSample::increment(
911            t_j2000_s,
912            [0.015625 * dt_s, -0.0078125 * dt_s, 0.00390625 * dt_s],
913            [
914                OMEGA_E_DOT_RAD_S * dt_s,
915                0.0009765625 * dt_s,
916                -0.00048828125 * dt_s,
917            ],
918            dt_s,
919        )
920    }
921
922    fn measurement_at(t_j2000_s: f64, position_ecef_m: [f64; 3]) -> GnssFixMeasurement {
923        GnssFixMeasurement::position(
924            t_j2000_s,
925            position_ecef_m,
926            [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]],
927            8,
928        )
929        .expect("measurement")
930    }
931
932    fn logdet_spd(covariance: &[Vec<f64>]) -> f64 {
933        let dimension = covariance.len();
934        let mut data = Vec::<f64>::with_capacity(dimension * dimension);
935        for row in covariance {
936            data.extend(row.iter().copied());
937        }
938        let matrix = DMatrix::from_row_slice(dimension, dimension, &data);
939        let cholesky = matrix.cholesky().expect("covariance SPD");
940        2.0 * cholesky
941            .l()
942            .diagonal()
943            .iter()
944            .map(|value| value.ln())
945            .sum::<f64>()
946    }
947
948    #[test]
949    fn split_increment_substep_matches_explicit_epoch_split_bits() {
950        let mut split = filter_at(0.0);
951        split
952            .configure_time_sync_history(TimeSyncHistoryConfig {
953                imu_capacity: 4,
954                checkpoint_capacity: 4,
955            })
956            .expect("history");
957        split.propagate(increment(1.0, 1.0)).expect("propagate");
958        let mut explicit = filter_at(0.0);
959        explicit
960            .configure_time_sync_history(TimeSyncHistoryConfig {
961                imu_capacity: 4,
962                checkpoint_capacity: 4,
963            })
964            .expect("history");
965        explicit
966            .propagate(increment(0.75, 0.75))
967            .expect("first split");
968        let measurement = measurement_at(0.75, [WGS84_A_M + 0.125, -0.0625, 0.03125]);
969        explicit.update_loose(&measurement).expect("direct update");
970        explicit
971            .propagate(increment(1.0, 0.25))
972            .expect("second split");
973
974        let update = split
975            .update_loose_time_sync(&measurement)
976            .expect("time-sync update");
977        assert!(update.late_measurement);
978        assert_eq!(update.replayed_imu_segments, 2);
979        assert_filter_bits(split.state(), explicit.state());
980    }
981
982    #[test]
983    fn late_measurement_replay_matches_in_order_bits() {
984        let mut in_order = filter_at(0.0);
985        in_order.propagate(increment(0.5, 0.5)).expect("imu");
986        let first = measurement_at(0.5, [WGS84_A_M + 0.25, 0.0, 0.0]);
987        in_order.update_loose(&first).expect("first update");
988        in_order.propagate(increment(1.0, 0.5)).expect("imu");
989        let late = measurement_at(1.0, [WGS84_A_M - 0.125, 0.0625, 0.0]);
990        in_order.update_loose(&late).expect("late in order");
991        in_order.propagate(increment(1.5, 0.5)).expect("imu");
992        let final_fix = measurement_at(1.5, [WGS84_A_M, 0.0, 0.03125]);
993        in_order.update_loose(&final_fix).expect("final update");
994
995        let mut replay = filter_at(0.0);
996        replay.propagate(increment(0.5, 0.5)).expect("imu");
997        replay.update_loose(&first).expect("first update");
998        replay.propagate(increment(1.0, 0.5)).expect("imu");
999        replay.propagate(increment(1.5, 0.5)).expect("imu");
1000        replay.update_loose(&final_fix).expect("final update");
1001        let update = replay
1002            .update_loose_time_sync(&late)
1003            .expect("late update replay");
1004
1005        assert!(update.late_measurement);
1006        assert_eq!(
1007            update.restored_checkpoint_epoch_j2000_s.to_bits(),
1008            0.5f64.to_bits()
1009        );
1010        assert_filter_bits(replay.state(), in_order.state());
1011    }
1012
1013    #[test]
1014    fn coasting_covariance_logdet_grows_monotonically() {
1015        let mut filter = noisy_filter_at(0.0);
1016        let mut previous_logdet = logdet_spd(&filter.state().covariance);
1017        for step in 1..=6 {
1018            filter
1019                .propagate(increment(step as f64 * 0.25, 0.25))
1020                .expect("coast");
1021            let logdet = logdet_spd(&filter.state().covariance);
1022            assert!(
1023                logdet > previous_logdet,
1024                "step {step} logdet {logdet:.17e}, previous {previous_logdet:.17e}"
1025            );
1026            previous_logdet = logdet;
1027        }
1028    }
1029
1030    #[test]
1031    fn ring_buffer_wraparound_retains_exact_tail_epochs() {
1032        let mut filter = filter_at(0.0);
1033        filter
1034            .configure_time_sync_history(TimeSyncHistoryConfig::new(3, 2))
1035            .expect("history");
1036        for step in 1..=5 {
1037            filter
1038                .propagate(increment(step as f64 * 0.25, 0.25))
1039                .expect("imu");
1040        }
1041        let status = filter.time_sync_history_status();
1042        assert_eq!(status.imu_len, 3);
1043        assert_eq!(
1044            status.oldest_imu_epoch_j2000_s.map(f64::to_bits),
1045            Some(0.75f64.to_bits())
1046        );
1047        assert_eq!(
1048            status.newest_imu_epoch_j2000_s.map(f64::to_bits),
1049            Some(1.25f64.to_bits())
1050        );
1051
1052        let first = GnssFixMeasurement::position(
1053            1.25,
1054            filter.state().nominal.position_ecef_m,
1055            [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]],
1056            8,
1057        )
1058        .expect("first");
1059        filter.update_loose(&first).expect("first update");
1060        filter
1061            .propagate(increment(1.5, 0.25))
1062            .expect("additional imu");
1063        let second = GnssFixMeasurement::position(
1064            1.5,
1065            filter.state().nominal.position_ecef_m,
1066            [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]],
1067            8,
1068        )
1069        .expect("second");
1070        filter.update_loose(&second).expect("second update");
1071        let status = filter.time_sync_history_status();
1072        assert_eq!(status.checkpoint_len, 2);
1073        assert_eq!(
1074            status.oldest_checkpoint_epoch_j2000_s.map(f64::to_bits),
1075            Some(1.25f64.to_bits())
1076        );
1077        assert_eq!(
1078            status.newest_checkpoint_epoch_j2000_s.map(f64::to_bits),
1079            Some(1.5f64.to_bits())
1080        );
1081    }
1082
1083    #[test]
1084    fn restore_snapshot_trims_future_history_bits() {
1085        let mut filter = filter_at(0.0);
1086        filter
1087            .configure_time_sync_history(TimeSyncHistoryConfig::new(4, 4))
1088            .expect("history");
1089        filter.propagate(increment(1.0, 1.0)).expect("first");
1090        let snapshot = filter.snapshot();
1091        filter.propagate(increment(2.0, 1.0)).expect("second");
1092        assert_eq!(
1093            filter
1094                .time_sync_history_status()
1095                .newest_imu_epoch_j2000_s
1096                .map(f64::to_bits),
1097            Some(2.0f64.to_bits())
1098        );
1099
1100        filter.restore_snapshot(&snapshot).expect("restore");
1101        assert_eq!(
1102            filter
1103                .time_sync_history_status()
1104                .newest_imu_epoch_j2000_s
1105                .map(f64::to_bits),
1106            Some(1.0f64.to_bits())
1107        );
1108        filter
1109            .propagate(increment(1.5, 0.5))
1110            .expect("after restore");
1111        let status = filter.time_sync_history_status();
1112        assert_eq!(
1113            status.newest_imu_epoch_j2000_s.map(f64::to_bits),
1114            Some(1.5f64.to_bits())
1115        );
1116        assert_eq!(filter.state().nominal.t_j2000_s.to_bits(), 1.5f64.to_bits());
1117    }
1118
1119    #[test]
1120    fn validators_reject_unordered_epochs() {
1121        let samples = [increment(1.0, 1.0), increment(0.5, 0.5)];
1122        assert!(matches!(
1123            validate_time_sync_imu_order(&samples),
1124            Err(FusionError::InvalidInput {
1125                field: "imu_samples",
1126                reason: "must be strictly ordered by epoch"
1127            })
1128        ));
1129
1130        let first = GnssFixMeasurement::position(
1131            2.0,
1132            [WGS84_A_M, 0.0, 0.0],
1133            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1134            8,
1135        )
1136        .expect("first");
1137        let second = GnssFixMeasurement::position(
1138            1.0,
1139            [WGS84_A_M, 0.0, 0.0],
1140            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1141            8,
1142        )
1143        .expect("second");
1144        assert!(matches!(
1145            validate_time_sync_gnss_order(&[first, second]),
1146            Err(FusionError::InvalidInput {
1147                field: "gnss_measurements",
1148                reason: "must be strictly ordered by epoch"
1149            })
1150        ));
1151    }
1152
1153    #[test]
1154    fn validators_reject_equal_gnss_epochs() {
1155        let first = GnssFixMeasurement::position(
1156            2.0,
1157            [WGS84_A_M, 0.0, 0.0],
1158            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1159            8,
1160        )
1161        .expect("first");
1162        let second = GnssFixMeasurement::position(
1163            2.0,
1164            [WGS84_A_M, 1.0, 0.0],
1165            [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]],
1166            8,
1167        )
1168        .expect("second");
1169        assert!(matches!(
1170            validate_time_sync_gnss_order(&[first, second]),
1171            Err(FusionError::InvalidInput {
1172                field: "gnss_measurements",
1173                reason: "must be strictly ordered by epoch"
1174            })
1175        ));
1176    }
1177
1178    #[test]
1179    fn fractional_rate_sample_uses_linear_average_oracle() {
1180        let stored = StoredImuSample {
1181            previous_t_j2000_s: 1.0,
1182            sample: ImuSample::rate(2.0, [3.0, 5.0, 7.0], [11.0, 13.0, 17.0]),
1183            previous_rate: Some(RateEndpoint {
1184                t_j2000_s: 1.0,
1185                specific_force_mps2: [1.0, 2.0, 4.0],
1186                angular_rate_rps: [6.0, 8.0, 10.0],
1187            }),
1188        };
1189        let segment = stored.segment_sample(1.25, 1.75).expect("segment");
1190        match segment.kind {
1191            ImuSampleKind::Rate {
1192                specific_force_mps2,
1193                angular_rate_rps,
1194            } => {
1195                assert_eq!(specific_force_mps2[0].to_bits(), 2.0f64.to_bits());
1196                assert_eq!(specific_force_mps2[1].to_bits(), 3.5f64.to_bits());
1197                assert_eq!(specific_force_mps2[2].to_bits(), 5.5f64.to_bits());
1198                assert_eq!(angular_rate_rps[0].to_bits(), 8.5f64.to_bits());
1199                assert_eq!(angular_rate_rps[1].to_bits(), 10.5f64.to_bits());
1200                assert_eq!(angular_rate_rps[2].to_bits(), 13.5f64.to_bits());
1201            }
1202            ImuSampleKind::Increment { .. } => panic!("rate segment expected"),
1203        }
1204    }
1205
1206    #[test]
1207    fn fractional_rate_without_prior_endpoint_is_rejected() {
1208        let stored = StoredImuSample {
1209            previous_t_j2000_s: 1.0,
1210            sample: ImuSample::rate(2.0, [3.0, 5.0, 7.0], [11.0, 13.0, 17.0]),
1211            previous_rate: None,
1212        };
1213        assert!(matches!(
1214            stored.segment_sample(1.25, 1.75),
1215            Err(FusionError::InvalidInput {
1216                field: "imu_samples",
1217                reason: "fractional rate segment needs prior rate"
1218            })
1219        ));
1220        let full = stored.segment_sample(1.0, 2.0).expect("full segment");
1221        assert_eq!(full, stored.sample);
1222    }
1223
1224    #[test]
1225    fn fractional_increment_sample_splits_integral_bits() {
1226        let stored = StoredImuSample {
1227            previous_t_j2000_s: 10.0,
1228            sample: ImuSample::increment(14.0, [8.0, -4.0, 2.0], [1.0, -0.5, 0.25], 3.999999999999),
1229            previous_rate: None,
1230        };
1231        let segment = stored.segment_sample(11.0, 12.0).expect("segment");
1232        match segment.kind {
1233            ImuSampleKind::Increment {
1234                delta_velocity_mps,
1235                delta_theta_rad,
1236                dt_s,
1237            } => {
1238                assert_eq!(segment.t_j2000_s.to_bits(), 12.0f64.to_bits());
1239                assert_eq!(dt_s.to_bits(), 1.0f64.to_bits());
1240                assert_eq!(delta_velocity_mps[0].to_bits(), 2.0f64.to_bits());
1241                assert_eq!(delta_velocity_mps[1].to_bits(), (-1.0f64).to_bits());
1242                assert_eq!(delta_velocity_mps[2].to_bits(), 0.5f64.to_bits());
1243                assert_eq!(delta_theta_rad[0].to_bits(), 0.25f64.to_bits());
1244                assert_eq!(delta_theta_rad[1].to_bits(), (-0.125f64).to_bits());
1245                assert_eq!(delta_theta_rad[2].to_bits(), 0.0625f64.to_bits());
1246            }
1247            ImuSampleKind::Rate { .. } => panic!("increment segment expected"),
1248        }
1249    }
1250
1251    fn assert_filter_bits(actual: &InsFilterState, expected: &InsFilterState) {
1252        assert_eq!(
1253            actual.nominal.t_j2000_s.to_bits(),
1254            expected.nominal.t_j2000_s.to_bits()
1255        );
1256        assert_vec_bits(
1257            actual.nominal.position_ecef_m,
1258            expected.nominal.position_ecef_m,
1259        );
1260        assert_vec_bits(
1261            actual.nominal.velocity_ecef_mps,
1262            expected.nominal.velocity_ecef_mps,
1263        );
1264        for row in 0..3 {
1265            assert_vec_bits(
1266                actual.nominal.attitude_body_to_ecef[row],
1267                expected.nominal.attitude_body_to_ecef[row],
1268            );
1269        }
1270        for row in 0..actual.covariance.len() {
1271            for col in 0..actual.covariance[row].len() {
1272                assert_eq!(
1273                    actual.covariance[row][col].to_bits(),
1274                    expected.covariance[row][col].to_bits(),
1275                    "covariance {row},{col}"
1276                );
1277            }
1278        }
1279        for axis in 0..3 {
1280            assert_eq!(
1281                actual.nominal.accel_bias_mps2[axis].to_bits(),
1282                expected.nominal.accel_bias_mps2[axis].to_bits()
1283            );
1284            assert_eq!(
1285                actual.nominal.gyro_bias_rps[axis].to_bits(),
1286                expected.nominal.gyro_bias_rps[axis].to_bits()
1287            );
1288            assert_eq!(
1289                actual.accel_scale_factor[axis].to_bits(),
1290                expected.accel_scale_factor[axis].to_bits()
1291            );
1292            assert_eq!(
1293                actual.gyro_scale_factor[axis].to_bits(),
1294                expected.gyro_scale_factor[axis].to_bits()
1295            );
1296        }
1297    }
1298
1299    fn assert_vec_bits(actual: [f64; 3], expected: [f64; 3]) {
1300        for axis in 0..3 {
1301            assert_eq!(
1302                actual[axis].to_bits(),
1303                expected[axis].to_bits(),
1304                "axis {axis}"
1305            );
1306        }
1307    }
1308
1309    #[test]
1310    fn measurement_update_moves_only_position_states_in_test_setup() {
1311        let mut filter = filter_at(0.0);
1312        let measurement = GnssFixMeasurement::position(
1313            0.0,
1314            [WGS84_A_M + 1.0, -2.0, 3.0],
1315            [[4.0, 0.0, 0.0], [0.0, 4.0, 0.0], [0.0, 0.0, 4.0]],
1316            8,
1317        )
1318        .expect("measurement");
1319        let update = filter.update_loose_time_sync(&measurement).expect("update");
1320        assert!(update.update.applied);
1321        for axis in 0..3 {
1322            assert!(
1323                filter.state().covariance[ERROR_POSITION_INDEX + axis][ERROR_POSITION_INDEX + axis]
1324                    < 1.0
1325            );
1326            assert_eq!(
1327                filter.state().covariance[ERROR_VELOCITY_INDEX + axis][ERROR_VELOCITY_INDEX + axis]
1328                    .to_bits(),
1329                1.0f64.to_bits()
1330            );
1331        }
1332    }
1333
1334    #[test]
1335    fn duplicate_gnss_epochs_are_rejected_on_stateful_paths() {
1336        // Regression: the stateful surface must enforce the same strict
1337        // ordering as the standalone time-sync validator.
1338        let mut filter = filter_at(0.0);
1339        filter.propagate(increment(1.0, 1.0)).expect("propagate");
1340        let fix = measurement_at(1.0, [WGS84_A_M + 0.125, 0.0, 0.0]);
1341        filter.update_loose(&fix).expect("first update");
1342        assert!(
1343            filter.update_loose(&fix).is_err(),
1344            "duplicate epoch must be rejected"
1345        );
1346        let regressed = measurement_at(0.5, [WGS84_A_M + 0.125, 0.0, 0.0]);
1347        assert!(
1348            filter.update_loose(&regressed).is_err(),
1349            "regressed epoch must be rejected"
1350        );
1351    }
1352}