Skip to main content

kinavis_kernel/
estimation.rs

1//! Estimator ports: process model and observation.
2//!
3//! An estimator fuses observations into a [`NavigationState`] without knowing
4//! their source. A filter with `update_gnss`, `update_gyro`, `update_log`
5//! methods must change for every new sensor and cannot be tested without faking
6//! one; instead each observation describes itself — measurement, prediction,
7//! Jacobian, noise — through the [`Observation`] port. The [`ProcessModel`]
8//! port describes motion between observations.
9//!
10//! Neither port exposes the state layout: an observation names
11//! [`StateComponent::Heading`] and the crate maps names to columns. Standard
12//! observations and process models live in `kinavis`; an adapter with an
13//! unusual sensor implements [`Observation`] without touching the estimator.
14
15use core::time::Duration;
16
17use crate::error::{ensure_finite, KernelError, Result};
18use crate::event::SensorId;
19use crate::inline::Inline;
20use crate::matrix::Matrix;
21use crate::state::{NavigationState, StateComponent, STATE_DIM};
22use crate::time::{Instant, Utc};
23
24/// Maximum observation dimension.
25///
26/// Position 2, velocity 2, heading 1, fix with velocity 4. Larger observations
27/// should be split.
28pub const MAX_OBSERVATION_DIM: usize = 4;
29
30/// Observation elements, measured or predicted.
31#[derive(Debug, Clone, Copy, PartialEq)]
32pub struct ObservationVector {
33    elements: Inline<f64, MAX_OBSERVATION_DIM>,
34}
35
36impl ObservationVector {
37    /// Empty vector; rejected by estimators.
38    pub const EMPTY: Self = Self {
39        elements: Inline::new(0.0),
40    };
41
42    /// Vector from elements.
43    ///
44    /// # Errors
45    ///
46    /// [`KernelError::CapacityExceeded`] beyond [`MAX_OBSERVATION_DIM`];
47    /// [`KernelError::NotFinite`] for a non-finite element.
48    pub fn new(elements: &[f64]) -> Result<Self> {
49        let mut inline = Inline::new(0.0);
50        for &element in elements {
51            ensure_finite("observation element", element)?;
52            inline
53                .push(element)
54                .map_err(|full| KernelError::CapacityExceeded {
55                    context: "an observation",
56                    needed: elements.len(),
57                    capacity: full.capacity,
58                })?;
59        }
60        Ok(Self { elements: inline })
61    }
62
63    /// Elements.
64    #[must_use]
65    pub fn as_slice(&self) -> &[f64] {
66        self.elements.as_slice()
67    }
68
69    /// Length.
70    #[must_use]
71    pub const fn len(&self) -> usize {
72        self.elements.len()
73    }
74
75    /// Whether empty (degenerate observation).
76    #[must_use]
77    pub const fn is_empty(&self) -> bool {
78        self.elements.is_empty()
79    }
80
81    /// Element at `index`; `None` past the end.
82    #[must_use]
83    pub fn get(&self, index: usize) -> Option<f64> {
84        self.elements.get(index).copied()
85    }
86
87    /// Element-wise `self − other`; `None` for different lengths.
88    #[must_use]
89    pub fn minus(&self, other: &Self) -> Option<Self> {
90        if self.len() != other.len() {
91            return None;
92        }
93        let mut elements = Inline::new(0.0);
94        for (a, b) in self.as_slice().iter().zip(other.as_slice()) {
95            elements.push(a - b).ok()?;
96        }
97        Some(Self { elements })
98    }
99}
100
101/// One Jacobian row: derivatives of one element with respect to each state
102/// component.
103///
104/// Built by naming components; unnamed entries are zero.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct JacobianRow {
107    entries: [f64; STATE_DIM],
108}
109
110impl JacobianRow {
111    /// Zero row.
112    #[must_use]
113    pub const fn new() -> Self {
114        Self {
115            entries: [0.0; STATE_DIM],
116        }
117    }
118
119    /// Sets the derivative with respect to one component.
120    #[must_use]
121    pub fn with(mut self, component: StateComponent, derivative: f64) -> Self {
122        if let Some(slot) = self.entries.get_mut(component.index()) {
123            *slot = derivative;
124        }
125        self
126    }
127
128    /// Derivative with respect to a component.
129    #[must_use]
130    pub fn derivative(&self, component: StateComponent) -> f64 {
131        self.entries.get(component.index()).copied().unwrap_or(0.0)
132    }
133
134    /// Row in state-vector order.
135    ///
136    /// Internal to the crate family: hidden, not covered by the stability
137    /// guarantee. See [hidden items](crate#hidden-items).
138    #[doc(hidden)]
139    #[must_use]
140    pub const fn entries(&self) -> &[f64; STATE_DIM] {
141        &self.entries
142    }
143}
144
145impl Default for JacobianRow {
146    fn default() -> Self {
147        Self::new()
148    }
149}
150
151/// Observation Jacobian: one row per element, in [`ObservationVector`] order.
152#[derive(Debug, Clone, Copy, PartialEq)]
153pub struct ObservationJacobian {
154    rows: Inline<JacobianRow, MAX_OBSERVATION_DIM>,
155}
156
157impl ObservationJacobian {
158    /// Empty Jacobian.
159    #[must_use]
160    pub const fn new() -> Self {
161        Self {
162            rows: Inline::new(JacobianRow::new()),
163        }
164    }
165
166    /// Appends a row.
167    ///
168    /// # Errors
169    ///
170    /// [`KernelError::CapacityExceeded`] beyond [`MAX_OBSERVATION_DIM`] rows;
171    /// [`KernelError::NotFinite`] for a non-finite derivative.
172    pub fn with_row(mut self, row: JacobianRow) -> Result<Self> {
173        for &entry in row.entries() {
174            ensure_finite("jacobian entry", entry)?;
175        }
176        let needed = self.rows.len().saturating_add(1);
177        self.rows
178            .push(row)
179            .map_err(|full| KernelError::CapacityExceeded {
180                context: "an observation jacobian",
181                needed,
182                capacity: full.capacity,
183            })?;
184        Ok(self)
185    }
186
187    /// Row count.
188    #[must_use]
189    pub const fn len(&self) -> usize {
190        self.rows.len()
191    }
192
193    /// Whether empty.
194    #[must_use]
195    pub const fn is_empty(&self) -> bool {
196        self.rows.is_empty()
197    }
198
199    /// Rows.
200    #[must_use]
201    pub fn rows(&self) -> &[JacobianRow] {
202        self.rows.as_slice()
203    }
204}
205
206impl Default for ObservationJacobian {
207    fn default() -> Self {
208        Self::new()
209    }
210}
211
212/// Observation noise covariance `R`.
213///
214/// Usually diagonal; a full matrix is allowed for correlated elements.
215#[derive(Debug, Clone, Copy, PartialEq)]
216pub struct ObservationNoise {
217    entries: [[f64; MAX_OBSERVATION_DIM]; MAX_OBSERVATION_DIM],
218    len: usize,
219}
220
221impl ObservationNoise {
222    /// Unit variance, one element.
223    pub const UNIT: Self = {
224        let mut entries = [[0.0; MAX_OBSERVATION_DIM]; MAX_OBSERVATION_DIM];
225        entries[0][0] = 1.0;
226        Self { entries, len: 1 }
227    };
228
229    /// Independent elements with the given variances.
230    ///
231    /// # Errors
232    ///
233    /// [`KernelError::CapacityExceeded`] beyond [`MAX_OBSERVATION_DIM`];
234    /// [`KernelError::OutOfRange`] for a non-positive variance (a noiseless
235    /// observation makes the update singular).
236    pub fn diagonal(variances: &[f64]) -> Result<Self> {
237        if variances.len() > MAX_OBSERVATION_DIM {
238            return Err(KernelError::CapacityExceeded {
239                context: "an observation noise",
240                needed: variances.len(),
241                capacity: MAX_OBSERVATION_DIM,
242            });
243        }
244        let mut entries = [[0.0; MAX_OBSERVATION_DIM]; MAX_OBSERVATION_DIM];
245        for (index, &variance) in variances.iter().enumerate() {
246            ensure_finite("observation variance", variance)?;
247            if variance <= 0.0 {
248                return Err(KernelError::OutOfRange {
249                    parameter: "observation variance",
250                    value: variance,
251                    min: f64::MIN_POSITIVE,
252                    max: f64::MAX,
253                });
254            }
255            if let Some(slot) = entries.get_mut(index).and_then(|row| row.get_mut(index)) {
256                *slot = variance;
257            }
258        }
259        Ok(Self {
260            entries,
261            len: variances.len(),
262        })
263    }
264
265    /// Independent elements with the given standard deviations.
266    ///
267    /// # Errors
268    ///
269    /// As [`ObservationNoise::diagonal`].
270    pub fn sigmas(sigmas: &[f64]) -> Result<Self> {
271        let mut variances = Inline::<f64, MAX_OBSERVATION_DIM>::new(0.0);
272        for &sigma in sigmas {
273            variances
274                .push(sigma * sigma)
275                .map_err(|full| KernelError::CapacityExceeded {
276                    context: "an observation noise",
277                    needed: sigmas.len(),
278                    capacity: full.capacity,
279                })?;
280        }
281        Self::diagonal(variances.as_slice())
282    }
283
284    /// Full covariance for correlated elements.
285    ///
286    /// # Errors
287    ///
288    /// [`KernelError::CapacityExceeded`] beyond [`MAX_OBSERVATION_DIM`] rows;
289    /// [`KernelError::NotCovariance`] unless symmetric positive definite.
290    pub fn full(rows: &[&[f64]]) -> Result<Self> {
291        let len = rows.len();
292        if len > MAX_OBSERVATION_DIM {
293            return Err(KernelError::CapacityExceeded {
294                context: "an observation noise",
295                needed: len,
296                capacity: MAX_OBSERVATION_DIM,
297            });
298        }
299        let mut entries = [[0.0; MAX_OBSERVATION_DIM]; MAX_OBSERVATION_DIM];
300        for (i, row) in rows.iter().enumerate() {
301            if row.len() != len {
302                return Err(KernelError::NotCovariance {
303                    context: "an observation noise with rows of unequal length",
304                });
305            }
306            for (j, &value) in row.iter().enumerate() {
307                ensure_finite("observation covariance", value)?;
308                if let Some(slot) = entries.get_mut(i).and_then(|row| row.get_mut(j)) {
309                    *slot = value;
310                }
311            }
312        }
313        let noise = Self { entries, len };
314        // Positive definite on the used block, identity-padded so the unused
315        // block does not affect the check.
316        let padded = Matrix::<MAX_OBSERVATION_DIM, MAX_OBSERVATION_DIM>::from_fn(|i, j| {
317            if i < len && j < len {
318                noise.get(i, j).unwrap_or(0.0)
319            } else if i == j {
320                1.0
321            } else {
322                0.0
323            }
324        });
325        if !padded.is_covariance() || (0..len).any(|i| noise.get(i, i).unwrap_or(0.0) <= 0.0) {
326            return Err(KernelError::NotCovariance {
327                context: "an observation noise that is not positive definite",
328            });
329        }
330        Ok(noise)
331    }
332
333    /// Dimension.
334    #[must_use]
335    pub const fn len(&self) -> usize {
336        self.len
337    }
338
339    /// Whether empty.
340    #[must_use]
341    pub const fn is_empty(&self) -> bool {
342        self.len == 0
343    }
344
345    /// Covariance entry; `None` outside.
346    #[must_use]
347    pub fn get(&self, row: usize, column: usize) -> Option<f64> {
348        if row >= self.len || column >= self.len {
349            return None;
350        }
351        self.entries.get(row)?.get(column).copied()
352    }
353}
354
355/// Innovation gating policy.
356///
357/// The normalised innovation squared is χ²-distributed with as many degrees of
358/// freedom as the observation has elements when the model holds. A value far in
359/// the tail is more likely a fault than a surprise.
360#[derive(Debug, Clone, Copy, PartialEq)]
361pub struct GatingPolicy {
362    threshold: Option<f64>,
363}
364
365impl GatingPolicy {
366    /// No gate.
367    #[must_use]
368    pub const fn none() -> Self {
369        Self { threshold: None }
370    }
371
372    /// Reject when NIS exceeds the threshold.
373    ///
374    /// 2 dof: 5.99 rejects 5 %, 9.21 rejects 1 %. 1 dof: 3.84 and 6.63.
375    #[must_use]
376    pub const fn reject_above(threshold: f64) -> Self {
377        Self {
378            threshold: Some(threshold),
379        }
380    }
381
382    /// Threshold, if any.
383    #[must_use]
384    pub const fn threshold(&self) -> Option<f64> {
385        self.threshold
386    }
387}
388
389/// Observation from the estimator's view: `z`, `h(x)`, `H`, `R`, no source
390/// details.
391///
392/// Vectors and Jacobian must agree in length; the estimator rejects the
393/// observation otherwise.
394pub trait Observation {
395    /// Source identifier, for reporting and per-source health: the estimator
396    /// tracks accept/reject runs per [`SensorId`], so differently named
397    /// receivers are judged separately.
398    fn sensor(&self) -> SensorId;
399
400    /// Time of the measurement.
401    fn taken_at(&self) -> Instant<Utc>;
402
403    /// Measurement `z`.
404    fn measured(&self) -> ObservationVector;
405
406    /// Predicted measurement `h(x)`.
407    ///
408    /// # Errors
409    ///
410    /// Any error preventing the prediction from this state.
411    fn predict(&self, state: &NavigationState) -> Result<ObservationVector>;
412
413    /// Jacobian `H = ∂h/∂x` at `x`.
414    ///
415    /// # Errors
416    ///
417    /// As [`Observation::predict`].
418    fn jacobian(&self, state: &NavigationState) -> Result<ObservationJacobian>;
419
420    /// Measurement noise `R`.
421    fn noise(&self) -> ObservationNoise;
422
423    /// Gating policy.
424    fn gate(&self) -> GatingPolicy {
425        GatingPolicy::none()
426    }
427
428    /// Innovation `z − h(x)`.
429    ///
430    /// Plain difference by default. Angle observations override it to wrap into
431    /// `[−π, π)`: 359° measured vs 1° predicted is −2°, not 358°.
432    ///
433    /// # Errors
434    ///
435    /// [`KernelError::BufferTooSmall`] if measured and predicted vectors differ
436    /// in length.
437    fn innovation(&self, predicted: &ObservationVector) -> Result<ObservationVector> {
438        let measured = self.measured();
439        measured
440            .minus(predicted)
441            .ok_or(KernelError::BufferTooSmall {
442                needed: measured.len(),
443                found: predicted.len(),
444            })
445    }
446}
447
448/// Process Jacobian `F = ∂f/∂x` over a step.
449///
450/// Built from the identity by setting non-identity entries by name.
451#[derive(Debug, Clone, Copy, PartialEq)]
452pub struct StateJacobian {
453    matrix: Matrix<STATE_DIM, STATE_DIM>,
454}
455
456impl StateJacobian {
457    /// Identity.
458    #[must_use]
459    pub fn identity() -> Self {
460        Self {
461            matrix: Matrix::identity(),
462        }
463    }
464
465    /// Sets `∂(row)/∂(column)`.
466    #[must_use]
467    pub fn with(mut self, row: StateComponent, column: StateComponent, derivative: f64) -> Self {
468        self.matrix.set(row.index(), column.index(), derivative);
469        self
470    }
471
472    /// `∂(row)/∂(column)`.
473    #[must_use]
474    pub fn derivative(&self, row: StateComponent, column: StateComponent) -> f64 {
475        self.matrix.get(row.index(), column.index()).unwrap_or(0.0)
476    }
477
478    /// Matrix form.
479    ///
480    /// Internal to the crate family: hidden, not covered by the stability
481    /// guarantee. See [hidden items](crate#hidden-items).
482    #[doc(hidden)]
483    #[must_use]
484    pub const fn matrix(&self) -> &Matrix<STATE_DIM, STATE_DIM> {
485        &self.matrix
486    }
487}
488
489/// Process noise `Q`: unmodelled state change over a step.
490#[derive(Debug, Clone, Copy, PartialEq)]
491pub struct ProcessNoise {
492    matrix: Matrix<STATE_DIM, STATE_DIM>,
493}
494
495impl ProcessNoise {
496    /// Zero noise.
497    #[must_use]
498    pub const fn zero() -> Self {
499        Self {
500            matrix: Matrix::ZERO,
501        }
502    }
503
504    /// Sets one component's variance.
505    #[must_use]
506    pub fn with_variance(mut self, component: StateComponent, variance: f64) -> Self {
507        self.matrix
508            .set(component.index(), component.index(), variance);
509        self
510    }
511
512    /// Sets the covariance of two components, symmetrically.
513    #[must_use]
514    pub fn with_covariance(
515        mut self,
516        a: StateComponent,
517        b: StateComponent,
518        covariance: f64,
519    ) -> Self {
520        self.matrix.set(a.index(), b.index(), covariance);
521        self.matrix.set(b.index(), a.index(), covariance);
522        self
523    }
524
525    /// Variance of one component.
526    #[must_use]
527    pub fn variance(&self, component: StateComponent) -> f64 {
528        self.matrix
529            .get(component.index(), component.index())
530            .unwrap_or(0.0)
531    }
532
533    /// Matrix form.
534    ///
535    /// Internal to the crate family: hidden, not covered by the stability
536    /// guarantee. See [hidden items](crate#hidden-items).
537    #[doc(hidden)]
538    #[must_use]
539    pub const fn matrix(&self) -> &Matrix<STATE_DIM, STATE_DIM> {
540        &self.matrix
541    }
542}
543
544/// State propagation between observations; injected into the estimator.
545pub trait ProcessModel {
546    /// State after a step, `f(x)`, with the covariance unchanged; the estimator
547    /// propagates the covariance from `F` and `Q`.
548    ///
549    /// # Errors
550    ///
551    /// Any error the model raises: negative step, unpropagatable state.
552    fn propagate(&self, state: &NavigationState, over: Duration) -> Result<NavigationState>;
553
554    /// Process Jacobian `F`.
555    ///
556    /// # Errors
557    ///
558    /// As [`ProcessModel::propagate`].
559    fn jacobian(&self, state: &NavigationState, over: Duration) -> Result<StateJacobian>;
560
561    /// Process noise `Q` at the start state.
562    ///
563    /// State-dependent because noise may enter through the state (heading noise
564    /// moving position across track at the speed made good). A self-consistent
565    /// model gives the same `Q` for one step as for its two halves;
566    /// late-observation handling relies on it.
567    fn noise(&self, state: &NavigationState, over: Duration) -> ProcessNoise;
568}
569
570#[cfg(test)]
571#[allow(clippy::unwrap_used, clippy::float_cmp)]
572mod tests {
573    use super::*;
574
575    #[test]
576    fn observation_vectors_are_bounded_and_finite() {
577        let vector = ObservationVector::new(&[1.0, 2.0]).unwrap();
578        assert_eq!(vector.as_slice(), &[1.0, 2.0]);
579        assert_eq!(vector.get(1), Some(2.0));
580        assert_eq!(vector.get(2), None);
581        assert!(ObservationVector::new(&[1.0; 5]).is_err());
582        assert!(ObservationVector::new(&[f64::NAN]).is_err());
583        let difference = vector
584            .minus(&ObservationVector::new(&[0.5, 0.5]).unwrap())
585            .unwrap();
586        assert_eq!(difference.as_slice(), &[0.5, 1.5]);
587        assert!(vector
588            .minus(&ObservationVector::new(&[1.0]).unwrap())
589            .is_none());
590    }
591
592    #[test]
593    fn jacobians_are_built_by_name() {
594        let row = JacobianRow::new().with(StateComponent::Heading, 2.0);
595        assert_eq!(row.derivative(StateComponent::Heading), 2.0);
596        assert_eq!(row.derivative(StateComponent::North), 0.0);
597        let jacobian = ObservationJacobian::new().with_row(row).unwrap();
598        assert_eq!(jacobian.len(), 1);
599        assert_eq!(jacobian.rows().first(), Some(&row));
600        let mut full = jacobian;
601        for _ in 1..MAX_OBSERVATION_DIM {
602            full = full.with_row(row).unwrap();
603        }
604        assert!(full.with_row(row).is_err());
605        assert!(ObservationJacobian::new()
606            .with_row(JacobianRow::new().with(StateComponent::East, f64::INFINITY))
607            .is_err());
608
609        let state_jacobian = StateJacobian::identity().with(
610            StateComponent::North,
611            StateComponent::SpeedThroughWater,
612            0.5,
613        );
614        assert_eq!(
615            state_jacobian.derivative(StateComponent::North, StateComponent::SpeedThroughWater),
616            0.5
617        );
618        assert_eq!(
619            state_jacobian.derivative(StateComponent::North, StateComponent::North),
620            1.0
621        );
622    }
623
624    #[test]
625    fn noise_must_be_positive_definite() {
626        let diagonal = ObservationNoise::sigmas(&[2.0, 3.0]).unwrap();
627        assert_eq!(diagonal.get(0, 0), Some(4.0));
628        assert_eq!(diagonal.get(1, 1), Some(9.0));
629        assert_eq!(diagonal.get(0, 1), Some(0.0));
630        assert_eq!(diagonal.get(2, 2), None);
631        assert!(ObservationNoise::diagonal(&[0.0]).is_err());
632        assert!(ObservationNoise::diagonal(&[-1.0]).is_err());
633        assert!(ObservationNoise::diagonal(&[1.0; 5]).is_err());
634        let full = ObservationNoise::full(&[&[2.0, 0.5], &[0.5, 2.0]]).unwrap();
635        assert_eq!(full.get(1, 0), Some(0.5));
636        assert!(ObservationNoise::full(&[&[1.0, 2.0], &[2.0, 1.0]]).is_err());
637        assert!(ObservationNoise::full(&[&[1.0, 0.0], &[0.0]]).is_err());
638
639        let process = ProcessNoise::zero()
640            .with_variance(StateComponent::Heading, 0.01)
641            .with_covariance(
642                StateComponent::CurrentNorth,
643                StateComponent::CurrentEast,
644                0.1,
645            );
646        assert_eq!(process.variance(StateComponent::Heading), 0.01);
647        assert_eq!(process.variance(StateComponent::North), 0.0);
648        assert_eq!(
649            process.matrix().get(
650                StateComponent::CurrentEast.index(),
651                StateComponent::CurrentNorth.index()
652            ),
653            Some(0.1)
654        );
655    }
656
657    #[test]
658    fn gating_is_optional() {
659        assert_eq!(GatingPolicy::none().threshold(), None);
660        assert_eq!(GatingPolicy::reject_above(5.99).threshold(), Some(5.99));
661    }
662
663    /// One-element observation for exercising the trait defaults.
664    struct Constant(f64);
665
666    impl Observation for Constant {
667        fn sensor(&self) -> SensorId {
668            SensorId::named("constant")
669        }
670        fn taken_at(&self) -> Instant<Utc> {
671            Instant::UNIX_EPOCH
672        }
673        fn measured(&self) -> ObservationVector {
674            ObservationVector::new(&[self.0]).unwrap()
675        }
676        fn predict(&self, _: &NavigationState) -> Result<ObservationVector> {
677            ObservationVector::new(&[0.0])
678        }
679        fn jacobian(&self, _: &NavigationState) -> Result<ObservationJacobian> {
680            ObservationJacobian::new().with_row(JacobianRow::new())
681        }
682        fn noise(&self) -> ObservationNoise {
683            ObservationNoise::sigmas(&[1.0]).unwrap()
684        }
685    }
686
687    #[test]
688    fn the_default_innovation_is_the_difference() {
689        let observation = Constant(3.0);
690        let predicted = ObservationVector::new(&[1.0]).unwrap();
691        assert_eq!(
692            observation.innovation(&predicted).unwrap().as_slice(),
693            &[2.0]
694        );
695        assert!(observation
696            .innovation(&ObservationVector::new(&[1.0, 1.0]).unwrap())
697            .is_err());
698        assert_eq!(observation.gate(), GatingPolicy::none());
699    }
700}