Skip to main content

sidereon_core/estimation/
track.rs

1//! Constant-velocity track filtering and fixed-interval RTS smoothing.
2//!
3//! This module is a no-IMU position-domain estimator. The state vector is
4//! `[position, velocity]` in a caller-selected Cartesian frame. Position-only
5//! updates use `H = [I, 0]`; position-and-velocity updates use `H = I`.
6//!
7//! Prediction uses the white-noise-acceleration constant-velocity model
8//!
9//! `F = [[I, dt I], [0, I]]`
10//!
11//! `Q = q [[dt^3 / 3 I, dt^2 / 2 I], [dt^2 / 2 I, dt I]]`
12//!
13//! where `q` is the acceleration variance spectral density in `m^2 / s^3`.
14//!
15//! Measurement correction uses the linear Kalman equations
16//!
17//! `nu = z - H x`
18//!
19//! `S = H P H' + R`
20//!
21//! `K = P H' S^-1`
22//!
23//! `x = x + K nu`
24//!
25//! `P = P - K H P`.
26//!
27//! The update methods also expose `nu`, `S`, `K`, and normalized innovation
28//! squared, `nu' S^-1 nu`, so callers can gate fixes before applying them.
29//!
30//! The backward smoother follows the Rauch-Tung-Striebel recursion:
31//!
32//! `G_k = P_k F_{k+1}' (P_{k+1|k})^-1`
33//!
34//! `x_k^s = x_k + G_k (x_{k+1}^s - x_{k+1|k})`
35//!
36//! `P_k^s = P_k + G_k (P_{k+1}^s - P_{k+1|k}) G_k'`.
37
38use crate::astro::math::linear::{solve_flat_normal_square_root_into, FlatCholeskySolveScratch};
39use crate::dop::PositionCovariance;
40use crate::estimation::primitives::{nis_gate_threshold, NisGate};
41use crate::validate::{self, FieldError};
42
43/// Cartesian frame used by a track filter.
44///
45/// The observation vector and observation covariance must be expressed in this
46/// same frame. The filter does not rotate between frames.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum TrackCoordinateFrame {
49    /// Earth-Centered-Earth-Fixed position in metres.
50    Ecef,
51    /// Local East-North-Up position in metres, using an origin managed by the caller.
52    Enu,
53    /// Any fixed Cartesian frame in metres, with axes defined by the caller.
54    CallerDefinedCartesian,
55}
56
57/// Error returned by no-IMU track filtering and smoothing.
58#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
59pub enum TrackError {
60    /// A public input was non-finite or outside its documented range.
61    #[error("invalid track input {field}: {reason}")]
62    InvalidInput {
63        /// Name of the invalid field.
64        field: &'static str,
65        /// Reason suitable for logs and tests.
66        reason: &'static str,
67    },
68    /// A vector or matrix dimension did not match the filter state.
69    #[error("invalid track dimension {field}: expected {expected}, got {actual}")]
70    DimensionMismatch {
71        /// Name of the invalid field.
72        field: &'static str,
73        /// Expected length, row count, or column count.
74        expected: usize,
75        /// Actual length, row count, or column count.
76        actual: usize,
77    },
78    /// A covariance was not positive semidefinite within numerical bounds.
79    #[error("track covariance {field} is not positive semidefinite")]
80    NonPositiveSemidefinite {
81        /// Name of the covariance field.
82        field: &'static str,
83    },
84    /// A covariance that must be inverted was not positive definite.
85    #[error("track covariance {field} is not positive definite")]
86    NonPositiveDefinite {
87        /// Name of the covariance field.
88        field: &'static str,
89    },
90}
91
92/// Configuration for [`TrackFilter`].
93///
94/// The state covariance is a `2n x 2n` row-major matrix over
95/// `[position_m, velocity_m_s]`. Its blocks have units `m^2`, `m^2/s`, and
96/// `m^2/s^2`.
97#[derive(Debug, Clone, PartialEq)]
98pub struct TrackFilterConfig {
99    /// Cartesian frame of the state, observations, and covariances.
100    pub frame: TrackCoordinateFrame,
101    /// Initial epoch in seconds in a caller-selected monotonic time base.
102    pub initial_t_s: f64,
103    /// Initial position components in metres.
104    pub initial_position_m: Vec<f64>,
105    /// Initial velocity components in metres per second.
106    pub initial_velocity_m_s: Vec<f64>,
107    /// Initial covariance over `[position, velocity]`.
108    pub initial_covariance: Vec<Vec<f64>>,
109    /// White acceleration variance spectral density in `m^2 / s^3`.
110    pub acceleration_variance_spectral_density_m2_s3: f64,
111}
112
113impl TrackFilterConfig {
114    /// Build a configuration from a position fix and uncertain initial velocity.
115    ///
116    /// This is the position-only entry point. The velocity starts at zero and
117    /// its diagonal variance is `initial_velocity_variance_m2_s2`.
118    pub fn from_position(
119        frame: TrackCoordinateFrame,
120        initial_t_s: f64,
121        initial_position_m: Vec<f64>,
122        position_covariance_m2: Vec<Vec<f64>>,
123        initial_velocity_variance_m2_s2: f64,
124        acceleration_variance_spectral_density_m2_s3: f64,
125    ) -> Result<Self, TrackError> {
126        let dimension = initial_position_m.len();
127        validate_dimension(dimension, "initial_position_m")?;
128        validate_vector_len(&initial_position_m, dimension, "initial_position_m")?;
129        validate_covariance_matrix(&position_covariance_m2, dimension, "position_covariance_m2")?;
130        validate_nonnegative(
131            initial_velocity_variance_m2_s2,
132            "initial_velocity_variance_m2_s2",
133        )?;
134
135        let mut covariance = vec![vec![0.0; 2 * dimension]; 2 * dimension];
136        for row in 0..dimension {
137            for col in 0..dimension {
138                covariance[row][col] = position_covariance_m2[row][col];
139            }
140            covariance[dimension + row][dimension + row] = initial_velocity_variance_m2_s2;
141        }
142
143        Ok(Self {
144            frame,
145            initial_t_s,
146            initial_position_m,
147            initial_velocity_m_s: vec![0.0; dimension],
148            initial_covariance: covariance,
149            acceleration_variance_spectral_density_m2_s3,
150        })
151    }
152
153    /// Build a configuration from a position-and-velocity state.
154    pub fn from_position_velocity(
155        frame: TrackCoordinateFrame,
156        initial_t_s: f64,
157        initial_position_m: Vec<f64>,
158        initial_velocity_m_s: Vec<f64>,
159        initial_covariance: Vec<Vec<f64>>,
160        acceleration_variance_spectral_density_m2_s3: f64,
161    ) -> Result<Self, TrackError> {
162        let config = Self {
163            frame,
164            initial_t_s,
165            initial_position_m,
166            initial_velocity_m_s,
167            initial_covariance,
168            acceleration_variance_spectral_density_m2_s3,
169        };
170        config.validate()?;
171        Ok(config)
172    }
173
174    /// Return the position dimension.
175    pub fn dimension(&self) -> usize {
176        self.initial_position_m.len()
177    }
178
179    /// Validate the configuration.
180    pub fn validate(&self) -> Result<(), TrackError> {
181        validate_time(self.initial_t_s, "initial_t_s")?;
182        validate_dimension(self.dimension(), "initial_position_m")?;
183        validate_vector_len(
184            &self.initial_position_m,
185            self.dimension(),
186            "initial_position_m",
187        )?;
188        validate_vector_len(
189            &self.initial_velocity_m_s,
190            self.dimension(),
191            "initial_velocity_m_s",
192        )?;
193        validate_covariance_matrix(
194            &self.initial_covariance,
195            2 * self.dimension(),
196            "initial_covariance",
197        )?;
198        validate_nonnegative(
199            self.acceleration_variance_spectral_density_m2_s3,
200            "acceleration_variance_spectral_density_m2_s3",
201        )
202    }
203}
204
205/// State of a no-IMU constant-velocity track filter.
206#[derive(Debug, Clone, PartialEq)]
207pub struct TrackState {
208    /// Cartesian frame of the state and covariance.
209    pub frame: TrackCoordinateFrame,
210    /// Epoch in seconds in the caller's monotonic time base.
211    pub t_s: f64,
212    /// Position components in metres.
213    pub position_m: Vec<f64>,
214    /// Velocity components in metres per second.
215    pub velocity_m_s: Vec<f64>,
216    /// Covariance over `[position, velocity]`.
217    pub covariance: Vec<Vec<f64>>,
218}
219
220impl TrackState {
221    /// Build and validate one track state.
222    pub fn new(
223        frame: TrackCoordinateFrame,
224        t_s: f64,
225        position_m: Vec<f64>,
226        velocity_m_s: Vec<f64>,
227        covariance: Vec<Vec<f64>>,
228    ) -> Result<Self, TrackError> {
229        let state = Self {
230            frame,
231            t_s,
232            position_m,
233            velocity_m_s,
234            covariance,
235        };
236        state.validate()?;
237        Ok(state)
238    }
239
240    /// Return the position dimension.
241    pub fn dimension(&self) -> usize {
242        self.position_m.len()
243    }
244
245    /// Return the full state dimension.
246    pub fn state_dimension(&self) -> usize {
247        2 * self.dimension()
248    }
249
250    /// Return `[position, velocity]` as an owned vector.
251    pub fn state_vector(&self) -> Vec<f64> {
252        state_vector(&self.position_m, &self.velocity_m_s)
253    }
254
255    /// Return the leading position covariance block.
256    pub fn position_covariance_m2(&self) -> Vec<Vec<f64>> {
257        let dimension = self.dimension();
258        self.covariance
259            .iter()
260            .take(dimension)
261            .map(|row| row[..dimension].to_vec())
262            .collect()
263    }
264
265    /// Return the 3D position covariance block.
266    pub fn position_covariance3_m2(&self) -> Result<[[f64; 3]; 3], TrackError> {
267        if self.dimension() != 3 {
268            return Err(TrackError::DimensionMismatch {
269                field: "position_covariance3_m2",
270                expected: 3,
271                actual: self.dimension(),
272            });
273        }
274        Ok(matrix3_from_rows(&self.position_covariance_m2()))
275    }
276
277    /// Return the 3D position vector.
278    pub fn position3_m(&self) -> Result<[f64; 3], TrackError> {
279        if self.dimension() != 3 {
280            return Err(TrackError::DimensionMismatch {
281                field: "position3_m",
282                expected: 3,
283                actual: self.dimension(),
284            });
285        }
286        Ok([self.position_m[0], self.position_m[1], self.position_m[2]])
287    }
288
289    /// Return the 3D velocity vector.
290    pub fn velocity3_m_s(&self) -> Result<[f64; 3], TrackError> {
291        if self.dimension() != 3 {
292            return Err(TrackError::DimensionMismatch {
293                field: "velocity3_m_s",
294                expected: 3,
295                actual: self.dimension(),
296            });
297        }
298        Ok([
299            self.velocity_m_s[0],
300            self.velocity_m_s[1],
301            self.velocity_m_s[2],
302        ])
303    }
304
305    /// Validate vector dimensions, finiteness, and covariance.
306    pub fn validate(&self) -> Result<(), TrackError> {
307        validate_time(self.t_s, "t_s")?;
308        validate_dimension(self.dimension(), "position_m")?;
309        validate_vector_len(&self.position_m, self.dimension(), "position_m")?;
310        validate_vector_len(&self.velocity_m_s, self.dimension(), "velocity_m_s")?;
311        validate_covariance_matrix(&self.covariance, self.state_dimension(), "covariance")
312    }
313}
314
315/// Prediction result returned by [`TrackFilter::predict`].
316#[derive(Debug, Clone, PartialEq)]
317pub struct TrackPrediction {
318    /// Time step in seconds.
319    pub dt_s: f64,
320    /// State-transition matrix used for the prediction.
321    pub transition: Vec<Vec<f64>>,
322    /// Discrete process-noise covariance added during prediction.
323    pub process_noise: Vec<Vec<f64>>,
324    /// Predicted state after the time step.
325    pub predicted: TrackState,
326}
327
328/// Innovation report for a pending or applied track update.
329#[derive(Debug, Clone, PartialEq)]
330pub struct TrackInnovation {
331    /// Measurement residual `z - H x`.
332    pub innovation: Vec<f64>,
333    /// Innovation covariance `S = H P H' + R`.
334    pub innovation_covariance: Vec<Vec<f64>>,
335    /// Normalized innovation squared, `innovation' S^-1 innovation`.
336    pub nis: f64,
337}
338
339impl TrackInnovation {
340    /// Evaluate the innovation against a chi-square NIS gate.
341    pub fn gate(&self, confidence: f64) -> Result<NisGate, TrackError> {
342        let threshold =
343            nis_gate_threshold(self.innovation.len(), confidence).map_err(|err| match err {
344                crate::estimation::PrimitiveError::InvalidInput { field, reason } => {
345                    invalid_input(field, reason)
346                }
347            })?;
348        Ok(NisGate {
349            nis: self.nis,
350            threshold,
351            in_gate: self.nis <= threshold,
352            dof: self.innovation.len(),
353        })
354    }
355}
356
357/// Update result returned after a covariance-weighted correction.
358#[derive(Debug, Clone, PartialEq)]
359pub struct TrackUpdate {
360    /// State before the correction.
361    pub predicted: TrackState,
362    /// State after the correction.
363    pub updated: TrackState,
364    /// Innovation report for the correction.
365    pub innovation: TrackInnovation,
366    /// Kalman gain used to map the innovation into the state.
367    pub kalman_gain: Vec<Vec<f64>>,
368}
369
370/// Gated update result.
371#[derive(Debug, Clone, PartialEq)]
372pub struct TrackGatedUpdate {
373    /// NIS gate result.
374    pub gate: NisGate,
375    /// Applied update when the measurement passed the gate.
376    pub update: Option<TrackUpdate>,
377    /// State after the gate decision.
378    pub state: TrackState,
379}
380
381/// No-IMU constant-velocity Kalman filter over a Cartesian position track.
382#[derive(Debug, Clone, PartialEq)]
383pub struct TrackFilter {
384    state: TrackState,
385    acceleration_variance_spectral_density_m2_s3: f64,
386}
387
388impl TrackFilter {
389    /// Build a filter from a validated configuration.
390    pub fn new(config: TrackFilterConfig) -> Result<Self, TrackError> {
391        config.validate()?;
392        let state = TrackState::new(
393            config.frame,
394            config.initial_t_s,
395            config.initial_position_m,
396            config.initial_velocity_m_s,
397            config.initial_covariance,
398        )?;
399        Ok(Self {
400            state,
401            acceleration_variance_spectral_density_m2_s3: config
402                .acceleration_variance_spectral_density_m2_s3,
403        })
404    }
405
406    /// Build a filter from a position fix and uncertain initial velocity.
407    pub fn from_position(
408        frame: TrackCoordinateFrame,
409        initial_t_s: f64,
410        initial_position_m: Vec<f64>,
411        position_covariance_m2: Vec<Vec<f64>>,
412        initial_velocity_variance_m2_s2: f64,
413        acceleration_variance_spectral_density_m2_s3: f64,
414    ) -> Result<Self, TrackError> {
415        Self::new(TrackFilterConfig::from_position(
416            frame,
417            initial_t_s,
418            initial_position_m,
419            position_covariance_m2,
420            initial_velocity_variance_m2_s2,
421            acceleration_variance_spectral_density_m2_s3,
422        )?)
423    }
424
425    /// Build a filter from a 3D position fix and uncertain initial velocity.
426    pub fn from_position3(
427        frame: TrackCoordinateFrame,
428        initial_t_s: f64,
429        initial_position_m: [f64; 3],
430        position_covariance_m2: [[f64; 3]; 3],
431        initial_velocity_variance_m2_s2: f64,
432        acceleration_variance_spectral_density_m2_s3: f64,
433    ) -> Result<Self, TrackError> {
434        Self::from_position(
435            frame,
436            initial_t_s,
437            initial_position_m.to_vec(),
438            matrix3_to_rows(position_covariance_m2),
439            initial_velocity_variance_m2_s2,
440            acceleration_variance_spectral_density_m2_s3,
441        )
442    }
443
444    /// Build a filter from a position-and-velocity state.
445    pub fn from_position_velocity(
446        frame: TrackCoordinateFrame,
447        initial_t_s: f64,
448        initial_position_m: Vec<f64>,
449        initial_velocity_m_s: Vec<f64>,
450        initial_covariance: Vec<Vec<f64>>,
451        acceleration_variance_spectral_density_m2_s3: f64,
452    ) -> Result<Self, TrackError> {
453        Self::new(TrackFilterConfig::from_position_velocity(
454            frame,
455            initial_t_s,
456            initial_position_m,
457            initial_velocity_m_s,
458            initial_covariance,
459            acceleration_variance_spectral_density_m2_s3,
460        )?)
461    }
462
463    /// Borrow the current state.
464    pub fn state(&self) -> &TrackState {
465        &self.state
466    }
467
468    /// Return the position dimension.
469    pub fn dimension(&self) -> usize {
470        self.state.dimension()
471    }
472
473    /// Return the white acceleration variance spectral density in `m^2 / s^3`.
474    pub fn acceleration_variance_spectral_density_m2_s3(&self) -> f64 {
475        self.acceleration_variance_spectral_density_m2_s3
476    }
477
478    /// Advance the state with the constant-velocity prediction model.
479    pub fn predict(&mut self, dt_s: f64) -> Result<TrackPrediction, TrackError> {
480        validate_positive(dt_s, "dt_s")?;
481        let dimension = self.dimension();
482        let transition = transition_matrix(dimension, dt_s);
483        let process_noise = process_noise_matrix(
484            dimension,
485            dt_s,
486            self.acceleration_variance_spectral_density_m2_s3,
487        );
488        let predicted_vector = matvec(&transition, &self.state.state_vector())?;
489        let transition_t = transpose(&transition)?;
490        let fp = matmul(&transition, &self.state.covariance)?;
491        let mut predicted_covariance = matrix_add(&matmul(&fp, &transition_t)?, &process_noise)?;
492        copy_lower_to_upper(&mut predicted_covariance);
493        validate_covariance_matrix(&predicted_covariance, 2 * dimension, "covariance")?;
494
495        self.state = TrackState::new(
496            self.state.frame,
497            self.state.t_s + dt_s,
498            predicted_vector[..dimension].to_vec(),
499            predicted_vector[dimension..].to_vec(),
500            predicted_covariance,
501        )?;
502
503        Ok(TrackPrediction {
504            dt_s,
505            transition,
506            process_noise,
507            predicted: self.state.clone(),
508        })
509    }
510
511    /// Advance the state and record the prediction for RTS smoothing.
512    pub fn predict_recorded(
513        &mut self,
514        dt_s: f64,
515        history: &mut TrackRtsHistoryBuilder,
516    ) -> Result<TrackPrediction, TrackError> {
517        let mut working_filter = self.clone();
518        let mut working_history = history.clone();
519        let prediction = working_filter.predict(dt_s)?;
520        working_history
521            .record_prediction(prediction.predicted.clone(), prediction.transition.clone())?;
522        *self = working_filter;
523        *history = working_history;
524        Ok(prediction)
525    }
526
527    /// Compute the innovation for a position-only observation without applying it.
528    pub fn position_innovation(
529        &self,
530        observation_position_m: &[f64],
531        observation_covariance_m2: &[Vec<f64>],
532    ) -> Result<TrackInnovation, TrackError> {
533        let dimension = self.dimension();
534        validate_vector_len(observation_position_m, dimension, "observation_position_m")?;
535        validate_covariance_matrix(
536            observation_covariance_m2,
537            dimension,
538            "observation_covariance_m2",
539        )?;
540        let innovation = observation_position_m
541            .iter()
542            .zip(&self.state.position_m)
543            .map(|(obs, pred)| obs - pred)
544            .collect::<Vec<_>>();
545        let predicted_position_covariance = self.state.position_covariance_m2();
546        let innovation_covariance =
547            matrix_add(&predicted_position_covariance, observation_covariance_m2)?;
548        validate_spd_matrix(&innovation_covariance, dimension, "innovation_covariance")?;
549        let nis = nis_from_innovation(&innovation, &innovation_covariance)?;
550        Ok(TrackInnovation {
551            innovation,
552            innovation_covariance,
553            nis,
554        })
555    }
556
557    /// Compute the innovation for a position-and-velocity observation without applying it.
558    pub fn state_innovation(
559        &self,
560        observation_state: &[f64],
561        observation_covariance: &[Vec<f64>],
562    ) -> Result<TrackInnovation, TrackError> {
563        let state_dimension = self.state.state_dimension();
564        validate_vector_len(observation_state, state_dimension, "observation_state")?;
565        validate_covariance_matrix(
566            observation_covariance,
567            state_dimension,
568            "observation_covariance",
569        )?;
570        let predicted = self.state.state_vector();
571        let innovation = observation_state
572            .iter()
573            .zip(predicted)
574            .map(|(obs, pred)| obs - pred)
575            .collect::<Vec<_>>();
576        let innovation_covariance = matrix_add(&self.state.covariance, observation_covariance)?;
577        validate_spd_matrix(
578            &innovation_covariance,
579            state_dimension,
580            "innovation_covariance",
581        )?;
582        let nis = nis_from_innovation(&innovation, &innovation_covariance)?;
583        Ok(TrackInnovation {
584            innovation,
585            innovation_covariance,
586            nis,
587        })
588    }
589
590    /// Apply a position-only observation with covariance weighting.
591    pub fn update_position(
592        &mut self,
593        observation_position_m: &[f64],
594        observation_covariance_m2: &[Vec<f64>],
595    ) -> Result<TrackUpdate, TrackError> {
596        let predicted = self.state.clone();
597        let update = self.position_update_from_predicted(
598            predicted,
599            observation_position_m,
600            observation_covariance_m2,
601        )?;
602        self.state = update.updated.clone();
603        Ok(update)
604    }
605
606    /// Apply a 3D position-only observation with covariance weighting.
607    pub fn update_position3(
608        &mut self,
609        observation_position_m: [f64; 3],
610        observation_covariance_m2: [[f64; 3]; 3],
611    ) -> Result<TrackUpdate, TrackError> {
612        self.update_position(
613            &observation_position_m,
614            &matrix3_to_rows(observation_covariance_m2),
615        )
616    }
617
618    /// Apply a 3D position observation using an existing GNSS position covariance.
619    ///
620    /// `TrackCoordinateFrame::Ecef` consumes `PositionCovariance::ecef_m2`.
621    /// `TrackCoordinateFrame::Enu` consumes `PositionCovariance::enu_m2`.
622    pub fn update_position_covariance(
623        &mut self,
624        observation_position_m: [f64; 3],
625        observation_covariance: &PositionCovariance,
626    ) -> Result<TrackUpdate, TrackError> {
627        let covariance = covariance_for_frame(self.state.frame, observation_covariance)?;
628        self.update_position3(observation_position_m, covariance)
629    }
630
631    /// Apply a position-and-velocity observation with covariance weighting.
632    pub fn update_state(
633        &mut self,
634        observation_state: &[f64],
635        observation_covariance: &[Vec<f64>],
636    ) -> Result<TrackUpdate, TrackError> {
637        let predicted = self.state.clone();
638        let update =
639            self.state_update_from_predicted(predicted, observation_state, observation_covariance)?;
640        self.state = update.updated.clone();
641        Ok(update)
642    }
643
644    /// Apply a gated position-only observation.
645    ///
646    /// If the NIS gate rejects the observation, the current predicted state is
647    /// left unchanged.
648    pub fn update_position_gated(
649        &mut self,
650        observation_position_m: &[f64],
651        observation_covariance_m2: &[Vec<f64>],
652        confidence: f64,
653    ) -> Result<TrackGatedUpdate, TrackError> {
654        let innovation =
655            self.position_innovation(observation_position_m, observation_covariance_m2)?;
656        let gate = innovation.gate(confidence)?;
657        if gate.in_gate {
658            let update = self.update_position(observation_position_m, observation_covariance_m2)?;
659            Ok(TrackGatedUpdate {
660                gate,
661                state: self.state.clone(),
662                update: Some(update),
663            })
664        } else {
665            Ok(TrackGatedUpdate {
666                gate,
667                state: self.state.clone(),
668                update: None,
669            })
670        }
671    }
672
673    /// Apply a position-only observation and record the epoch for RTS smoothing.
674    pub fn update_position_recorded(
675        &mut self,
676        observation_position_m: &[f64],
677        observation_covariance_m2: &[Vec<f64>],
678        history: &mut TrackRtsHistoryBuilder,
679    ) -> Result<TrackUpdate, TrackError> {
680        history.validate_update_ready()?;
681        let predicted = self.state.clone();
682        let mut working_filter = self.clone();
683        let mut working_history = history.clone();
684        let update =
685            working_filter.update_position(observation_position_m, observation_covariance_m2)?;
686        working_history.record_update(predicted, working_filter.state.clone())?;
687        *self = working_filter;
688        *history = working_history;
689        Ok(update)
690    }
691
692    /// Apply a gated position-only observation and record accepted or rejected epochs.
693    pub fn update_position_gated_recorded(
694        &mut self,
695        observation_position_m: &[f64],
696        observation_covariance_m2: &[Vec<f64>],
697        confidence: f64,
698        history: &mut TrackRtsHistoryBuilder,
699    ) -> Result<TrackGatedUpdate, TrackError> {
700        history.validate_update_ready()?;
701        let predicted = self.state.clone();
702        let mut working_filter = self.clone();
703        let mut working_history = history.clone();
704        let gated = working_filter.update_position_gated(
705            observation_position_m,
706            observation_covariance_m2,
707            confidence,
708        )?;
709        working_history.record_update(predicted, working_filter.state.clone())?;
710        *self = working_filter;
711        *history = working_history;
712        Ok(gated)
713    }
714
715    /// Record the current predicted state as an epoch with no measurement update.
716    pub fn record_prediction_only(
717        &self,
718        history: &mut TrackRtsHistoryBuilder,
719    ) -> Result<(), TrackError> {
720        history.record_update(self.state.clone(), self.state.clone())
721    }
722
723    fn position_update_from_predicted(
724        &self,
725        predicted: TrackState,
726        observation_position_m: &[f64],
727        observation_covariance_m2: &[Vec<f64>],
728    ) -> Result<TrackUpdate, TrackError> {
729        let dimension = predicted.dimension();
730        let innovation =
731            self.position_innovation(observation_position_m, observation_covariance_m2)?;
732        let cross = predicted
733            .covariance
734            .iter()
735            .map(|row| row[..dimension].to_vec())
736            .collect::<Vec<_>>();
737        let kalman_gain = gain_from_cross(&cross, &innovation.innovation_covariance)?;
738        let update_delta = matvec(&kalman_gain, &innovation.innovation)?;
739        let mut updated_vector = predicted.state_vector();
740        for (value, delta) in updated_vector.iter_mut().zip(update_delta) {
741            *value += delta;
742        }
743
744        let hp = predicted
745            .covariance
746            .iter()
747            .take(dimension)
748            .cloned()
749            .collect::<Vec<_>>();
750        let khp = matmul(&kalman_gain, &hp)?;
751        let mut covariance = matrix_sub(&predicted.covariance, &khp)?;
752        copy_lower_to_upper(&mut covariance);
753        validate_covariance_matrix(&covariance, 2 * dimension, "covariance")?;
754
755        let updated = TrackState::new(
756            predicted.frame,
757            predicted.t_s,
758            updated_vector[..dimension].to_vec(),
759            updated_vector[dimension..].to_vec(),
760            covariance,
761        )?;
762        Ok(TrackUpdate {
763            predicted,
764            updated,
765            innovation,
766            kalman_gain,
767        })
768    }
769
770    fn state_update_from_predicted(
771        &self,
772        predicted: TrackState,
773        observation_state: &[f64],
774        observation_covariance: &[Vec<f64>],
775    ) -> Result<TrackUpdate, TrackError> {
776        let state_dimension = predicted.state_dimension();
777        let innovation = self.state_innovation(observation_state, observation_covariance)?;
778        let kalman_gain =
779            gain_from_cross(&predicted.covariance, &innovation.innovation_covariance)?;
780        let update_delta = matvec(&kalman_gain, &innovation.innovation)?;
781        let mut updated_vector = predicted.state_vector();
782        for (value, delta) in updated_vector.iter_mut().zip(update_delta) {
783            *value += delta;
784        }
785
786        let khp = matmul(&kalman_gain, &predicted.covariance)?;
787        let mut covariance = matrix_sub(&predicted.covariance, &khp)?;
788        copy_lower_to_upper(&mut covariance);
789        validate_covariance_matrix(&covariance, state_dimension, "covariance")?;
790
791        let dimension = predicted.dimension();
792        let updated = TrackState::new(
793            predicted.frame,
794            predicted.t_s,
795            updated_vector[..dimension].to_vec(),
796            updated_vector[dimension..].to_vec(),
797            covariance,
798        )?;
799        Ok(TrackUpdate {
800            predicted,
801            updated,
802            innovation,
803            kalman_gain,
804        })
805    }
806}
807
808/// One epoch in a recorded forward pass for track RTS smoothing.
809#[derive(Debug, Clone, PartialEq)]
810pub struct TrackRtsEpoch {
811    /// Epoch in seconds in the caller's monotonic time base.
812    pub t_s: f64,
813    /// Predicted state before a correction at this epoch.
814    pub predicted: TrackState,
815    /// Updated state after the correction, or the same as `predicted` for no update.
816    pub updated: TrackState,
817    /// Transition from the previous updated epoch to this predicted epoch.
818    pub transition_from_previous: Option<Vec<Vec<f64>>>,
819}
820
821impl TrackRtsEpoch {
822    /// Build and validate one recorded epoch.
823    pub fn new(
824        predicted: TrackState,
825        updated: TrackState,
826        transition_from_previous: Option<Vec<Vec<f64>>>,
827    ) -> Result<Self, TrackError> {
828        let epoch = Self {
829            t_s: predicted.t_s,
830            predicted,
831            updated,
832            transition_from_previous,
833        };
834        epoch.validate()?;
835        Ok(epoch)
836    }
837
838    /// Validate state frames, dimensions, covariance, and transition dimensions.
839    pub fn validate(&self) -> Result<(), TrackError> {
840        self.predicted.validate()?;
841        self.updated.validate()?;
842        if self.predicted.frame != self.updated.frame {
843            return Err(invalid_input(
844                "frame",
845                "predicted and updated frames differ",
846            ));
847        }
848        if self.predicted.dimension() != self.updated.dimension() {
849            return Err(TrackError::DimensionMismatch {
850                field: "dimension",
851                expected: self.predicted.dimension(),
852                actual: self.updated.dimension(),
853            });
854        }
855        if self.predicted.t_s.to_bits() != self.t_s.to_bits()
856            || self.updated.t_s.to_bits() != self.t_s.to_bits()
857        {
858            return Err(invalid_input("t_s", "state epochs must match"));
859        }
860        if let Some(transition) = &self.transition_from_previous {
861            validate_square_matrix(transition, self.updated.state_dimension(), "transition")?;
862        }
863        Ok(())
864    }
865}
866
867/// Recorded history accepted by [`rts_smooth`].
868#[derive(Debug, Clone, PartialEq)]
869pub struct TrackRtsHistory {
870    /// Ordered epochs to smooth.
871    pub epochs: Vec<TrackRtsEpoch>,
872}
873
874impl TrackRtsHistory {
875    /// Build and validate an ordered track history.
876    pub fn new(epochs: Vec<TrackRtsEpoch>) -> Result<Self, TrackError> {
877        let history = Self { epochs };
878        history.validate()?;
879        Ok(history)
880    }
881
882    /// Validate epoch order, frame consistency, and transition placement.
883    pub fn validate(&self) -> Result<(), TrackError> {
884        if self.epochs.is_empty() {
885            return Err(invalid_input("history", "must not be empty"));
886        }
887        let frame = self.epochs[0].updated.frame;
888        let dimension = self.epochs[0].updated.dimension();
889        for (idx, epoch) in self.epochs.iter().enumerate() {
890            epoch.validate()?;
891            if epoch.updated.frame != frame {
892                return Err(invalid_input("frame", "history frames differ"));
893            }
894            if epoch.updated.dimension() != dimension {
895                return Err(TrackError::DimensionMismatch {
896                    field: "dimension",
897                    expected: dimension,
898                    actual: epoch.updated.dimension(),
899                });
900            }
901            match (idx, &epoch.transition_from_previous) {
902                (0, None) => {}
903                (0, Some(_)) => {
904                    return Err(invalid_input(
905                        "transition_from_previous",
906                        "first epoch must not have a transition",
907                    ));
908                }
909                (_, Some(transition)) => {
910                    validate_square_matrix(transition, 2 * dimension, "transition")?;
911                    if epoch.t_s <= self.epochs[idx - 1].t_s {
912                        return Err(invalid_input("history", "epochs must be increasing"));
913                    }
914                }
915                (_, None) => {
916                    return Err(invalid_input(
917                        "transition_from_previous",
918                        "missing transition",
919                    ));
920                }
921            }
922        }
923        Ok(())
924    }
925}
926
927/// Builder for recording a forward track-filter pass for RTS smoothing.
928#[derive(Debug, Clone, PartialEq)]
929pub struct TrackRtsHistoryBuilder {
930    epochs: Vec<TrackRtsEpoch>,
931    pending_transition: Option<Vec<Vec<f64>>>,
932    pending_predicted: Option<TrackState>,
933}
934
935impl TrackRtsHistoryBuilder {
936    /// Start a history from the filter's current state.
937    pub fn from_filter(filter: &TrackFilter) -> Result<Self, TrackError> {
938        let initial = TrackRtsEpoch::new(filter.state.clone(), filter.state.clone(), None)?;
939        Ok(Self {
940            epochs: vec![initial],
941            pending_transition: None,
942            pending_predicted: None,
943        })
944    }
945
946    /// Start an empty history for callers that record epochs manually.
947    pub const fn empty() -> Self {
948        Self {
949            epochs: Vec::new(),
950            pending_transition: None,
951            pending_predicted: None,
952        }
953    }
954
955    /// Record a prediction interval.
956    pub fn record_prediction(
957        &mut self,
958        predicted: TrackState,
959        transition: Vec<Vec<f64>>,
960    ) -> Result<(), TrackError> {
961        predicted.validate()?;
962        validate_square_matrix(&transition, predicted.state_dimension(), "transition")?;
963        let combined = if let Some(previous) = &self.pending_transition {
964            matmul(&transition, previous)?
965        } else {
966            transition
967        };
968        self.pending_transition = Some(combined);
969        self.pending_predicted = Some(predicted);
970        Ok(())
971    }
972
973    /// Append an update epoch, consuming the transition accumulated since the previous epoch.
974    pub fn record_update(
975        &mut self,
976        predicted: TrackState,
977        updated: TrackState,
978    ) -> Result<(), TrackError> {
979        let transition = if self.epochs.is_empty() {
980            None
981        } else {
982            Some(
983                self.pending_transition
984                    .clone()
985                    .ok_or_else(|| invalid_input("transition", "missing propagated interval"))?,
986            )
987        };
988        if let Some(pending) = &self.pending_predicted {
989            if pending.t_s.to_bits() != predicted.t_s.to_bits() {
990                return Err(invalid_input(
991                    "predicted",
992                    "does not match pending prediction",
993                ));
994            }
995            if pending.dimension() != predicted.dimension() || pending.frame != predicted.frame {
996                return Err(invalid_input("predicted", "does not match pending state"));
997            }
998        }
999        let epoch = TrackRtsEpoch::new(predicted, updated, transition)?;
1000        let mut epochs = self.epochs.clone();
1001        epochs.push(epoch);
1002        TrackRtsHistory {
1003            epochs: epochs.clone(),
1004        }
1005        .validate()?;
1006        self.epochs = epochs;
1007        self.pending_transition = None;
1008        self.pending_predicted = None;
1009        Ok(())
1010    }
1011
1012    /// Return a validated history.
1013    pub fn finish(self) -> Result<TrackRtsHistory, TrackError> {
1014        if self.pending_transition.is_some() {
1015            return Err(invalid_input("transition", "unclosed propagated interval"));
1016        }
1017        TrackRtsHistory::new(self.epochs)
1018    }
1019
1020    /// Validate that a recorded update can be appended.
1021    pub fn validate_update_ready(&self) -> Result<(), TrackError> {
1022        if self.epochs.is_empty() || self.pending_transition.is_some() {
1023            Ok(())
1024        } else {
1025            Err(invalid_input("transition", "missing propagated interval"))
1026        }
1027    }
1028}
1029
1030/// One epoch in a smoothed track.
1031#[derive(Debug, Clone, PartialEq)]
1032pub struct SmoothedTrackEpoch {
1033    /// Epoch in seconds in the caller's monotonic time base.
1034    pub t_s: f64,
1035    /// Smoothed state and covariance.
1036    pub state: TrackState,
1037    /// RTS gain from this epoch to the next, absent at the final epoch.
1038    pub rts_gain_to_next: Option<Vec<Vec<f64>>>,
1039}
1040
1041/// Smoothed track returned by [`rts_smooth`].
1042#[derive(Debug, Clone, PartialEq)]
1043pub struct SmoothedTrack {
1044    /// Smoothed epochs in the same order as the recorded history.
1045    pub epochs: Vec<SmoothedTrackEpoch>,
1046}
1047
1048/// Apply a fixed-interval RTS smoother to a recorded no-IMU track history.
1049pub fn rts_smooth(history: &TrackRtsHistory) -> Result<SmoothedTrack, TrackError> {
1050    history.validate()?;
1051    let len = history.epochs.len();
1052    let state_dimension = history.epochs[0].updated.state_dimension();
1053    let mut output: Vec<Option<SmoothedTrackEpoch>> = vec![None; len];
1054
1055    let final_epoch = &history.epochs[len - 1];
1056    output[len - 1] = Some(SmoothedTrackEpoch {
1057        t_s: final_epoch.t_s,
1058        state: final_epoch.updated.clone(),
1059        rts_gain_to_next: None,
1060    });
1061
1062    for idx in (0..len - 1).rev() {
1063        let current = &history.epochs[idx];
1064        let next = &history.epochs[idx + 1];
1065        let next_smoothed = output[idx + 1]
1066            .as_ref()
1067            .expect("next smoothed epoch is populated");
1068        let transition = next
1069            .transition_from_previous
1070            .as_ref()
1071            .ok_or_else(|| invalid_input("transition_from_previous", "missing transition"))?;
1072        let gain = rts_gain(
1073            &current.updated.covariance,
1074            transition,
1075            &next.predicted.covariance,
1076        )?;
1077        let next_delta = vector_sub(
1078            &next_smoothed.state.state_vector(),
1079            &next.predicted.state_vector(),
1080            "state_delta",
1081        )?;
1082        let correction = matvec(&gain, &next_delta)?;
1083        let mut smoothed_vector = current.updated.state_vector();
1084        for (value, delta) in smoothed_vector.iter_mut().zip(correction) {
1085            *value += delta;
1086        }
1087
1088        let mut covariance = smoothed_covariance(
1089            &current.updated.covariance,
1090            &gain,
1091            &next.predicted.covariance,
1092            &next_smoothed.state.covariance,
1093        )?;
1094        copy_lower_to_upper(&mut covariance);
1095        validate_covariance_matrix(&covariance, state_dimension, "smoothed_covariance")?;
1096
1097        let dimension = current.updated.dimension();
1098        let state = TrackState::new(
1099            current.updated.frame,
1100            current.t_s,
1101            smoothed_vector[..dimension].to_vec(),
1102            smoothed_vector[dimension..].to_vec(),
1103            covariance,
1104        )?;
1105        output[idx] = Some(SmoothedTrackEpoch {
1106            t_s: current.t_s,
1107            state,
1108            rts_gain_to_next: Some(gain),
1109        });
1110    }
1111
1112    let epochs = output
1113        .into_iter()
1114        .map(|epoch| epoch.expect("all smoothed epochs populated"))
1115        .collect::<Vec<_>>();
1116    Ok(SmoothedTrack { epochs })
1117}
1118
1119/// Alias for [`rts_smooth`] with the track domain in the function name.
1120pub fn smooth_track_rts(history: &TrackRtsHistory) -> Result<SmoothedTrack, TrackError> {
1121    rts_smooth(history)
1122}
1123
1124fn covariance_for_frame(
1125    frame: TrackCoordinateFrame,
1126    covariance: &PositionCovariance,
1127) -> Result<[[f64; 3]; 3], TrackError> {
1128    match frame {
1129        TrackCoordinateFrame::Ecef => Ok(covariance.ecef_m2),
1130        TrackCoordinateFrame::Enu => Ok(covariance.enu_m2),
1131        TrackCoordinateFrame::CallerDefinedCartesian => Err(invalid_input(
1132            "frame",
1133            "PositionCovariance requires ECEF or ENU",
1134        )),
1135    }
1136}
1137
1138fn rts_gain(
1139    filtered_covariance: &[Vec<f64>],
1140    transition: &[Vec<f64>],
1141    predicted_covariance_next: &[Vec<f64>],
1142) -> Result<Vec<Vec<f64>>, TrackError> {
1143    let dimension = filtered_covariance.len();
1144    validate_covariance_matrix(filtered_covariance, dimension, "filtered_covariance")?;
1145    validate_square_matrix(transition, dimension, "transition")?;
1146    validate_spd_matrix(
1147        predicted_covariance_next,
1148        dimension,
1149        "predicted_covariance_next",
1150    )?;
1151    let transition_t = transpose(transition)?;
1152    let cross = matmul(filtered_covariance, &transition_t)?;
1153    gain_from_cross(&cross, predicted_covariance_next)
1154}
1155
1156fn smoothed_covariance(
1157    filtered_covariance: &[Vec<f64>],
1158    gain: &[Vec<f64>],
1159    predicted_covariance_next: &[Vec<f64>],
1160    smoothed_covariance_next: &[Vec<f64>],
1161) -> Result<Vec<Vec<f64>>, TrackError> {
1162    let dimension = filtered_covariance.len();
1163    validate_covariance_matrix(filtered_covariance, dimension, "filtered_covariance")?;
1164    validate_square_matrix(gain, dimension, "rts_gain")?;
1165    validate_covariance_matrix(
1166        predicted_covariance_next,
1167        dimension,
1168        "predicted_covariance_next",
1169    )?;
1170    validate_covariance_matrix(
1171        smoothed_covariance_next,
1172        dimension,
1173        "smoothed_covariance_next",
1174    )?;
1175    let delta = matrix_sub(smoothed_covariance_next, predicted_covariance_next)?;
1176    let left = matmul(gain, &delta)?;
1177    let gain_t = transpose(gain)?;
1178    let adjustment = matmul(&left, &gain_t)?;
1179    matrix_add(filtered_covariance, &adjustment)
1180}
1181
1182fn gain_from_cross(
1183    cross: &[Vec<f64>],
1184    innovation_covariance: &[Vec<f64>],
1185) -> Result<Vec<Vec<f64>>, TrackError> {
1186    let measurement_dimension = innovation_covariance.len();
1187    validate_spd_matrix(
1188        innovation_covariance,
1189        measurement_dimension,
1190        "innovation_covariance",
1191    )?;
1192    if cross.is_empty() {
1193        return Err(invalid_input("cross_covariance", "must not be empty"));
1194    }
1195    for row in cross {
1196        validate_vector_len(row, measurement_dimension, "cross_covariance")?;
1197    }
1198
1199    if measurement_dimension == 1 {
1200        let variance = innovation_covariance[0][0];
1201        return Ok(cross
1202            .iter()
1203            .map(|row| vec![row[0] / variance])
1204            .collect::<Vec<_>>());
1205    }
1206
1207    let mut scratch = FlatCholeskySolveScratch::default();
1208    let flat = flatten(innovation_covariance)?;
1209    let mut gain = Vec::with_capacity(cross.len());
1210    for row in cross {
1211        let solved = solve_flat_normal_square_root_into(&flat, row, &mut scratch)
1212            .ok_or(TrackError::NonPositiveDefinite {
1213                field: "innovation_covariance",
1214            })?
1215            .to_vec();
1216        gain.push(solved);
1217    }
1218    Ok(gain)
1219}
1220
1221fn nis_from_innovation(
1222    innovation: &[f64],
1223    innovation_covariance: &[Vec<f64>],
1224) -> Result<f64, TrackError> {
1225    if innovation.len() == 1
1226        && innovation_covariance.len() == 1
1227        && innovation_covariance[0].len() == 1
1228    {
1229        let variance = innovation_covariance[0][0];
1230        validate_positive(variance, "innovation_covariance")?;
1231        let nis = innovation[0] * innovation[0] / variance;
1232        validate_time(nis, "nis")?;
1233        return Ok(nis);
1234    }
1235
1236    let mut scratch = FlatCholeskySolveScratch::default();
1237    let flat = flatten(innovation_covariance)?;
1238    let solved = solve_flat_normal_square_root_into(&flat, innovation, &mut scratch).ok_or(
1239        TrackError::NonPositiveDefinite {
1240            field: "innovation_covariance",
1241        },
1242    )?;
1243    let mut nis = 0.0;
1244    for (lhs, rhs) in innovation.iter().zip(solved) {
1245        nis += lhs * rhs;
1246    }
1247    validate_time(nis, "nis")?;
1248    Ok(nis)
1249}
1250
1251fn transition_matrix(dimension: usize, dt_s: f64) -> Vec<Vec<f64>> {
1252    let state_dimension = 2 * dimension;
1253    let mut transition = vec![vec![0.0; state_dimension]; state_dimension];
1254    for axis in 0..dimension {
1255        transition[axis][axis] = 1.0;
1256        transition[axis][dimension + axis] = dt_s;
1257        transition[dimension + axis][dimension + axis] = 1.0;
1258    }
1259    transition
1260}
1261
1262fn process_noise_matrix(
1263    dimension: usize,
1264    dt_s: f64,
1265    acceleration_variance_spectral_density_m2_s3: f64,
1266) -> Vec<Vec<f64>> {
1267    let state_dimension = 2 * dimension;
1268    let mut process_noise = vec![vec![0.0; state_dimension]; state_dimension];
1269    let q00 = acceleration_variance_spectral_density_m2_s3 * dt_s * dt_s * dt_s / 3.0;
1270    let q01 = acceleration_variance_spectral_density_m2_s3 * dt_s * dt_s / 2.0;
1271    let q11 = acceleration_variance_spectral_density_m2_s3 * dt_s;
1272    for axis in 0..dimension {
1273        process_noise[axis][axis] = q00;
1274        process_noise[axis][dimension + axis] = q01;
1275        process_noise[dimension + axis][axis] = q01;
1276        process_noise[dimension + axis][dimension + axis] = q11;
1277    }
1278    process_noise
1279}
1280
1281fn state_vector(position_m: &[f64], velocity_m_s: &[f64]) -> Vec<f64> {
1282    let mut state = Vec::with_capacity(position_m.len() + velocity_m_s.len());
1283    state.extend(position_m);
1284    state.extend(velocity_m_s);
1285    state
1286}
1287
1288fn matrix3_to_rows(matrix: [[f64; 3]; 3]) -> Vec<Vec<f64>> {
1289    matrix.iter().map(|row| row.to_vec()).collect()
1290}
1291
1292fn matrix3_from_rows(rows: &[Vec<f64>]) -> [[f64; 3]; 3] {
1293    [
1294        [rows[0][0], rows[0][1], rows[0][2]],
1295        [rows[1][0], rows[1][1], rows[1][2]],
1296        [rows[2][0], rows[2][1], rows[2][2]],
1297    ]
1298}
1299
1300fn validate_time(value: f64, field: &'static str) -> Result<(), TrackError> {
1301    if value.is_finite() {
1302        Ok(())
1303    } else {
1304        Err(invalid_input(field, "not finite"))
1305    }
1306}
1307
1308fn validate_positive(value: f64, field: &'static str) -> Result<(), TrackError> {
1309    validate_time(value, field)?;
1310    if value > 0.0 {
1311        Ok(())
1312    } else {
1313        Err(invalid_input(field, "must be positive"))
1314    }
1315}
1316
1317fn validate_nonnegative(value: f64, field: &'static str) -> Result<(), TrackError> {
1318    validate_time(value, field)?;
1319    if value >= 0.0 {
1320        Ok(())
1321    } else {
1322        Err(invalid_input(field, "must be non-negative"))
1323    }
1324}
1325
1326fn validate_dimension(dimension: usize, field: &'static str) -> Result<(), TrackError> {
1327    if dimension > 0 {
1328        Ok(())
1329    } else {
1330        Err(invalid_input(field, "dimension must be positive"))
1331    }
1332}
1333
1334fn validate_vector_len(
1335    values: &[f64],
1336    expected: usize,
1337    field: &'static str,
1338) -> Result<(), TrackError> {
1339    if values.len() != expected {
1340        return Err(TrackError::DimensionMismatch {
1341            field,
1342            expected,
1343            actual: values.len(),
1344        });
1345    }
1346    validate::finite_slice(values, field).map_err(map_field_error)
1347}
1348
1349fn validate_square_matrix(
1350    matrix: &[Vec<f64>],
1351    dimension: usize,
1352    field: &'static str,
1353) -> Result<(), TrackError> {
1354    if matrix.len() != dimension {
1355        return Err(TrackError::DimensionMismatch {
1356            field,
1357            expected: dimension,
1358            actual: matrix.len(),
1359        });
1360    }
1361    for row in matrix {
1362        if row.len() != dimension {
1363            return Err(TrackError::DimensionMismatch {
1364                field,
1365                expected: dimension,
1366                actual: row.len(),
1367            });
1368        }
1369        validate::finite_slice(row, field).map_err(map_field_error)?;
1370    }
1371    Ok(())
1372}
1373
1374fn validate_covariance_matrix(
1375    matrix: &[Vec<f64>],
1376    dimension: usize,
1377    field: &'static str,
1378) -> Result<(), TrackError> {
1379    validate_square_matrix(matrix, dimension, field)?;
1380    let rows = matrix.iter().map(Vec::as_slice).collect::<Vec<_>>();
1381    validate::validate_covariance_psd_rows(&rows, field)
1382        .map_err(|_| TrackError::NonPositiveSemidefinite { field })
1383}
1384
1385fn validate_spd_matrix(
1386    matrix: &[Vec<f64>],
1387    dimension: usize,
1388    field: &'static str,
1389) -> Result<(), TrackError> {
1390    validate_covariance_matrix(matrix, dimension, field)?;
1391    let flat = flatten(matrix)?;
1392    let mut scratch = FlatCholeskySolveScratch::default();
1393    let rhs = vec![0.0; dimension];
1394    solve_flat_normal_square_root_into(&flat, &rhs, &mut scratch)
1395        .map(|_| ())
1396        .ok_or(TrackError::NonPositiveDefinite { field })
1397}
1398
1399fn map_field_error(error: FieldError) -> TrackError {
1400    invalid_input(error.field(), error.reason())
1401}
1402
1403fn invalid_input(field: &'static str, reason: &'static str) -> TrackError {
1404    TrackError::InvalidInput { field, reason }
1405}
1406
1407fn transpose(matrix: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, TrackError> {
1408    if matrix.is_empty() {
1409        return Err(invalid_input("matrix", "must not be empty"));
1410    }
1411    let rows = matrix.len();
1412    let cols = matrix[0].len();
1413    if cols == 0 {
1414        return Err(invalid_input("matrix", "must not be empty"));
1415    }
1416    for row in matrix {
1417        validate_vector_len(row, cols, "matrix")?;
1418    }
1419    let mut out = vec![vec![0.0; rows]; cols];
1420    for row in 0..rows {
1421        for col in 0..cols {
1422            out[col][row] = matrix[row][col];
1423        }
1424    }
1425    Ok(out)
1426}
1427
1428fn matvec(matrix: &[Vec<f64>], vector: &[f64]) -> Result<Vec<f64>, TrackError> {
1429    if matrix.is_empty() {
1430        return Err(invalid_input("matrix", "must not be empty"));
1431    }
1432    let cols = vector.len();
1433    if cols == 0 {
1434        return Err(invalid_input("vector", "must not be empty"));
1435    }
1436    for row in matrix {
1437        validate_vector_len(row, cols, "matrix")?;
1438    }
1439    validate_vector_len(vector, cols, "vector")?;
1440    let mut out = vec![0.0; matrix.len()];
1441    for row in 0..matrix.len() {
1442        for (col, value) in vector.iter().enumerate() {
1443            out[row] += matrix[row][col] * value;
1444        }
1445    }
1446    validate_vector_len(&out, matrix.len(), "matrix_vector_product")?;
1447    Ok(out)
1448}
1449
1450fn matmul(a: &[Vec<f64>], b: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, TrackError> {
1451    if a.is_empty() || b.is_empty() {
1452        return Err(invalid_input("matrix", "must not be empty"));
1453    }
1454    let inner = a[0].len();
1455    if inner == 0 {
1456        return Err(invalid_input("matrix", "must not be empty"));
1457    }
1458    for row in a {
1459        validate_vector_len(row, inner, "matrix_a")?;
1460    }
1461    if b.len() != inner {
1462        return Err(TrackError::DimensionMismatch {
1463            field: "matrix_b",
1464            expected: inner,
1465            actual: b.len(),
1466        });
1467    }
1468    let cols = b[0].len();
1469    if cols == 0 {
1470        return Err(invalid_input("matrix_b", "must not be empty"));
1471    }
1472    for row in b {
1473        validate_vector_len(row, cols, "matrix_b")?;
1474    }
1475    let mut out = vec![vec![0.0; cols]; a.len()];
1476    for row in 0..a.len() {
1477        for col in 0..cols {
1478            for k in 0..inner {
1479                out[row][col] += a[row][k] * b[k][col];
1480            }
1481        }
1482    }
1483    for row in &out {
1484        validate_vector_len(row, cols, "matrix_product")?;
1485    }
1486    Ok(out)
1487}
1488
1489fn matrix_add(a: &[Vec<f64>], b: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, TrackError> {
1490    validate_same_matrix_size(a, b, "matrix_add")?;
1491    let mut out = vec![vec![0.0; a[0].len()]; a.len()];
1492    for row in 0..a.len() {
1493        for col in 0..a[0].len() {
1494            out[row][col] = a[row][col] + b[row][col];
1495        }
1496    }
1497    Ok(out)
1498}
1499
1500fn matrix_sub(a: &[Vec<f64>], b: &[Vec<f64>]) -> Result<Vec<Vec<f64>>, TrackError> {
1501    validate_same_matrix_size(a, b, "matrix_sub")?;
1502    let mut out = vec![vec![0.0; a[0].len()]; a.len()];
1503    for row in 0..a.len() {
1504        for col in 0..a[0].len() {
1505            out[row][col] = a[row][col] - b[row][col];
1506        }
1507    }
1508    Ok(out)
1509}
1510
1511fn vector_sub(a: &[f64], b: &[f64], field: &'static str) -> Result<Vec<f64>, TrackError> {
1512    if a.len() != b.len() {
1513        return Err(TrackError::DimensionMismatch {
1514            field,
1515            expected: a.len(),
1516            actual: b.len(),
1517        });
1518    }
1519    validate_vector_len(a, a.len(), field)?;
1520    validate_vector_len(b, b.len(), field)?;
1521    let out = a
1522        .iter()
1523        .zip(b)
1524        .map(|(lhs, rhs)| lhs - rhs)
1525        .collect::<Vec<_>>();
1526    validate_vector_len(&out, a.len(), field)?;
1527    Ok(out)
1528}
1529
1530fn validate_same_matrix_size(
1531    a: &[Vec<f64>],
1532    b: &[Vec<f64>],
1533    field: &'static str,
1534) -> Result<(), TrackError> {
1535    if a.is_empty() || b.is_empty() {
1536        return Err(invalid_input(field, "must not be empty"));
1537    }
1538    if a.len() != b.len() {
1539        return Err(TrackError::DimensionMismatch {
1540            field,
1541            expected: a.len(),
1542            actual: b.len(),
1543        });
1544    }
1545    let cols = a[0].len();
1546    if cols == 0 {
1547        return Err(invalid_input(field, "must not be empty"));
1548    }
1549    for row in a {
1550        validate_vector_len(row, cols, field)?;
1551    }
1552    for row in b {
1553        validate_vector_len(row, cols, field)?;
1554    }
1555    Ok(())
1556}
1557
1558fn flatten(matrix: &[Vec<f64>]) -> Result<Vec<f64>, TrackError> {
1559    if matrix.is_empty() {
1560        return Err(invalid_input("matrix", "must not be empty"));
1561    }
1562    let cols = matrix[0].len();
1563    for row in matrix {
1564        validate_vector_len(row, cols, "matrix")?;
1565    }
1566    let mut out = Vec::with_capacity(matrix.len() * cols);
1567    for row in matrix {
1568        out.extend(row);
1569    }
1570    Ok(out)
1571}
1572
1573fn copy_lower_to_upper(matrix: &mut [Vec<f64>]) {
1574    let dimension = matrix.len();
1575    for row in 0..dimension {
1576        let (head, tail) = matrix.split_at_mut(row + 1);
1577        let row_values = &mut head[row];
1578        for (offset, lower_row) in tail.iter().enumerate() {
1579            let col = row + 1 + offset;
1580            row_values[col] = lower_row[row];
1581        }
1582    }
1583}
1584
1585#[cfg(test)]
1586mod tests {
1587    //! Analytic oracles for no-IMU track filtering and smoothing.
1588    //!
1589    //! The scalar parity checks use the Bar-Shalom constant-velocity Kalman
1590    //! fixed point already exposed by the scalar estimation primitives. The
1591    //! covariance-weighting and RTS checks use closed-form Kalman and
1592    //! Rauch-Tung-Striebel equations evaluated in the test, not serialized
1593    //! fixtures or version-stamped outputs.
1594
1595    use super::*;
1596    use crate::astro::math::linear::invert_symmetric_pd;
1597    use crate::estimation::kalman_cv_steady_state_gains;
1598
1599    const TOL: f64 = 1.0e-12;
1600
1601    #[test]
1602    fn scalar_one_step_matches_direct_cv_recurrence_bits() {
1603        let dt_s: f64 = 0.75;
1604        let q: f64 = 0.125;
1605        let measurement_variance: f64 = 0.8;
1606        let p00: f64 = 3.0;
1607        let p01: f64 = 0.25;
1608        let p11: f64 = 2.0;
1609        let x0: f64 = 4.0;
1610        let v0: f64 = -0.5;
1611        let observation_delta: f64 = 1.3;
1612        let mut filter = TrackFilter::from_position_velocity(
1613            TrackCoordinateFrame::CallerDefinedCartesian,
1614            0.0,
1615            vec![x0],
1616            vec![v0],
1617            vec![vec![p00, p01], vec![p01, p11]],
1618            q,
1619        )
1620        .unwrap();
1621
1622        let q00 = q * dt_s * dt_s * dt_s / 3.0;
1623        let q01 = q * dt_s * dt_s / 2.0;
1624        let q11 = q * dt_s;
1625        let predicted_position = x0 + dt_s * v0;
1626        let p00_pred = p00 + 2.0 * dt_s * p01 + dt_s * dt_s * p11 + q00;
1627        let p01_pred = p01 + dt_s * p11 + q01;
1628        let p11_pred = p11 + q11;
1629        let s = p00_pred + measurement_variance;
1630        let k0 = p00_pred / s;
1631        let k1 = p01_pred / s;
1632
1633        let observation = predicted_position + observation_delta;
1634        let innovation = observation - predicted_position;
1635
1636        filter.predict(dt_s).unwrap();
1637        let update = filter
1638            .update_position(&[observation], &[vec![measurement_variance]])
1639            .unwrap();
1640
1641        assert_eq!(update.kalman_gain[0][0].to_bits(), k0.to_bits());
1642        assert_eq!(update.kalman_gain[1][0].to_bits(), k1.to_bits());
1643        assert_eq!(
1644            update.updated.position_m[0].to_bits(),
1645            (predicted_position + k0 * innovation).to_bits()
1646        );
1647        assert_eq!(
1648            update.updated.velocity_m_s[0].to_bits(),
1649            (v0 + k1 * innovation).to_bits()
1650        );
1651        assert_eq!(
1652            update.updated.covariance[0][0].to_bits(),
1653            (p00_pred - k0 * p00_pred).to_bits()
1654        );
1655        assert_eq!(
1656            update.updated.covariance[0][1].to_bits(),
1657            (p01_pred - k1 * p00_pred).to_bits()
1658        );
1659        assert_eq!(
1660            update.updated.covariance[1][0].to_bits(),
1661            (p01_pred - k1 * p00_pred).to_bits()
1662        );
1663        assert_eq!(
1664            update.updated.covariance[1][1].to_bits(),
1665            (p11_pred - k1 * p01_pred).to_bits()
1666        );
1667    }
1668
1669    #[test]
1670    fn scalar_cv_gains_match_estimation_primitives() {
1671        let tracking_index: f64 = 4.0;
1672        let dt_s: f64 = 1.0;
1673        let measurement_variance: f64 = 1.0;
1674        let q = tracking_index * measurement_variance / dt_s.powi(3);
1675        let expected =
1676            kalman_cv_steady_state_gains(tracking_index, dt_s, measurement_variance).unwrap();
1677        let mut filter = TrackFilter::from_position_velocity(
1678            TrackCoordinateFrame::CallerDefinedCartesian,
1679            0.0,
1680            vec![0.0],
1681            vec![0.0],
1682            vec![
1683                vec![measurement_variance, 0.0],
1684                vec![0.0, measurement_variance],
1685            ],
1686            q,
1687        )
1688        .unwrap();
1689        let observation_covariance = vec![vec![measurement_variance]];
1690        let mut last_gain = Vec::new();
1691        for _ in 0..5_000 {
1692            filter.predict(dt_s).unwrap();
1693            let update = filter
1694                .update_position(&[0.0], &observation_covariance)
1695                .unwrap();
1696            last_gain = update.kalman_gain;
1697        }
1698        filter.predict(dt_s).unwrap();
1699        let update = filter
1700            .update_position(&[0.0], &observation_covariance)
1701            .unwrap();
1702        assert!(
1703            (last_gain[0][0] - update.kalman_gain[0][0]).abs() <= TOL,
1704            "position gain did not settle"
1705        );
1706        assert!(
1707            (last_gain[1][0] - update.kalman_gain[1][0]).abs() <= TOL,
1708            "velocity gain did not settle"
1709        );
1710        assert!(
1711            (update.kalman_gain[0][0] - expected.position_gain).abs() <= TOL,
1712            "position gain mismatch: got {}, expected {}",
1713            update.kalman_gain[0][0],
1714            expected.position_gain
1715        );
1716        assert!(
1717            (update.kalman_gain[1][0] - expected.rate_gain).abs() <= TOL,
1718            "velocity gain mismatch: got {}, expected {}",
1719            update.kalman_gain[1][0],
1720            expected.rate_gain
1721        );
1722    }
1723
1724    #[test]
1725    fn covariance_weighting_limits_wide_outlier_motion() {
1726        let mut filter = TrackFilter::from_position_velocity(
1727            TrackCoordinateFrame::CallerDefinedCartesian,
1728            0.0,
1729            vec![10.0],
1730            vec![1.0],
1731            vec![vec![4.0, 0.0], vec![0.0, 1.0]],
1732            0.0,
1733        )
1734        .unwrap();
1735        filter.predict(1.0).unwrap();
1736        let predicted_position = filter.state().position_m[0];
1737        let predicted_covariance = filter.state().covariance.clone();
1738        let wide_variance = 1.0e6;
1739        let outlier = predicted_position + 500.0;
1740        let update = filter
1741            .update_position(&[outlier], &[vec![wide_variance]])
1742            .unwrap();
1743
1744        let s = predicted_covariance[0][0] + wide_variance;
1745        let expected_position_gain = predicted_covariance[0][0] / s;
1746        let expected_velocity_gain = predicted_covariance[1][0] / s;
1747        let innovation = outlier - predicted_position;
1748        assert_eq!(update.innovation.innovation, vec![innovation]);
1749        assert!(
1750            (update.kalman_gain[0][0] - expected_position_gain).abs() <= TOL,
1751            "position gain mismatch"
1752        );
1753        assert!(
1754            (update.kalman_gain[1][0] - expected_velocity_gain).abs() <= TOL,
1755            "velocity gain mismatch"
1756        );
1757        assert!(
1758            (update.updated.position_m[0]
1759                - (predicted_position + expected_position_gain * innovation))
1760                .abs()
1761                <= TOL,
1762            "position update mismatch"
1763        );
1764        assert!(
1765            (update.updated.position_m[0] - predicted_position).abs() < 0.01,
1766            "wide covariance outlier moved the track too far"
1767        );
1768    }
1769
1770    #[test]
1771    fn tight_covariance_observation_pulls_track_more_than_wide_one() {
1772        let base = TrackFilter::from_position_velocity(
1773            TrackCoordinateFrame::CallerDefinedCartesian,
1774            0.0,
1775            vec![0.0],
1776            vec![0.0],
1777            vec![vec![9.0, 0.0], vec![0.0, 1.0]],
1778            0.0,
1779        )
1780        .unwrap();
1781        let mut wide = base.clone();
1782        let mut tight = base;
1783        let wide_update = wide.update_position(&[10.0], &[vec![10_000.0]]).unwrap();
1784        let tight_update = tight.update_position(&[10.0], &[vec![1.0]]).unwrap();
1785
1786        assert!(wide_update.kalman_gain[0][0] < 0.001);
1787        assert!(tight_update.kalman_gain[0][0] > 0.8);
1788        assert!(wide_update.updated.position_m[0] < 0.01);
1789        assert!(tight_update.updated.position_m[0] > 8.0);
1790    }
1791
1792    #[test]
1793    fn rts_smoother_matches_closed_form_two_epoch_recursion() {
1794        let mut filter = TrackFilter::from_position_velocity(
1795            TrackCoordinateFrame::CallerDefinedCartesian,
1796            0.0,
1797            vec![0.0],
1798            vec![1.0],
1799            vec![vec![2.0, 0.2], vec![0.2, 1.0]],
1800            0.5,
1801        )
1802        .unwrap();
1803        let mut history = TrackRtsHistoryBuilder::from_filter(&filter).unwrap();
1804        filter.predict_recorded(1.0, &mut history).unwrap();
1805        filter
1806            .update_position_recorded(&[1.2], &[vec![0.25]], &mut history)
1807            .unwrap();
1808        let history = history.finish().unwrap();
1809        let smoothed = rts_smooth(&history).unwrap();
1810
1811        let first = &history.epochs[0];
1812        let second = &history.epochs[1];
1813        let transition = second.transition_from_previous.as_ref().unwrap();
1814        let expected_gain = closed_form_rts_gain(
1815            &first.updated.covariance,
1816            transition,
1817            &second.predicted.covariance,
1818        );
1819        let expected_delta = vector_sub(
1820            &second.updated.state_vector(),
1821            &second.predicted.state_vector(),
1822            "delta",
1823        )
1824        .unwrap();
1825        let expected_correction = matvec(&expected_gain, &expected_delta).unwrap();
1826        let expected_state = first
1827            .updated
1828            .state_vector()
1829            .iter()
1830            .zip(expected_correction)
1831            .map(|(value, correction)| value + correction)
1832            .collect::<Vec<_>>();
1833        let expected_covariance = smoothed_covariance(
1834            &first.updated.covariance,
1835            &expected_gain,
1836            &second.predicted.covariance,
1837            &second.updated.covariance,
1838        )
1839        .unwrap();
1840
1841        let got_first = &smoothed.epochs[0];
1842        assert_close_vec(&got_first.state.state_vector(), &expected_state, TOL);
1843        assert_close_matrix(
1844            &got_first.rts_gain_to_next.clone().unwrap(),
1845            &expected_gain,
1846            TOL,
1847        );
1848        assert_close_matrix(&got_first.state.covariance, &expected_covariance, TOL);
1849        assert_eq!(smoothed.epochs[1].state, second.updated);
1850    }
1851
1852    #[test]
1853    fn smoothing_covariance_does_not_exceed_filtering_covariance_diagonal() {
1854        let mut filter = TrackFilter::from_position_velocity(
1855            TrackCoordinateFrame::CallerDefinedCartesian,
1856            0.0,
1857            vec![0.0],
1858            vec![0.0],
1859            vec![vec![10.0, 0.0], vec![0.0, 10.0]],
1860            0.1,
1861        )
1862        .unwrap();
1863        let mut history = TrackRtsHistoryBuilder::from_filter(&filter).unwrap();
1864        for (idx, observation) in [0.1, 0.9, 2.2, 3.1].iter().enumerate() {
1865            filter.predict_recorded(1.0, &mut history).unwrap();
1866            if idx == 2 {
1867                filter.record_prediction_only(&mut history).unwrap();
1868            } else {
1869                filter
1870                    .update_position_recorded(&[*observation], &[vec![0.5]], &mut history)
1871                    .unwrap();
1872            }
1873        }
1874        let history = history.finish().unwrap();
1875        let smoothed = rts_smooth(&history).unwrap();
1876        assert_eq!(smoothed.epochs.len(), history.epochs.len());
1877        for (smoothed_epoch, filtered_epoch) in smoothed.epochs.iter().zip(&history.epochs) {
1878            for idx in 0..smoothed_epoch.state.state_dimension() {
1879                assert!(
1880                    smoothed_epoch.state.covariance[idx][idx]
1881                        <= filtered_epoch.updated.covariance[idx][idx] + 1.0e-10,
1882                    "smoothed covariance diagonal exceeded filtered covariance"
1883                );
1884            }
1885        }
1886    }
1887
1888    #[test]
1889    fn gated_rejection_records_prediction_only_epoch() {
1890        let mut filter = TrackFilter::from_position_velocity(
1891            TrackCoordinateFrame::CallerDefinedCartesian,
1892            0.0,
1893            vec![0.0],
1894            vec![1.0],
1895            vec![vec![1.0, 0.0], vec![0.0, 1.0]],
1896            0.1,
1897        )
1898        .unwrap();
1899        let mut history = TrackRtsHistoryBuilder::from_filter(&filter).unwrap();
1900        filter.predict_recorded(1.0, &mut history).unwrap();
1901        let predicted = filter.state().clone();
1902        let gated = filter
1903            .update_position_gated_recorded(&[100.0], &[vec![0.01]], 0.95, &mut history)
1904            .unwrap();
1905        assert!(!gated.gate.in_gate);
1906        assert!(gated.update.is_none());
1907        assert_eq!(*filter.state(), predicted);
1908        let history = history.finish().unwrap();
1909        assert_eq!(history.epochs.len(), 2);
1910        assert_eq!(history.epochs[1].predicted, predicted);
1911        assert_eq!(history.epochs[1].updated, predicted);
1912    }
1913
1914    #[test]
1915    fn position_covariance_type_selects_frame_block() {
1916        let covariance = PositionCovariance {
1917            ecef_m2: [[1.0, 0.0, 0.0], [0.0, 2.0, 0.0], [0.0, 0.0, 3.0]],
1918            enu_m2: [[4.0, 0.0, 0.0], [0.0, 5.0, 0.0], [0.0, 0.0, 6.0]],
1919        };
1920        let mut ecef = TrackFilter::from_position3(
1921            TrackCoordinateFrame::Ecef,
1922            0.0,
1923            [0.0, 0.0, 0.0],
1924            [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]],
1925            1.0,
1926            0.0,
1927        )
1928        .unwrap();
1929        let update = ecef
1930            .update_position_covariance([1.0, 0.0, 0.0], &covariance)
1931            .unwrap();
1932        assert!((update.kalman_gain[0][0] - 10.0 / 11.0).abs() <= TOL);
1933
1934        let mut enu = TrackFilter::from_position3(
1935            TrackCoordinateFrame::Enu,
1936            0.0,
1937            [0.0, 0.0, 0.0],
1938            [[10.0, 0.0, 0.0], [0.0, 10.0, 0.0], [0.0, 0.0, 10.0]],
1939            1.0,
1940            0.0,
1941        )
1942        .unwrap();
1943        let update = enu
1944            .update_position_covariance([1.0, 0.0, 0.0], &covariance)
1945            .unwrap();
1946        assert!((update.kalman_gain[0][0] - 10.0 / 14.0).abs() <= TOL);
1947    }
1948
1949    fn closed_form_rts_gain(
1950        filtered_covariance: &[Vec<f64>],
1951        transition: &[Vec<f64>],
1952        predicted_covariance_next: &[Vec<f64>],
1953    ) -> Vec<Vec<f64>> {
1954        let transition_t = transpose(transition).unwrap();
1955        let cross = matmul(filtered_covariance, &transition_t).unwrap();
1956        let inverse = invert_symmetric_pd(predicted_covariance_next).unwrap();
1957        matmul(&cross, &inverse).unwrap()
1958    }
1959
1960    fn assert_close_vec(got: &[f64], expected: &[f64], tolerance: f64) {
1961        assert_eq!(got.len(), expected.len());
1962        for (lhs, rhs) in got.iter().zip(expected) {
1963            assert!(
1964                (lhs - rhs).abs() <= tolerance,
1965                "vector mismatch: got {lhs}, expected {rhs}"
1966            );
1967        }
1968    }
1969
1970    fn assert_close_matrix(got: &[Vec<f64>], expected: &[Vec<f64>], tolerance: f64) {
1971        assert_eq!(got.len(), expected.len());
1972        for (row_lhs, row_rhs) in got.iter().zip(expected) {
1973            assert_close_vec(row_lhs, row_rhs, tolerance);
1974        }
1975    }
1976}