Skip to main content

kinavis_kernel/
observation.rs

1//! Observed value: time and quality.
2//!
3//! A sensor reading is a statement about the world at an instant, with an
4//! uncertainty, from a source that may be degraded. Displays, estimators and
5//! alarms all need the same three facts, so they are kept in one wrapper
6//! instead of repeated on every reading type.
7//!
8//! Age is not stored: it depends on the evaluation time and would be wrong
9//! immediately. Use [`Observed::age_at`].
10//!
11//! ```rust
12//! use core::time::Duration;
13//! use kinavis_kernel::observation::{Observed, ObservationStatus, Quality};
14//! use kinavis_kernel::time::{Civil, Instant, Utc};
15//! use kinavis_kernel::{Distance, Position};
16//!
17//! let taken_at = Instant::<Utc>::from_civil(Civil::date(2026, 9, 11))?;
18//! let fix = Observed::new(
19//!     "50°45.3'N 001°20.0'W".parse::<Position>()?,
20//!     taken_at,
21//!     Quality::new(ObservationStatus::Valid).with_sigma(Distance::from_metres(5.0)?),
22//! );
23//!
24//! let now = taken_at.saturating_add(Duration::from_secs(90));
25//! assert_eq!(fix.age_at(now)?, Duration::from_secs(90));
26//! assert!(fix.is_stale_at(now, Duration::from_secs(60)));
27//! assert!(!fix.is_stale_at(now, Duration::from_secs(120)));
28//! # Ok::<(), kinavis_kernel::KernelError>(())
29//! ```
30
31use core::time::Duration;
32
33use crate::error::Result;
34use crate::time::{Instant, Utc};
35
36/// Source-reported validity of a reading.
37///
38/// What the source reports, not a judgement of the value (a GNSS fix the
39/// receiver distrusts, a settling gyro, a stopped log impeller). Handling of
40/// `Suspect` is the consumer's decision; `Invalid` carries no information.
41///
42/// `#[non_exhaustive]`; match with a wildcard arm.
43#[non_exhaustive]
44#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
45#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
46pub enum ObservationStatus {
47    /// Valid.
48    Valid,
49    /// Flagged by the source: use with caution or not at all.
50    Suspect,
51    /// No usable reading; the value is a placeholder.
52    Invalid,
53}
54
55impl ObservationStatus {
56    /// Whether the reading carries information: anything but
57    /// [`Invalid`](ObservationStatus::Invalid).
58    #[must_use]
59    pub const fn is_usable(self) -> bool {
60        !matches!(self, Self::Invalid)
61    }
62}
63
64/// Reading quality: status and optional 1σ uncertainty.
65///
66/// The uncertainty has the type of the quantity it bounds — [`Distance`] for a
67/// position, [`Angle`] for a course, [`Speed`] for a speed — so units cannot be
68/// confused. `U = ()` for sources without an error estimate.
69///
70/// [`Distance`]: crate::Distance
71/// [`Angle`]: crate::Angle
72/// [`Speed`]: crate::Speed
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
75pub struct Quality<U = ()> {
76    status: ObservationStatus,
77    sigma: Option<U>,
78}
79
80impl<U> Quality<U> {
81    /// Quality with status only.
82    #[must_use]
83    pub const fn new(status: ObservationStatus) -> Self {
84        Self {
85            status,
86            sigma: None,
87        }
88    }
89
90    /// Adds a 1σ uncertainty.
91    #[must_use]
92    pub fn with_sigma(self, sigma: U) -> Self {
93        Self {
94            sigma: Some(sigma),
95            ..self
96        }
97    }
98
99    /// Status.
100    #[must_use]
101    pub const fn status(&self) -> ObservationStatus {
102        self.status
103    }
104
105    /// 1σ uncertainty, if given.
106    #[must_use]
107    pub const fn sigma(&self) -> Option<&U> {
108        self.sigma.as_ref()
109    }
110}
111
112impl<U> Default for Quality<U> {
113    /// `Valid` without uncertainty: the default for sources that report
114    /// neither.
115    fn default() -> Self {
116        Self::new(ObservationStatus::Valid)
117    }
118}
119
120/// Value with its observation time and quality.
121///
122/// `T` is the value; `U` the uncertainty type (see [`Quality`]). Transparent to
123/// the value ([`Observed::value`], [`Observed::map`]); answers age and
124/// staleness.
125#[derive(Debug, Clone, Copy, PartialEq)]
126#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
127pub struct Observed<T, U = ()> {
128    value: T,
129    taken_at: Instant<Utc>,
130    quality: Quality<U>,
131}
132
133impl<T, U> Observed<T, U> {
134    /// Wraps a value with its time and quality.
135    #[must_use]
136    pub const fn new(value: T, taken_at: Instant<Utc>, quality: Quality<U>) -> Self {
137        Self {
138            value,
139            taken_at,
140            quality,
141        }
142    }
143
144    /// Value.
145    #[must_use]
146    pub const fn value(&self) -> &T {
147        &self.value
148    }
149
150    /// Unwraps the value.
151    #[must_use]
152    pub fn into_value(self) -> T {
153        self.value
154    }
155
156    /// Observation time.
157    #[must_use]
158    pub const fn taken_at(&self) -> Instant<Utc> {
159        self.taken_at
160    }
161
162    /// Quality.
163    #[must_use]
164    pub const fn quality(&self) -> &Quality<U> {
165        &self.quality
166    }
167
168    /// Age at `now`.
169    ///
170    /// Computed, never stored.
171    ///
172    /// # Errors
173    ///
174    /// [`KernelError::TimeReversed`](crate::KernelError::TimeReversed) if `now`
175    /// precedes the observation (clock stepped back, or a future timestamp).
176    pub fn age_at(&self, now: Instant<Utc>) -> Result<Duration> {
177        now.duration_since(self.taken_at)
178    }
179
180    /// Whether older than `limit` at `now`.
181    ///
182    /// `false` for a reading timestamped after `now`; that error is reported by
183    /// [`Observed::age_at`].
184    #[must_use]
185    pub fn is_stale_at(&self, now: Instant<Utc>, limit: Duration) -> bool {
186        now.checked_duration_since(self.taken_at)
187            .is_some_and(|age| age > limit)
188    }
189
190    /// Derives another value, keeping time and quality.
191    ///
192    /// For quantities computed from the value alone (e.g. course from a fix
193    /// velocity). A derivation that changes the uncertainty should build a new
194    /// [`Observed`].
195    #[must_use]
196    pub fn map<V>(self, derive: impl FnOnce(T) -> V) -> Observed<V, U> {
197        Observed {
198            value: derive(self.value),
199            taken_at: self.taken_at,
200            quality: self.quality,
201        }
202    }
203}
204
205#[cfg(test)]
206#[allow(clippy::unwrap_used, clippy::float_cmp)]
207mod tests {
208    use super::*;
209    use crate::error::KernelError;
210    use crate::units::Speed;
211
212    fn at(seconds: i64) -> Instant<Utc> {
213        Instant::from_unix_seconds(seconds)
214    }
215
216    #[test]
217    fn age_is_computed_from_two_moments() {
218        let reading = Observed::<_, ()>::new(12.5_f64, at(100), Quality::default());
219        assert_eq!(reading.age_at(at(160)).unwrap(), Duration::from_secs(60));
220        assert_eq!(reading.age_at(at(100)).unwrap(), Duration::ZERO);
221        assert_eq!(
222            reading.age_at(at(90)),
223            Err(KernelError::TimeReversed {
224                by: Duration::from_secs(10)
225            })
226        );
227    }
228
229    #[test]
230    fn staleness_is_age_beyond_a_limit_and_never_from_the_future() {
231        let reading = Observed::<_, ()>::new((), at(100), Quality::default());
232        let limit = Duration::from_secs(30);
233        assert!(!reading.is_stale_at(at(130), limit));
234        assert!(reading.is_stale_at(at(131), limit));
235        assert!(!reading.is_stale_at(at(50), limit));
236    }
237
238    #[test]
239    fn quality_carries_a_typed_uncertainty() {
240        let sigma = Speed::from_knots(0.2).unwrap();
241        let quality = Quality::new(ObservationStatus::Suspect).with_sigma(sigma);
242        assert_eq!(quality.status(), ObservationStatus::Suspect);
243        assert_eq!(quality.sigma(), Some(&sigma));
244        assert!(quality.status().is_usable());
245        assert!(!ObservationStatus::Invalid.is_usable());
246        assert_eq!(Quality::<Speed>::default().sigma(), None);
247    }
248
249    #[test]
250    fn map_keeps_the_moment_and_the_quality() {
251        let quality =
252            Quality::new(ObservationStatus::Valid).with_sigma(Speed::from_knots(0.2).unwrap());
253        let speed = Observed::new(Speed::from_knots(10.0).unwrap(), at(100), quality);
254        let doubled = speed.map(|s| s * 2.0);
255        assert_eq!(doubled.value().knots(), 20.0);
256        assert_eq!(doubled.taken_at(), at(100));
257        assert_eq!(doubled.quality(), &quality);
258        assert_eq!(doubled.into_value().knots(), 20.0);
259    }
260
261    #[cfg(feature = "serde")]
262    #[test]
263    fn serde_round_trips() {
264        let quality =
265            Quality::new(ObservationStatus::Valid).with_sigma(Speed::from_knots(0.2).unwrap());
266        let speed = Observed::new(Speed::from_knots(10.0).unwrap(), at(100), quality);
267        let json = serde_json::to_string(&speed).unwrap();
268        assert_eq!(
269            serde_json::from_str::<Observed<Speed, Speed>>(&json).unwrap(),
270            speed
271        );
272    }
273}