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