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