1use 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
17pub const DEFAULT_TIME_SYNC_IMU_CAPACITY: usize = 256;
19pub const DEFAULT_TIME_SYNC_CHECKPOINT_CAPACITY: usize = 64;
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub struct TimeSyncHistoryConfig {
25 pub imu_capacity: usize,
27 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 pub const fn new(imu_capacity: usize, checkpoint_capacity: usize) -> Self {
43 Self {
44 imu_capacity,
45 checkpoint_capacity,
46 }
47 }
48
49 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#[derive(Debug, Clone, PartialEq)]
58pub struct InertialFilterSnapshot {
59 pub state: InsFilterState,
61 pub last_body_rate_wrt_ecef_rps: [f64; 3],
63 pub stationarity_window: Vec<StationarityDetectorSnapshotSample>,
65 pub last_stationary_update_t_j2000_s: Option<f64>,
67 pub last_non_holonomic_update_t_j2000_s: Option<f64>,
69 pub tight: TightFilterSnapshot,
71}
72
73#[derive(Debug, Clone, Copy, PartialEq)]
75pub struct StationarityDetectorSnapshotSample {
76 pub specific_force_norm_error_mps2: f64,
78 pub body_rate_wrt_ecef_norm_rps: f64,
80}
81
82#[derive(Debug, Clone, Copy, PartialEq)]
84pub struct TimeSyncHistoryStatus {
85 pub imu_capacity: usize,
87 pub imu_len: usize,
89 pub checkpoint_capacity: usize,
91 pub checkpoint_len: usize,
93 pub oldest_imu_epoch_j2000_s: Option<f64>,
95 pub newest_imu_epoch_j2000_s: Option<f64>,
97 pub oldest_checkpoint_epoch_j2000_s: Option<f64>,
99 pub newest_checkpoint_epoch_j2000_s: Option<f64>,
101}
102
103#[derive(Debug, Clone, PartialEq)]
105pub struct TimeSyncUpdate {
106 pub update: FusionUpdate,
108 pub late_measurement: bool,
110 pub replayed_imu_segments: usize,
112 pub restored_checkpoint_epoch_j2000_s: f64,
114 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 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
402pub 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
420pub 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 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 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 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 pub fn time_sync_history_status(&self) -> TimeSyncHistoryStatus {
504 self.time_sync.status()
505 }
506
507 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 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 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 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(®ressed).is_err(),
1585 "regressed epoch must be rejected"
1586 );
1587 }
1588}