Skip to main content

sidereon_core/fusion/
serial.rs

1//! Versioned serialization for fusion filter checkpoints.
2
3use super::loose::{GnssFixMeasurement, GnssFixStatus, InertialFilter};
4use super::state::{
5    validate_covariance_matrix, validate_finite_slice, ErrorStateLayout, ErrorStateVector,
6    InsFilterState,
7};
8use super::tight::{
9    augmented_dimension, TightCarrierPhaseObservation, TightFilterSnapshot, TightGnssEpoch,
10    TightGnssObservation, TightRangeRateObservation,
11};
12use super::timesync::{
13    InertialFilterSnapshot, RateEndpoint, StationarityDetectorSnapshotSample, StoredCheckpoint,
14    StoredGnssMeasurement, StoredImuSample, TimeSyncHistoryConfig, TimeSyncHistorySnapshot,
15};
16use crate::inertial::{ImuSample, ImuSampleKind, NavState};
17use crate::{GnssSatelliteId, GnssSystem};
18
19const FUSION_STATE_MAGIC: [u8; 8] = *b"FUSSTAT\0";
20const FNV_OFFSET_BASIS: u64 = 0xcbf2_9ce4_8422_2325;
21const FNV_PRIME: u64 = 0x0000_0100_0000_01b3;
22
23/// Current binary and serde schema version for fusion checkpoints.
24pub const FUSION_STATE_CODEC_VERSION: u16 = 4;
25const MIN_SUPPORTED_FUSION_STATE_CODEC_VERSION: u16 = 1;
26
27/// Exact JSON representation of an `f64` bit pattern.
28#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
29#[serde(transparent)]
30pub struct F64Bits {
31    /// IEEE-754 binary64 bit pattern.
32    pub bits: u64,
33}
34
35impl F64Bits {
36    /// Convert an `f64` to its raw bit representation.
37    pub const fn from_f64(value: f64) -> Self {
38        Self {
39            bits: value.to_bits(),
40        }
41    }
42
43    /// Convert the raw bit representation back to `f64`.
44    pub const fn to_f64(self) -> f64 {
45        f64::from_bits(self.bits)
46    }
47}
48
49/// Serializable error-state layout tag.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
51pub enum SerializableErrorStateLayout {
52    /// Fifteen-state layout `dr, dv, psi, b_a, b_g`.
53    Fifteen,
54    /// Twenty-one-state layout with accelerometer and gyroscope scale factors.
55    TwentyOne,
56}
57
58impl SerializableErrorStateLayout {
59    /// Convert a native layout into its stable serialized tag.
60    pub const fn from_native(layout: ErrorStateLayout) -> Self {
61        match layout {
62            ErrorStateLayout::Fifteen => Self::Fifteen,
63            ErrorStateLayout::TwentyOne => Self::TwentyOne,
64        }
65    }
66
67    /// Convert the stable serialized tag back to the native layout.
68    pub const fn to_native(self) -> ErrorStateLayout {
69        match self {
70            Self::Fifteen => ErrorStateLayout::Fifteen,
71            Self::TwentyOne => ErrorStateLayout::TwentyOne,
72        }
73    }
74}
75
76/// Serializable navigation state with exact floating-point bit storage.
77#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
78pub struct SerializableNavState {
79    /// State time in seconds since J2000, stored as raw `f64` bits.
80    pub t_j2000_s: F64Bits,
81    /// IMU ECEF position in meters, stored as raw `f64` bits.
82    pub position_ecef_m: [F64Bits; 3],
83    /// IMU ECEF velocity in meters per second, stored as raw `f64` bits.
84    pub velocity_ecef_mps: [F64Bits; 3],
85    /// Body-to-ECEF direction cosine matrix, stored as raw `f64` bits.
86    pub attitude_body_to_ecef: [[F64Bits; 3]; 3],
87    /// Closed-loop accelerometer bias estimate, stored as raw `f64` bits.
88    pub accel_bias_mps2: [F64Bits; 3],
89    /// Closed-loop gyroscope bias estimate, stored as raw `f64` bits.
90    pub gyro_bias_rps: [F64Bits; 3],
91}
92
93impl SerializableNavState {
94    /// Convert a native navigation state into the stable serialized form.
95    pub fn from_native(state: &NavState) -> Self {
96        Self {
97            t_j2000_s: F64Bits::from_f64(state.t_j2000_s),
98            position_ecef_m: bits3(state.position_ecef_m),
99            velocity_ecef_mps: bits3(state.velocity_ecef_mps),
100            attitude_body_to_ecef: bits3x3(state.attitude_body_to_ecef),
101            accel_bias_mps2: bits3(state.accel_bias_mps2),
102            gyro_bias_rps: bits3(state.gyro_bias_rps),
103        }
104    }
105
106    /// Convert the stable serialized form back to a validated native state.
107    pub fn to_native(&self) -> Result<NavState, FusionStateCodecError> {
108        let state = NavState {
109            t_j2000_s: self.t_j2000_s.to_f64(),
110            position_ecef_m: f643(self.position_ecef_m),
111            velocity_ecef_mps: f643(self.velocity_ecef_mps),
112            attitude_body_to_ecef: f643x3(self.attitude_body_to_ecef),
113            accel_bias_mps2: f643(self.accel_bias_mps2),
114            gyro_bias_rps: f643(self.gyro_bias_rps),
115        };
116        state.validate().map_err(invalid_state)?;
117        Ok(state)
118    }
119}
120
121/// Serializable INS filter state and covariance.
122#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
123pub struct SerializableInsFilterState {
124    /// Error-state covariance layout.
125    pub layout: SerializableErrorStateLayout,
126    /// Nonlinear mechanized navigation state.
127    pub nominal: SerializableNavState,
128    /// Error-state vector values, stored as raw `f64` bits.
129    pub error_state: Vec<F64Bits>,
130    /// Error-state covariance matrix, stored as raw `f64` bits.
131    pub covariance: Vec<Vec<F64Bits>>,
132    /// Accelerometer scale-factor estimates, stored as raw `f64` bits.
133    pub accel_scale_factor: [F64Bits; 3],
134    /// Gyroscope scale-factor estimates, stored as raw `f64` bits.
135    pub gyro_scale_factor: [F64Bits; 3],
136}
137
138impl SerializableInsFilterState {
139    /// Convert a native INS filter state into the stable serialized form.
140    pub fn from_native(state: &InsFilterState) -> Self {
141        Self {
142            layout: SerializableErrorStateLayout::from_native(state.layout()),
143            nominal: SerializableNavState::from_native(&state.nominal),
144            error_state: bits_slice(state.error_state.as_slice()),
145            covariance: bits_matrix(&state.covariance),
146            accel_scale_factor: bits3(state.accel_scale_factor),
147            gyro_scale_factor: bits3(state.gyro_scale_factor),
148        }
149    }
150
151    /// Convert the stable serialized form back to a validated native state.
152    pub fn to_native(&self) -> Result<InsFilterState, FusionStateCodecError> {
153        let layout = self.layout.to_native();
154        let nominal = self.nominal.to_native()?;
155        let error_state = ErrorStateVector::from_vec(layout, f64_vec(&self.error_state))
156            .map_err(invalid_state)?;
157        let state = InsFilterState {
158            nominal,
159            error_state,
160            covariance: f64_matrix(&self.covariance),
161            accel_scale_factor: f643(self.accel_scale_factor),
162            gyro_scale_factor: f643(self.gyro_scale_factor),
163        };
164        state.validate().map_err(invalid_state)?;
165        Ok(state)
166    }
167}
168
169/// Serializable tight receiver-clock augmentation.
170#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
171pub struct SerializableTightFilterState {
172    /// Receiver-clock range bias in meters, stored as raw `f64` bits.
173    pub clock_bias_m: F64Bits,
174    /// Receiver-clock drift in meters per second, stored as raw `f64` bits.
175    pub clock_drift_m_s: F64Bits,
176    /// Full augmented covariance, stored as raw `f64` bits.
177    pub augmented_covariance: Vec<Vec<F64Bits>>,
178}
179
180impl SerializableTightFilterState {
181    /// Convert a native tight snapshot into the stable serialized form.
182    pub fn from_native(snapshot: &TightFilterSnapshot) -> Self {
183        Self {
184            clock_bias_m: F64Bits::from_f64(snapshot.clock_bias_m),
185            clock_drift_m_s: F64Bits::from_f64(snapshot.clock_drift_m_s),
186            augmented_covariance: bits_matrix(&snapshot.augmented_covariance),
187        }
188    }
189
190    /// Convert the stable serialized form back to a native tight snapshot.
191    pub fn to_native(
192        &self,
193        base_dimension: usize,
194    ) -> Result<TightFilterSnapshot, FusionStateCodecError> {
195        let snapshot = TightFilterSnapshot {
196            clock_bias_m: self.clock_bias_m.to_f64(),
197            clock_drift_m_s: self.clock_drift_m_s.to_f64(),
198            augmented_covariance: f64_matrix(&self.augmented_covariance),
199        };
200        validate_finite_slice(
201            &[snapshot.clock_bias_m, snapshot.clock_drift_m_s],
202            "tight_clock",
203        )
204        .map_err(invalid_state)?;
205        validate_covariance_matrix(
206            &snapshot.augmented_covariance,
207            augmented_dimension(base_dimension),
208            "tight_augmented_covariance",
209        )
210        .map_err(invalid_state)?;
211        Ok(snapshot)
212    }
213}
214
215/// Serializable retained-history capacity settings.
216#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
217pub struct SerializableTimeSyncHistoryConfig {
218    /// Number of retained IMU samples.
219    pub imu_capacity: u32,
220    /// Number of retained GNSS checkpoints.
221    pub checkpoint_capacity: u32,
222}
223
224impl SerializableTimeSyncHistoryConfig {
225    /// Convert native time-sync capacity settings into the serialized form.
226    pub fn from_native(config: TimeSyncHistoryConfig) -> Result<Self, FusionStateCodecError> {
227        Ok(Self {
228            imu_capacity: checked_u32(config.imu_capacity)?,
229            checkpoint_capacity: checked_u32(config.checkpoint_capacity)?,
230        })
231    }
232
233    /// Convert the serialized capacity settings back to native settings.
234    pub fn to_native(self) -> Result<TimeSyncHistoryConfig, FusionStateCodecError> {
235        let config = TimeSyncHistoryConfig::new(
236            self.imu_capacity as usize,
237            self.checkpoint_capacity as usize,
238        );
239        config.validate().map_err(invalid_state)?;
240        Ok(config)
241    }
242}
243
244/// Serializable GNSS satellite identifier.
245#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
246pub struct SerializableSatelliteId {
247    /// GNSS constellation.
248    pub system: GnssSystem,
249    /// Within-system PRN or slot number.
250    pub prn: u8,
251}
252
253impl SerializableSatelliteId {
254    /// Convert a native satellite identifier into the serialized form.
255    pub const fn from_native(id: GnssSatelliteId) -> Self {
256        Self {
257            system: id.system,
258            prn: id.prn,
259        }
260    }
261
262    /// Convert the serialized identifier back to the native type.
263    pub fn to_native(self) -> Result<GnssSatelliteId, FusionStateCodecError> {
264        GnssSatelliteId::new(self.system, self.prn).map_err(invalid_state)
265    }
266}
267
268/// Serializable body-rate interpolation endpoint for retained IMU samples.
269#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
270pub struct SerializableRateEndpoint {
271    /// Endpoint epoch in seconds since J2000, stored as raw `f64` bits.
272    pub t_j2000_s: F64Bits,
273    /// Specific force in body axes, stored as raw `f64` bits.
274    pub specific_force_mps2: [F64Bits; 3],
275    /// Angular rate in body axes, stored as raw `f64` bits.
276    pub angular_rate_rps: [F64Bits; 3],
277}
278
279impl SerializableRateEndpoint {
280    /// Convert a native rate endpoint into the serialized form.
281    fn from_native(endpoint: RateEndpoint) -> Self {
282        Self {
283            t_j2000_s: F64Bits::from_f64(endpoint.t_j2000_s),
284            specific_force_mps2: bits3(endpoint.specific_force_mps2),
285            angular_rate_rps: bits3(endpoint.angular_rate_rps),
286        }
287    }
288
289    /// Convert the serialized endpoint back to native values.
290    fn to_native(self) -> RateEndpoint {
291        RateEndpoint {
292            t_j2000_s: self.t_j2000_s.to_f64(),
293            specific_force_mps2: f643(self.specific_force_mps2),
294            angular_rate_rps: f643(self.angular_rate_rps),
295        }
296    }
297}
298
299/// Serializable stationary-detector sample retained by a filter snapshot.
300#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
301pub struct SerializableStationarityDetectorSample {
302    /// Specific-force norm error in m/s^2, stored as raw `f64` bits.
303    pub specific_force_norm_error_mps2: F64Bits,
304    /// Body angular-rate norm relative to ECEF in rad/s, stored as raw `f64` bits.
305    pub body_rate_wrt_ecef_norm_rps: F64Bits,
306}
307
308impl SerializableStationarityDetectorSample {
309    fn from_native(sample: StationarityDetectorSnapshotSample) -> Self {
310        Self {
311            specific_force_norm_error_mps2: F64Bits::from_f64(
312                sample.specific_force_norm_error_mps2,
313            ),
314            body_rate_wrt_ecef_norm_rps: F64Bits::from_f64(sample.body_rate_wrt_ecef_norm_rps),
315        }
316    }
317
318    fn to_native(self) -> StationarityDetectorSnapshotSample {
319        StationarityDetectorSnapshotSample {
320            specific_force_norm_error_mps2: self.specific_force_norm_error_mps2.to_f64(),
321            body_rate_wrt_ecef_norm_rps: self.body_rate_wrt_ecef_norm_rps.to_f64(),
322        }
323    }
324}
325
326/// Serializable IMU sample payload.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
328pub enum SerializableImuSampleKind {
329    /// Specific force and angular rate payload.
330    Rate {
331        /// Specific force in body axes, stored as raw `f64` bits.
332        specific_force_mps2: [F64Bits; 3],
333        /// Angular rate in body axes, stored as raw `f64` bits.
334        angular_rate_rps: [F64Bits; 3],
335    },
336    /// Integrated delta-velocity and delta-angle payload.
337    Increment {
338        /// Body-frame delta velocity, stored as raw `f64` bits.
339        delta_velocity_mps: [F64Bits; 3],
340        /// Body-frame delta angle, stored as raw `f64` bits.
341        delta_theta_rad: [F64Bits; 3],
342        /// Sample integration interval, stored as raw `f64` bits.
343        dt_s: F64Bits,
344    },
345}
346
347impl SerializableImuSampleKind {
348    /// Convert a native IMU sample payload into the serialized form.
349    pub fn from_native(kind: ImuSampleKind) -> Self {
350        match kind {
351            ImuSampleKind::Rate {
352                specific_force_mps2,
353                angular_rate_rps,
354            } => Self::Rate {
355                specific_force_mps2: bits3(specific_force_mps2),
356                angular_rate_rps: bits3(angular_rate_rps),
357            },
358            ImuSampleKind::Increment {
359                delta_velocity_mps,
360                delta_theta_rad,
361                dt_s,
362            } => Self::Increment {
363                delta_velocity_mps: bits3(delta_velocity_mps),
364                delta_theta_rad: bits3(delta_theta_rad),
365                dt_s: F64Bits::from_f64(dt_s),
366            },
367        }
368    }
369
370    /// Convert the serialized payload back to native IMU sample values.
371    pub fn to_native(self) -> ImuSampleKind {
372        match self {
373            Self::Rate {
374                specific_force_mps2,
375                angular_rate_rps,
376            } => ImuSampleKind::Rate {
377                specific_force_mps2: f643(specific_force_mps2),
378                angular_rate_rps: f643(angular_rate_rps),
379            },
380            Self::Increment {
381                delta_velocity_mps,
382                delta_theta_rad,
383                dt_s,
384            } => ImuSampleKind::Increment {
385                delta_velocity_mps: f643(delta_velocity_mps),
386                delta_theta_rad: f643(delta_theta_rad),
387                dt_s: dt_s.to_f64(),
388            },
389        }
390    }
391}
392
393/// Serializable IMU sample.
394#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
395pub struct SerializableImuSample {
396    /// Sample end epoch in seconds since J2000, stored as raw `f64` bits.
397    pub t_j2000_s: F64Bits,
398    /// Sample payload.
399    pub kind: SerializableImuSampleKind,
400}
401
402impl SerializableImuSample {
403    /// Convert a native IMU sample into the serialized form.
404    pub fn from_native(sample: ImuSample) -> Self {
405        Self {
406            t_j2000_s: F64Bits::from_f64(sample.t_j2000_s),
407            kind: SerializableImuSampleKind::from_native(sample.kind),
408        }
409    }
410
411    /// Convert the serialized sample back to native IMU sample values.
412    pub fn to_native(self) -> ImuSample {
413        ImuSample {
414            t_j2000_s: self.t_j2000_s.to_f64(),
415            kind: self.kind.to_native(),
416        }
417    }
418}
419
420/// Serializable retained IMU sample with its replay interval metadata.
421#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
422pub struct SerializableStoredImuSample {
423    /// Previous sample boundary in seconds since J2000, stored as raw `f64` bits.
424    pub previous_t_j2000_s: F64Bits,
425    /// Stored sample at the interval end.
426    pub sample: SerializableImuSample,
427    /// Previous rate endpoint for fractional rate replay.
428    pub previous_rate: Option<SerializableRateEndpoint>,
429}
430
431impl SerializableStoredImuSample {
432    /// Convert a retained IMU sample into the serialized form.
433    fn from_native(sample: StoredImuSample) -> Self {
434        Self {
435            previous_t_j2000_s: F64Bits::from_f64(sample.previous_t_j2000_s),
436            sample: SerializableImuSample::from_native(sample.sample),
437            previous_rate: sample
438                .previous_rate
439                .map(SerializableRateEndpoint::from_native),
440        }
441    }
442
443    /// Convert the serialized retained IMU sample back to native values.
444    fn to_native(self) -> StoredImuSample {
445        StoredImuSample {
446            previous_t_j2000_s: self.previous_t_j2000_s.to_f64(),
447            sample: self.sample.to_native(),
448            previous_rate: self.previous_rate.map(SerializableRateEndpoint::to_native),
449        }
450    }
451}
452
453/// Serializable loose GNSS fix measurement.
454#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
455pub struct SerializableLooseMeasurement {
456    /// Measurement epoch in seconds since J2000, stored as raw `f64` bits.
457    pub t_j2000_s: F64Bits,
458    /// GNSS antenna position in ECEF meters, stored as raw `f64` bits.
459    pub position_ecef_m: [F64Bits; 3],
460    /// Optional GNSS antenna velocity in ECEF meters per second.
461    pub velocity_ecef_mps: Option<[F64Bits; 3]>,
462    /// Measurement covariance, stored as raw `f64` bits.
463    pub covariance: Vec<Vec<F64Bits>>,
464    /// Number of satellites used by the upstream GNSS fix.
465    pub satellites_used: u32,
466    /// Whether the upstream GNSS solver reported a successful fix.
467    pub solution_valid: bool,
468    /// Upstream fix class used for loose covariance scaling.
469    #[serde(default = "default_serializable_fix_status")]
470    pub fix_status: SerializableGnssFixStatus,
471}
472
473/// Serializable loose GNSS fix status.
474#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
475pub enum SerializableGnssFixStatus {
476    /// Code-only or standalone GNSS fix.
477    Single,
478    /// Float carrier-phase ambiguity solution.
479    Float,
480    /// Fixed carrier-phase ambiguity solution.
481    Fixed,
482}
483
484fn default_serializable_fix_status() -> SerializableGnssFixStatus {
485    SerializableGnssFixStatus::Single
486}
487
488impl SerializableGnssFixStatus {
489    fn from_native(status: GnssFixStatus) -> Self {
490        match status {
491            GnssFixStatus::Single => Self::Single,
492            GnssFixStatus::Float => Self::Float,
493            GnssFixStatus::Fixed => Self::Fixed,
494        }
495    }
496
497    const fn to_native(self) -> GnssFixStatus {
498        match self {
499            Self::Single => GnssFixStatus::Single,
500            Self::Float => GnssFixStatus::Float,
501            Self::Fixed => GnssFixStatus::Fixed,
502        }
503    }
504}
505
506impl SerializableLooseMeasurement {
507    /// Convert a native loose measurement into the serialized form.
508    pub fn from_native(measurement: &GnssFixMeasurement) -> Result<Self, FusionStateCodecError> {
509        Ok(Self {
510            t_j2000_s: F64Bits::from_f64(measurement.t_j2000_s),
511            position_ecef_m: bits3(measurement.position_ecef_m),
512            velocity_ecef_mps: measurement.velocity_ecef_mps.map(bits3),
513            covariance: bits_matrix(&measurement.covariance),
514            satellites_used: checked_u32(measurement.satellites_used)?,
515            solution_valid: measurement.solution_valid,
516            fix_status: SerializableGnssFixStatus::from_native(measurement.fix_status),
517        })
518    }
519
520    /// Convert the serialized loose measurement back to native values.
521    pub fn to_native(&self) -> Result<GnssFixMeasurement, FusionStateCodecError> {
522        let measurement = GnssFixMeasurement {
523            t_j2000_s: self.t_j2000_s.to_f64(),
524            position_ecef_m: f643(self.position_ecef_m),
525            velocity_ecef_mps: self.velocity_ecef_mps.map(f643),
526            covariance: f64_matrix(&self.covariance),
527            satellites_used: self.satellites_used as usize,
528            solution_valid: self.solution_valid,
529            fix_status: self.fix_status.to_native(),
530        };
531        measurement.validate().map_err(invalid_state)?;
532        Ok(measurement)
533    }
534}
535
536/// Serializable tight range-rate observation.
537#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
538pub struct SerializableTightRangeRateObservation {
539    /// Measured range rate, stored as raw `f64` bits.
540    pub measured_range_rate_m_s: F64Bits,
541    /// One-sigma range-rate uncertainty, stored as raw `f64` bits.
542    pub sigma_m_s: F64Bits,
543    /// Satellite clock drift as a range-rate bias, stored as raw `f64` bits.
544    pub satellite_clock_drift_m_s: F64Bits,
545}
546
547impl SerializableTightRangeRateObservation {
548    /// Convert a native range-rate observation into the serialized form.
549    pub fn from_native(observation: TightRangeRateObservation) -> Self {
550        Self {
551            measured_range_rate_m_s: F64Bits::from_f64(observation.measured_range_rate_m_s),
552            sigma_m_s: F64Bits::from_f64(observation.sigma_m_s),
553            satellite_clock_drift_m_s: F64Bits::from_f64(observation.satellite_clock_drift_m_s),
554        }
555    }
556
557    /// Convert the serialized range-rate observation back to native values.
558    pub fn to_native(self) -> TightRangeRateObservation {
559        TightRangeRateObservation {
560            measured_range_rate_m_s: self.measured_range_rate_m_s.to_f64(),
561            sigma_m_s: self.sigma_m_s.to_f64(),
562            satellite_clock_drift_m_s: self.satellite_clock_drift_m_s.to_f64(),
563        }
564    }
565}
566
567/// Serializable tight carrier-phase observation.
568#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
569pub struct SerializableTightCarrierPhaseObservation {
570    /// Carrier phase converted to range units, stored as raw `f64` bits.
571    pub phase_range_m: F64Bits,
572    /// One-sigma carrier-phase range uncertainty, stored as raw `f64` bits.
573    pub sigma_m: F64Bits,
574    /// Current float ambiguity estimate, stored as raw `f64` bits.
575    pub float_ambiguity_m: F64Bits,
576}
577
578impl SerializableTightCarrierPhaseObservation {
579    /// Convert a native carrier-phase observation into the serialized form.
580    pub fn from_native(observation: TightCarrierPhaseObservation) -> Self {
581        Self {
582            phase_range_m: F64Bits::from_f64(observation.phase_range_m),
583            sigma_m: F64Bits::from_f64(observation.sigma_m),
584            float_ambiguity_m: F64Bits::from_f64(observation.float_ambiguity_m),
585        }
586    }
587
588    /// Convert the serialized carrier-phase observation back to native values.
589    pub fn to_native(self) -> TightCarrierPhaseObservation {
590        TightCarrierPhaseObservation {
591            phase_range_m: self.phase_range_m.to_f64(),
592            sigma_m: self.sigma_m.to_f64(),
593            float_ambiguity_m: self.float_ambiguity_m.to_f64(),
594        }
595    }
596}
597
598/// Serializable tight raw GNSS observation.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
600pub struct SerializableTightGnssObservation {
601    /// Satellite identifier.
602    pub satellite_id: SerializableSatelliteId,
603    /// Measured code pseudorange, stored as raw `f64` bits.
604    pub pseudorange_m: F64Bits,
605    /// One-sigma pseudorange uncertainty, stored as raw `f64` bits.
606    pub pseudorange_sigma_m: F64Bits,
607    /// Optional Doppler-derived range-rate row.
608    pub range_rate: Option<SerializableTightRangeRateObservation>,
609    /// Optional carrier-phase row.
610    pub carrier_phase: Option<SerializableTightCarrierPhaseObservation>,
611    /// Ionospheric group delay correction for code, stored as raw `f64` bits.
612    pub ionosphere_delay_m: F64Bits,
613    /// Tropospheric delay correction, stored as raw `f64` bits.
614    pub troposphere_delay_m: F64Bits,
615}
616
617impl SerializableTightGnssObservation {
618    /// Convert a native tight observation into the serialized form.
619    pub fn from_native(observation: TightGnssObservation) -> Self {
620        Self {
621            satellite_id: SerializableSatelliteId::from_native(observation.satellite_id),
622            pseudorange_m: F64Bits::from_f64(observation.pseudorange_m),
623            pseudorange_sigma_m: F64Bits::from_f64(observation.pseudorange_sigma_m),
624            range_rate: observation
625                .range_rate
626                .map(SerializableTightRangeRateObservation::from_native),
627            carrier_phase: observation
628                .carrier_phase
629                .map(SerializableTightCarrierPhaseObservation::from_native),
630            ionosphere_delay_m: F64Bits::from_f64(observation.ionosphere_delay_m),
631            troposphere_delay_m: F64Bits::from_f64(observation.troposphere_delay_m),
632        }
633    }
634
635    /// Convert the serialized tight observation back to native values.
636    pub fn to_native(self) -> Result<TightGnssObservation, FusionStateCodecError> {
637        let observation = TightGnssObservation {
638            satellite_id: self.satellite_id.to_native()?,
639            pseudorange_m: self.pseudorange_m.to_f64(),
640            pseudorange_sigma_m: self.pseudorange_sigma_m.to_f64(),
641            range_rate: self
642                .range_rate
643                .map(SerializableTightRangeRateObservation::to_native),
644            carrier_phase: self
645                .carrier_phase
646                .map(SerializableTightCarrierPhaseObservation::to_native),
647            ionosphere_delay_m: self.ionosphere_delay_m.to_f64(),
648            troposphere_delay_m: self.troposphere_delay_m.to_f64(),
649        };
650        observation.validate().map_err(invalid_state)?;
651        Ok(observation)
652    }
653}
654
655/// Serializable tight raw GNSS epoch.
656#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
657pub struct SerializableTightGnssEpoch {
658    /// Measurement epoch in seconds since J2000, stored as raw `f64` bits.
659    pub t_j2000_s: F64Bits,
660    /// Satellite observations at the epoch.
661    pub observations: Vec<SerializableTightGnssObservation>,
662}
663
664impl SerializableTightGnssEpoch {
665    /// Convert a native tight epoch into the serialized form.
666    pub fn from_native(epoch: &TightGnssEpoch) -> Self {
667        Self {
668            t_j2000_s: F64Bits::from_f64(epoch.t_j2000_s),
669            observations: epoch
670                .observations
671                .iter()
672                .copied()
673                .map(SerializableTightGnssObservation::from_native)
674                .collect(),
675        }
676    }
677
678    /// Convert the serialized tight epoch back to native values.
679    pub fn to_native(&self) -> Result<TightGnssEpoch, FusionStateCodecError> {
680        let observations = self
681            .observations
682            .iter()
683            .copied()
684            .map(SerializableTightGnssObservation::to_native)
685            .collect::<Result<Vec<_>, _>>()?;
686        let epoch = TightGnssEpoch {
687            t_j2000_s: self.t_j2000_s.to_f64(),
688            observations,
689        };
690        epoch.validate().map_err(invalid_state)?;
691        Ok(epoch)
692    }
693}
694
695/// Serializable retained GNSS measurement.
696#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
697pub enum SerializableStoredGnssMeasurement {
698    /// Retained loose GNSS fix.
699    Loose(SerializableLooseMeasurement),
700    /// Retained tight raw GNSS epoch.
701    Tight(SerializableTightGnssEpoch),
702}
703
704impl SerializableStoredGnssMeasurement {
705    /// Convert a retained GNSS measurement into the serialized form.
706    fn from_native(measurement: &StoredGnssMeasurement) -> Result<Self, FusionStateCodecError> {
707        match measurement {
708            StoredGnssMeasurement::Loose(measurement) => Ok(Self::Loose(
709                SerializableLooseMeasurement::from_native(measurement)?,
710            )),
711            StoredGnssMeasurement::Tight(epoch) => {
712                Ok(Self::Tight(SerializableTightGnssEpoch::from_native(epoch)))
713            }
714        }
715    }
716
717    /// Convert the serialized GNSS measurement back to native values.
718    fn to_native(&self) -> Result<StoredGnssMeasurement, FusionStateCodecError> {
719        match self {
720            Self::Loose(measurement) => Ok(StoredGnssMeasurement::Loose(measurement.to_native()?)),
721            Self::Tight(epoch) => Ok(StoredGnssMeasurement::Tight(epoch.to_native()?)),
722        }
723    }
724}
725
726/// Serializable retained filter checkpoint.
727#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
728pub struct SerializableStoredCheckpoint {
729    /// Checkpoint epoch in seconds since J2000, stored as raw `f64` bits.
730    pub t_j2000_s: F64Bits,
731    /// Closed-loop filter snapshot at the checkpoint.
732    pub snapshot: Box<SerializableFusionSnapshot>,
733}
734
735impl SerializableStoredCheckpoint {
736    /// Convert a retained checkpoint into the serialized form.
737    fn from_native(checkpoint: &StoredCheckpoint) -> Self {
738        Self {
739            t_j2000_s: F64Bits::from_f64(checkpoint.t_j2000_s),
740            snapshot: Box::new(SerializableFusionSnapshot::from_snapshot(
741                &checkpoint.snapshot,
742            )),
743        }
744    }
745
746    /// Convert the serialized checkpoint back to native values.
747    fn to_native(&self) -> Result<StoredCheckpoint, FusionStateCodecError> {
748        let snapshot = self.snapshot.to_snapshot()?;
749        let checkpoint = StoredCheckpoint {
750            t_j2000_s: self.t_j2000_s.to_f64(),
751            snapshot,
752        };
753        if checkpoint.t_j2000_s != checkpoint.snapshot.state.nominal.t_j2000_s {
754            return Err(FusionStateCodecError::InvalidState {
755                reason: "checkpoint epoch must match snapshot".to_string(),
756            });
757        }
758        Ok(checkpoint)
759    }
760}
761
762/// Stable serializable fusion snapshot without retained replay history.
763#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
764pub struct SerializableFusionSnapshot {
765    /// Closed-loop INS state and covariance.
766    pub state: SerializableInsFilterState,
767    /// Last propagated body angular rate relative to ECEF, stored as raw `f64` bits.
768    pub last_body_rate_wrt_ecef_rps: [F64Bits; 3],
769    /// Recent detector samples used by stationary-update gating.
770    #[serde(default)]
771    pub stationarity_window: Vec<SerializableStationarityDetectorSample>,
772    /// Epoch of the last stationary pseudo-measurement, if any.
773    #[serde(default)]
774    pub last_stationary_update_t_j2000_s: Option<F64Bits>,
775    /// Epoch of the last non-holonomic pseudo-measurement, if any.
776    #[serde(default)]
777    pub last_non_holonomic_update_t_j2000_s: Option<F64Bits>,
778    /// Tight receiver-clock augmentation and augmented covariance.
779    pub tight: SerializableTightFilterState,
780}
781
782impl SerializableFusionSnapshot {
783    /// Convert a native fusion snapshot into the stable serialized form.
784    pub fn from_snapshot(snapshot: &InertialFilterSnapshot) -> Self {
785        Self {
786            state: SerializableInsFilterState::from_native(&snapshot.state),
787            last_body_rate_wrt_ecef_rps: bits3(snapshot.last_body_rate_wrt_ecef_rps),
788            stationarity_window: snapshot
789                .stationarity_window
790                .iter()
791                .copied()
792                .map(SerializableStationarityDetectorSample::from_native)
793                .collect(),
794            last_stationary_update_t_j2000_s: snapshot
795                .last_stationary_update_t_j2000_s
796                .map(F64Bits::from_f64),
797            last_non_holonomic_update_t_j2000_s: snapshot
798                .last_non_holonomic_update_t_j2000_s
799                .map(F64Bits::from_f64),
800            tight: SerializableTightFilterState::from_native(&snapshot.tight),
801        }
802    }
803
804    /// Convert the stable serialized form back to a validated native snapshot.
805    pub fn to_snapshot(&self) -> Result<InertialFilterSnapshot, FusionStateCodecError> {
806        let state = self.state.to_native()?;
807        let last_body_rate_wrt_ecef_rps = f643(self.last_body_rate_wrt_ecef_rps);
808        validate_finite_slice(&last_body_rate_wrt_ecef_rps, "last_body_rate_wrt_ecef_rps")
809            .map_err(invalid_state)?;
810        let stationarity_window = stationarity_window_to_native(&self.stationarity_window)?;
811        let last_stationary_update_t_j2000_s = optional_epoch_to_native(
812            self.last_stationary_update_t_j2000_s,
813            "last_stationary_update_t_j2000_s",
814        )?;
815        let last_non_holonomic_update_t_j2000_s = optional_epoch_to_native(
816            self.last_non_holonomic_update_t_j2000_s,
817            "last_non_holonomic_update_t_j2000_s",
818        )?;
819        let tight = self.tight.to_native(state.dimension())?;
820        Ok(InertialFilterSnapshot {
821            state,
822            last_body_rate_wrt_ecef_rps,
823            stationarity_window,
824            last_stationary_update_t_j2000_s,
825            last_non_holonomic_update_t_j2000_s,
826            tight,
827        })
828    }
829}
830
831fn stationarity_window_to_native(
832    samples: &[SerializableStationarityDetectorSample],
833) -> Result<Vec<StationarityDetectorSnapshotSample>, FusionStateCodecError> {
834    let out = samples
835        .iter()
836        .copied()
837        .map(SerializableStationarityDetectorSample::to_native)
838        .collect::<Vec<_>>();
839    for sample in &out {
840        validate_finite_slice(
841            &[
842                sample.specific_force_norm_error_mps2,
843                sample.body_rate_wrt_ecef_norm_rps,
844            ],
845            "stationarity_window",
846        )
847        .map_err(invalid_state)?;
848    }
849    Ok(out)
850}
851
852fn optional_epoch_to_native(
853    value: Option<F64Bits>,
854    field: &'static str,
855) -> Result<Option<f64>, FusionStateCodecError> {
856    let Some(value) = value else {
857        return Ok(None);
858    };
859    let value = value.to_f64();
860    validate_finite_slice(&[value], field).map_err(invalid_state)?;
861    Ok(Some(value))
862}
863
864/// Serializable retained time-sync replay history.
865#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
866pub struct SerializableTimeSyncHistory {
867    /// Retained-history capacity settings.
868    pub config: SerializableTimeSyncHistoryConfig,
869    /// Retained IMU samples for replay.
870    pub imu_samples: Vec<SerializableStoredImuSample>,
871    /// Retained filter checkpoints at GNSS epochs.
872    pub checkpoints: Vec<SerializableStoredCheckpoint>,
873    /// Retained GNSS measurements for replay ordering.
874    pub measurements: Vec<SerializableStoredGnssMeasurement>,
875}
876
877impl SerializableTimeSyncHistory {
878    /// Convert native retained history into the serialized form.
879    fn from_native(history: &TimeSyncHistorySnapshot) -> Result<Self, FusionStateCodecError> {
880        Ok(Self {
881            config: SerializableTimeSyncHistoryConfig::from_native(history.config)?,
882            imu_samples: history
883                .imu_samples
884                .iter()
885                .copied()
886                .map(SerializableStoredImuSample::from_native)
887                .collect(),
888            checkpoints: history
889                .checkpoints
890                .iter()
891                .map(SerializableStoredCheckpoint::from_native)
892                .collect(),
893            measurements: history
894                .measurements
895                .iter()
896                .map(SerializableStoredGnssMeasurement::from_native)
897                .collect::<Result<Vec<_>, _>>()?,
898        })
899    }
900
901    /// Convert the serialized retained history back to native values.
902    fn to_native(&self) -> Result<TimeSyncHistorySnapshot, FusionStateCodecError> {
903        let snapshot = TimeSyncHistorySnapshot {
904            config: self.config.to_native()?,
905            imu_samples: self
906                .imu_samples
907                .iter()
908                .copied()
909                .map(SerializableStoredImuSample::to_native)
910                .collect(),
911            checkpoints: self
912                .checkpoints
913                .iter()
914                .map(SerializableStoredCheckpoint::to_native)
915                .collect::<Result<Vec<_>, _>>()?,
916            measurements: self
917                .measurements
918                .iter()
919                .map(SerializableStoredGnssMeasurement::to_native)
920                .collect::<Result<Vec<_>, _>>()?,
921        };
922        validate_history_by_restore(snapshot)
923    }
924}
925
926/// Stable serializable fusion checkpoint.
927#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
928pub struct SerializableFusionState {
929    /// Serialization schema version.
930    pub version: u16,
931    /// Closed-loop INS state and covariance.
932    pub state: SerializableInsFilterState,
933    /// Last propagated body angular rate relative to ECEF, stored as raw `f64` bits.
934    pub last_body_rate_wrt_ecef_rps: [F64Bits; 3],
935    /// Recent detector samples used by stationary-update gating.
936    #[serde(default)]
937    pub stationarity_window: Vec<SerializableStationarityDetectorSample>,
938    /// Epoch of the last stationary pseudo-measurement, if any.
939    #[serde(default)]
940    pub last_stationary_update_t_j2000_s: Option<F64Bits>,
941    /// Epoch of the last non-holonomic pseudo-measurement, if any.
942    #[serde(default)]
943    pub last_non_holonomic_update_t_j2000_s: Option<F64Bits>,
944    /// Tight receiver-clock augmentation and augmented covariance.
945    pub tight: SerializableTightFilterState,
946    /// Retained time-sync replay history.
947    pub time_sync: SerializableTimeSyncHistory,
948}
949
950impl SerializableFusionState {
951    /// Convert a native fusion snapshot into the stable serialized form.
952    pub fn from_snapshot(snapshot: &InertialFilterSnapshot) -> Self {
953        let history = TimeSyncHistorySnapshot::from_filter_snapshot(snapshot.clone());
954        let time_sync =
955            SerializableTimeSyncHistory::from_native(&history).expect("default history encodes");
956        Self {
957            version: FUSION_STATE_CODEC_VERSION,
958            state: SerializableInsFilterState::from_native(&snapshot.state),
959            last_body_rate_wrt_ecef_rps: bits3(snapshot.last_body_rate_wrt_ecef_rps),
960            stationarity_window: snapshot
961                .stationarity_window
962                .iter()
963                .copied()
964                .map(SerializableStationarityDetectorSample::from_native)
965                .collect(),
966            last_stationary_update_t_j2000_s: snapshot
967                .last_stationary_update_t_j2000_s
968                .map(F64Bits::from_f64),
969            last_non_holonomic_update_t_j2000_s: snapshot
970                .last_non_holonomic_update_t_j2000_s
971                .map(F64Bits::from_f64),
972            tight: SerializableTightFilterState::from_native(&snapshot.tight),
973            time_sync,
974        }
975    }
976
977    /// Convert a live fusion filter into the stable serialized form.
978    pub fn from_filter(filter: &InertialFilter) -> Result<Self, FusionStateCodecError> {
979        let snapshot = filter.snapshot();
980        Ok(Self {
981            version: FUSION_STATE_CODEC_VERSION,
982            state: SerializableInsFilterState::from_native(&snapshot.state),
983            last_body_rate_wrt_ecef_rps: bits3(snapshot.last_body_rate_wrt_ecef_rps),
984            stationarity_window: snapshot
985                .stationarity_window
986                .iter()
987                .copied()
988                .map(SerializableStationarityDetectorSample::from_native)
989                .collect(),
990            last_stationary_update_t_j2000_s: snapshot
991                .last_stationary_update_t_j2000_s
992                .map(F64Bits::from_f64),
993            last_non_holonomic_update_t_j2000_s: snapshot
994                .last_non_holonomic_update_t_j2000_s
995                .map(F64Bits::from_f64),
996            tight: SerializableTightFilterState::from_native(&snapshot.tight),
997            time_sync: SerializableTimeSyncHistory::from_native(
998                &filter.time_sync.snapshot_history(),
999            )?,
1000        })
1001    }
1002
1003    /// Convert the stable serialized form back to a validated native snapshot.
1004    pub fn to_snapshot(&self) -> Result<InertialFilterSnapshot, FusionStateCodecError> {
1005        self.validate_version()?;
1006        let state = self.state.to_native()?;
1007        let last_body_rate_wrt_ecef_rps = f643(self.last_body_rate_wrt_ecef_rps);
1008        validate_finite_slice(&last_body_rate_wrt_ecef_rps, "last_body_rate_wrt_ecef_rps")
1009            .map_err(invalid_state)?;
1010        let stationarity_window = stationarity_window_to_native(&self.stationarity_window)?;
1011        let last_stationary_update_t_j2000_s = optional_epoch_to_native(
1012            self.last_stationary_update_t_j2000_s,
1013            "last_stationary_update_t_j2000_s",
1014        )?;
1015        let last_non_holonomic_update_t_j2000_s = optional_epoch_to_native(
1016            self.last_non_holonomic_update_t_j2000_s,
1017            "last_non_holonomic_update_t_j2000_s",
1018        )?;
1019        let tight = self.tight.to_native(state.dimension())?;
1020        Ok(InertialFilterSnapshot {
1021            state,
1022            last_body_rate_wrt_ecef_rps,
1023            stationarity_window,
1024            last_stationary_update_t_j2000_s,
1025            last_non_holonomic_update_t_j2000_s,
1026            tight,
1027        })
1028    }
1029
1030    /// Convert the stable serialized form back to retained replay history.
1031    fn to_time_sync_history(&self) -> Result<TimeSyncHistorySnapshot, FusionStateCodecError> {
1032        self.validate_version()?;
1033        self.time_sync.to_native()
1034    }
1035
1036    /// Encode the checkpoint with a binary magic, version, and checksum.
1037    pub fn encode_versioned(&self) -> Result<Vec<u8>, FusionStateCodecError> {
1038        self.validate_current_version()?;
1039        let mut bytes = Vec::new();
1040        bytes.extend_from_slice(&FUSION_STATE_MAGIC);
1041        write_u16(&mut bytes, self.version);
1042        write_layout(&mut bytes, self.state.layout);
1043        write_nav(&mut bytes, &self.state.nominal);
1044        write_f64_vec(&mut bytes, &self.state.error_state)?;
1045        write_f64_matrix(&mut bytes, &self.state.covariance)?;
1046        write_f64_array(&mut bytes, &self.state.accel_scale_factor);
1047        write_f64_array(&mut bytes, &self.state.gyro_scale_factor);
1048        write_f64_array(&mut bytes, &self.last_body_rate_wrt_ecef_rps);
1049        write_stationarity_window(&mut bytes, &self.stationarity_window)?;
1050        write_optional_f64(&mut bytes, self.last_stationary_update_t_j2000_s);
1051        write_optional_f64(&mut bytes, self.last_non_holonomic_update_t_j2000_s);
1052        write_f64(&mut bytes, self.tight.clock_bias_m);
1053        write_f64(&mut bytes, self.tight.clock_drift_m_s);
1054        write_f64_matrix(&mut bytes, &self.tight.augmented_covariance)?;
1055        write_time_sync_history(&mut bytes, &self.time_sync)?;
1056        let checksum = fnv1a64(&bytes);
1057        write_u64(&mut bytes, checksum);
1058        Ok(bytes)
1059    }
1060
1061    /// Decode a binary checkpoint produced by [`Self::encode_versioned`].
1062    pub fn decode_versioned(bytes: &[u8]) -> Result<Self, FusionStateCodecError> {
1063        let minimum = FUSION_STATE_MAGIC.len() + 2 + 8;
1064        if bytes.len() < minimum {
1065            return Err(FusionStateCodecError::Truncated {
1066                offset: 0,
1067                needed: minimum,
1068                actual: bytes.len(),
1069            });
1070        }
1071        if bytes[..FUSION_STATE_MAGIC.len()] != FUSION_STATE_MAGIC {
1072            return Err(FusionStateCodecError::InvalidMagic);
1073        }
1074        let checksum_offset = bytes.len() - 8;
1075        let expected = read_u64_at(bytes, checksum_offset)?;
1076        let found = fnv1a64(&bytes[..checksum_offset]);
1077        if expected != found {
1078            return Err(FusionStateCodecError::Checksum { expected, found });
1079        }
1080
1081        let mut cursor = FUSION_STATE_MAGIC.len();
1082        let version = read_u16(bytes, &mut cursor, checksum_offset)?;
1083        if !is_supported_fusion_state_codec_version(version) {
1084            return Err(FusionStateCodecError::UnsupportedVersion { version });
1085        }
1086        let layout = read_layout(bytes, &mut cursor, checksum_offset)?;
1087        let nominal = read_nav(bytes, &mut cursor, checksum_offset)?;
1088        let error_state = read_f64_vec(bytes, &mut cursor, checksum_offset)?;
1089        let covariance = read_f64_matrix(bytes, &mut cursor, checksum_offset)?;
1090        let accel_scale_factor = read_f64_array(bytes, &mut cursor, checksum_offset)?;
1091        let gyro_scale_factor = read_f64_array(bytes, &mut cursor, checksum_offset)?;
1092        let last_body_rate_wrt_ecef_rps = read_f64_array(bytes, &mut cursor, checksum_offset)?;
1093        let stationarity_window = if version >= 3 {
1094            read_stationarity_window(bytes, &mut cursor, checksum_offset)?
1095        } else {
1096            Vec::new()
1097        };
1098        let (last_stationary_update_t_j2000_s, last_non_holonomic_update_t_j2000_s) =
1099            if version >= 4 {
1100                (
1101                    read_optional_f64(bytes, &mut cursor, checksum_offset)?,
1102                    read_optional_f64(bytes, &mut cursor, checksum_offset)?,
1103                )
1104            } else {
1105                (None, None)
1106            };
1107        let clock_bias_m = read_f64(bytes, &mut cursor, checksum_offset)?;
1108        let clock_drift_m_s = read_f64(bytes, &mut cursor, checksum_offset)?;
1109        let augmented_covariance = read_f64_matrix(bytes, &mut cursor, checksum_offset)?;
1110        let time_sync = read_time_sync_history(bytes, &mut cursor, checksum_offset, version)?;
1111        if cursor != checksum_offset {
1112            return Err(FusionStateCodecError::TrailingBytes {
1113                remaining: checksum_offset - cursor,
1114            });
1115        }
1116        let state = Self {
1117            version,
1118            state: SerializableInsFilterState {
1119                layout,
1120                nominal,
1121                error_state,
1122                covariance,
1123                accel_scale_factor,
1124                gyro_scale_factor,
1125            },
1126            last_body_rate_wrt_ecef_rps,
1127            stationarity_window,
1128            last_stationary_update_t_j2000_s,
1129            last_non_holonomic_update_t_j2000_s,
1130            tight: SerializableTightFilterState {
1131                clock_bias_m,
1132                clock_drift_m_s,
1133                augmented_covariance,
1134            },
1135            time_sync,
1136        };
1137        state.to_snapshot()?;
1138        state.to_time_sync_history()?;
1139        Ok(state)
1140    }
1141
1142    /// Serialize this checkpoint to JSON using the serde representation.
1143    pub fn to_json_string(&self) -> Result<String, FusionStateCodecError> {
1144        self.validate_current_version()?;
1145        serde_json::to_string(self).map_err(|error| FusionStateCodecError::Json {
1146            message: error.to_string(),
1147        })
1148    }
1149
1150    /// Parse a JSON checkpoint using the serde representation.
1151    pub fn from_json_str(text: &str) -> Result<Self, FusionStateCodecError> {
1152        let state: Self =
1153            serde_json::from_str(text).map_err(|error| FusionStateCodecError::Json {
1154                message: error.to_string(),
1155            })?;
1156        state.to_snapshot()?;
1157        state.to_time_sync_history()?;
1158        Ok(state)
1159    }
1160
1161    fn validate_version(&self) -> Result<(), FusionStateCodecError> {
1162        if is_supported_fusion_state_codec_version(self.version) {
1163            Ok(())
1164        } else {
1165            Err(FusionStateCodecError::UnsupportedVersion {
1166                version: self.version,
1167            })
1168        }
1169    }
1170
1171    fn validate_current_version(&self) -> Result<(), FusionStateCodecError> {
1172        if self.version == FUSION_STATE_CODEC_VERSION {
1173            Ok(())
1174        } else {
1175            Err(FusionStateCodecError::UnsupportedVersion {
1176                version: self.version,
1177            })
1178        }
1179    }
1180}
1181
1182fn is_supported_fusion_state_codec_version(version: u16) -> bool {
1183    (MIN_SUPPORTED_FUSION_STATE_CODEC_VERSION..=FUSION_STATE_CODEC_VERSION).contains(&version)
1184}
1185
1186impl InertialFilterSnapshot {
1187    /// Convert this snapshot into a stable serializable checkpoint.
1188    pub fn to_serializable_fusion_state(&self) -> SerializableFusionState {
1189        SerializableFusionState::from_snapshot(self)
1190    }
1191
1192    /// Encode this snapshot with the versioned binary fusion codec.
1193    pub fn encode_fusion_state(&self) -> Result<Vec<u8>, FusionStateCodecError> {
1194        self.to_serializable_fusion_state().encode_versioned()
1195    }
1196
1197    /// Decode a snapshot from the versioned binary fusion codec.
1198    pub fn decode_fusion_state(bytes: &[u8]) -> Result<Self, FusionStateCodecError> {
1199        SerializableFusionState::decode_versioned(bytes)?.to_snapshot()
1200    }
1201}
1202
1203impl InertialFilter {
1204    /// Return the current filter checkpoint in the stable serializable form.
1205    pub fn serializable_state(&self) -> Result<SerializableFusionState, FusionStateCodecError> {
1206        SerializableFusionState::from_filter(self)
1207    }
1208
1209    /// Encode the current filter checkpoint with the versioned binary codec.
1210    pub fn encode_state(&self) -> Result<Vec<u8>, FusionStateCodecError> {
1211        SerializableFusionState::from_filter(self)?.encode_versioned()
1212    }
1213
1214    /// Restore this filter from a stable serializable checkpoint.
1215    pub fn restore_serializable_state(
1216        &mut self,
1217        state: &SerializableFusionState,
1218    ) -> Result<(), FusionStateCodecError> {
1219        let snapshot = state.to_snapshot()?;
1220        let history = state.to_time_sync_history()?;
1221        self.restore_snapshot(&snapshot).map_err(invalid_state)?;
1222        self.time_sync
1223            .restore_history(history)
1224            .map_err(invalid_state)
1225    }
1226
1227    /// Restore this filter from the versioned binary fusion codec.
1228    pub fn restore_encoded_state(&mut self, bytes: &[u8]) -> Result<(), FusionStateCodecError> {
1229        let state = SerializableFusionState::decode_versioned(bytes)?;
1230        self.restore_serializable_state(&state)
1231    }
1232}
1233
1234/// Errors returned by the versioned fusion-state codec.
1235#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
1236pub enum FusionStateCodecError {
1237    /// The binary payload did not begin with the fusion-state magic bytes.
1238    #[error("fusion state payload has invalid magic")]
1239    InvalidMagic,
1240    /// The payload schema version is not supported by this decoder.
1241    #[error("fusion state version {version} is not supported")]
1242    UnsupportedVersion {
1243        /// Version tag found in the payload.
1244        version: u16,
1245    },
1246    /// The payload ended before a complete field could be read.
1247    #[error("fusion state payload truncated at {offset}, needed {needed} bytes, got {actual}")]
1248    Truncated {
1249        /// Byte offset of the attempted read.
1250        offset: usize,
1251        /// Number of bytes required for the read or minimum payload.
1252        needed: usize,
1253        /// Number of bytes available in the payload or read window.
1254        actual: usize,
1255    },
1256    /// The checksum did not match the decoded payload bytes.
1257    #[error("fusion state checksum expected {expected:#x} but found {found:#x}")]
1258    Checksum {
1259        /// Checksum stored in the payload.
1260        expected: u64,
1261        /// Checksum computed from the payload.
1262        found: u64,
1263    },
1264    /// The payload contained bytes after the versioned fields.
1265    #[error("fusion state payload has {remaining} trailing bytes")]
1266    TrailingBytes {
1267        /// Number of bytes not consumed before the checksum.
1268        remaining: usize,
1269    },
1270    /// The payload decoded but did not validate as a native fusion state.
1271    #[error("invalid fusion state payload: {reason}")]
1272    InvalidState {
1273        /// Stable validation reason.
1274        reason: String,
1275    },
1276    /// JSON serde encoding or decoding failed.
1277    #[error("fusion state JSON error: {message}")]
1278    Json {
1279        /// Serde error text.
1280        message: String,
1281    },
1282}
1283
1284fn invalid_state(error: impl core::fmt::Display) -> FusionStateCodecError {
1285    FusionStateCodecError::InvalidState {
1286        reason: error.to_string(),
1287    }
1288}
1289
1290fn checked_u32(value: usize) -> Result<u32, FusionStateCodecError> {
1291    u32::try_from(value).map_err(|_| FusionStateCodecError::InvalidState {
1292        reason: "length exceeds u32".to_string(),
1293    })
1294}
1295
1296fn validate_history_by_restore(
1297    snapshot: TimeSyncHistorySnapshot,
1298) -> Result<TimeSyncHistorySnapshot, FusionStateCodecError> {
1299    snapshot.validate().map_err(invalid_state)?;
1300    Ok(snapshot)
1301}
1302
1303fn bits3(values: [f64; 3]) -> [F64Bits; 3] {
1304    values.map(F64Bits::from_f64)
1305}
1306
1307fn f643(values: [F64Bits; 3]) -> [f64; 3] {
1308    values.map(F64Bits::to_f64)
1309}
1310
1311fn bits3x3(values: [[f64; 3]; 3]) -> [[F64Bits; 3]; 3] {
1312    values.map(bits3)
1313}
1314
1315fn f643x3(values: [[F64Bits; 3]; 3]) -> [[f64; 3]; 3] {
1316    values.map(f643)
1317}
1318
1319fn bits_slice(values: &[f64]) -> Vec<F64Bits> {
1320    values.iter().copied().map(F64Bits::from_f64).collect()
1321}
1322
1323fn f64_vec(values: &[F64Bits]) -> Vec<f64> {
1324    values.iter().copied().map(F64Bits::to_f64).collect()
1325}
1326
1327fn bits_matrix(values: &[Vec<f64>]) -> Vec<Vec<F64Bits>> {
1328    values.iter().map(|row| bits_slice(row)).collect()
1329}
1330
1331fn f64_matrix(values: &[Vec<F64Bits>]) -> Vec<Vec<f64>> {
1332    values.iter().map(|row| f64_vec(row)).collect()
1333}
1334
1335fn write_nav(bytes: &mut Vec<u8>, state: &SerializableNavState) {
1336    write_f64(bytes, state.t_j2000_s);
1337    write_f64_array(bytes, &state.position_ecef_m);
1338    write_f64_array(bytes, &state.velocity_ecef_mps);
1339    for row in &state.attitude_body_to_ecef {
1340        write_f64_array(bytes, row);
1341    }
1342    write_f64_array(bytes, &state.accel_bias_mps2);
1343    write_f64_array(bytes, &state.gyro_bias_rps);
1344}
1345
1346fn read_nav(
1347    bytes: &[u8],
1348    cursor: &mut usize,
1349    limit: usize,
1350) -> Result<SerializableNavState, FusionStateCodecError> {
1351    Ok(SerializableNavState {
1352        t_j2000_s: read_f64(bytes, cursor, limit)?,
1353        position_ecef_m: read_f64_array(bytes, cursor, limit)?,
1354        velocity_ecef_mps: read_f64_array(bytes, cursor, limit)?,
1355        attitude_body_to_ecef: [
1356            read_f64_array(bytes, cursor, limit)?,
1357            read_f64_array(bytes, cursor, limit)?,
1358            read_f64_array(bytes, cursor, limit)?,
1359        ],
1360        accel_bias_mps2: read_f64_array(bytes, cursor, limit)?,
1361        gyro_bias_rps: read_f64_array(bytes, cursor, limit)?,
1362    })
1363}
1364
1365fn write_layout(bytes: &mut Vec<u8>, layout: SerializableErrorStateLayout) {
1366    bytes.push(match layout {
1367        SerializableErrorStateLayout::Fifteen => 15,
1368        SerializableErrorStateLayout::TwentyOne => 21,
1369    });
1370}
1371
1372fn read_layout(
1373    bytes: &[u8],
1374    cursor: &mut usize,
1375    limit: usize,
1376) -> Result<SerializableErrorStateLayout, FusionStateCodecError> {
1377    match read_u8(bytes, cursor, limit)? {
1378        15 => Ok(SerializableErrorStateLayout::Fifteen),
1379        21 => Ok(SerializableErrorStateLayout::TwentyOne),
1380        _ => Err(FusionStateCodecError::InvalidState {
1381            reason: "invalid error-state layout tag".to_string(),
1382        }),
1383    }
1384}
1385
1386fn write_time_sync_history(
1387    bytes: &mut Vec<u8>,
1388    history: &SerializableTimeSyncHistory,
1389) -> Result<(), FusionStateCodecError> {
1390    write_u32_checked(bytes, history.config.imu_capacity as usize)?;
1391    write_u32_checked(bytes, history.config.checkpoint_capacity as usize)?;
1392    write_u32_checked(bytes, history.imu_samples.len())?;
1393    for sample in &history.imu_samples {
1394        write_stored_imu_sample(bytes, sample);
1395    }
1396    write_u32_checked(bytes, history.checkpoints.len())?;
1397    for checkpoint in &history.checkpoints {
1398        write_stored_checkpoint(bytes, checkpoint)?;
1399    }
1400    write_u32_checked(bytes, history.measurements.len())?;
1401    for measurement in &history.measurements {
1402        write_stored_measurement(bytes, measurement)?;
1403    }
1404    Ok(())
1405}
1406
1407fn read_time_sync_history(
1408    bytes: &[u8],
1409    cursor: &mut usize,
1410    limit: usize,
1411    version: u16,
1412) -> Result<SerializableTimeSyncHistory, FusionStateCodecError> {
1413    let config = SerializableTimeSyncHistoryConfig {
1414        imu_capacity: read_u32(bytes, cursor, limit)?,
1415        checkpoint_capacity: read_u32(bytes, cursor, limit)?,
1416    };
1417    let imu_len = read_len(bytes, cursor, limit, 1, "imu_samples")?;
1418    let mut imu_samples = Vec::with_capacity(imu_len);
1419    for _ in 0..imu_len {
1420        imu_samples.push(read_stored_imu_sample(bytes, cursor, limit)?);
1421    }
1422    let checkpoint_len = read_len(bytes, cursor, limit, 1, "checkpoints")?;
1423    let mut checkpoints = Vec::with_capacity(checkpoint_len);
1424    for _ in 0..checkpoint_len {
1425        checkpoints.push(read_stored_checkpoint(bytes, cursor, limit, version)?);
1426    }
1427    let measurement_len = read_len(bytes, cursor, limit, 1, "gnss_measurements")?;
1428    let mut measurements = Vec::with_capacity(measurement_len);
1429    for _ in 0..measurement_len {
1430        measurements.push(read_stored_measurement(bytes, cursor, limit)?);
1431    }
1432    let history = SerializableTimeSyncHistory {
1433        config,
1434        imu_samples,
1435        checkpoints,
1436        measurements,
1437    };
1438    history.to_native()?;
1439    Ok(history)
1440}
1441
1442fn write_stored_imu_sample(bytes: &mut Vec<u8>, sample: &SerializableStoredImuSample) {
1443    write_f64(bytes, sample.previous_t_j2000_s);
1444    write_imu_sample(bytes, &sample.sample);
1445    match sample.previous_rate {
1446        Some(endpoint) => {
1447            write_bool(bytes, true);
1448            write_rate_endpoint(bytes, endpoint);
1449        }
1450        None => write_bool(bytes, false),
1451    }
1452}
1453
1454fn read_stored_imu_sample(
1455    bytes: &[u8],
1456    cursor: &mut usize,
1457    limit: usize,
1458) -> Result<SerializableStoredImuSample, FusionStateCodecError> {
1459    let previous_t_j2000_s = read_f64(bytes, cursor, limit)?;
1460    let sample = read_imu_sample(bytes, cursor, limit)?;
1461    let previous_rate = if read_bool(bytes, cursor, limit)? {
1462        Some(read_rate_endpoint(bytes, cursor, limit)?)
1463    } else {
1464        None
1465    };
1466    Ok(SerializableStoredImuSample {
1467        previous_t_j2000_s,
1468        sample,
1469        previous_rate,
1470    })
1471}
1472
1473fn write_imu_sample(bytes: &mut Vec<u8>, sample: &SerializableImuSample) {
1474    write_f64(bytes, sample.t_j2000_s);
1475    match sample.kind {
1476        SerializableImuSampleKind::Rate {
1477            specific_force_mps2,
1478            angular_rate_rps,
1479        } => {
1480            bytes.push(0);
1481            write_f64_array(bytes, &specific_force_mps2);
1482            write_f64_array(bytes, &angular_rate_rps);
1483        }
1484        SerializableImuSampleKind::Increment {
1485            delta_velocity_mps,
1486            delta_theta_rad,
1487            dt_s,
1488        } => {
1489            bytes.push(1);
1490            write_f64_array(bytes, &delta_velocity_mps);
1491            write_f64_array(bytes, &delta_theta_rad);
1492            write_f64(bytes, dt_s);
1493        }
1494    }
1495}
1496
1497fn read_imu_sample(
1498    bytes: &[u8],
1499    cursor: &mut usize,
1500    limit: usize,
1501) -> Result<SerializableImuSample, FusionStateCodecError> {
1502    let t_j2000_s = read_f64(bytes, cursor, limit)?;
1503    let kind = match read_u8(bytes, cursor, limit)? {
1504        0 => SerializableImuSampleKind::Rate {
1505            specific_force_mps2: read_f64_array(bytes, cursor, limit)?,
1506            angular_rate_rps: read_f64_array(bytes, cursor, limit)?,
1507        },
1508        1 => SerializableImuSampleKind::Increment {
1509            delta_velocity_mps: read_f64_array(bytes, cursor, limit)?,
1510            delta_theta_rad: read_f64_array(bytes, cursor, limit)?,
1511            dt_s: read_f64(bytes, cursor, limit)?,
1512        },
1513        _ => {
1514            return Err(FusionStateCodecError::InvalidState {
1515                reason: "invalid IMU sample kind tag".to_string(),
1516            });
1517        }
1518    };
1519    Ok(SerializableImuSample { t_j2000_s, kind })
1520}
1521
1522fn write_rate_endpoint(bytes: &mut Vec<u8>, endpoint: SerializableRateEndpoint) {
1523    write_f64(bytes, endpoint.t_j2000_s);
1524    write_f64_array(bytes, &endpoint.specific_force_mps2);
1525    write_f64_array(bytes, &endpoint.angular_rate_rps);
1526}
1527
1528fn read_rate_endpoint(
1529    bytes: &[u8],
1530    cursor: &mut usize,
1531    limit: usize,
1532) -> Result<SerializableRateEndpoint, FusionStateCodecError> {
1533    Ok(SerializableRateEndpoint {
1534        t_j2000_s: read_f64(bytes, cursor, limit)?,
1535        specific_force_mps2: read_f64_array(bytes, cursor, limit)?,
1536        angular_rate_rps: read_f64_array(bytes, cursor, limit)?,
1537    })
1538}
1539
1540fn write_stored_checkpoint(
1541    bytes: &mut Vec<u8>,
1542    checkpoint: &SerializableStoredCheckpoint,
1543) -> Result<(), FusionStateCodecError> {
1544    write_f64(bytes, checkpoint.t_j2000_s);
1545    write_fusion_snapshot(bytes, checkpoint.snapshot.as_ref())
1546}
1547
1548fn read_stored_checkpoint(
1549    bytes: &[u8],
1550    cursor: &mut usize,
1551    limit: usize,
1552    version: u16,
1553) -> Result<SerializableStoredCheckpoint, FusionStateCodecError> {
1554    Ok(SerializableStoredCheckpoint {
1555        t_j2000_s: read_f64(bytes, cursor, limit)?,
1556        snapshot: Box::new(read_fusion_snapshot(bytes, cursor, limit, version)?),
1557    })
1558}
1559
1560fn write_fusion_snapshot(
1561    bytes: &mut Vec<u8>,
1562    snapshot: &SerializableFusionSnapshot,
1563) -> Result<(), FusionStateCodecError> {
1564    write_layout(bytes, snapshot.state.layout);
1565    write_nav(bytes, &snapshot.state.nominal);
1566    write_f64_vec(bytes, &snapshot.state.error_state)?;
1567    write_f64_matrix(bytes, &snapshot.state.covariance)?;
1568    write_f64_array(bytes, &snapshot.state.accel_scale_factor);
1569    write_f64_array(bytes, &snapshot.state.gyro_scale_factor);
1570    write_f64_array(bytes, &snapshot.last_body_rate_wrt_ecef_rps);
1571    write_stationarity_window(bytes, &snapshot.stationarity_window)?;
1572    write_optional_f64(bytes, snapshot.last_stationary_update_t_j2000_s);
1573    write_optional_f64(bytes, snapshot.last_non_holonomic_update_t_j2000_s);
1574    write_f64(bytes, snapshot.tight.clock_bias_m);
1575    write_f64(bytes, snapshot.tight.clock_drift_m_s);
1576    write_f64_matrix(bytes, &snapshot.tight.augmented_covariance)
1577}
1578
1579fn read_fusion_snapshot(
1580    bytes: &[u8],
1581    cursor: &mut usize,
1582    limit: usize,
1583    version: u16,
1584) -> Result<SerializableFusionSnapshot, FusionStateCodecError> {
1585    let state = SerializableInsFilterState {
1586        layout: read_layout(bytes, cursor, limit)?,
1587        nominal: read_nav(bytes, cursor, limit)?,
1588        error_state: read_f64_vec(bytes, cursor, limit)?,
1589        covariance: read_f64_matrix(bytes, cursor, limit)?,
1590        accel_scale_factor: read_f64_array(bytes, cursor, limit)?,
1591        gyro_scale_factor: read_f64_array(bytes, cursor, limit)?,
1592    };
1593    let last_body_rate_wrt_ecef_rps = read_f64_array(bytes, cursor, limit)?;
1594    let stationarity_window = if version >= 3 {
1595        read_stationarity_window(bytes, cursor, limit)?
1596    } else {
1597        Vec::new()
1598    };
1599    let (last_stationary_update_t_j2000_s, last_non_holonomic_update_t_j2000_s) = if version >= 4 {
1600        (
1601            read_optional_f64(bytes, cursor, limit)?,
1602            read_optional_f64(bytes, cursor, limit)?,
1603        )
1604    } else {
1605        (None, None)
1606    };
1607    Ok(SerializableFusionSnapshot {
1608        state,
1609        last_body_rate_wrt_ecef_rps,
1610        stationarity_window,
1611        last_stationary_update_t_j2000_s,
1612        last_non_holonomic_update_t_j2000_s,
1613        tight: SerializableTightFilterState {
1614            clock_bias_m: read_f64(bytes, cursor, limit)?,
1615            clock_drift_m_s: read_f64(bytes, cursor, limit)?,
1616            augmented_covariance: read_f64_matrix(bytes, cursor, limit)?,
1617        },
1618    })
1619}
1620
1621fn write_stationarity_window(
1622    bytes: &mut Vec<u8>,
1623    samples: &[SerializableStationarityDetectorSample],
1624) -> Result<(), FusionStateCodecError> {
1625    write_u32_checked(bytes, samples.len())?;
1626    for sample in samples {
1627        write_f64(bytes, sample.specific_force_norm_error_mps2);
1628        write_f64(bytes, sample.body_rate_wrt_ecef_norm_rps);
1629    }
1630    Ok(())
1631}
1632
1633fn read_stationarity_window(
1634    bytes: &[u8],
1635    cursor: &mut usize,
1636    limit: usize,
1637) -> Result<Vec<SerializableStationarityDetectorSample>, FusionStateCodecError> {
1638    let len = read_len(bytes, cursor, limit, 16, "stationarity_window")?;
1639    let mut samples = Vec::with_capacity(len);
1640    for _ in 0..len {
1641        samples.push(SerializableStationarityDetectorSample {
1642            specific_force_norm_error_mps2: read_f64(bytes, cursor, limit)?,
1643            body_rate_wrt_ecef_norm_rps: read_f64(bytes, cursor, limit)?,
1644        });
1645    }
1646    Ok(samples)
1647}
1648
1649fn write_optional_f64(bytes: &mut Vec<u8>, value: Option<F64Bits>) {
1650    match value {
1651        Some(value) => {
1652            write_bool(bytes, true);
1653            write_f64(bytes, value);
1654        }
1655        None => write_bool(bytes, false),
1656    }
1657}
1658
1659fn read_optional_f64(
1660    bytes: &[u8],
1661    cursor: &mut usize,
1662    limit: usize,
1663) -> Result<Option<F64Bits>, FusionStateCodecError> {
1664    if read_bool(bytes, cursor, limit)? {
1665        Ok(Some(read_f64(bytes, cursor, limit)?))
1666    } else {
1667        Ok(None)
1668    }
1669}
1670
1671fn write_stored_measurement(
1672    bytes: &mut Vec<u8>,
1673    measurement: &SerializableStoredGnssMeasurement,
1674) -> Result<(), FusionStateCodecError> {
1675    match measurement {
1676        SerializableStoredGnssMeasurement::Loose(measurement) => {
1677            bytes.push(2);
1678            write_loose_measurement(bytes, measurement)
1679        }
1680        SerializableStoredGnssMeasurement::Tight(epoch) => {
1681            bytes.push(1);
1682            write_tight_epoch(bytes, epoch)
1683        }
1684    }
1685}
1686
1687fn read_stored_measurement(
1688    bytes: &[u8],
1689    cursor: &mut usize,
1690    limit: usize,
1691) -> Result<SerializableStoredGnssMeasurement, FusionStateCodecError> {
1692    match read_u8(bytes, cursor, limit)? {
1693        0 => Ok(SerializableStoredGnssMeasurement::Loose(
1694            read_loose_measurement_with_default_status(bytes, cursor, limit)?,
1695        )),
1696        1 => Ok(SerializableStoredGnssMeasurement::Tight(read_tight_epoch(
1697            bytes, cursor, limit,
1698        )?)),
1699        2 => Ok(SerializableStoredGnssMeasurement::Loose(
1700            read_loose_measurement(bytes, cursor, limit)?,
1701        )),
1702        _ => Err(FusionStateCodecError::InvalidState {
1703            reason: "invalid GNSS measurement tag".to_string(),
1704        }),
1705    }
1706}
1707
1708fn write_loose_measurement(
1709    bytes: &mut Vec<u8>,
1710    measurement: &SerializableLooseMeasurement,
1711) -> Result<(), FusionStateCodecError> {
1712    write_f64(bytes, measurement.t_j2000_s);
1713    write_f64_array(bytes, &measurement.position_ecef_m);
1714    match measurement.velocity_ecef_mps {
1715        Some(velocity) => {
1716            write_bool(bytes, true);
1717            write_f64_array(bytes, &velocity);
1718        }
1719        None => write_bool(bytes, false),
1720    }
1721    write_f64_matrix(bytes, &measurement.covariance)?;
1722    write_u32_checked(bytes, measurement.satellites_used as usize)?;
1723    write_bool(bytes, measurement.solution_valid);
1724    write_fix_status(bytes, measurement.fix_status);
1725    Ok(())
1726}
1727
1728fn read_loose_measurement(
1729    bytes: &[u8],
1730    cursor: &mut usize,
1731    limit: usize,
1732) -> Result<SerializableLooseMeasurement, FusionStateCodecError> {
1733    read_loose_measurement_core(bytes, cursor, limit, true)
1734}
1735
1736fn read_loose_measurement_with_default_status(
1737    bytes: &[u8],
1738    cursor: &mut usize,
1739    limit: usize,
1740) -> Result<SerializableLooseMeasurement, FusionStateCodecError> {
1741    read_loose_measurement_core(bytes, cursor, limit, false)
1742}
1743
1744fn read_loose_measurement_core(
1745    bytes: &[u8],
1746    cursor: &mut usize,
1747    limit: usize,
1748    read_status: bool,
1749) -> Result<SerializableLooseMeasurement, FusionStateCodecError> {
1750    let t_j2000_s = read_f64(bytes, cursor, limit)?;
1751    let position_ecef_m = read_f64_array(bytes, cursor, limit)?;
1752    let velocity_ecef_mps = if read_bool(bytes, cursor, limit)? {
1753        Some(read_f64_array(bytes, cursor, limit)?)
1754    } else {
1755        None
1756    };
1757    let covariance = read_f64_matrix(bytes, cursor, limit)?;
1758    let satellites_used = read_u32(bytes, cursor, limit)?;
1759    let solution_valid = read_bool(bytes, cursor, limit)?;
1760    let fix_status = if read_status {
1761        read_fix_status(bytes, cursor, limit)?
1762    } else {
1763        SerializableGnssFixStatus::Single
1764    };
1765    Ok(SerializableLooseMeasurement {
1766        t_j2000_s,
1767        position_ecef_m,
1768        velocity_ecef_mps,
1769        covariance,
1770        satellites_used,
1771        solution_valid,
1772        fix_status,
1773    })
1774}
1775
1776fn write_fix_status(bytes: &mut Vec<u8>, status: SerializableGnssFixStatus) {
1777    bytes.push(match status {
1778        SerializableGnssFixStatus::Single => 0,
1779        SerializableGnssFixStatus::Float => 1,
1780        SerializableGnssFixStatus::Fixed => 2,
1781    });
1782}
1783
1784fn read_fix_status(
1785    bytes: &[u8],
1786    cursor: &mut usize,
1787    limit: usize,
1788) -> Result<SerializableGnssFixStatus, FusionStateCodecError> {
1789    match read_u8(bytes, cursor, limit)? {
1790        0 => Ok(SerializableGnssFixStatus::Single),
1791        1 => Ok(SerializableGnssFixStatus::Float),
1792        2 => Ok(SerializableGnssFixStatus::Fixed),
1793        _ => Err(FusionStateCodecError::InvalidState {
1794            reason: "invalid loose fix status".to_string(),
1795        }),
1796    }
1797}
1798
1799fn write_tight_epoch(
1800    bytes: &mut Vec<u8>,
1801    epoch: &SerializableTightGnssEpoch,
1802) -> Result<(), FusionStateCodecError> {
1803    write_f64(bytes, epoch.t_j2000_s);
1804    write_u32_checked(bytes, epoch.observations.len())?;
1805    for observation in &epoch.observations {
1806        write_tight_observation(bytes, observation);
1807    }
1808    Ok(())
1809}
1810
1811fn read_tight_epoch(
1812    bytes: &[u8],
1813    cursor: &mut usize,
1814    limit: usize,
1815) -> Result<SerializableTightGnssEpoch, FusionStateCodecError> {
1816    let t_j2000_s = read_f64(bytes, cursor, limit)?;
1817    let len = read_len(bytes, cursor, limit, 1, "tight_observations")?;
1818    let mut observations = Vec::with_capacity(len);
1819    for _ in 0..len {
1820        observations.push(read_tight_observation(bytes, cursor, limit)?);
1821    }
1822    Ok(SerializableTightGnssEpoch {
1823        t_j2000_s,
1824        observations,
1825    })
1826}
1827
1828fn write_tight_observation(bytes: &mut Vec<u8>, observation: &SerializableTightGnssObservation) {
1829    write_satellite_id(bytes, observation.satellite_id);
1830    write_f64(bytes, observation.pseudorange_m);
1831    write_f64(bytes, observation.pseudorange_sigma_m);
1832    match observation.range_rate {
1833        Some(range_rate) => {
1834            write_bool(bytes, true);
1835            write_range_rate_observation(bytes, range_rate);
1836        }
1837        None => write_bool(bytes, false),
1838    }
1839    match observation.carrier_phase {
1840        Some(carrier_phase) => {
1841            write_bool(bytes, true);
1842            write_carrier_phase_observation(bytes, carrier_phase);
1843        }
1844        None => write_bool(bytes, false),
1845    }
1846    write_f64(bytes, observation.ionosphere_delay_m);
1847    write_f64(bytes, observation.troposphere_delay_m);
1848}
1849
1850fn read_tight_observation(
1851    bytes: &[u8],
1852    cursor: &mut usize,
1853    limit: usize,
1854) -> Result<SerializableTightGnssObservation, FusionStateCodecError> {
1855    let satellite_id = read_satellite_id(bytes, cursor, limit)?;
1856    let pseudorange_m = read_f64(bytes, cursor, limit)?;
1857    let pseudorange_sigma_m = read_f64(bytes, cursor, limit)?;
1858    let range_rate = if read_bool(bytes, cursor, limit)? {
1859        Some(read_range_rate_observation(bytes, cursor, limit)?)
1860    } else {
1861        None
1862    };
1863    let carrier_phase = if read_bool(bytes, cursor, limit)? {
1864        Some(read_carrier_phase_observation(bytes, cursor, limit)?)
1865    } else {
1866        None
1867    };
1868    Ok(SerializableTightGnssObservation {
1869        satellite_id,
1870        pseudorange_m,
1871        pseudorange_sigma_m,
1872        range_rate,
1873        carrier_phase,
1874        ionosphere_delay_m: read_f64(bytes, cursor, limit)?,
1875        troposphere_delay_m: read_f64(bytes, cursor, limit)?,
1876    })
1877}
1878
1879fn write_range_rate_observation(
1880    bytes: &mut Vec<u8>,
1881    observation: SerializableTightRangeRateObservation,
1882) {
1883    write_f64(bytes, observation.measured_range_rate_m_s);
1884    write_f64(bytes, observation.sigma_m_s);
1885    write_f64(bytes, observation.satellite_clock_drift_m_s);
1886}
1887
1888fn read_range_rate_observation(
1889    bytes: &[u8],
1890    cursor: &mut usize,
1891    limit: usize,
1892) -> Result<SerializableTightRangeRateObservation, FusionStateCodecError> {
1893    Ok(SerializableTightRangeRateObservation {
1894        measured_range_rate_m_s: read_f64(bytes, cursor, limit)?,
1895        sigma_m_s: read_f64(bytes, cursor, limit)?,
1896        satellite_clock_drift_m_s: read_f64(bytes, cursor, limit)?,
1897    })
1898}
1899
1900fn write_carrier_phase_observation(
1901    bytes: &mut Vec<u8>,
1902    observation: SerializableTightCarrierPhaseObservation,
1903) {
1904    write_f64(bytes, observation.phase_range_m);
1905    write_f64(bytes, observation.sigma_m);
1906    write_f64(bytes, observation.float_ambiguity_m);
1907}
1908
1909fn read_carrier_phase_observation(
1910    bytes: &[u8],
1911    cursor: &mut usize,
1912    limit: usize,
1913) -> Result<SerializableTightCarrierPhaseObservation, FusionStateCodecError> {
1914    Ok(SerializableTightCarrierPhaseObservation {
1915        phase_range_m: read_f64(bytes, cursor, limit)?,
1916        sigma_m: read_f64(bytes, cursor, limit)?,
1917        float_ambiguity_m: read_f64(bytes, cursor, limit)?,
1918    })
1919}
1920
1921fn write_satellite_id(bytes: &mut Vec<u8>, id: SerializableSatelliteId) {
1922    bytes.push(match id.system {
1923        GnssSystem::Gps => 0,
1924        GnssSystem::Glonass => 1,
1925        GnssSystem::Galileo => 2,
1926        GnssSystem::BeiDou => 3,
1927        GnssSystem::Qzss => 4,
1928        GnssSystem::Navic => 5,
1929        GnssSystem::Sbas => 6,
1930    });
1931    bytes.push(id.prn);
1932}
1933
1934fn read_satellite_id(
1935    bytes: &[u8],
1936    cursor: &mut usize,
1937    limit: usize,
1938) -> Result<SerializableSatelliteId, FusionStateCodecError> {
1939    let system = match read_u8(bytes, cursor, limit)? {
1940        0 => GnssSystem::Gps,
1941        1 => GnssSystem::Glonass,
1942        2 => GnssSystem::Galileo,
1943        3 => GnssSystem::BeiDou,
1944        4 => GnssSystem::Qzss,
1945        5 => GnssSystem::Navic,
1946        6 => GnssSystem::Sbas,
1947        _ => {
1948            return Err(FusionStateCodecError::InvalidState {
1949                reason: "invalid GNSS system tag".to_string(),
1950            });
1951        }
1952    };
1953    Ok(SerializableSatelliteId {
1954        system,
1955        prn: read_u8(bytes, cursor, limit)?,
1956    })
1957}
1958
1959fn write_f64_matrix(
1960    bytes: &mut Vec<u8>,
1961    matrix: &[Vec<F64Bits>],
1962) -> Result<(), FusionStateCodecError> {
1963    write_u32_checked(bytes, matrix.len())?;
1964    let cols = matrix.first().map_or(0, Vec::len);
1965    write_u32_checked(bytes, cols)?;
1966    for row in matrix {
1967        if row.len() != cols {
1968            return Err(FusionStateCodecError::InvalidState {
1969                reason: "ragged matrix cannot be encoded".to_string(),
1970            });
1971        }
1972        write_f64_vec_body(bytes, row);
1973    }
1974    Ok(())
1975}
1976
1977fn read_f64_matrix(
1978    bytes: &[u8],
1979    cursor: &mut usize,
1980    limit: usize,
1981) -> Result<Vec<Vec<F64Bits>>, FusionStateCodecError> {
1982    let rows = read_u32(bytes, cursor, limit)? as usize;
1983    let cols = read_u32(bytes, cursor, limit)? as usize;
1984    if rows == 0 || cols == 0 {
1985        return Err(FusionStateCodecError::InvalidState {
1986            reason: "matrix dimensions must be positive".to_string(),
1987        });
1988    }
1989    let count = rows
1990        .checked_mul(cols)
1991        .ok_or_else(|| FusionStateCodecError::InvalidState {
1992            reason: "matrix dimensions overflow usize".to_string(),
1993        })?;
1994    let needed = count
1995        .checked_mul(8)
1996        .ok_or_else(|| FusionStateCodecError::InvalidState {
1997            reason: "matrix byte length overflows usize".to_string(),
1998        })?;
1999    ensure_available(*cursor, needed, limit)?;
2000    let mut matrix = Vec::with_capacity(rows);
2001    for _ in 0..rows {
2002        let mut row = Vec::with_capacity(cols);
2003        for _ in 0..cols {
2004            row.push(read_f64(bytes, cursor, limit)?);
2005        }
2006        matrix.push(row);
2007    }
2008    Ok(matrix)
2009}
2010
2011fn write_f64_vec(bytes: &mut Vec<u8>, values: &[F64Bits]) -> Result<(), FusionStateCodecError> {
2012    write_u32_checked(bytes, values.len())?;
2013    write_f64_vec_body(bytes, values);
2014    Ok(())
2015}
2016
2017fn write_f64_vec_body(bytes: &mut Vec<u8>, values: &[F64Bits]) {
2018    for value in values {
2019        write_f64(bytes, *value);
2020    }
2021}
2022
2023fn read_f64_vec(
2024    bytes: &[u8],
2025    cursor: &mut usize,
2026    limit: usize,
2027) -> Result<Vec<F64Bits>, FusionStateCodecError> {
2028    let len = read_u32(bytes, cursor, limit)? as usize;
2029    let needed = len
2030        .checked_mul(8)
2031        .ok_or_else(|| FusionStateCodecError::InvalidState {
2032            reason: "vector byte length overflows usize".to_string(),
2033        })?;
2034    ensure_available(*cursor, needed, limit)?;
2035    let mut values = Vec::with_capacity(len);
2036    for _ in 0..len {
2037        values.push(read_f64(bytes, cursor, limit)?);
2038    }
2039    Ok(values)
2040}
2041
2042fn write_f64_array<const N: usize>(bytes: &mut Vec<u8>, values: &[F64Bits; N]) {
2043    for value in values {
2044        write_f64(bytes, *value);
2045    }
2046}
2047
2048fn read_f64_array<const N: usize>(
2049    bytes: &[u8],
2050    cursor: &mut usize,
2051    limit: usize,
2052) -> Result<[F64Bits; N], FusionStateCodecError> {
2053    let mut out = [F64Bits { bits: 0 }; N];
2054    for value in &mut out {
2055        *value = read_f64(bytes, cursor, limit)?;
2056    }
2057    Ok(out)
2058}
2059
2060fn write_f64(bytes: &mut Vec<u8>, value: F64Bits) {
2061    write_u64(bytes, value.bits);
2062}
2063
2064fn read_f64(
2065    bytes: &[u8],
2066    cursor: &mut usize,
2067    limit: usize,
2068) -> Result<F64Bits, FusionStateCodecError> {
2069    Ok(F64Bits {
2070        bits: read_u64(bytes, cursor, limit)?,
2071    })
2072}
2073
2074fn write_u16(bytes: &mut Vec<u8>, value: u16) {
2075    bytes.extend_from_slice(&value.to_le_bytes());
2076}
2077
2078fn write_u32_checked(bytes: &mut Vec<u8>, value: usize) -> Result<(), FusionStateCodecError> {
2079    let value = u32::try_from(value).map_err(|_| FusionStateCodecError::InvalidState {
2080        reason: "length exceeds u32".to_string(),
2081    })?;
2082    bytes.extend_from_slice(&value.to_le_bytes());
2083    Ok(())
2084}
2085
2086fn write_u64(bytes: &mut Vec<u8>, value: u64) {
2087    bytes.extend_from_slice(&value.to_le_bytes());
2088}
2089
2090fn write_bool(bytes: &mut Vec<u8>, value: bool) {
2091    bytes.push(u8::from(value));
2092}
2093
2094fn read_bool(
2095    bytes: &[u8],
2096    cursor: &mut usize,
2097    limit: usize,
2098) -> Result<bool, FusionStateCodecError> {
2099    match read_u8(bytes, cursor, limit)? {
2100        0 => Ok(false),
2101        1 => Ok(true),
2102        _ => Err(FusionStateCodecError::InvalidState {
2103            reason: "invalid boolean tag".to_string(),
2104        }),
2105    }
2106}
2107
2108fn read_u8(bytes: &[u8], cursor: &mut usize, limit: usize) -> Result<u8, FusionStateCodecError> {
2109    ensure_available(*cursor, 1, limit)?;
2110    let value = bytes[*cursor];
2111    *cursor += 1;
2112    Ok(value)
2113}
2114
2115fn read_u16(bytes: &[u8], cursor: &mut usize, limit: usize) -> Result<u16, FusionStateCodecError> {
2116    let data = read_array::<2>(bytes, *cursor, limit)?;
2117    *cursor += 2;
2118    Ok(u16::from_le_bytes(data))
2119}
2120
2121fn read_u32(bytes: &[u8], cursor: &mut usize, limit: usize) -> Result<u32, FusionStateCodecError> {
2122    let data = read_array::<4>(bytes, *cursor, limit)?;
2123    *cursor += 4;
2124    Ok(u32::from_le_bytes(data))
2125}
2126
2127fn read_u64(bytes: &[u8], cursor: &mut usize, limit: usize) -> Result<u64, FusionStateCodecError> {
2128    let data = read_array::<8>(bytes, *cursor, limit)?;
2129    *cursor += 8;
2130    Ok(u64::from_le_bytes(data))
2131}
2132
2133fn read_u64_at(bytes: &[u8], offset: usize) -> Result<u64, FusionStateCodecError> {
2134    Ok(u64::from_le_bytes(read_array::<8>(
2135        bytes,
2136        offset,
2137        bytes.len(),
2138    )?))
2139}
2140
2141fn read_len(
2142    bytes: &[u8],
2143    cursor: &mut usize,
2144    limit: usize,
2145    min_element_bytes: usize,
2146    field: &'static str,
2147) -> Result<usize, FusionStateCodecError> {
2148    let len = read_u32(bytes, cursor, limit)? as usize;
2149    let needed =
2150        len.checked_mul(min_element_bytes)
2151            .ok_or_else(|| FusionStateCodecError::InvalidState {
2152                reason: format!("{field} byte length overflows usize"),
2153            })?;
2154    ensure_available(*cursor, needed, limit)?;
2155    Ok(len)
2156}
2157
2158fn read_array<const N: usize>(
2159    bytes: &[u8],
2160    offset: usize,
2161    limit: usize,
2162) -> Result<[u8; N], FusionStateCodecError> {
2163    ensure_available(offset, N, limit)?;
2164    let end = offset + N;
2165    let mut out = [0u8; N];
2166    out.copy_from_slice(&bytes[offset..end]);
2167    Ok(out)
2168}
2169
2170fn ensure_available(
2171    offset: usize,
2172    needed: usize,
2173    limit: usize,
2174) -> Result<(), FusionStateCodecError> {
2175    let end = offset
2176        .checked_add(needed)
2177        .ok_or(FusionStateCodecError::Truncated {
2178            offset,
2179            needed,
2180            actual: limit.saturating_sub(offset),
2181        })?;
2182    if end <= limit {
2183        Ok(())
2184    } else {
2185        Err(FusionStateCodecError::Truncated {
2186            offset,
2187            needed,
2188            actual: limit.saturating_sub(offset),
2189        })
2190    }
2191}
2192
2193fn fnv1a64(bytes: &[u8]) -> u64 {
2194    bytes.iter().fold(FNV_OFFSET_BASIS, |hash, byte| {
2195        (hash ^ u64::from(*byte)).wrapping_mul(FNV_PRIME)
2196    })
2197}
2198
2199#[cfg(test)]
2200mod tests {
2201    //! Provenance: serial codec tests assert the documented ABI contract for
2202    //! versioned little-endian fields and FNV-1a corruption detection. The
2203    //! numeric oracle is exact IEEE-754 bit identity before and after binary and
2204    //! serde JSON round trips.
2205
2206    use super::*;
2207    use crate::astro::constants::earth::WGS84_A_M;
2208    use crate::fusion::state::{ErrorStateLayout, ERROR_STATE_DIMENSION_15};
2209    use crate::fusion::TimeSyncHistoryConfig;
2210    use crate::inertial::config::RANDOM_WALK_BIAS_TAU_S;
2211    use crate::inertial::state::mat3_identity;
2212    use crate::inertial::{ImuSample, ImuSpec, NavState};
2213
2214    fn test_filter() -> InertialFilter {
2215        let nominal = NavState::new(
2216            12.5,
2217            [WGS84_A_M, -0.0, 3.25],
2218            [0.5, -0.25, 0.125],
2219            mat3_identity(),
2220        )
2221        .expect("nominal")
2222        .with_biases([0.01, -0.02, 0.03], [-0.001, 0.002, -0.003])
2223        .expect("biases");
2224        let mut diagonal = vec![1.0; ERROR_STATE_DIMENSION_15];
2225        diagonal[0] = 4.0;
2226        diagonal[1] = 9.0;
2227        let state = InsFilterState::from_diagonal(nominal, ErrorStateLayout::Fifteen, &diagonal)
2228            .expect("state");
2229        let spec = ImuSpec::datasheet(
2230            0.0,
2231            0.0,
2232            0.0,
2233            0.0,
2234            RANDOM_WALK_BIAS_TAU_S,
2235            RANDOM_WALK_BIAS_TAU_S,
2236            None,
2237            None,
2238        );
2239        InertialFilter::new(state, spec).expect("filter")
2240    }
2241
2242    fn increment(t_j2000_s: f64, dt_s: f64) -> ImuSample {
2243        ImuSample::increment(
2244            t_j2000_s,
2245            [0.015625 * dt_s, -0.0078125 * dt_s, 0.00390625 * dt_s],
2246            [
2247                0.0009765625 * dt_s,
2248                -0.00048828125 * dt_s,
2249                0.000244140625 * dt_s,
2250            ],
2251            dt_s,
2252        )
2253    }
2254
2255    fn measurement_at(t_j2000_s: f64, position_ecef_m: [f64; 3]) -> GnssFixMeasurement {
2256        GnssFixMeasurement::position(
2257            t_j2000_s,
2258            position_ecef_m,
2259            [[4.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 6.0]],
2260            8,
2261        )
2262        .expect("measurement")
2263    }
2264
2265    #[test]
2266    fn binary_stored_loose_measurement_preserves_fix_status() {
2267        let measurement = SerializableLooseMeasurement::from_native(
2268            &measurement_at(13.0, [WGS84_A_M + 0.25, -0.125, 3.5])
2269                .with_fix_status(GnssFixStatus::Float),
2270        )
2271        .expect("serial measurement");
2272        let stored = SerializableStoredGnssMeasurement::Loose(measurement.clone());
2273        let mut encoded = Vec::new();
2274        write_stored_measurement(&mut encoded, &stored).expect("write measurement");
2275
2276        let mut cursor = 0usize;
2277        let decoded = read_stored_measurement(&encoded, &mut cursor, encoded.len()).expect("read");
2278
2279        assert_eq!(decoded, stored);
2280        assert_eq!(cursor, encoded.len());
2281    }
2282
2283    #[test]
2284    fn binary_stored_loose_measurement_old_tag_defaults_fix_status() {
2285        let measurement = SerializableLooseMeasurement::from_native(
2286            &measurement_at(13.0, [WGS84_A_M + 0.25, -0.125, 3.5])
2287                .with_fix_status(GnssFixStatus::Fixed),
2288        )
2289        .expect("serial measurement");
2290        let mut encoded = Vec::new();
2291        encoded.push(0);
2292        write_f64(&mut encoded, measurement.t_j2000_s);
2293        write_f64_array(&mut encoded, &measurement.position_ecef_m);
2294        if let Some(velocity) = measurement.velocity_ecef_mps {
2295            write_bool(&mut encoded, true);
2296            write_f64_array(&mut encoded, &velocity);
2297        } else {
2298            write_bool(&mut encoded, false);
2299        }
2300        write_f64_matrix(&mut encoded, &measurement.covariance).expect("covariance");
2301        write_u32_checked(&mut encoded, measurement.satellites_used as usize).expect("satellites");
2302        write_bool(&mut encoded, measurement.solution_valid);
2303
2304        let mut cursor = 0usize;
2305        let decoded = read_stored_measurement(&encoded, &mut cursor, encoded.len()).expect("read");
2306
2307        let SerializableStoredGnssMeasurement::Loose(decoded) = decoded else {
2308            panic!("expected loose measurement");
2309        };
2310        assert_eq!(decoded.fix_status, SerializableGnssFixStatus::Single);
2311        assert_eq!(decoded.t_j2000_s, measurement.t_j2000_s);
2312        assert_eq!(decoded.position_ecef_m, measurement.position_ecef_m);
2313        assert_eq!(cursor, encoded.len());
2314    }
2315
2316    #[test]
2317    fn binary_and_json_round_trip_preserve_bits() {
2318        let filter = test_filter();
2319        let serial = filter.serializable_state().expect("serial state");
2320        let encoded = serial.encode_versioned().expect("encode");
2321        let decoded = SerializableFusionState::decode_versioned(&encoded).expect("decode");
2322        assert_eq!(decoded, serial);
2323        assert_snapshot_bits(
2324            &decoded.to_snapshot().expect("snapshot"),
2325            &filter.snapshot(),
2326        );
2327
2328        let json = serial.to_json_string().expect("json");
2329        let decoded_json = SerializableFusionState::from_json_str(&json).expect("json decode");
2330        assert_eq!(decoded_json, serial);
2331        assert_snapshot_bits(
2332            &decoded_json.to_snapshot().expect("json snapshot"),
2333            &filter.snapshot(),
2334        );
2335    }
2336
2337    #[test]
2338    fn binary_decoder_accepts_legacy_v2_without_stationarity_window() {
2339        let filter = test_filter();
2340        let serial = filter.serializable_state().expect("serial state");
2341        let encoded = encode_legacy_without_stationarity(&serial, 2).expect("legacy encode");
2342
2343        let decoded = SerializableFusionState::decode_versioned(&encoded).expect("decode v2");
2344        assert_eq!(decoded.version, 2);
2345        let mut expected = filter.snapshot();
2346        expected.stationarity_window.clear();
2347        expected.last_stationary_update_t_j2000_s = None;
2348        expected.last_non_holonomic_update_t_j2000_s = None;
2349        assert_snapshot_bits(&decoded.to_snapshot().expect("snapshot"), &expected);
2350    }
2351
2352    #[test]
2353    fn truncated_and_corrupted_payloads_are_typed_errors() {
2354        let serial = test_filter().serializable_state().expect("serial state");
2355        let encoded = serial.encode_versioned().expect("encode");
2356        let truncated = &encoded[..encoded.len() - 3];
2357        assert!(matches!(
2358            SerializableFusionState::decode_versioned(truncated),
2359            Err(FusionStateCodecError::Checksum { .. })
2360        ));
2361
2362        let mut corrupted = encoded;
2363        let idx = corrupted.len() / 2;
2364        corrupted[idx] ^= 0x55;
2365        assert!(matches!(
2366            SerializableFusionState::decode_versioned(&corrupted),
2367            Err(FusionStateCodecError::Checksum { .. })
2368        ));
2369
2370        let too_short = [0u8; 5];
2371        assert!(matches!(
2372            SerializableFusionState::decode_versioned(&too_short),
2373            Err(FusionStateCodecError::Truncated { .. })
2374        ));
2375    }
2376
2377    #[test]
2378    fn malformed_matrix_dimensions_are_typed_errors() {
2379        let mut bytes = Vec::new();
2380        bytes.extend_from_slice(&u32::MAX.to_le_bytes());
2381        bytes.extend_from_slice(&0u32.to_le_bytes());
2382        let mut cursor = 0usize;
2383        assert!(matches!(
2384            read_f64_matrix(&bytes, &mut cursor, bytes.len()),
2385            Err(FusionStateCodecError::InvalidState { .. })
2386        ));
2387    }
2388
2389    #[test]
2390    fn restored_encoded_state_retains_time_sync_history_for_late_replay_bits() {
2391        let first = measurement_at(13.0, [WGS84_A_M + 0.25, -0.125, 3.5]);
2392        let late = measurement_at(13.25, [WGS84_A_M - 0.0625, 0.1875, 3.0]);
2393        let final_fix = measurement_at(13.5, [WGS84_A_M + 0.03125, -0.25, 3.125]);
2394
2395        let mut direct = test_filter();
2396        direct
2397            .configure_time_sync_history(TimeSyncHistoryConfig::new(8, 8))
2398            .expect("history");
2399        direct.propagate(increment(13.0, 0.5)).expect("imu");
2400        direct.update_loose(&first).expect("first");
2401        direct.propagate(increment(13.25, 0.25)).expect("imu");
2402        direct.update_loose(&late).expect("late in order");
2403        direct.propagate(increment(13.5, 0.25)).expect("imu");
2404        direct.update_loose(&final_fix).expect("final");
2405        direct.propagate(increment(14.0, 0.5)).expect("imu");
2406
2407        let mut delayed = test_filter();
2408        delayed
2409            .configure_time_sync_history(TimeSyncHistoryConfig::new(8, 8))
2410            .expect("history");
2411        delayed.propagate(increment(13.0, 0.5)).expect("imu");
2412        delayed.update_loose(&first).expect("first");
2413        delayed.propagate(increment(13.5, 0.5)).expect("imu");
2414        delayed.update_loose(&final_fix).expect("final");
2415        delayed.propagate(increment(14.0, 0.5)).expect("imu");
2416        let encoded = delayed.encode_state().expect("encode");
2417
2418        let mut restored = test_filter();
2419        restored.restore_encoded_state(&encoded).expect("restore");
2420        let update = restored
2421            .update_loose_time_sync(&late)
2422            .expect("late replay after restore");
2423
2424        assert!(update.late_measurement);
2425        assert_snapshot_bits(&restored.snapshot(), &direct.snapshot());
2426    }
2427
2428    fn assert_snapshot_bits(actual: &InertialFilterSnapshot, expected: &InertialFilterSnapshot) {
2429        assert_eq!(
2430            actual.state.nominal.t_j2000_s.to_bits(),
2431            expected.state.nominal.t_j2000_s.to_bits()
2432        );
2433        for axis in 0..3 {
2434            assert_eq!(
2435                actual.state.nominal.position_ecef_m[axis].to_bits(),
2436                expected.state.nominal.position_ecef_m[axis].to_bits()
2437            );
2438            assert_eq!(
2439                actual.last_body_rate_wrt_ecef_rps[axis].to_bits(),
2440                expected.last_body_rate_wrt_ecef_rps[axis].to_bits()
2441            );
2442        }
2443        assert_eq!(
2444            actual.stationarity_window.len(),
2445            expected.stationarity_window.len()
2446        );
2447        for (actual_sample, expected_sample) in actual
2448            .stationarity_window
2449            .iter()
2450            .zip(expected.stationarity_window.iter())
2451        {
2452            assert_eq!(
2453                actual_sample.specific_force_norm_error_mps2.to_bits(),
2454                expected_sample.specific_force_norm_error_mps2.to_bits()
2455            );
2456            assert_eq!(
2457                actual_sample.body_rate_wrt_ecef_norm_rps.to_bits(),
2458                expected_sample.body_rate_wrt_ecef_norm_rps.to_bits()
2459            );
2460        }
2461        assert_eq!(
2462            actual.last_stationary_update_t_j2000_s.map(f64::to_bits),
2463            expected.last_stationary_update_t_j2000_s.map(f64::to_bits)
2464        );
2465        assert_eq!(
2466            actual.last_non_holonomic_update_t_j2000_s.map(f64::to_bits),
2467            expected
2468                .last_non_holonomic_update_t_j2000_s
2469                .map(f64::to_bits)
2470        );
2471        for row in 0..actual.state.covariance.len() {
2472            for col in 0..actual.state.covariance[row].len() {
2473                assert_eq!(
2474                    actual.state.covariance[row][col].to_bits(),
2475                    expected.state.covariance[row][col].to_bits()
2476                );
2477            }
2478        }
2479        assert_eq!(
2480            actual.tight.clock_bias_m.to_bits(),
2481            expected.tight.clock_bias_m.to_bits()
2482        );
2483        assert_eq!(
2484            actual.tight.clock_drift_m_s.to_bits(),
2485            expected.tight.clock_drift_m_s.to_bits()
2486        );
2487        for row in 0..actual.tight.augmented_covariance.len() {
2488            for col in 0..actual.tight.augmented_covariance[row].len() {
2489                assert_eq!(
2490                    actual.tight.augmented_covariance[row][col].to_bits(),
2491                    expected.tight.augmented_covariance[row][col].to_bits()
2492                );
2493            }
2494        }
2495    }
2496
2497    fn encode_legacy_without_stationarity(
2498        serial: &SerializableFusionState,
2499        version: u16,
2500    ) -> Result<Vec<u8>, FusionStateCodecError> {
2501        let mut bytes = Vec::new();
2502        bytes.extend_from_slice(&FUSION_STATE_MAGIC);
2503        write_u16(&mut bytes, version);
2504        write_layout(&mut bytes, serial.state.layout);
2505        write_nav(&mut bytes, &serial.state.nominal);
2506        write_f64_vec(&mut bytes, &serial.state.error_state)?;
2507        write_f64_matrix(&mut bytes, &serial.state.covariance)?;
2508        write_f64_array(&mut bytes, &serial.state.accel_scale_factor);
2509        write_f64_array(&mut bytes, &serial.state.gyro_scale_factor);
2510        write_f64_array(&mut bytes, &serial.last_body_rate_wrt_ecef_rps);
2511        write_f64(&mut bytes, serial.tight.clock_bias_m);
2512        write_f64(&mut bytes, serial.tight.clock_drift_m_s);
2513        write_f64_matrix(&mut bytes, &serial.tight.augmented_covariance)?;
2514        write_time_sync_history_legacy_without_stationarity(&mut bytes, &serial.time_sync)?;
2515        let checksum = fnv1a64(&bytes);
2516        write_u64(&mut bytes, checksum);
2517        Ok(bytes)
2518    }
2519
2520    fn write_time_sync_history_legacy_without_stationarity(
2521        bytes: &mut Vec<u8>,
2522        history: &SerializableTimeSyncHistory,
2523    ) -> Result<(), FusionStateCodecError> {
2524        write_u32_checked(bytes, history.config.imu_capacity as usize)?;
2525        write_u32_checked(bytes, history.config.checkpoint_capacity as usize)?;
2526        write_u32_checked(bytes, history.imu_samples.len())?;
2527        for sample in &history.imu_samples {
2528            write_stored_imu_sample(bytes, sample);
2529        }
2530        write_u32_checked(bytes, history.checkpoints.len())?;
2531        for checkpoint in &history.checkpoints {
2532            write_f64(bytes, checkpoint.t_j2000_s);
2533            write_fusion_snapshot_legacy_without_stationarity(bytes, checkpoint.snapshot.as_ref())?;
2534        }
2535        write_u32_checked(bytes, history.measurements.len())?;
2536        for measurement in &history.measurements {
2537            write_stored_measurement(bytes, measurement)?;
2538        }
2539        Ok(())
2540    }
2541
2542    fn write_fusion_snapshot_legacy_without_stationarity(
2543        bytes: &mut Vec<u8>,
2544        snapshot: &SerializableFusionSnapshot,
2545    ) -> Result<(), FusionStateCodecError> {
2546        write_layout(bytes, snapshot.state.layout);
2547        write_nav(bytes, &snapshot.state.nominal);
2548        write_f64_vec(bytes, &snapshot.state.error_state)?;
2549        write_f64_matrix(bytes, &snapshot.state.covariance)?;
2550        write_f64_array(bytes, &snapshot.state.accel_scale_factor);
2551        write_f64_array(bytes, &snapshot.state.gyro_scale_factor);
2552        write_f64_array(bytes, &snapshot.last_body_rate_wrt_ecef_rps);
2553        write_f64(bytes, snapshot.tight.clock_bias_m);
2554        write_f64(bytes, snapshot.tight.clock_drift_m_s);
2555        write_f64_matrix(bytes, &snapshot.tight.augmented_covariance)
2556    }
2557}