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