Skip to main content

kinavis_traffic/
lib.rs

1//! Target tracking from radar plots and AIS reports, collision assessment and
2//! avoidance.
3//!
4//! An observation gives a numbered target's position at an instant. A
5//! [`TargetTrack`] derives course and speed (least-squares over recent fixes,
6//! or as reported by the target), an extrapolated position and an age.
7//! [`Traffic`] holds the tracks, ingests observations, drops silent targets and
8//! produces a [`TrafficView`] plus events: [`TrafficEvent::TargetAcquired`],
9//! [`TrafficEvent::TargetLost`], [`TrafficEvent::ObservationRejected`] (out of
10//! order or implausible speed).
11//!
12//! Thresholds are vessel settings in a [`TrackingPolicy`]: fixes to acquire,
13//! stale and lost timeouts, maximum plausible speed.
14//!
15//! Collision assessment: [`assess`] for one encounter (CPA/TCPA, bearing at
16//! CPA, bearing rate, bow crossing range, [`CollisionRisk`] against a
17//! [`CpaPolicy`]); [`assess_track`] for a tracked target; [`assess_traffic`]
18//! for the whole picture against own ship's snapshot, raising
19//! [`TrafficEvent::CpaAlarm`]. Give-way rules live in `kinavis-colregs`; their
20//! permitted manoeuvre comes back as [`ManoeuvreConstraints`], within which
21//! [`avoid`] and [`avoid_all`] find the smallest course alteration that opens
22//! one or all targets to the requested passing distance.
23//!
24//! ```rust
25//! use kinavis_kernel::{Instant, Position, Speed, TargetId, Utc};
26//! use kinavis_traffic::{TargetObservation, Traffic, TrackingPolicy, TrafficEvent, WhenFull};
27//! use core::time::Duration;
28//!
29//! // Three plots to acquire, stale after half a minute, dropped after three,
30//! // nothing faster than sixty knots; and when the picture is full, the
31//! // target that has gone quietest makes way for the newcomer.
32//! let policy = TrackingPolicy::new(
33//!     3,
34//!     Duration::from_secs(30),
35//!     Duration::from_secs(180),
36//!     Speed::from_knots(60.0)?,
37//! )?
38//! .when_full(WhenFull::EvictStalest);
39//! let mut traffic = Traffic::new(policy);
40//!
41//! // Three radar plots of one target, a minute apart, heading north at
42//! // twelve knots: a fifth of a mile a minute.
43//! let start = Instant::<Utc>::from_unix_seconds(1_789_000_000);
44//! let target = TargetId::new(7);
45//! let mut acquired = false;
46//! for minute in 0_u32..3 {
47//!     let position = Position::from_degrees(50.0 + 0.2 * f64::from(minute) / 60.0, -1.0)?;
48//!     let at = start.checked_add(Duration::from_secs(60 * u64::from(minute))).unwrap();
49//!     let events = traffic.ingest(TargetObservation::new(target, position, at))?;
50//!     acquired |= events
51//!         .iter()
52//!         .any(|event| matches!(event, TrafficEvent::TargetAcquired { .. }));
53//! }
54//! assert!(acquired);
55//!
56//! // Two and a half minutes in, the picture has it half a mile up the track.
57//! let now = start.checked_add(Duration::from_secs(150)).unwrap();
58//! let view = traffic.view(now);
59//! let seen = &view.targets()[0];
60//! let motion = seen.motion.unwrap();
61//! assert_eq!(format!("{:.0}", motion.course_over_ground), "000°T");
62//! assert_eq!(format!("{:.1}", motion.speed_over_ground.knots()), "12.0");
63//! assert_eq!(format!("{:.3}", seen.position.latitude().degrees()), "50.008");
64//! assert!(!seen.stale);
65//!
66//! // Ten minutes of silence and it is gone.
67//! let later = now.checked_add(Duration::from_secs(600)).unwrap();
68//! let events = traffic.sweep(later);
69//! assert!(matches!(events[0], TrafficEvent::TargetLost { target, .. } if target == TargetId::new(7)));
70//! assert!(traffic.is_empty());
71//! # Ok::<(), kinavis::NavigationError>(())
72//! ```
73//!
74//! # Feature flags
75//!
76//! - `std` *(default)* — standard library maths in the kernel.
77//! - `libm` — for `no_std` targets: `--no-default-features --features libm`.
78//! - `serde` — serialisation of observations, policies and view entries.
79//!
80//! No allocation; builds for bare-metal targets. The picture is stored inline
81//! ([`MAX_TARGETS`] tracks × [`MAX_TRACK_HISTORY`] fixes), so [`Traffic`] is
82//! large: keep it behind a reference or in a `static`.
83//!
84
85#![cfg_attr(not(feature = "std"), no_std)]
86
87// The crate does not allocate; tests use `format!`.
88#[cfg(test)]
89extern crate alloc;
90
91mod assessment;
92mod avoidance;
93mod event;
94mod observation;
95mod track;
96
97/// Runs the `README.md` example as a doctest.
98#[cfg(doctest)]
99#[doc = include_str!("../README.md")]
100pub struct ReadmeExamples;
101
102use core::time::Duration;
103
104use kinavis::error::{ensure_range, KernelError, NavigationError, Result};
105use kinavis::sailings::great_circle;
106use kinavis_kernel::angle::TrueCourse;
107use kinavis_kernel::event::{EventList, RejectionReason, TargetId};
108use kinavis_kernel::inline::Inline;
109#[cfg(test)]
110use kinavis_kernel::math;
111use kinavis_kernel::position::Position;
112use kinavis_kernel::snapshot::GroundTrack;
113use kinavis_kernel::time::{Instant, Utc};
114use kinavis_kernel::units::Speed;
115
116pub use assessment::{
117    assess, assess_track, assess_traffic, CollisionAssessment, CollisionPicture, CollisionRisk,
118    CpaPolicy, TargetAssessment,
119};
120pub use avoidance::{
121    avoid, avoid_all, AvoidanceManoeuvre, ManoeuvreConstraints, PermittedSides,
122    ALTERATION_STEP_DEG, MAX_ALTERATION_DEG,
123};
124pub use event::TrafficEvent;
125pub use observation::TargetObservation;
126pub use track::{TargetTrack, TrackStatus, MAX_TRACK_HISTORY};
127
128/// Default picture capacity.
129///
130/// ARPA tracks a few dozen targets; a dense AIS picture should be
131/// range-filtered first or use a larger capacity ([`Traffic`] takes it as a
132/// parameter). Behaviour when full is set by [`WhenFull`].
133pub const MAX_TARGETS: usize = 32;
134
135/// Behaviour for a new target when the picture is full.
136///
137/// A full picture may be caused by spoofed identities (fake AIS, corrupt feed)
138/// as well as by traffic density; refusing every newcomer would then hide real
139/// targets. The policy decides.
140#[non_exhaustive]
141#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
142#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
143pub enum WhenFull {
144    /// Reject the newcomer with [`KernelError::CapacityExceeded`]; the picture
145    /// is unchanged.
146    #[default]
147    Refuse,
148    /// Evict the least recently observed target, reporting
149    /// [`TrafficEvent::TargetEvicted`], and track the newcomer. Active targets
150    /// are kept; silent ones go first.
151    EvictStalest,
152}
153
154/// Track acquisition and loss thresholds: vessel settings.
155///
156/// Validated by [`TrackingPolicy::new`]: at least one fix to acquire, lost
157/// timeout ≥ stale timeout, positive maximum speed.
158#[derive(Debug, Clone, Copy, PartialEq)]
159#[cfg_attr(
160    feature = "serde",
161    derive(serde::Serialize, serde::Deserialize),
162    serde(try_from = "StoredTrackingPolicy", into = "StoredTrackingPolicy")
163)]
164pub struct TrackingPolicy {
165    fixes_to_acquire: u8,
166    stale_after: Duration,
167    lost_after: Duration,
168    max_speed: Speed,
169    when_full: WhenFull,
170}
171
172impl TrackingPolicy {
173    /// Policy from four thresholds; full picture refuses newcomers.
174    ///
175    /// - `fixes_to_acquire`: observations before
176    ///   [`TrafficEvent::TargetAcquired`] (3 is the radar convention; AIS can
177    ///   be trusted from the first).
178    /// - `stale_after`: time since the last observation after which the track
179    ///   is marked stale in the view (still shown and extrapolated).
180    /// - `lost_after` (≥ `stale_after`): time after which [`Traffic::sweep`]
181    ///   drops the track with [`TrafficEvent::TargetLost`].
182    /// - `max_speed`: maximum plausible target speed; an observation implying
183    ///   more is rejected as an implausible jump (swapped track, corrupt
184    ///   report).
185    ///
186    /// # Errors
187    ///
188    /// [`KernelError::OutOfRange`] for zero fixes, `lost_after < stale_after`,
189    /// or a non-positive speed.
190    pub fn new(
191        fixes_to_acquire: u8,
192        stale_after: Duration,
193        lost_after: Duration,
194        max_speed: Speed,
195    ) -> Result<Self> {
196        ensure_range(
197            "fixes to acquire",
198            f64::from(fixes_to_acquire),
199            1.0,
200            f64::from(u8::MAX),
201        )?;
202        ensure_range(
203            "lost after",
204            lost_after.as_secs_f64(),
205            stale_after.as_secs_f64(),
206            f64::MAX,
207        )?;
208        ensure_range("max speed", max_speed.knots(), f64::MIN_POSITIVE, f64::MAX)?;
209        Ok(Self {
210            fixes_to_acquire,
211            stale_after,
212            lost_after,
213            max_speed,
214            when_full: WhenFull::Refuse,
215        })
216    }
217
218    /// Sets the full-picture behaviour.
219    #[must_use]
220    pub const fn when_full(mut self, when_full: WhenFull) -> Self {
221        self.when_full = when_full;
222        self
223    }
224
225    /// Observations needed to acquire.
226    #[must_use]
227    pub const fn fixes_to_acquire(&self) -> u8 {
228        self.fixes_to_acquire
229    }
230
231    /// Stale timeout.
232    #[must_use]
233    pub const fn stale_after(&self) -> Duration {
234        self.stale_after
235    }
236
237    /// Lost timeout.
238    #[must_use]
239    pub const fn lost_after(&self) -> Duration {
240        self.lost_after
241    }
242
243    /// Maximum plausible target speed.
244    #[must_use]
245    pub const fn max_speed(&self) -> Speed {
246        self.max_speed
247    }
248
249    /// Full-picture behaviour.
250    #[must_use]
251    pub const fn on_full(&self) -> WhenFull {
252        self.when_full
253    }
254}
255
256/// Serialised form; deserialisation goes through [`TrackingPolicy::new`].
257#[cfg(feature = "serde")]
258#[derive(serde::Serialize, serde::Deserialize)]
259struct StoredTrackingPolicy {
260    fixes_to_acquire: u8,
261    stale_after: Duration,
262    lost_after: Duration,
263    max_speed: Speed,
264    when_full: WhenFull,
265}
266
267#[cfg(feature = "serde")]
268impl TryFrom<StoredTrackingPolicy> for TrackingPolicy {
269    type Error = NavigationError;
270
271    fn try_from(stored: StoredTrackingPolicy) -> Result<Self> {
272        Ok(Self::new(
273            stored.fixes_to_acquire,
274            stored.stale_after,
275            stored.lost_after,
276            stored.max_speed,
277        )?
278        .when_full(stored.when_full))
279    }
280}
281
282#[cfg(feature = "serde")]
283impl From<TrackingPolicy> for StoredTrackingPolicy {
284    fn from(policy: TrackingPolicy) -> Self {
285        Self {
286            fixes_to_acquire: policy.fixes_to_acquire,
287            stale_after: policy.stale_after,
288            lost_after: policy.lost_after,
289            max_speed: policy.max_speed,
290            when_full: policy.when_full,
291        }
292    }
293}
294
295/// Traffic picture: up to `N` tracked targets.
296///
297/// Aggregate with its policy: observations via [`Traffic::ingest`], time via
298/// [`Traffic::sweep`], output via [`Traffic::view`]. Stored inline and large
299/// (each track holds [`MAX_TRACK_HISTORY`] fixes): keep it behind a reference
300/// or in a `static`. Deliberately not `Copy`, so the picture cannot be
301/// duplicated by accident:
302///
303/// ```compile_fail
304/// fn is_copy<T: Copy>() {}
305/// is_copy::<kinavis_traffic::Traffic>();
306/// ```
307#[derive(Debug, Clone, PartialEq)]
308pub struct Traffic<const N: usize = MAX_TARGETS> {
309    policy: TrackingPolicy,
310    tracks: Inline<TargetTrack, N>,
311}
312
313impl Traffic<MAX_TARGETS> {
314    /// Empty picture with [`MAX_TARGETS`] capacity.
315    #[must_use]
316    pub fn new(policy: TrackingPolicy) -> Self {
317        Self::with_capacity(policy)
318    }
319}
320
321impl<const N: usize> Traffic<N> {
322    /// Empty picture with capacity `N`, for dense pictures without range
323    /// filtering.
324    #[must_use]
325    pub fn with_capacity(policy: TrackingPolicy) -> Self {
326        Self {
327            policy,
328            tracks: Inline::new(TargetTrack::placeholder()),
329        }
330    }
331
332    /// Policy.
333    #[must_use]
334    pub const fn policy(&self) -> TrackingPolicy {
335        self.policy
336    }
337
338    /// Capacity.
339    #[must_use]
340    pub const fn capacity() -> usize {
341        N
342    }
343
344    /// Number of tracks, including those still acquiring.
345    #[must_use]
346    pub const fn len(&self) -> usize {
347        self.tracks.len()
348    }
349
350    /// Whether empty.
351    #[must_use]
352    pub const fn is_empty(&self) -> bool {
353        self.tracks.is_empty()
354    }
355
356    /// Tracks, in order of first detection.
357    #[must_use]
358    pub fn tracks(&self) -> &[TargetTrack] {
359        &self.tracks
360    }
361
362    /// Track for a target, if present.
363    #[must_use]
364    pub fn track(&self, target: TargetId) -> Option<&TargetTrack> {
365        self.tracks.iter().find(|track| track.target() == target)
366    }
367
368    /// Ingests an observation.
369    ///
370    /// A new target starts a track; a known target's track is extended unless
371    /// the observation is not newer than the last or implies a speed above
372    /// `max_speed`, in which case it is rejected with
373    /// [`TrafficEvent::ObservationRejected`]. [`TrafficEvent::TargetAcquired`]
374    /// is reported when the track reaches `fixes_to_acquire`.
375    ///
376    /// A new target in a full picture is handled per [`WhenFull`]: rejected, or
377    /// admitted by evicting the least recently observed target
378    /// ([`TrafficEvent::TargetEvicted`]).
379    ///
380    /// # Errors
381    ///
382    /// - [`KernelError::CapacityExceeded`] for a new target in a full picture
383    ///   under [`WhenFull::Refuse`]; the picture is unchanged.
384    /// - A sailing failure in the plausibility check.
385    pub fn ingest(&mut self, observation: TargetObservation) -> Result<EventList<TrafficEvent>> {
386        let mut events = EventList::new();
387        let index = self
388            .tracks
389            .iter()
390            .position(|track| track.target() == observation.target());
391
392        let Some(index) = index else {
393            if self.tracks.len() >= N {
394                self.make_room(&observation, &mut events)?;
395            }
396            let mut track = TargetTrack::started_by(&observation);
397            if self.policy.fixes_to_acquire <= 1 {
398                track.acquire();
399                events.push(TrafficEvent::TargetAcquired {
400                    target: observation.target(),
401                    at: observation.at(),
402                });
403            }
404            self.tracks.push(track).map_err(|full| {
405                NavigationError::Kernel(KernelError::CapacityExceeded {
406                    context: "the traffic picture",
407                    needed: full.capacity.saturating_add(1),
408                    capacity: full.capacity,
409                })
410            })?;
411            return Ok(events);
412        };
413
414        let Some(track) = self.tracks.as_mut_slice().get_mut(index) else {
415            return Ok(events);
416        };
417        let Some(elapsed) = observation
418            .at()
419            .checked_duration_since(track.last_seen())
420            .filter(|elapsed| !elapsed.is_zero())
421        else {
422            events.push(TrafficEvent::ObservationRejected {
423                target: observation.target(),
424                reason: RejectionReason::OutOfOrder,
425                at: observation.at(),
426            });
427            return Ok(events);
428        };
429        let jump = great_circle(track.last_position(), observation.position())?.distance;
430        let implied = jump.nautical_miles() / (elapsed.as_secs_f64() / 3600.0);
431        if !implied.is_finite() || implied > self.policy.max_speed.knots() {
432            events.push(TrafficEvent::ObservationRejected {
433                target: observation.target(),
434                reason: RejectionReason::ImplausibleJump {
435                    implied_speed: Speed::from_knots_unchecked(implied.min(f64::MAX)),
436                },
437                at: observation.at(),
438            });
439            return Ok(events);
440        }
441
442        track.extend(&observation);
443        if track.status() == TrackStatus::Acquiring
444            && track.fix_count() >= usize::from(self.policy.fixes_to_acquire)
445        {
446            track.acquire();
447            events.push(TrafficEvent::TargetAcquired {
448                target: observation.target(),
449                at: observation.at(),
450            });
451        }
452        Ok(events)
453    }
454
455    /// Frees a slot for `newcomer` in a full picture, per policy.
456    fn make_room(
457        &mut self,
458        newcomer: &TargetObservation,
459        events: &mut EventList<TrafficEvent>,
460    ) -> Result<()> {
461        match self.policy.when_full {
462            WhenFull::Refuse => Err(NavigationError::Kernel(KernelError::CapacityExceeded {
463                context: "the traffic picture",
464                needed: N.saturating_add(1),
465                capacity: N,
466            })),
467            WhenFull::EvictStalest => {
468                let stalest = self
469                    .tracks
470                    .iter()
471                    .enumerate()
472                    .min_by_key(|(_, track)| track.last_seen())
473                    .map(|(index, _)| index);
474                if let Some(evicted) = stalest.and_then(|index| self.tracks.remove(index)) {
475                    events.push(TrafficEvent::TargetEvicted {
476                        target: evicted.target(),
477                        last_seen: evicted.last_seen(),
478                        for_target: newcomer.target(),
479                    });
480                }
481                Ok(())
482            }
483        }
484    }
485
486    /// Drops tracks not observed within `lost_after`, reporting
487    /// [`TrafficEvent::TargetLost`] for each.
488    ///
489    /// The picture has no clock; call this as often as targets should expire.
490    /// The returned list has room for one loss per target.
491    pub fn sweep(&mut self, now: Instant<Utc>) -> EventList<TrafficEvent, N> {
492        let mut events = EventList::with_capacity();
493        let mut kept = Inline::<TargetTrack, N>::new(TargetTrack::placeholder());
494        for track in self.tracks.iter() {
495            if track.age(now) > self.policy.lost_after {
496                events.push(TrafficEvent::TargetLost {
497                    target: track.target(),
498                    last_seen: track.last_seen(),
499                });
500            } else {
501                // The new store has the capacity of the old one.
502                let _ = kept.push(*track);
503            }
504        }
505        self.tracks = kept;
506        events
507    }
508
509    /// Picture at `now`: each target at its extrapolated position, with motion,
510    /// age and staleness.
511    #[must_use]
512    pub fn view(&self, now: Instant<Utc>) -> TrafficView<N> {
513        let mut targets = Inline::<TargetView, N>::new(TargetView::PLACEHOLDER);
514        for track in self.tracks.iter() {
515            let age = track.age(now);
516            // If extrapolation fails (across a pole), fall back to the last
517            // observed position instead of dropping the target.
518            let position = track.position_at(now).unwrap_or(track.last_position());
519            let _ = targets.push(TargetView {
520                target: track.target(),
521                status: track.status(),
522                position,
523                motion: track.motion(),
524                heading: track.heading(),
525                age,
526                stale: age > self.policy.stale_after,
527            });
528        }
529        TrafficView { at: now, targets }
530    }
531}
532
533/// One target in the view.
534#[derive(Debug, Clone, Copy, PartialEq)]
535#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
536pub struct TargetView {
537    /// Target.
538    pub target: TargetId,
539    /// Whether acquired.
540    pub status: TrackStatus,
541    /// Extrapolated current position.
542    pub position: Position,
543    /// Course and speed over ground, if known.
544    pub motion: Option<GroundTrack>,
545    /// Reported heading, if any.
546    pub heading: Option<TrueCourse>,
547    /// Time since the last observation.
548    pub age: Duration,
549    /// Whether older than the stale timeout.
550    pub stale: bool,
551}
552
553impl TargetView {
554    /// Fill value for the view's store; never read.
555    const PLACEHOLDER: Self = Self {
556        target: TargetId::new(0),
557        status: TrackStatus::Acquiring,
558        position: Position::new(
559            kinavis_kernel::position::Latitude::EQUATOR,
560            kinavis_kernel::position::Longitude::GREENWICH,
561        ),
562        motion: None,
563        heading: None,
564        age: Duration::ZERO,
565        stale: false,
566    };
567}
568
569/// Traffic picture at one instant: read model built by [`Traffic::view`], with
570/// the source picture's capacity.
571#[derive(Debug, Clone, Copy, PartialEq)]
572pub struct TrafficView<const N: usize = MAX_TARGETS> {
573    at: Instant<Utc>,
574    targets: Inline<TargetView, N>,
575}
576
577impl<const N: usize> TrafficView<N> {
578    /// Time of the view.
579    #[must_use]
580    pub const fn at(&self) -> Instant<Utc> {
581        self.at
582    }
583
584    /// Targets, in order of first detection.
585    #[must_use]
586    pub fn targets(&self) -> &[TargetView] {
587        &self.targets
588    }
589
590    /// Number of targets.
591    #[must_use]
592    pub const fn len(&self) -> usize {
593        self.targets.len()
594    }
595
596    /// Whether empty.
597    #[must_use]
598    pub const fn is_empty(&self) -> bool {
599        self.targets.is_empty()
600    }
601
602    /// Entry for a target, if present.
603    #[must_use]
604    pub fn target(&self, target: TargetId) -> Option<&TargetView> {
605        self.targets.iter().find(|view| view.target == target)
606    }
607
608    /// Targets that are not stale.
609    pub fn current(&self) -> impl Iterator<Item = &TargetView> + '_ {
610        self.targets.iter().filter(|view| !view.stale)
611    }
612}
613
614#[cfg(test)]
615#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
616mod tests {
617    use super::*;
618    use kinavis::relative_motion::Contact;
619    use kinavis_kernel::angle::TrueBearing;
620    use kinavis_kernel::units::Distance;
621
622    fn at(latitude: f64, longitude: f64) -> Position {
623        Position::from_degrees(latitude, longitude).unwrap()
624    }
625
626    fn start() -> Instant<Utc> {
627        Instant::from_unix_seconds(1_789_000_000)
628    }
629
630    fn after(seconds: u64) -> Instant<Utc> {
631        start().checked_add(Duration::from_secs(seconds)).unwrap()
632    }
633
634    fn knots(value: f64) -> Speed {
635        Speed::from_knots(value).unwrap()
636    }
637
638    fn policy() -> TrackingPolicy {
639        TrackingPolicy::new(
640            3,
641            Duration::from_secs(30),
642            Duration::from_secs(180),
643            knots(60.0),
644        )
645        .unwrap()
646    }
647
648    /// Target heading north at 12 kn from 50°N 1°W, plotted every `interval`
649    /// seconds; fix `n` at `n × interval`.
650    fn northbound(target: u32, n: u64, interval: u64) -> TargetObservation {
651        let miles = 12.0 * f64::from(u32::try_from(n * interval).unwrap()) / 3600.0;
652        TargetObservation::new(
653            TargetId::new(target),
654            at(50.0 + miles / 60.0, -1.0),
655            after(n * interval),
656        )
657    }
658
659    fn kinds(events: &[TrafficEvent]) -> alloc::vec::Vec<&'static str> {
660        events
661            .iter()
662            .map(|event| match event {
663                TrafficEvent::TargetAcquired { .. } => "acquired",
664                TrafficEvent::TargetLost { .. } => "lost",
665                TrafficEvent::ObservationRejected { .. } => "rejected",
666                _ => "other",
667            })
668            .collect()
669    }
670
671    #[test]
672    fn a_target_is_acquired_on_the_policys_fix_and_not_before() {
673        let mut traffic = Traffic::new(policy());
674        assert!(traffic.is_empty());
675
676        assert!(traffic.ingest(northbound(7, 0, 60)).unwrap().is_empty());
677        assert_eq!(traffic.len(), 1);
678        assert_eq!(
679            traffic.track(TargetId::new(7)).unwrap().status(),
680            TrackStatus::Acquiring
681        );
682        assert!(traffic.ingest(northbound(7, 1, 60)).unwrap().is_empty());
683        let events = traffic.ingest(northbound(7, 2, 60)).unwrap();
684        assert_eq!(kinds(&events), ["acquired"]);
685        assert!(matches!(
686            events[0],
687            TrafficEvent::TargetAcquired { target, at } if target == TargetId::new(7) && at == after(120)
688        ));
689        assert_eq!(
690            traffic.track(TargetId::new(7)).unwrap().status(),
691            TrackStatus::Tracking
692        );
693        // Acquired once; no second event.
694        assert!(traffic.ingest(northbound(7, 3, 60)).unwrap().is_empty());
695    }
696
697    #[test]
698    fn a_policy_of_one_fix_acquires_on_sight() {
699        let mut traffic = Traffic::new(
700            TrackingPolicy::new(
701                1,
702                policy().stale_after(),
703                policy().lost_after(),
704                policy().max_speed(),
705            )
706            .unwrap(),
707        );
708        let events = traffic.ingest(northbound(9, 0, 10)).unwrap();
709        assert_eq!(kinds(&events), ["acquired"]);
710    }
711
712    #[test]
713    fn the_fitted_motion_is_the_targets_course_and_speed() {
714        let mut traffic = Traffic::new(policy());
715        for n in 0..5 {
716            let _ = traffic.ingest(northbound(7, n, 60)).unwrap();
717        }
718        let track = traffic.track(TargetId::new(7)).unwrap();
719        assert_eq!(track.fix_count(), 5);
720        assert_eq!(track.first_seen(), start());
721        assert_eq!(track.last_seen(), after(240));
722        let motion = track.fitted_motion().unwrap();
723        assert!(
724            motion.course_over_ground.degrees() < 0.01
725                || motion.course_over_ground.degrees() > 359.99
726        );
727        assert!((motion.speed_over_ground.knots() - 12.0).abs() < 0.01);
728        assert_eq!(track.motion(), Some(motion));
729        assert_eq!(track.reported_ground_track(), None);
730        assert_eq!(track.as_vessel().unwrap().speed, motion.speed_over_ground);
731
732        // +1 min: 0.2 NM further north; −1 min: less.
733        let ahead = track.position_at(after(300)).unwrap();
734        assert!((ahead.latitude().degrees() - (50.0 + 1.0 / 60.0)).abs() < 1e-4);
735        let behind = track.position_at(after(180)).unwrap();
736        assert!((behind.latitude().degrees() - (50.0 + 0.6 / 60.0)).abs() < 1e-4);
737    }
738
739    #[test]
740    fn the_fit_smooths_a_noisy_plot() {
741        let mut traffic = Traffic::new(policy());
742        // True track, every other plot offset 1 cable east.
743        for n in 0..8 {
744            let mut observation = northbound(7, n, 60);
745            if n % 2 == 1 {
746                observation = TargetObservation::new(
747                    observation.target(),
748                    at(
749                        observation.position().latitude().degrees(),
750                        -1.0 + 0.1 / 60.0 / math::cos(50.0_f64.to_radians()),
751                    ),
752                    observation.at(),
753                );
754            }
755            let _ = traffic.ingest(observation).unwrap();
756        }
757        let motion = traffic
758            .track(TargetId::new(7))
759            .unwrap()
760            .fitted_motion()
761            .unwrap();
762        // Northbound at 12 kn, with small scatter.
763        let course = motion.course_over_ground.degrees();
764        assert!(course < 2.0 || course > 358.0, "{course}");
765        assert!((motion.speed_over_ground.knots() - 12.0).abs() < 0.5);
766    }
767
768    #[test]
769    fn a_reported_track_beats_the_fitted_one() {
770        let mut traffic = Traffic::new(policy());
771        let reported = GroundTrack {
772            course_over_ground: TrueCourse::new(3.0).unwrap(),
773            speed_over_ground: knots(11.5),
774        };
775        let _ = traffic
776            .ingest(northbound(7, 0, 60).with_ground_track(reported))
777            .unwrap();
778        // One fix with a report: motion available immediately.
779        let track = traffic.track(TargetId::new(7)).unwrap();
780        assert_eq!(track.fitted_motion(), None);
781        assert_eq!(track.motion(), Some(reported));
782
783        let _ = traffic
784            .ingest(northbound(7, 1, 60).with_heading(TrueCourse::new(5.0).unwrap()))
785            .unwrap();
786        let track = traffic.track(TargetId::new(7)).unwrap();
787        // Course/speed report kept from the earlier observation, heading from
788        // the later one.
789        assert_eq!(track.motion(), Some(reported));
790        assert_eq!(track.heading(), Some(TrueCourse::new(5.0).unwrap()));
791        assert!(track.fitted_motion().is_some());
792    }
793
794    #[test]
795    fn the_window_keeps_the_latest_fixes() {
796        let mut traffic = Traffic::new(policy());
797        for n in 0..20 {
798            let _ = traffic.ingest(northbound(7, n, 30)).unwrap();
799        }
800        let track = traffic.track(TargetId::new(7)).unwrap();
801        assert_eq!(track.fix_count(), MAX_TRACK_HISTORY);
802        assert_eq!(track.last_seen(), after(19 * 30));
803        assert_eq!(
804            track.first_seen(),
805            after((20 - MAX_TRACK_HISTORY as u64) * 30)
806        );
807        let motion = track.fitted_motion().unwrap();
808        assert!((motion.speed_over_ground.knots() - 12.0).abs() < 0.01);
809    }
810
811    #[test]
812    fn an_observation_out_of_order_or_too_fast_is_turned_away() {
813        let mut traffic = Traffic::new(policy());
814        let _ = traffic.ingest(northbound(7, 1, 60)).unwrap();
815
816        // Same time again, and an earlier time.
817        let same = traffic.ingest(northbound(7, 1, 60)).unwrap();
818        assert!(matches!(
819            same[0],
820            TrafficEvent::ObservationRejected {
821                reason: RejectionReason::OutOfOrder,
822                ..
823            }
824        ));
825        let earlier = traffic.ingest(northbound(7, 0, 60)).unwrap();
826        assert_eq!(kinds(&earlier), ["rejected"]);
827
828        // 10 NM in 1 min: 600 kn.
829        let jump = traffic
830            .ingest(TargetObservation::new(
831                TargetId::new(7),
832                at(50.0 + 10.0 / 60.0, -1.0),
833                after(120),
834            ))
835            .unwrap();
836        assert!(matches!(
837            jump[0],
838            TrafficEvent::ObservationRejected {
839                target,
840                reason: RejectionReason::ImplausibleJump { implied_speed },
841                at
842            } if implied_speed.knots() > 500.0 && at == after(120) && target == TargetId::new(7)
843        ));
844        // Track unchanged.
845        let track = traffic.track(TargetId::new(7)).unwrap();
846        assert_eq!(track.fix_count(), 1);
847        assert_eq!(track.last_seen(), after(60));
848    }
849
850    #[test]
851    fn a_silent_target_goes_stale_and_then_is_lost() {
852        let mut traffic = Traffic::new(policy());
853        for n in 0..3 {
854            let _ = traffic.ingest(northbound(7, n, 60)).unwrap();
855        }
856        let _ = traffic.ingest(northbound(8, 0, 60)).unwrap();
857
858        let fresh = traffic.view(after(130));
859        assert_eq!(fresh.len(), 2);
860        assert!(!fresh.target(TargetId::new(7)).unwrap().stale);
861        assert_eq!(fresh.current().count(), 1);
862        // Target 8 last seen at the start, more than 30 s ago.
863        assert!(fresh.target(TargetId::new(8)).unwrap().stale);
864        assert_eq!(fresh.at(), after(130));
865
866        let stale = traffic.view(after(160));
867        assert!(stale.target(TargetId::new(7)).unwrap().stale);
868        assert_eq!(
869            stale.target(TargetId::new(7)).unwrap().age,
870            Duration::from_secs(40)
871        );
872        assert_eq!(stale.current().count(), 0);
873
874        // Nothing is dropped before a sweep, nor before the policy timeout.
875        assert!(traffic.sweep(after(170)).is_empty());
876        assert_eq!(traffic.len(), 2);
877        let events = traffic.sweep(after(181));
878        assert_eq!(traffic.len(), 1);
879        assert!(matches!(
880            events[0],
881            TrafficEvent::TargetLost { target, last_seen }
882                if target == TargetId::new(8) && last_seen == start()
883        ));
884        let events = traffic.sweep(after(120 + 181));
885        assert_eq!(kinds(&events), ["lost"]);
886        assert!(traffic.is_empty());
887        assert!(traffic.view(after(1000)).is_empty());
888    }
889
890    #[test]
891    fn the_view_carries_the_target_forward() {
892        let mut traffic = Traffic::new(policy());
893        for n in 0..3 {
894            let _ = traffic.ingest(northbound(7, n, 60)).unwrap();
895        }
896        let view = traffic.view(after(180));
897        let seen = view.targets()[0];
898        assert_eq!(seen.target, TargetId::new(7));
899        assert_eq!(seen.status, TrackStatus::Tracking);
900        // 3 min at 12 kn: 0.6 NM north.
901        assert!((seen.position.latitude().degrees() - (50.0 + 0.6 / 60.0)).abs() < 1e-4);
902        assert!(seen.motion.is_some());
903        assert_eq!(seen.heading, None);
904        assert_eq!(seen.age, Duration::from_secs(60));
905    }
906
907    #[test]
908    fn a_full_picture_refuses_a_new_target_and_keeps_the_old() {
909        let mut traffic = Traffic::new(policy());
910        for target in 0..MAX_TARGETS {
911            let _ = traffic
912                .ingest(northbound(u32::try_from(target).unwrap(), 0, 60))
913                .unwrap();
914        }
915        assert_eq!(traffic.len(), MAX_TARGETS);
916        assert!(matches!(
917            traffic
918                .ingest(northbound(u32::try_from(MAX_TARGETS).unwrap(), 0, 60))
919                .unwrap_err(),
920            NavigationError::Kernel(KernelError::CapacityExceeded {
921                context: "the traffic picture",
922                ..
923            })
924        ));
925        assert_eq!(traffic.len(), MAX_TARGETS);
926        // A known target is still accepted.
927        assert!(traffic.ingest(northbound(3, 1, 60)).is_ok());
928    }
929
930    #[test]
931    fn a_radar_contact_becomes_a_position() {
932        let own = at(50.0, -1.0);
933        let observation = TargetObservation::from_contact(
934            TargetId::new(4),
935            own,
936            Contact {
937                bearing: TrueBearing::new(90.0).unwrap(),
938                range: Distance::from_nautical_miles(6.0).unwrap(),
939            },
940            start(),
941        )
942        .unwrap();
943        assert_eq!(observation.target(), TargetId::new(4));
944        assert_eq!(observation.at(), start());
945        assert!((observation.position().latitude().degrees() - 50.0).abs() < 1e-9);
946        assert!(observation.position().longitude().degrees() > -1.0 + 0.15);
947        assert_eq!(observation.ground_track(), None);
948        assert_eq!(observation.heading(), None);
949    }
950
951    #[test]
952    fn a_policy_must_make_sense() {
953        let (stale, lost) = (Duration::from_secs(30), Duration::from_secs(180));
954        assert!(TrackingPolicy::new(0, stale, lost, knots(60.0)).is_err());
955        assert!(TrackingPolicy::new(3, stale, Duration::from_secs(10), knots(60.0)).is_err());
956        assert!(TrackingPolicy::new(3, stale, lost, Speed::ZERO).is_err());
957        assert_eq!(Traffic::new(policy()).policy(), policy());
958        assert_eq!(policy().on_full(), WhenFull::Refuse);
959        assert_eq!(
960            policy().when_full(WhenFull::EvictStalest).on_full(),
961            WhenFull::EvictStalest
962        );
963    }
964
965    #[test]
966    fn a_full_picture_told_to_evict_drops_the_stalest_and_says_so() {
967        let mut traffic = Traffic::<4>::with_capacity(policy().when_full(WhenFull::EvictStalest));
968        // Four targets first seen at 0, 10, 20, 30 s; target 1 seen again at 40
969        // s, so target 0 is the stalest.
970        for target in 0..4_u64 {
971            let observation = TargetObservation::new(
972                TargetId::new(u32::try_from(target).unwrap()),
973                at(50.0, -1.0),
974                after(10 * target),
975            );
976            let _ = traffic.ingest(observation).unwrap();
977        }
978        let _ = traffic
979            .ingest(TargetObservation::new(
980                TargetId::new(1),
981                at(50.01, -1.0),
982                after(40),
983            ))
984            .unwrap();
985        let events = traffic
986            .ingest(TargetObservation::new(
987                TargetId::new(9),
988                at(50.0, -1.0),
989                after(50),
990            ))
991            .unwrap();
992        assert_eq!(traffic.len(), 4);
993        assert!(traffic.track(TargetId::new(0)).is_none());
994        assert!(traffic.track(TargetId::new(9)).is_some());
995        assert_eq!(
996            events.as_slice(),
997            [TrafficEvent::TargetEvicted {
998                target: TargetId::new(0),
999                last_seen: start(),
1000                for_target: TargetId::new(9),
1001            }]
1002        );
1003        // A flood of newcomers cycles through the free slots and never evicts
1004        // the target that keeps reporting.
1005        for flood in 100..200_u64 {
1006            let seen = after(60 + flood);
1007            let _ = traffic
1008                .ingest(TargetObservation::new(
1009                    TargetId::new(1),
1010                    at(50.02, -1.0),
1011                    seen,
1012                ))
1013                .unwrap();
1014            let newcomer = TargetId::new(u32::try_from(flood).unwrap());
1015            let _ = traffic
1016                .ingest(TargetObservation::new(newcomer, at(50.0, -1.0), seen))
1017                .unwrap();
1018        }
1019        assert_eq!(traffic.len(), 4);
1020        assert!(traffic.track(TargetId::new(1)).is_some());
1021    }
1022}