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