1use core::ops::Deref;
4
5use kinavis_kernel::inline::Inline;
6use kinavis_kernel::time::{Instant, Utc};
7use kinavis_kernel::{KernelError, Result};
8
9use crate::{Alert, AlertId, AlertKind, AlertPolicy, AlertPriority, AlertState, Ended, Reportable};
10
11pub const MAX_ALERTS: usize = 32;
19
20pub const MAX_CHANGES: usize = 16;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27#[non_exhaustive]
28#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
29pub enum AlertChange {
30 Raised(AlertId),
32 Acknowledged(AlertId),
34 Rectified(AlertId),
36 Cleared(AlertId),
38 Dropped(AlertId),
41}
42
43#[must_use = "an unread change is an alert nobody annunciated"]
48#[derive(Debug, Clone, Copy)]
49pub struct AlertChanges {
50 changes: Inline<AlertChange, MAX_CHANGES>,
51 overflowed: bool,
52 lost: bool,
53}
54
55impl AlertChanges {
56 const fn new() -> Self {
57 Self {
58 changes: Inline::new(AlertChange::Raised(AlertId(0))),
59 overflowed: false,
60 lost: false,
61 }
62 }
63
64 fn push(&mut self, change: AlertChange) {
65 if self.changes.push(change).is_err() {
66 self.overflowed = true;
67 }
68 }
69
70 #[must_use]
73 pub const fn overflowed(&self) -> bool {
74 self.overflowed
75 }
76
77 #[must_use]
80 pub const fn lost(&self) -> bool {
81 self.lost
82 }
83}
84
85impl Deref for AlertChanges {
86 type Target = [AlertChange];
87
88 fn deref(&self) -> &[AlertChange] {
89 &self.changes
90 }
91}
92
93#[derive(Debug, Clone)]
105pub struct AlertManager<P: AlertPolicy> {
106 policy: P,
107 alerts: Inline<Alert, MAX_ALERTS>,
108 next: u32,
109}
110
111impl<P: AlertPolicy> AlertManager<P> {
112 pub fn new(policy: P) -> Self {
114 Self {
115 policy,
116 alerts: Inline::new(Alert {
117 id: AlertId(0),
118 kind: AlertKind::ObservationRejected,
119 priority: AlertPriority::Caution,
120 state: AlertState::Active,
121 raised_at: Instant::UNIX_EPOCH,
122 last_reported_at: Instant::UNIX_EPOCH,
123 occurrences: 0,
124 }),
125 next: 1,
126 }
127 }
128
129 pub const fn policy(&self) -> &P {
131 &self.policy
132 }
133
134 #[must_use]
136 pub fn alerts(&self) -> &[Alert] {
137 &self.alerts
138 }
139
140 #[must_use]
142 pub const fn len(&self) -> usize {
143 self.alerts.len()
144 }
145
146 #[must_use]
148 pub const fn is_empty(&self) -> bool {
149 self.alerts.is_empty()
150 }
151
152 #[must_use]
154 pub fn alert(&self, id: AlertId) -> Option<&Alert> {
155 self.alerts.iter().find(|alert| alert.id == id)
156 }
157
158 pub fn unacknowledged(&self) -> impl Iterator<Item = &Alert> + '_ {
160 self.alerts
161 .iter()
162 .filter(|alert| alert.state != AlertState::Acknowledged)
163 }
164
165 #[must_use]
167 pub fn highest(&self) -> Option<&Alert> {
168 self.alerts.first()
169 }
170
171 pub fn ingest<E: Reportable>(&mut self, events: &[E], now: Instant<Utc>) -> AlertChanges {
180 let mut changes = AlertChanges::new();
181 for event in events {
182 self.end(event.ends(), &mut changes);
183 if let Some(kind) = event.condition() {
184 if let Some(priority) = self.policy.classify(&kind) {
185 self.raise_or_repeat(kind, priority, now, &mut changes);
186 }
187 }
188 }
189 self.rectify_silent(now, &mut changes);
190 changes
191 }
192
193 pub fn tick(&mut self, now: Instant<Utc>) -> AlertChanges {
197 let mut changes = AlertChanges::new();
198 self.rectify_silent(now, &mut changes);
199 changes
200 }
201
202 pub fn acknowledge(&mut self, id: AlertId, at: Instant<Utc>) -> Result<AlertChanges> {
209 let mut changes = AlertChanges::new();
210 let index =
211 self.alerts
212 .iter()
213 .position(|alert| alert.id == id)
214 .ok_or(KernelError::OutOfRange {
215 parameter: "alert id",
216 value: f64::from(id.0),
217 min: 1.0,
218 max: f64::from(self.next.saturating_sub(1)),
219 })?;
220 self.acknowledge_at(index, at, &mut changes);
221 Ok(changes)
222 }
223
224 pub fn acknowledge_all(&mut self, at: Instant<Utc>) -> AlertChanges {
226 let mut changes = AlertChanges::new();
227 let mut index = self.alerts.len();
229 while index > 0 {
230 index -= 1;
231 self.acknowledge_at(index, at, &mut changes);
232 }
233 changes
234 }
235
236 fn acknowledge_at(&mut self, index: usize, _at: Instant<Utc>, changes: &mut AlertChanges) {
237 let Some(alert) = self.alerts.get(index).copied() else {
238 return;
239 };
240 match alert.state {
241 AlertState::Active => {
242 if let Some(slot) = self.alerts.as_mut_slice().get_mut(index) {
243 slot.state = AlertState::Acknowledged;
244 }
245 changes.push(AlertChange::Acknowledged(alert.id));
246 }
247 AlertState::Rectified => {
248 self.remove(index);
249 changes.push(AlertChange::Cleared(alert.id));
250 }
251 AlertState::Acknowledged => {}
252 }
253 }
254
255 fn raise_or_repeat(
257 &mut self,
258 kind: AlertKind,
259 priority: AlertPriority,
260 now: Instant<Utc>,
261 changes: &mut AlertChanges,
262 ) {
263 if let Some(standing) = self
264 .alerts
265 .as_mut_slice()
266 .iter_mut()
267 .find(|alert| alert.kind == kind)
268 {
269 standing.occurrences = standing.occurrences.saturating_add(1);
270 standing.last_reported_at = now;
271 if standing.state == AlertState::Rectified {
274 standing.state = AlertState::Active;
275 changes.push(AlertChange::Raised(standing.id));
276 }
277 return;
278 }
279
280 let alert = Alert {
281 id: AlertId(self.next),
282 kind,
283 priority,
284 state: AlertState::Active,
285 raised_at: now,
286 last_reported_at: now,
287 occurrences: 1,
288 };
289 let index = self
291 .alerts
292 .iter()
293 .position(|standing| standing.priority < priority)
294 .unwrap_or(self.alerts.len());
295 if self.alerts.len() >= MAX_ALERTS {
296 if index >= self.alerts.len() {
299 changes.lost = true;
300 return;
301 }
302 if let Some(dropped) = self.alerts.remove(self.alerts.len().saturating_sub(1)) {
303 changes.push(AlertChange::Dropped(dropped.id));
304 }
305 }
306 if self.alerts.insert(index, alert).is_err() {
307 changes.lost = true;
308 return;
309 }
310 self.next = self.next.wrapping_add(1).max(1);
311 changes.push(AlertChange::Raised(alert.id));
312 }
313
314 fn end(&mut self, ended: Ended, changes: &mut AlertChanges) {
316 let mut index = self.alerts.len();
317 while index > 0 {
318 index -= 1;
319 let Some(alert) = self.alerts.get(index).copied() else {
320 continue;
321 };
322 let ends = match ended {
323 Ended::One(kind) => alert.kind == kind,
324 Ended::EveryIntegrityDegradation => {
325 matches!(alert.kind, AlertKind::IntegrityDegraded { .. })
326 }
327 Ended::Nothing => false,
328 };
329 if ends {
330 self.rectify_at(index, changes);
331 }
332 }
333 }
334
335 fn rectify_silent(&mut self, now: Instant<Utc>, changes: &mut AlertChanges) {
337 let limit = self.policy.rectify_after();
338 let mut index = self.alerts.len();
339 while index > 0 {
340 index -= 1;
341 let Some(alert) = self.alerts.get(index).copied() else {
342 continue;
343 };
344 if alert.state == AlertState::Rectified {
345 continue;
346 }
347 let silent = now
348 .checked_duration_since(alert.last_reported_at)
349 .is_some_and(|silence| silence > limit);
350 if silent {
351 self.rectify_at(index, changes);
352 }
353 }
354 }
355
356 fn rectify_at(&mut self, index: usize, changes: &mut AlertChanges) {
359 let Some(alert) = self.alerts.get(index).copied() else {
360 return;
361 };
362 match alert.state {
363 AlertState::Acknowledged => {
364 self.remove(index);
365 changes.push(AlertChange::Cleared(alert.id));
366 }
367 AlertState::Active => {
368 if let Some(slot) = self.alerts.as_mut_slice().get_mut(index) {
369 slot.state = AlertState::Rectified;
370 }
371 changes.push(AlertChange::Rectified(alert.id));
372 }
373 AlertState::Rectified => {}
374 }
375 }
376
377 fn remove(&mut self, index: usize) {
379 let Some(first) = self.alerts.first().copied() else {
380 return;
381 };
382 let mut kept = Inline::<Alert, MAX_ALERTS>::new(first);
383 for (position, alert) in self.alerts.iter().enumerate() {
384 if position != index {
385 let _ = kept.push(*alert);
387 }
388 }
389 self.alerts = kept;
390 }
391}
392
393#[cfg(test)]
394#[allow(clippy::unwrap_used, clippy::indexing_slicing)]
395mod tests {
396 use super::*;
397 use crate::StandardPolicy;
398 use core::time::Duration;
399 use kinavis::event::GuidanceEvent;
400 use kinavis_kernel::event::{
401 Event, EventList, NavigationEvent, NavigationIntegrity, PositionSource, SensorHealth,
402 SensorId, TargetId,
403 };
404 use kinavis_kernel::units::Distance;
405 use kinavis_traffic::TrafficEvent;
406
407 fn start() -> Instant<Utc> {
408 Instant::from_unix_seconds(1_789_000_000)
409 }
410
411 fn after(seconds: u64) -> Instant<Utc> {
412 start().checked_add(Duration::from_secs(seconds)).unwrap()
413 }
414
415 fn manager() -> AlertManager<StandardPolicy> {
416 AlertManager::new(StandardPolicy::default())
417 }
418
419 fn one<E: Event>(event: E) -> EventList<E> {
420 let mut events = EventList::new();
421 events.push(event);
422 events
423 }
424
425 fn off_track(at: Instant<Utc>) -> GuidanceEvent {
426 GuidanceEvent::CrossTrackExceeded {
427 error: Distance::from_cables(7.0).unwrap(),
428 limit: Distance::from_cables(5.0).unwrap(),
429 at,
430 }
431 }
432
433 fn cpa(target: u32, at: Instant<Utc>) -> TrafficEvent {
434 TrafficEvent::CpaAlarm {
435 target: TargetId::new(target),
436 cpa: Distance::from_cables(3.0).unwrap(),
437 tcpa: Duration::from_secs(600),
438 at,
439 }
440 }
441
442 fn fix_lost(at: Instant<Utc>) -> NavigationEvent {
443 NavigationEvent::FixLost {
444 source: PositionSource::Gnss,
445 at,
446 last_good: at,
447 }
448 }
449
450 #[test]
451 fn a_condition_reported_many_times_is_one_alert() {
452 let mut alerts = manager();
453 let first = alerts.ingest(&one(off_track(start())), start());
454 assert_eq!(first.len(), 1);
455 assert!(matches!(first[0], AlertChange::Raised(id) if id.number() == 1));
456
457 for second in 1..10 {
458 let changes = alerts.ingest(&one(off_track(after(second))), after(second));
459 assert!(changes.is_empty());
460 }
461 assert_eq!(alerts.len(), 1);
462 let alert = alerts.alerts()[0];
463 assert_eq!(alert.occurrences(), 10);
464 assert_eq!(alert.raised_at(), start());
465 assert_eq!(alert.last_reported_at(), after(9));
466 assert_eq!(alert.kind(), AlertKind::CrossTrackExceeded);
467 assert_eq!(alert.priority(), AlertPriority::Warning);
468 assert_eq!(alert.state(), AlertState::Active);
469 assert_eq!(alloc::format!("{}", alert.id()), "A1");
470 assert_eq!(alloc::format!("{}", alert.priority()), "warning");
471 }
472
473 #[test]
474 fn the_alerts_stand_in_order_of_priority_then_of_raising() {
475 let mut alerts = manager();
476 let _ = alerts.ingest(&one(off_track(start())), start());
477 let _ = alerts.ingest(
478 &one(TrafficEvent::TargetLost {
479 target: TargetId::new(3),
480 last_seen: start(),
481 }),
482 after(1),
483 );
484 let _ = alerts.ingest(&one(cpa(7, after(2))), after(2));
485 let _ = alerts.ingest(&one(cpa(8, after(3))), after(3));
486 let _ = alerts.ingest(&one(fix_lost(after(4))), after(4));
487
488 let order = alerts
489 .alerts()
490 .iter()
491 .map(|alert| (alert.priority(), alert.id().number()))
492 .collect::<alloc::vec::Vec<_>>();
493 assert_eq!(
494 order,
495 [
496 (AlertPriority::Alarm, 3),
497 (AlertPriority::Alarm, 4),
498 (AlertPriority::Warning, 1),
499 (AlertPriority::Warning, 5),
500 (AlertPriority::Caution, 2),
501 ]
502 );
503 assert_eq!(alerts.highest().unwrap().id().number(), 3);
504 assert_eq!(alerts.unacknowledged().count(), 5);
505 assert!(alerts.alert(AlertId(4)).is_some());
506 assert!(alerts.alert(AlertId(9)).is_none());
507 }
508
509 #[test]
510 fn acknowledged_it_stands_quietly_and_goes_when_the_condition_does() {
511 let mut alerts = manager();
512 let _ = alerts.ingest(&one(off_track(start())), start());
513 let id = alerts.alerts()[0].id();
514
515 let changes = alerts.acknowledge(id, after(1)).unwrap();
516 assert!(matches!(changes[0], AlertChange::Acknowledged(acked) if acked == id));
517 assert_eq!(alerts.alerts()[0].state(), AlertState::Acknowledged);
518 assert_eq!(alerts.unacknowledged().count(), 0);
519
520 let changes = alerts.ingest(&one(off_track(after(5))), after(5));
522 assert!(changes.is_empty());
523 assert_eq!(alerts.alerts()[0].state(), AlertState::Acknowledged);
524
525 let changes = alerts.tick(after(5 + 31));
527 assert!(matches!(changes[0], AlertChange::Cleared(cleared) if cleared == id));
528 assert!(alerts.is_empty());
529 }
530
531 #[test]
532 fn unacknowledged_it_is_rectified_and_waits_and_comes_back_if_reported_again() {
533 let mut alerts = manager();
534 let _ = alerts.ingest(&one(off_track(start())), start());
535 let id = alerts.alerts()[0].id();
536
537 assert!(alerts.tick(after(30)).is_empty());
539 let changes = alerts.tick(after(31));
540 assert!(matches!(changes[0], AlertChange::Rectified(rectified) if rectified == id));
541 assert_eq!(alerts.alerts()[0].state(), AlertState::Rectified);
542 assert_eq!(alerts.unacknowledged().count(), 1);
543
544 let changes = alerts.ingest(&one(off_track(after(40))), after(40));
546 assert!(matches!(changes[0], AlertChange::Raised(raised) if raised == id));
547 assert_eq!(alerts.alerts()[0].state(), AlertState::Active);
548
549 let _ = alerts.tick(after(100));
551 let changes = alerts.acknowledge(id, after(101)).unwrap();
552 assert!(matches!(changes[0], AlertChange::Cleared(cleared) if cleared == id));
553 assert!(alerts.is_empty());
554 }
555
556 #[test]
557 #[allow(clippy::too_many_lines)]
559 fn an_event_ends_the_condition_it_is_the_end_of() {
560 let mut alerts = manager();
561 let _ = alerts.ingest(&one(fix_lost(start())), start());
562 let _ = alerts.ingest(&one(cpa(7, start())), start());
563 let _ = alerts.ingest(
564 &one(NavigationEvent::SensorHealthChanged {
565 sensor: SensorId::named("GNSS 1"),
566 from: SensorHealth::Healthy,
567 to: SensorHealth::Suspect,
568 at: start(),
569 }),
570 start(),
571 );
572 let _ = alerts.ingest(
573 &one(NavigationEvent::IntegrityChanged {
574 from: NavigationIntegrity::Nominal,
575 to: NavigationIntegrity::DeadReckoning,
576 at: start(),
577 }),
578 start(),
579 );
580 assert_eq!(alerts.len(), 4);
581
582 let mut ends = EventList::new();
583 ends.push(NavigationEvent::FixAcquired {
584 source: PositionSource::Gnss,
585 at: after(1),
586 });
587 ends.push(NavigationEvent::SensorHealthChanged {
588 sensor: SensorId::named("GNSS 1"),
589 from: SensorHealth::Suspect,
590 to: SensorHealth::Healthy,
591 at: after(1),
592 });
593 ends.push(NavigationEvent::IntegrityChanged {
594 from: NavigationIntegrity::DeadReckoning,
595 to: NavigationIntegrity::Nominal,
596 at: after(1),
597 });
598 let mut changes = alerts.ingest(&ends, after(1));
599 let lost = alerts.ingest(
600 &one(TrafficEvent::TargetLost {
601 target: TargetId::new(7),
602 last_seen: after(1),
603 }),
604 after(1),
605 );
606 for change in lost.iter() {
607 changes.push(*change);
608 }
609 assert_eq!(
610 changes
611 .iter()
612 .filter(|change| matches!(change, AlertChange::Rectified(_)))
613 .count(),
614 4
615 );
616 assert!(changes
618 .iter()
619 .any(|change| matches!(change, AlertChange::Raised(_))));
620 assert_eq!(alerts.len(), 5);
621 assert_eq!(
622 alerts
623 .alerts()
624 .iter()
625 .filter(|alert| alert.state() == AlertState::Rectified)
626 .count(),
627 4
628 );
629
630 let _ = alerts.acknowledge_all(after(2));
632 assert_eq!(alerts.len(), 1);
633 let _ = alerts.ingest(
634 &one(NavigationEvent::IntegrityChanged {
635 from: NavigationIntegrity::Nominal,
636 to: NavigationIntegrity::DeadReckoning,
637 at: after(3),
638 }),
639 after(3),
640 );
641 let _ = alerts.ingest(
642 &one(NavigationEvent::IntegrityChanged {
643 from: NavigationIntegrity::DeadReckoning,
644 to: NavigationIntegrity::Exceeded,
645 at: after(4),
646 }),
647 after(4),
648 );
649 let degradations = alerts
650 .alerts()
651 .iter()
652 .filter(|alert| matches!(alert.kind(), AlertKind::IntegrityDegraded { .. }))
653 .collect::<alloc::vec::Vec<_>>();
654 assert_eq!(degradations.len(), 2);
655 assert!(degradations
656 .iter()
657 .any(|alert| alert.state() == AlertState::Rectified
658 && alert.kind()
659 == AlertKind::IntegrityDegraded {
660 to: NavigationIntegrity::DeadReckoning
661 }));
662 assert!(degradations
663 .iter()
664 .any(|alert| alert.state() == AlertState::Active
665 && alert.priority() == AlertPriority::Warning));
666 }
667
668 #[test]
669 fn news_is_not_an_alert() {
670 let mut alerts = manager();
671 let reached = GuidanceEvent::WaypointReached {
672 index: 2,
673 at: start(),
674 };
675 let mut events = EventList::new();
676 events.push(NavigationEvent::FixAcquired {
677 source: PositionSource::Gnss,
678 at: start(),
679 });
680 events.push(NavigationEvent::ObservationRejected {
681 reason: kinavis_kernel::event::RejectionReason::Stale,
682 at: start(),
683 });
684 let acquired = TrafficEvent::TargetAcquired {
685 target: TargetId::new(1),
686 at: start(),
687 };
688 assert!(alerts.ingest(&one(reached), start()).is_empty());
689 assert!(alerts.ingest(&events, start()).is_empty());
690 assert!(alerts.ingest(&one(acquired), start()).is_empty());
691 assert!(alerts.is_empty());
692 assert_eq!(reached.condition(), None);
693 assert_eq!(acquired.condition(), None);
694 assert_eq!(events[1].condition(), Some(AlertKind::ObservationRejected));
695 }
696
697 #[test]
698 fn acknowledging_what_does_not_stand_is_refused() {
699 let mut alerts = manager();
700 assert!(matches!(
701 alerts.acknowledge(AlertId(1), start()).unwrap_err(),
702 KernelError::OutOfRange {
703 parameter: "alert id",
704 ..
705 }
706 ));
707 let _ = alerts.ingest(&one(off_track(start())), start());
708 assert!(alerts.acknowledge(AlertId(2), start()).is_err());
709 assert!(alerts.acknowledge(AlertId(1), start()).is_ok());
710 assert!(alerts.acknowledge(AlertId(1), start()).unwrap().is_empty());
712 }
713
714 #[test]
715 fn a_full_board_loses_nothing_in_silence() {
716 let mut alerts = manager();
717 for target in 0..MAX_ALERTS {
718 let changes =
719 alerts.ingest(&one(cpa(u32::try_from(target).unwrap(), start())), start());
720 assert!(!changes.lost());
721 }
722 assert_eq!(alerts.len(), MAX_ALERTS);
723 let changes = alerts.ingest(&one(cpa(u32::MAX, start())), start());
725 assert!(changes.lost());
726 assert!(changes.is_empty());
727 assert_eq!(alerts.len(), MAX_ALERTS);
728 let changes = alerts.ingest(&one(cpa(3, after(1))), after(1));
730 assert!(!changes.lost());
731
732 let changes = alerts.acknowledge_all(after(2));
735 assert!(changes.overflowed());
736 assert_eq!(changes.len(), MAX_CHANGES);
737 assert_eq!(alerts.unacknowledged().count(), 0);
738 }
739
740 #[test]
741 fn a_full_board_makes_room_for_an_alert_that_outranks_its_least() {
742 let mut alerts = manager();
743 for target in 0..MAX_ALERTS - 1 {
745 let _ = alerts.ingest(&one(cpa(u32::try_from(target).unwrap(), start())), start());
746 }
747 let lost = TrafficEvent::TargetLost {
748 target: TargetId::new(500),
749 last_seen: start(),
750 };
751 let caution = alerts.ingest(&one(lost), start());
752 let caution_id = match caution[0] {
753 AlertChange::Raised(id) => id,
754 other => unreachable!("the caution was raised, not {other:?}"),
755 };
756 assert_eq!(alerts.len(), MAX_ALERTS);
757 let changes = alerts.ingest(&one(fix_lost(after(1))), after(1));
759 assert!(!changes.lost());
760 assert_eq!(changes[0], AlertChange::Dropped(caution_id));
761 assert!(matches!(changes[1], AlertChange::Raised(_)));
762 assert_eq!(alerts.len(), MAX_ALERTS);
763 assert!(alerts
764 .alerts()
765 .iter()
766 .all(|alert| alert.priority() >= AlertPriority::Warning));
767 let changes = alerts.ingest(&one(lost), after(2));
769 assert!(changes.lost());
770 }
771}