Skip to main content

kinavis_traffic/
track.rs

1//! Target track: recent observations and derived motion.
2
3use core::time::Duration;
4
5use kinavis::error::Result;
6use kinavis::relative_motion::Vessel;
7use kinavis::sailings::rhumb_destination;
8use kinavis_kernel::angle::{Direction, True, TrueCourse};
9use kinavis_kernel::event::TargetId;
10use kinavis_kernel::inline::Inline;
11use kinavis_kernel::math;
12use kinavis_kernel::position::{Latitude, Longitude, Position};
13use kinavis_kernel::snapshot::GroundTrack;
14use kinavis_kernel::time::{Instant, Utc};
15use kinavis_kernel::units::{Distance, Speed};
16
17use crate::observation::TargetObservation;
18
19/// Maximum fixes per track.
20///
21/// Enough to smooth a radar plot over a few minutes; older fixes are discarded.
22/// A sliding window, not a log.
23pub const MAX_TRACK_HISTORY: usize = 12;
24
25/// Position and time of a sighting.
26#[derive(Debug, Clone, Copy, PartialEq)]
27struct Fix {
28    position: Position,
29    at: Instant<Utc>,
30}
31
32/// Inline fix storage.
33type Fixes = Inline<Fix, MAX_TRACK_HISTORY>;
34
35/// Track acquisition status.
36///
37/// `#[non_exhaustive]`; match with a wildcard arm.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39#[non_exhaustive]
40#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
41pub enum TrackStatus {
42    /// Fewer fixes than the policy requires; course and speed are provisional.
43    Acquiring,
44    /// Acquired.
45    Tracking,
46}
47
48/// Target track held by the traffic picture.
49///
50/// Aggregate of recent fixes, last reported motion and status. Mutated only by
51/// [`Traffic`].
52///
53/// [`Traffic`]: crate::Traffic
54#[derive(Debug, Clone, Copy, PartialEq)]
55pub struct TargetTrack {
56    target: TargetId,
57    /// Fixes in time order, latest last; never empty.
58    fixes: Fixes,
59    /// Copy of the latest fix, so reading it needs no non-empty proof.
60    latest: Fix,
61    reported: Option<GroundTrack>,
62    heading: Option<TrueCourse>,
63    status: TrackStatus,
64}
65
66/// Course and speed fitted to the fixes, and the fitted position at the latest
67/// fix.
68struct Fit {
69    north_knots: f64,
70    east_knots: f64,
71    /// Offset of the fitted position from the latest fix, NM.
72    north_offset: f64,
73    east_offset: f64,
74}
75
76impl TargetTrack {
77    /// Fill value for a store of tracks; never read.
78    pub(crate) fn placeholder() -> Self {
79        Self::started_by(&TargetObservation::new(
80            TargetId::new(0),
81            Position::new(Latitude::EQUATOR, Longitude::GREENWICH),
82            Instant::from_unix_seconds(0),
83        ))
84    }
85
86    /// Track started by one observation.
87    pub(crate) fn started_by(observation: &TargetObservation) -> Self {
88        let fix = Fix {
89            position: observation.position(),
90            at: observation.at(),
91        };
92        let mut fixes = Fixes::new(fix);
93        // The store was made with room for one; the push cannot fail.
94        let _ = fixes.push(fix);
95        Self {
96            target: observation.target(),
97            fixes,
98            latest: fix,
99            reported: observation.ground_track(),
100            heading: observation.heading(),
101            status: TrackStatus::Acquiring,
102        }
103    }
104
105    /// Appends an observation, discarding the oldest fix when full. The caller
106    /// has checked it is newer than the last.
107    pub(crate) fn extend(&mut self, observation: &TargetObservation) {
108        let fix = Fix {
109            position: observation.position(),
110            at: observation.at(),
111        };
112        if self.fixes.push(fix).is_err() {
113            let mut kept = Fixes::new(fix);
114            for &old in self.fixes.iter().skip(1) {
115                let _ = kept.push(old);
116            }
117            let _ = kept.push(fix);
118            self.fixes = kept;
119        }
120        self.latest = fix;
121        if observation.ground_track().is_some() {
122            self.reported = observation.ground_track();
123        }
124        if observation.heading().is_some() {
125            self.heading = observation.heading();
126        }
127    }
128
129    /// Marks the track acquired.
130    pub(crate) fn acquire(&mut self) {
131        self.status = TrackStatus::Tracking;
132    }
133
134    /// Target.
135    #[must_use]
136    pub const fn target(&self) -> TargetId {
137        self.target
138    }
139
140    /// Whether acquired.
141    #[must_use]
142    pub const fn status(&self) -> TrackStatus {
143        self.status
144    }
145
146    /// Number of fixes, up to [`MAX_TRACK_HISTORY`].
147    #[must_use]
148    pub const fn fix_count(&self) -> usize {
149        self.fixes.len()
150    }
151
152    /// Time of the last sighting.
153    #[must_use]
154    pub fn last_seen(&self) -> Instant<Utc> {
155        self.last().at
156    }
157
158    /// Last observed position, unsmoothed.
159    #[must_use]
160    pub fn last_position(&self) -> Position {
161        self.last().position
162    }
163
164    /// Time of the earliest fix in the window.
165    #[must_use]
166    pub fn first_seen(&self) -> Instant<Utc> {
167        self.fixes
168            .first()
169            .map_or_else(|| self.last().at, |fix| fix.at)
170    }
171
172    /// Last course and speed reported by the target, if any.
173    #[must_use]
174    pub const fn reported_ground_track(&self) -> Option<GroundTrack> {
175        self.reported
176    }
177
178    /// Last heading reported by the target, if any.
179    #[must_use]
180    pub const fn heading(&self) -> Option<TrueCourse> {
181        self.heading
182    }
183
184    /// Time since the last sighting; zero if `now` is earlier.
185    #[must_use]
186    pub fn age(&self, now: Instant<Utc>) -> Duration {
187        now.checked_duration_since(self.last_seen())
188            .unwrap_or_default()
189    }
190
191    /// Course and speed over ground: as reported, otherwise fitted from the
192    /// fixes.
193    ///
194    /// `None` for a single fix without a report.
195    #[must_use]
196    pub fn motion(&self) -> Option<GroundTrack> {
197        self.reported.or_else(|| self.fitted_motion())
198    }
199
200    /// Course and speed fitted to the fixes, ignoring reports (the radar plot).
201    ///
202    /// Least-squares line of position against time, robust to a single noisy
203    /// plot. `None` with fewer than two fixes or no movement.
204    #[must_use]
205    pub fn fitted_motion(&self) -> Option<GroundTrack> {
206        let fit = self.fit()?;
207        let speed = math::hypot(fit.north_knots, fit.east_knots);
208        if !speed.is_finite() || speed <= 0.0 {
209            return None;
210        }
211        Some(GroundTrack {
212            course_over_ground: Direction::<True>::from_degrees_wrapped(math::to_degrees(
213                math::atan2(fit.east_knots, fit.north_knots),
214            )),
215            speed_over_ground: Speed::from_knots_unchecked(speed),
216        })
217    }
218
219    /// Extrapolated position at `when`: the smoothed position at the last fix,
220    /// propagated by [`TargetTrack::motion`] forwards or backwards. Without
221    /// motion, the last position.
222    ///
223    /// # Errors
224    ///
225    /// As [`rhumb_destination`]: extrapolation across a pole.
226    pub fn position_at(&self, when: Instant<Utc>) -> Result<Position> {
227        let base = self.smoothed_position()?;
228        let Some(motion) = self.motion() else {
229            return Ok(base);
230        };
231        let last = self.last_seen();
232        let hours = match when.checked_duration_since(last) {
233            Some(ahead) => ahead.as_secs_f64() / 3600.0,
234            None => {
235                -last
236                    .checked_duration_since(when)
237                    .unwrap_or_default()
238                    .as_secs_f64()
239                    / 3600.0
240            }
241        };
242        let run = Distance::from_nautical_miles(motion.speed_over_ground.knots() * hours)?;
243        rhumb_destination(base, motion.course_over_ground, run)
244    }
245
246    /// Target as a vessel for relative-motion calculations, if it has motion.
247    #[must_use]
248    pub fn as_vessel(&self) -> Option<Vessel> {
249        self.motion().map(|motion| Vessel {
250            course: motion.course_over_ground,
251            speed: motion.speed_over_ground,
252        })
253    }
254
255    /// Latest fix; a track always has one.
256    const fn last(&self) -> Fix {
257        self.latest
258    }
259
260    /// Fitted position at the last fix; the fix itself if there is no fit or it
261    /// cannot be placed on the globe.
262    fn smoothed_position(&self) -> Result<Position> {
263        let last = self.last();
264        let Some(fit) = self.fit() else {
265            return Ok(last.position);
266        };
267        let latitude = last.position.latitude().degrees() + fit.north_offset / 60.0;
268        let stretch = math::cos(last.position.latitude().radians());
269        if stretch < 1e-6 {
270            return Ok(last.position);
271        }
272        let longitude = last.position.longitude().degrees() + fit.east_offset / (60.0 * stretch);
273        if !(-90.0..=90.0).contains(&latitude) {
274            return Ok(last.position);
275        }
276        Ok(Position::from_degrees(latitude, longitude)?)
277    }
278
279    /// Least-squares line through the fixes in a local plane about the latest:
280    /// NM north and east vs hours before it.
281    fn fit(&self) -> Option<Fit> {
282        if self.fixes.len() < 2 {
283            return None;
284        }
285        let last = self.last();
286        let count = math::count_to_f64(self.fixes.len());
287        let stretch = math::cos(last.position.latitude().radians());
288
289        let mut sum_t = 0.0;
290        let mut sum_n = 0.0;
291        let mut sum_e = 0.0;
292        for fix in self.fixes.iter() {
293            let (t, n, e) = local(fix, last, stretch);
294            sum_t += t;
295            sum_n += n;
296            sum_e += e;
297        }
298        let (mean_t, mean_n, mean_e) = (sum_t / count, sum_n / count, sum_e / count);
299
300        let mut s_tt = 0.0;
301        let mut s_tn = 0.0;
302        let mut s_te = 0.0;
303        for fix in self.fixes.iter() {
304            let (t, n, e) = local(fix, last, stretch);
305            s_tt += (t - mean_t) * (t - mean_t);
306            s_tn += (t - mean_t) * (n - mean_n);
307            s_te += (t - mean_t) * (e - mean_e);
308        }
309        if s_tt <= 0.0 || !s_tt.is_finite() {
310            return None;
311        }
312        let north_knots = s_tn / s_tt;
313        let east_knots = s_te / s_tt;
314        Some(Fit {
315            north_knots,
316            east_knots,
317            north_offset: mean_n - north_knots * mean_t,
318            east_offset: mean_e - east_knots * mean_t,
319        })
320    }
321}
322
323/// Fix in the local plane: hours (negative, before the latest), NM north, NM
324/// east.
325fn local(fix: &Fix, last: Fix, stretch: f64) -> (f64, f64, f64) {
326    let hours = -last
327        .at
328        .checked_duration_since(fix.at)
329        .unwrap_or_default()
330        .as_secs_f64()
331        / 3600.0;
332    let north = last.position.latitude_difference(fix.position).degrees() * 60.0;
333    let east = last.position.longitude_difference(fix.position).degrees() * 60.0 * stretch;
334    (hours, north, east)
335}