use core::time::Duration;
use crate::error::Result;
use crate::event::{EventList, NavigationEvent, PositionSource, RejectionReason};
use crate::gnss::GnssFix;
use crate::navigation_solutions::GroundTrack;
use crate::position::Position;
use crate::sailings::great_circle;
use crate::snapshot::ErrorEllipse;
use crate::time::{Instant, Utc};
use crate::units::{hours, Speed};
#[derive(Debug, Clone, Copy, PartialEq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct IntakeConfig {
pub max_age: Duration,
pub max_speed: Speed,
}
#[derive(Debug, Clone, Copy)]
pub struct GnssIntake {
config: IntakeConfig,
last: Option<GnssFix>,
previous: Option<GnssFix>,
delivering: bool,
}
#[must_use = "the events say what happened; an unread outcome is a lost event"]
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct IntakeOutcome {
accepted: bool,
events: EventList,
}
impl IntakeOutcome {
#[must_use]
pub const fn accepted(&self) -> bool {
self.accepted
}
pub const fn events(&self) -> &EventList {
&self.events
}
}
pub use kinavis_kernel::snapshot::NavigationSnapshot;
impl GnssIntake {
#[must_use]
pub const fn new(config: IntakeConfig) -> Self {
Self {
config,
last: None,
previous: None,
delivering: false,
}
}
#[must_use]
pub const fn config(&self) -> &IntakeConfig {
&self.config
}
#[must_use]
pub const fn last_fix(&self) -> Option<&GnssFix> {
self.last.as_ref()
}
pub fn accept(&mut self, fix: GnssFix) -> IntakeOutcome {
let mut events = EventList::new();
self.expire(fix.taken_at(), &mut events);
if let Some(reason) = self.reason_to_reject(&fix) {
events.push(NavigationEvent::ObservationRejected {
reason,
at: fix.taken_at(),
});
return IntakeOutcome {
accepted: false,
events,
};
}
if !self.delivering {
events.push(NavigationEvent::FixAcquired {
source: PositionSource::Gnss,
at: fix.taken_at(),
});
self.delivering = true;
}
self.previous = self.last;
self.last = Some(fix);
IntakeOutcome {
accepted: true,
events,
}
}
pub fn check_at(&mut self, now: Instant<Utc>) -> EventList {
let mut events = EventList::new();
self.expire(now, &mut events);
events
}
#[must_use]
pub fn snapshot_at(&self, now: Instant<Utc>) -> NavigationSnapshot {
let Some(last) = self.last else {
return NavigationSnapshot::EMPTY;
};
let age = now.checked_duration_since(last.taken_at());
let mut snapshot = NavigationSnapshot::EMPTY
.with_position(last.observed_position(), PositionSource::Gnss)
.with_age(age, age.is_some_and(|age| age > self.config.max_age));
if let Some(track) = self.ground_track(&last) {
snapshot = snapshot.with_ground_track(track);
}
if let Some(sigma) = last.horizontal_accuracy() {
snapshot = snapshot.with_horizontal_error(ErrorEllipse::circular(sigma));
}
snapshot
}
fn expire(&mut self, now: Instant<Utc>, events: &mut EventList) {
let Some(last) = self.last else { return };
if !self.delivering {
return;
}
let stale = now
.checked_duration_since(last.taken_at())
.is_some_and(|age| age > self.config.max_age);
if stale {
self.delivering = false;
events.push(NavigationEvent::FixLost {
source: PositionSource::Gnss,
at: last.taken_at().saturating_add(self.config.max_age),
last_good: last.taken_at(),
});
}
}
fn reason_to_reject(&self, fix: &GnssFix) -> Option<RejectionReason> {
if !fix.quality().fix_type().is_position_fix() {
return Some(RejectionReason::Invalid);
}
let last = self.last?;
if fix.taken_at() < last.taken_at() {
return Some(RejectionReason::OutOfOrder);
}
if !self.delivering {
return None;
}
let elapsed = fix.taken_at().checked_duration_since(last.taken_at())?;
let implied = implied_speed(last.position(), fix.position(), elapsed).ok()??;
(implied > self.config.max_speed).then_some(RejectionReason::ImplausibleJump {
implied_speed: implied,
})
}
fn ground_track(&self, last: &GnssFix) -> Option<GroundTrack> {
if let (Some(course_over_ground), Some(speed_over_ground)) =
(last.course_over_ground(), last.speed_over_ground())
{
return Some(GroundTrack {
course_over_ground,
speed_over_ground,
});
}
let previous = self.previous?;
let elapsed = last
.taken_at()
.checked_duration_since(previous.taken_at())?;
let sailing = great_circle(previous.position(), last.position()).ok()?;
let speed_over_ground =
implied_speed(previous.position(), last.position(), elapsed).ok()??;
Some(GroundTrack {
course_over_ground: sailing.initial_course,
speed_over_ground,
})
}
}
fn implied_speed(from: Position, to: Position, elapsed: Duration) -> Result<Option<Speed>> {
let elapsed_hours = hours(elapsed);
if elapsed_hours <= 0.0 {
return Ok(None);
}
let distance = great_circle(from, to)?.distance;
Ok(Speed::from_knots(distance.nautical_miles() / elapsed_hours).map(Some)?)
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::indexing_slicing,
clippy::panic,
clippy::float_cmp
)]
mod tests {
use super::*;
use crate::gnss::FixType;
use crate::TrueCourse;
fn config() -> IntakeConfig {
IntakeConfig {
max_age: Duration::from_secs(10),
max_speed: Speed::from_knots(30.0).unwrap(),
}
}
fn at(seconds: i64) -> Instant<Utc> {
Instant::from_unix_seconds(seconds)
}
fn fix(seconds: i64, minutes_north: f64) -> GnssFix {
let position = Position::new(
crate::Latitude::from_degrees(50.0 + minutes_north / 60.0).unwrap(),
crate::Longitude::from_degrees(0.0).unwrap(),
);
GnssFix::builder(at(seconds), position).build()
}
#[test]
fn the_first_fix_acquires_and_the_next_does_not_repeat_it() {
let mut intake = GnssIntake::new(config());
let first = intake.accept(fix(0, 0.0));
assert!(first.accepted());
assert_eq!(first.events().len(), 1);
assert!(matches!(
first.events()[0],
NavigationEvent::FixAcquired {
source: PositionSource::Gnss,
..
}
));
let second = intake.accept(fix(1, 0.0));
assert!(second.accepted());
assert!(second.events().is_empty());
}
#[test]
fn a_fix_the_receiver_does_not_vouch_for_is_rejected() {
let mut intake = GnssIntake::new(config());
let position = fix(0, 0.0).position();
let outcome = intake.accept(
GnssFix::builder(at(0), position)
.fix_type(FixType::None)
.build(),
);
assert!(!outcome.accepted());
assert!(matches!(
outcome.events()[0],
NavigationEvent::ObservationRejected {
reason: RejectionReason::Invalid,
..
}
));
assert!(intake.snapshot_at(at(1)).position().is_none());
}
#[test]
fn out_of_order_fixes_are_rejected_and_equal_times_are_not() {
let mut intake = GnssIntake::new(config());
let _ = intake.accept(fix(5, 0.0));
let late = intake.accept(fix(4, 0.0));
assert!(matches!(
late.events()[0],
NavigationEvent::ObservationRejected {
reason: RejectionReason::OutOfOrder,
..
}
));
assert!(intake.accept(fix(5, 0.0)).accepted());
}
#[test]
fn a_jump_faster_than_the_vessel_is_rejected_with_the_speed_it_needed() {
let mut intake = GnssIntake::new(config());
let _ = intake.accept(fix(0, 0.0));
let outcome = intake.accept(fix(1, 1.0));
assert!(!outcome.accepted());
let NavigationEvent::ObservationRejected {
reason: RejectionReason::ImplausibleJump { implied_speed },
..
} = outcome.events()[0]
else {
panic!("expected a jump: {:?}", outcome.events());
};
assert!((implied_speed.knots() - 3600.0).abs() < 5.0);
assert_eq!(intake.last_fix().unwrap().taken_at(), at(0));
}
#[test]
fn a_gap_longer_than_the_limit_is_a_loss_then_an_acquisition() {
let mut intake = GnssIntake::new(config());
let _ = intake.accept(fix(0, 0.0));
let outcome = intake.accept(fix(100, 30.0));
assert!(outcome.accepted());
assert_eq!(outcome.events().len(), 2);
assert!(matches!(
outcome.events()[0],
NavigationEvent::FixLost {
source: PositionSource::Gnss,
at,
last_good,
} if at == at_secs(10) && last_good == at_secs(0)
));
assert!(matches!(
outcome.events()[1],
NavigationEvent::FixAcquired { .. }
));
}
fn at_secs(seconds: i64) -> Instant<Utc> {
at(seconds)
}
#[test]
fn silence_is_noticed_by_the_clock() {
let mut intake = GnssIntake::new(config());
let _ = intake.accept(fix(0, 0.0));
assert!(intake.check_at(at(10)).is_empty());
let events = intake.check_at(at(11));
assert!(matches!(events[0], NavigationEvent::FixLost { .. }));
assert!(intake.check_at(at(12)).is_empty());
let snapshot = intake.snapshot_at(at(12));
assert!(snapshot.position().is_some());
assert!(snapshot.is_stale());
assert_eq!(snapshot.age(), Some(Duration::from_secs(12)));
assert_eq!(snapshot.source(), Some(PositionSource::Gnss));
}
#[test]
fn the_ground_track_comes_from_the_receiver_or_from_two_fixes() {
let mut intake = GnssIntake::new(config());
assert!(intake.snapshot_at(at(0)).ground_track().is_none());
let _ = intake.accept(fix(0, 0.0));
assert!(intake.snapshot_at(at(0)).ground_track().is_none());
let _ = intake.accept(fix(360, 1.0));
let track = intake.snapshot_at(at(360)).ground_track().unwrap();
assert!((track.speed_over_ground.knots() - 10.0).abs() < 0.01);
assert!(track.course_over_ground.degrees() < 0.01);
let position = fix(361, 1.0).position();
let _ = intake.accept(
GnssFix::builder(at(361), position)
.course_over_ground(TrueCourse::new(90.0).unwrap())
.speed_over_ground(Speed::from_knots(7.0).unwrap())
.build(),
);
let track = intake.snapshot_at(at(361)).ground_track().unwrap();
assert_eq!(track.course_over_ground.degrees(), 90.0);
assert_eq!(track.speed_over_ground.knots(), 7.0);
}
#[test]
fn a_snapshot_before_the_fix_has_no_age_and_is_not_stale() {
let mut intake = GnssIntake::new(config());
let _ = intake.accept(fix(100, 0.0));
let snapshot = intake.snapshot_at(at(50));
assert_eq!(snapshot.age(), None);
assert!(!snapshot.is_stale());
}
}