Skip to main content

kinavis_traffic/
assessment.rs

1//! Collision assessment: CPA, TCPA, bearing rate, bow crossing range and risk.
2//!
3//! Kinematics only: CPA/TCPA from [`closest_point_of_approach`], bearing at
4//! CPA, current bearing rate (a steady bearing on a closing range indicates a
5//! collision course), bow crossing range, and the resulting risk against the
6//! vessel's [`CpaPolicy`]. Give-way responsibility is a rule and lives in
7//! `kinavis-colregs`.
8
9use core::time::Duration;
10
11use kinavis::error::{ensure_range, KernelError, NavigationError, Result};
12use kinavis::relative_motion::{
13    bow_crossing_range, closest_point_of_approach, Approach, Contact, Cpa, Vessel,
14};
15use kinavis::sailings::rhumb_line;
16use kinavis_kernel::angle::{Direction, True, TrueBearing, TrueCourse};
17use kinavis_kernel::event::{EventList, TargetId};
18
19use crate::event::TrafficEvent;
20use kinavis_kernel::inline::Inline;
21use kinavis_kernel::math;
22use kinavis_kernel::position::Position;
23use kinavis_kernel::snapshot::NavigationSnapshot;
24use kinavis_kernel::time::{Instant, Utc};
25use kinavis_kernel::units::{Distance, RateOfTurn, Speed};
26
27use crate::track::TargetTrack;
28use crate::{Traffic, MAX_TARGETS};
29
30/// CPA and TCPA limits: vessel settings.
31///
32/// Validated once by [`CpaPolicy::new`].
33#[derive(Debug, Clone, Copy, PartialEq)]
34#[cfg_attr(
35    feature = "serde",
36    derive(serde::Serialize, serde::Deserialize),
37    serde(try_from = "StoredCpaPolicy", into = "StoredCpaPolicy")
38)]
39pub struct CpaPolicy {
40    cpa_limit: Distance,
41    tcpa_limit: Duration,
42}
43
44impl CpaPolicy {
45    /// A CPA within `cpa_limit` is dangerous; a dangerous CPA with TCPA beyond
46    /// `tcpa_limit` is developing, not yet an alarm.
47    ///
48    /// # Errors
49    ///
50    /// [`KernelError::OutOfRange`] for a negative CPA limit.
51    pub fn new(cpa_limit: Distance, tcpa_limit: Duration) -> Result<Self> {
52        ensure_range("CPA limit", cpa_limit.nautical_miles(), 0.0, f64::MAX)?;
53        Ok(Self {
54            cpa_limit,
55            tcpa_limit,
56        })
57    }
58
59    /// CPA limit.
60    #[must_use]
61    pub const fn cpa_limit(&self) -> Distance {
62        self.cpa_limit
63    }
64
65    /// TCPA limit; beyond it a dangerous approach is developing, not an alarm.
66    #[must_use]
67    pub const fn tcpa_limit(&self) -> Duration {
68        self.tcpa_limit
69    }
70}
71
72/// Serialised form; deserialisation goes through [`CpaPolicy::new`].
73#[cfg(feature = "serde")]
74#[derive(serde::Serialize, serde::Deserialize)]
75struct StoredCpaPolicy {
76    cpa_limit: Distance,
77    tcpa_limit: Duration,
78}
79
80#[cfg(feature = "serde")]
81impl TryFrom<StoredCpaPolicy> for CpaPolicy {
82    type Error = NavigationError;
83
84    fn try_from(stored: StoredCpaPolicy) -> Result<Self> {
85        Self::new(stored.cpa_limit, stored.tcpa_limit)
86    }
87}
88
89#[cfg(feature = "serde")]
90impl From<CpaPolicy> for StoredCpaPolicy {
91    fn from(policy: CpaPolicy) -> Self {
92        Self {
93            cpa_limit: policy.cpa_limit,
94            tcpa_limit: policy.tcpa_limit,
95        }
96    }
97}
98
99/// Risk level of an encounter.
100///
101/// Ordered by increasing concern, so assessments can be compared.
102///
103/// `#[non_exhaustive]`; match with a wildcard arm.
104#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
105#[non_exhaustive]
106#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
107pub enum CollisionRisk {
108    /// Range opening, or no relative motion: CPA is in the past.
109    Opening,
110    /// Closing, CPA outside the limit.
111    Passing,
112    /// Closing, CPA inside the limit, TCPA beyond the limit: monitor.
113    Developing,
114    /// Closing, CPA and TCPA inside the limits: alarm.
115    Dangerous,
116}
117
118/// Assessed encounter.
119///
120/// Projection returned by [`assess`]. CPA is `None` when the range is opening
121/// or the contact holds bearing and range.
122#[derive(Debug, Clone, Copy, PartialEq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
124pub struct CollisionAssessment {
125    range: Distance,
126    bearing: TrueBearing,
127    relative_course: TrueCourse,
128    relative_speed: Speed,
129    bearing_drift: RateOfTurn,
130    cpa: Option<Cpa>,
131    bow_crossing: Option<Distance>,
132    risk: CollisionRisk,
133}
134
135impl CollisionAssessment {
136    /// Current range.
137    #[must_use]
138    pub const fn range(&self) -> Distance {
139        self.range
140    }
141
142    /// Current bearing.
143    #[must_use]
144    pub const fn bearing(&self) -> TrueBearing {
145        self.bearing
146    }
147
148    /// Relative course of the target (direction of its radar echo trail).
149    #[must_use]
150    pub const fn relative_course(&self) -> TrueCourse {
151        self.relative_course
152    }
153
154    /// Relative speed of the target.
155    #[must_use]
156    pub const fn relative_speed(&self) -> Speed {
157        self.relative_speed
158    }
159
160    /// Bearing rate, positive drawing right. Zero on a closing range means a
161    /// collision course.
162    #[must_use]
163    pub const fn bearing_drift(&self) -> RateOfTurn {
164        self.bearing_drift
165    }
166
167    /// Closest point of approach: distance, time, bearing. `None` unless the
168    /// range is closing.
169    #[must_use]
170    pub const fn cpa(&self) -> Option<Cpa> {
171        self.cpa
172    }
173
174    /// CPA distance, if ahead.
175    #[must_use]
176    pub fn cpa_distance(&self) -> Option<Distance> {
177        self.cpa.map(|cpa| cpa.distance)
178    }
179
180    /// TCPA, if ahead.
181    #[must_use]
182    pub fn tcpa(&self) -> Option<Duration> {
183        self.cpa.map(|cpa| cpa.time_to_go)
184    }
185
186    /// Bearing at CPA.
187    #[must_use]
188    pub fn bearing_at_cpa(&self) -> Option<TrueBearing> {
189        self.cpa.map(|cpa| cpa.bearing)
190    }
191
192    /// Bow crossing range; `None` if the target will not cross ahead.
193    #[must_use]
194    pub const fn bow_crossing(&self) -> Option<Distance> {
195        self.bow_crossing
196    }
197
198    /// Risk against the policy.
199    #[must_use]
200    pub const fn risk(&self) -> CollisionRisk {
201        self.risk
202    }
203}
204
205/// Assesses one encounter from own course and speed, the contact's bearing and
206/// range, and the target's course and speed.
207///
208/// # Errors
209///
210/// - [`KernelError::OutOfRange`] for a negative range.
211/// - [`KernelError::Indeterminate`] if TCPA is too large to represent.
212pub fn assess(
213    own: Vessel,
214    contact: Contact,
215    target: Vessel,
216    policy: &CpaPolicy,
217) -> Result<CollisionAssessment> {
218    ensure_range("range", contact.range.nautical_miles(), 0.0, f64::MAX)?;
219
220    let (relative_north, relative_east) = relative_velocity(own, target);
221    let relative_speed = math::hypot(relative_north, relative_east);
222    let relative_course = Direction::<True>::from_degrees_wrapped(math::to_degrees(math::atan2(
223        relative_east,
224        relative_north,
225    )));
226
227    // Bearing rate: transverse relative velocity over range, rad/h, converted
228    // to °/min.
229    let (offset_north, offset_east) = (
230        contact.range.nautical_miles() * math::cos(contact.bearing.radians()),
231        contact.range.nautical_miles() * math::sin(contact.bearing.radians()),
232    );
233    let range_squared = contact.range.nautical_miles() * contact.range.nautical_miles();
234    let drift_per_hour = if range_squared > 0.0 {
235        (offset_north * relative_east - offset_east * relative_north) / range_squared
236    } else {
237        0.0
238    };
239    let bearing_drift = RateOfTurn::from_degrees_per_minute(if drift_per_hour.is_finite() {
240        math::to_degrees(drift_per_hour) / 60.0
241    } else {
242        0.0
243    })?;
244
245    let cpa = match closest_point_of_approach(own, contact, target)? {
246        Approach::Closing(cpa) => Some(cpa),
247        _ => None,
248    };
249    let bow_crossing = bow_crossing_range(own, contact, target).ok();
250
251    let risk = match cpa {
252        None => CollisionRisk::Opening,
253        Some(cpa) if cpa.distance > policy.cpa_limit => CollisionRisk::Passing,
254        Some(cpa) if cpa.time_to_go > policy.tcpa_limit => CollisionRisk::Developing,
255        Some(_) => CollisionRisk::Dangerous,
256    };
257
258    Ok(CollisionAssessment {
259        range: contact.range,
260        bearing: contact.bearing,
261        relative_course,
262        relative_speed: Speed::from_knots_unchecked(relative_speed),
263        bearing_drift,
264        cpa,
265        bow_crossing,
266        risk,
267    })
268}
269
270/// Assesses a tracked target against own ship at `now`, using the track's
271/// extrapolated position and motion.
272///
273/// `None` if the track has no motion yet (single radar plot): no course, so no
274/// CPA.
275///
276/// # Errors
277///
278/// As [`assess`], plus a sailing failure placing the target.
279pub fn assess_track(
280    own_position: Position,
281    own: Vessel,
282    track: &TargetTrack,
283    now: Instant<Utc>,
284    policy: &CpaPolicy,
285) -> Result<Option<CollisionAssessment>> {
286    let Some(target) = track.as_vessel() else {
287        return Ok(None);
288    };
289    let line = rhumb_line(own_position, track.position_at(now)?)?;
290    let contact = Contact {
291        bearing: TrueBearing::new(line.initial_course.degrees())?,
292        range: line.distance,
293    };
294    assess(own, contact, target, policy).map(Some)
295}
296
297/// One target's assessment.
298#[derive(Debug, Clone, Copy, PartialEq)]
299#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
300pub struct TargetAssessment {
301    /// Target.
302    pub target: TargetId,
303    /// Encounter with own ship.
304    pub assessment: CollisionAssessment,
305}
306
307/// All tracked targets assessed against own ship at one instant.
308///
309/// Read model built by [`assess_traffic`]. Targets without motion are omitted.
310#[derive(Debug, Clone, Copy, PartialEq)]
311pub struct CollisionPicture<const N: usize = MAX_TARGETS> {
312    at: Instant<Utc>,
313    targets: Inline<TargetAssessment, N>,
314}
315
316impl<const N: usize> CollisionPicture<N> {
317    /// Time of the picture.
318    #[must_use]
319    pub const fn at(&self) -> Instant<Utc> {
320        self.at
321    }
322
323    /// Assessments, in order of first detection.
324    #[must_use]
325    pub fn targets(&self) -> &[TargetAssessment] {
326        &self.targets
327    }
328
329    /// Number of assessed targets.
330    #[must_use]
331    pub const fn len(&self) -> usize {
332        self.targets.len()
333    }
334
335    /// Whether no target was assessed.
336    #[must_use]
337    pub const fn is_empty(&self) -> bool {
338        self.targets.is_empty()
339    }
340
341    /// Assessment for one target, if present.
342    #[must_use]
343    pub fn target(&self, target: TargetId) -> Option<&TargetAssessment> {
344        self.targets.iter().find(|entry| entry.target == target)
345    }
346
347    /// Most dangerous target: highest risk, then earliest CPA. `None` if empty.
348    #[must_use]
349    pub fn most_dangerous(&self) -> Option<&TargetAssessment> {
350        self.targets.iter().max_by(|left, right| {
351            left.assessment
352                .risk
353                .cmp(&right.assessment.risk)
354                .then_with(|| {
355                    // Earlier CPA ranks higher: greater TCPA sorts lower.
356                    right
357                        .assessment
358                        .tcpa()
359                        .unwrap_or(Duration::MAX)
360                        .cmp(&left.assessment.tcpa().unwrap_or(Duration::MAX))
361                })
362        })
363    }
364
365    /// Targets with risk ≥ `least`, in order of first detection.
366    pub fn at_least(&self, least: CollisionRisk) -> impl Iterator<Item = &TargetAssessment> + '_ {
367        self.targets
368            .iter()
369            .filter(move |entry| entry.assessment.risk >= least)
370    }
371}
372
373/// Assesses every target against own ship from the snapshot, reporting
374/// [`TrafficEvent::CpaAlarm`] for each dangerous one.
375///
376/// Pure function of snapshot and picture: alarms are repeated on every call
377/// while the condition holds.
378///
379/// # Errors
380///
381/// - [`KernelError::Indeterminate`] if the snapshot has no position or ground
382///   track (a stopped ship has a ground track with zero speed).
383/// - As [`assess_track`] for any target.
384pub fn assess_traffic<const N: usize>(
385    state: &NavigationSnapshot,
386    traffic: &Traffic<N>,
387    policy: &CpaPolicy,
388) -> Result<(CollisionPicture<N>, EventList<TrafficEvent, N>)> {
389    let observed = state
390        .position()
391        .ok_or(NavigationError::Kernel(KernelError::Missing {
392            what: "the vessel's position",
393        }))?;
394    let ground = state
395        .ground_track()
396        .ok_or(NavigationError::Kernel(KernelError::Missing {
397            what: "the vessel's course and speed over the ground",
398        }))?;
399    let now = observed.taken_at();
400    let own = Vessel {
401        course: ground.course_over_ground,
402        speed: ground.speed_over_ground,
403    };
404
405    let mut events = EventList::with_capacity();
406    let mut targets = Inline::<TargetAssessment, N>::new(TargetAssessment {
407        target: TargetId::new(0),
408        assessment: CollisionAssessment {
409            range: Distance::ZERO,
410            bearing: TrueBearing::NORTH,
411            relative_course: TrueCourse::NORTH,
412            relative_speed: Speed::ZERO,
413            bearing_drift: RateOfTurn::ZERO,
414            cpa: None,
415            bow_crossing: None,
416            risk: CollisionRisk::Opening,
417        },
418    });
419    for track in traffic.tracks() {
420        let Some(assessment) = assess_track(*observed.value(), own, track, now, policy)? else {
421            continue;
422        };
423        if assessment.risk == CollisionRisk::Dangerous {
424            if let Some(cpa) = assessment.cpa {
425                events.push(TrafficEvent::CpaAlarm {
426                    target: track.target(),
427                    cpa: cpa.distance,
428                    tcpa: cpa.time_to_go,
429                    at: now,
430                });
431            }
432        }
433        // The store has room for every track in the picture.
434        let _ = targets.push(TargetAssessment {
435            target: track.target(),
436            assessment,
437        });
438    }
439    Ok((CollisionPicture { at: now, targets }, events))
440}
441
442/// Target velocity minus own velocity, north and east, knots.
443fn relative_velocity(own: Vessel, target: Vessel) -> (f64, f64) {
444    let (own_north, own_east) = own.course.components(own.speed.knots());
445    let (target_north, target_east) = target.course.components(target.speed.knots());
446    (target_north - own_north, target_east - own_east)
447}
448
449#[cfg(test)]
450#[allow(clippy::unwrap_used, clippy::float_cmp, clippy::indexing_slicing)]
451mod tests {
452    use super::*;
453    use crate::{TargetObservation, TrackingPolicy};
454    use kinavis_kernel::event::PositionSource;
455    use kinavis_kernel::observation::{ObservationStatus, Observed, Quality};
456    use kinavis_kernel::snapshot::GroundTrack;
457
458    fn knots(value: f64) -> Speed {
459        Speed::from_knots(value).unwrap()
460    }
461
462    fn miles(value: f64) -> Distance {
463        Distance::from_nautical_miles(value).unwrap()
464    }
465
466    fn vessel(course: f64, speed: f64) -> Vessel {
467        Vessel {
468            course: TrueCourse::new(course).unwrap(),
469            speed: knots(speed),
470        }
471    }
472
473    fn contact(bearing: f64, range: f64) -> Contact {
474        Contact {
475            bearing: TrueBearing::new(bearing).unwrap(),
476            range: miles(range),
477        }
478    }
479
480    fn policy(cpa: f64, tcpa_minutes: u64) -> CpaPolicy {
481        CpaPolicy::new(miles(cpa), Duration::from_secs(tcpa_minutes * 60)).unwrap()
482    }
483
484    fn noon() -> Instant<Utc> {
485        Instant::from_unix_seconds(1_789_000_000)
486    }
487
488    #[test]
489    fn a_crossing_target_is_assessed_as_the_plotting_triangle_says() {
490        // The `relative_motion` example: 2.59 NM in 27 min.
491        let own = vessel(0.0, 15.0);
492        let target = vessel(270.0, 15.0);
493        let assessment = assess(own, contact(30.0, 10.0), target, &policy(2.0, 30)).unwrap();
494
495        assert!((assessment.cpa_distance().unwrap().nautical_miles() - 2.59).abs() < 0.01);
496        assert_eq!(assessment.tcpa().unwrap().as_secs() / 60, 27);
497        assert!(assessment.bearing_at_cpa().is_some());
498        assert_eq!(assessment.range(), miles(10.0));
499        assert_eq!(assessment.bearing(), TrueBearing::new(30.0).unwrap());
500        // Relative motion: target 15 kn west minus own 15 kn north = 225° at
501        // 21.2 kn.
502        assert!((assessment.relative_course().degrees() - 225.0).abs() < 1e-9);
503        assert!((assessment.relative_speed().knots() - 21.21).abs() < 0.01);
504        // Crosses ahead, passes outside 2 NM.
505        assert!(assessment.bow_crossing().unwrap().nautical_miles() > 0.0);
506        assert_eq!(assessment.risk(), CollisionRisk::Passing);
507
508        // With a 3 NM limit: dangerous with a 30 min TCPA limit, developing
509        // with 20 min.
510        let tight = assess(own, contact(30.0, 10.0), target, &policy(3.0, 30)).unwrap();
511        assert_eq!(tight.risk(), CollisionRisk::Dangerous);
512        let far = assess(own, contact(30.0, 10.0), target, &policy(3.0, 20)).unwrap();
513        assert_eq!(far.risk(), CollisionRisk::Developing);
514    }
515
516    #[test]
517    fn a_steady_bearing_on_a_closing_range_is_a_collision_course() {
518        // Both at 10 kn, target on the starboard bow heading west: steady
519        // bearing, CPA zero.
520        let assessment = assess(
521            vessel(0.0, 10.0),
522            contact(45.0, 5.0),
523            vessel(270.0, 10.0),
524            &policy(1.0, 30),
525        )
526        .unwrap();
527        assert!(assessment.bearing_drift().degrees_per_minute().abs() < 1e-9);
528        assert!(assessment.cpa_distance().unwrap().nautical_miles() < 1e-9);
529        assert_eq!(assessment.risk(), CollisionRisk::Dangerous);
530    }
531
532    #[test]
533    fn the_bearing_drift_is_signed_the_way_the_bearing_draws() {
534        // Dead ahead crossing left to right: bearing draws right.
535        let right = assess(
536            vessel(0.0, 10.0),
537            contact(0.0, 5.0),
538            vessel(90.0, 10.0),
539            &policy(1.0, 30),
540        )
541        .unwrap();
542        assert!(right.bearing_drift().degrees_per_minute() > 0.0);
543        // 10 kn across at 5 NM: 2 rad/h = 1.9°/min.
544        assert!((right.bearing_drift().degrees_per_minute() - 1.91).abs() < 0.01);
545
546        let left = assess(
547            vessel(0.0, 10.0),
548            contact(0.0, 5.0),
549            vessel(270.0, 10.0),
550            &policy(1.0, 30),
551        )
552        .unwrap();
553        assert!(left.bearing_drift().degrees_per_minute() < 0.0);
554    }
555
556    #[test]
557    fn an_opening_range_has_no_closest_approach_ahead() {
558        // Astern, opposite course.
559        let assessment = assess(
560            vessel(0.0, 10.0),
561            contact(180.0, 3.0),
562            vessel(180.0, 10.0),
563            &policy(1.0, 30),
564        )
565        .unwrap();
566        assert_eq!(assessment.cpa(), None);
567        assert_eq!(assessment.tcpa(), None);
568        assert_eq!(assessment.bow_crossing(), None);
569        assert_eq!(assessment.risk(), CollisionRisk::Opening);
570
571        // No relative motion counts as opening.
572        let still = assess(
573            vessel(0.0, 10.0),
574            contact(90.0, 3.0),
575            vessel(0.0, 10.0),
576            &policy(1.0, 30),
577        )
578        .unwrap();
579        assert_eq!(still.risk(), CollisionRisk::Opening);
580        assert_eq!(still.relative_speed(), Speed::ZERO);
581    }
582
583    #[test]
584    fn a_policy_with_a_negative_limit_is_refused() {
585        assert!(matches!(
586            CpaPolicy::new(miles(-1.0), Duration::from_secs(1800)).unwrap_err(),
587            NavigationError::Kernel(KernelError::OutOfRange {
588                parameter: "CPA limit",
589                ..
590            })
591        ));
592        assert!(CpaPolicy::new(Distance::ZERO, Duration::ZERO).is_ok());
593        assert!(assess(
594            vessel(0.0, 10.0),
595            contact(90.0, -3.0),
596            vessel(0.0, 10.0),
597            &policy(1.0, 30),
598        )
599        .is_err());
600    }
601
602    /// Own ship at 50°N 1°W, heading north at 10 kn.
603    fn own_ship() -> NavigationSnapshot {
604        NavigationSnapshot::EMPTY
605            .with_position(
606                Observed::new(
607                    Position::from_degrees(50.0, -1.0).unwrap(),
608                    noon(),
609                    Quality::<Distance>::new(ObservationStatus::Valid),
610                ),
611                PositionSource::Gnss,
612            )
613            .with_ground_track(GroundTrack {
614                course_over_ground: TrueCourse::NORTH,
615                speed_over_ground: knots(10.0),
616            })
617    }
618
619    /// Target with reported course and speed, 5 NM off on the given bearing.
620    fn reporting(id: u32, bearing: f64, course: f64, speed: f64) -> TargetObservation {
621        let here = Position::from_degrees(50.0, -1.0).unwrap();
622        let there = kinavis::sailings::rhumb_destination(
623            here,
624            TrueCourse::new(bearing).unwrap(),
625            miles(5.0),
626        )
627        .unwrap();
628        TargetObservation::new(TargetId::new(id), there, noon()).with_ground_track(GroundTrack {
629            course_over_ground: TrueCourse::new(course).unwrap(),
630            speed_over_ground: knots(speed),
631        })
632    }
633
634    fn traffic() -> Traffic {
635        Traffic::new(
636            TrackingPolicy::new(
637                1,
638                Duration::from_secs(30),
639                Duration::from_secs(180),
640                knots(60.0),
641            )
642            .unwrap(),
643        )
644    }
645
646    #[test]
647    fn the_picture_assesses_every_moving_target_and_alarms_on_the_dangerous() {
648        let mut traffic = traffic();
649        // Starboard bow, heading west at own speed: collision course.
650        let _ = traffic.ingest(reporting(1, 45.0, 270.0, 10.0)).unwrap();
651        // Ahead, same course, faster: opening.
652        let _ = traffic.ingest(reporting(2, 0.0, 0.0, 15.0)).unwrap();
653        // Abeam to port, heading north at own speed: no relative motion.
654        let _ = traffic.ingest(reporting(3, 270.0, 0.0, 10.0)).unwrap();
655        // Single plot, nothing reported: no motion, not assessed.
656        let _ = traffic
657            .ingest(TargetObservation::new(
658                TargetId::new(4),
659                Position::from_degrees(50.1, -0.9).unwrap(),
660                noon(),
661            ))
662            .unwrap();
663
664        let (picture, events) = assess_traffic(&own_ship(), &traffic, &policy(1.0, 30)).unwrap();
665        assert_eq!(picture.at(), noon());
666        assert_eq!(picture.len(), 3);
667        assert!(picture.target(TargetId::new(4)).is_none());
668
669        let first = picture.target(TargetId::new(1)).unwrap();
670        assert_eq!(first.assessment.risk(), CollisionRisk::Dangerous);
671        assert!(first.assessment.cpa_distance().unwrap().nautical_miles() < 0.05);
672        assert_eq!(
673            picture.target(TargetId::new(2)).unwrap().assessment.risk(),
674            CollisionRisk::Opening
675        );
676        assert_eq!(
677            picture.target(TargetId::new(3)).unwrap().assessment.risk(),
678            CollisionRisk::Opening
679        );
680        assert_eq!(picture.most_dangerous().unwrap().target, TargetId::new(1));
681        assert_eq!(picture.at_least(CollisionRisk::Passing).count(), 1);
682        assert_eq!(picture.at_least(CollisionRisk::Opening).count(), 3);
683
684        assert_eq!(events.len(), 1);
685        assert!(matches!(
686            events[0],
687            TrafficEvent::CpaAlarm { target, cpa, tcpa, at }
688                if target == TargetId::new(1)
689                    && cpa.nautical_miles() < 0.05
690                    && tcpa.as_secs() / 60 == 21
691                    && at == noon()
692        ));
693    }
694
695    #[test]
696    fn a_track_is_assessed_where_it_is_reckoned_to_be() {
697        let mut traffic = traffic();
698        let _ = traffic.ingest(reporting(1, 90.0, 270.0, 10.0)).unwrap();
699        let track = traffic.track(TargetId::new(1)).unwrap();
700        let own = vessel(0.0, 10.0);
701        let here = Position::from_degrees(50.0, -1.0).unwrap();
702
703        let now = assess_track(here, own, track, noon(), &policy(1.0, 30))
704            .unwrap()
705            .unwrap();
706        assert!((now.range().nautical_miles() - 5.0).abs() < 1e-6);
707        // 12 min later: 2 NM closer and still closing.
708        let later = noon().checked_add(Duration::from_secs(720)).unwrap();
709        let then = assess_track(here, own, track, later, &policy(1.0, 30))
710            .unwrap()
711            .unwrap();
712        assert!((then.range().nautical_miles() - 3.0).abs() < 0.01);
713        assert!(then.tcpa().unwrap() < now.tcpa().unwrap());
714    }
715
716    #[test]
717    fn own_ship_must_be_somewhere_and_moving() {
718        let traffic = traffic();
719        assert!(matches!(
720            assess_traffic(&NavigationSnapshot::EMPTY, &traffic, &policy(1.0, 30)).unwrap_err(),
721            NavigationError::Kernel(KernelError::Missing {
722                what: "the vessel's position"
723            })
724        ));
725        let anchored = NavigationSnapshot::EMPTY.with_position(
726            Observed::new(
727                Position::from_degrees(50.0, -1.0).unwrap(),
728                noon(),
729                Quality::<Distance>::new(ObservationStatus::Valid),
730            ),
731            PositionSource::Gnss,
732        );
733        assert!(matches!(
734            assess_traffic(&anchored, &traffic, &policy(1.0, 30)).unwrap_err(),
735            NavigationError::Kernel(KernelError::Missing { .. })
736        ));
737        // Empty picture yields an empty result, not an error.
738        let (picture, events) = assess_traffic(&own_ship(), &traffic, &policy(1.0, 30)).unwrap();
739        assert!(picture.is_empty());
740        assert!(picture.most_dangerous().is_none());
741        assert!(events.is_empty());
742    }
743}