1use 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#[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 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 #[must_use]
61 pub const fn cpa_limit(&self) -> Distance {
62 self.cpa_limit
63 }
64
65 #[must_use]
67 pub const fn tcpa_limit(&self) -> Duration {
68 self.tcpa_limit
69 }
70}
71
72#[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#[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 Opening,
110 Passing,
112 Developing,
114 Dangerous,
116}
117
118#[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 #[must_use]
138 pub const fn range(&self) -> Distance {
139 self.range
140 }
141
142 #[must_use]
144 pub const fn bearing(&self) -> TrueBearing {
145 self.bearing
146 }
147
148 #[must_use]
150 pub const fn relative_course(&self) -> TrueCourse {
151 self.relative_course
152 }
153
154 #[must_use]
156 pub const fn relative_speed(&self) -> Speed {
157 self.relative_speed
158 }
159
160 #[must_use]
163 pub const fn bearing_drift(&self) -> RateOfTurn {
164 self.bearing_drift
165 }
166
167 #[must_use]
170 pub const fn cpa(&self) -> Option<Cpa> {
171 self.cpa
172 }
173
174 #[must_use]
176 pub fn cpa_distance(&self) -> Option<Distance> {
177 self.cpa.map(|cpa| cpa.distance)
178 }
179
180 #[must_use]
182 pub fn tcpa(&self) -> Option<Duration> {
183 self.cpa.map(|cpa| cpa.time_to_go)
184 }
185
186 #[must_use]
188 pub fn bearing_at_cpa(&self) -> Option<TrueBearing> {
189 self.cpa.map(|cpa| cpa.bearing)
190 }
191
192 #[must_use]
194 pub const fn bow_crossing(&self) -> Option<Distance> {
195 self.bow_crossing
196 }
197
198 #[must_use]
200 pub const fn risk(&self) -> CollisionRisk {
201 self.risk
202 }
203}
204
205pub 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 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
270pub 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#[derive(Debug, Clone, Copy, PartialEq)]
299#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
300pub struct TargetAssessment {
301 pub target: TargetId,
303 pub assessment: CollisionAssessment,
305}
306
307#[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 #[must_use]
319 pub const fn at(&self) -> Instant<Utc> {
320 self.at
321 }
322
323 #[must_use]
325 pub fn targets(&self) -> &[TargetAssessment] {
326 &self.targets
327 }
328
329 #[must_use]
331 pub const fn len(&self) -> usize {
332 self.targets.len()
333 }
334
335 #[must_use]
337 pub const fn is_empty(&self) -> bool {
338 self.targets.is_empty()
339 }
340
341 #[must_use]
343 pub fn target(&self, target: TargetId) -> Option<&TargetAssessment> {
344 self.targets.iter().find(|entry| entry.target == target)
345 }
346
347 #[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 right
357 .assessment
358 .tcpa()
359 .unwrap_or(Duration::MAX)
360 .cmp(&left.assessment.tcpa().unwrap_or(Duration::MAX))
361 })
362 })
363 }
364
365 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
373pub 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 let _ = targets.push(TargetAssessment {
435 target: track.target(),
436 assessment,
437 });
438 }
439 Ok((CollisionPicture { at: now, targets }, events))
440}
441
442fn 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 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 assert!((assessment.relative_course().degrees() - 225.0).abs() < 1e-9);
503 assert!((assessment.relative_speed().knots() - 21.21).abs() < 0.01);
504 assert!(assessment.bow_crossing().unwrap().nautical_miles() > 0.0);
506 assert_eq!(assessment.risk(), CollisionRisk::Passing);
507
508 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 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 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 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 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 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 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 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 let _ = traffic.ingest(reporting(1, 45.0, 270.0, 10.0)).unwrap();
651 let _ = traffic.ingest(reporting(2, 0.0, 0.0, 15.0)).unwrap();
653 let _ = traffic.ingest(reporting(3, 270.0, 0.0, 10.0)).unwrap();
655 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 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 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}