use core::time::Duration;
use kinavis::error::Result;
use kinavis::relative_motion::Vessel;
use kinavis::sailings::rhumb_destination;
use kinavis_kernel::angle::{Direction, True, TrueCourse};
use kinavis_kernel::event::TargetId;
use kinavis_kernel::inline::Inline;
use kinavis_kernel::math;
use kinavis_kernel::position::{Latitude, Longitude, Position};
use kinavis_kernel::snapshot::GroundTrack;
use kinavis_kernel::time::{Instant, Utc};
use kinavis_kernel::units::{Distance, Speed};
use crate::observation::TargetObservation;
pub const MAX_TRACK_HISTORY: usize = 12;
#[derive(Debug, Clone, Copy, PartialEq)]
struct Fix {
position: Position,
at: Instant<Utc>,
}
type Fixes = Inline<Fix, MAX_TRACK_HISTORY>;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum TrackStatus {
Acquiring,
Tracking,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TargetTrack {
target: TargetId,
fixes: Fixes,
latest: Fix,
reported: Option<GroundTrack>,
heading: Option<TrueCourse>,
status: TrackStatus,
}
struct Fit {
north_knots: f64,
east_knots: f64,
north_offset: f64,
east_offset: f64,
}
impl TargetTrack {
pub(crate) fn placeholder() -> Self {
Self::started_by(&TargetObservation::new(
TargetId::new(0),
Position::new(Latitude::EQUATOR, Longitude::GREENWICH),
Instant::from_unix_seconds(0),
))
}
pub(crate) fn started_by(observation: &TargetObservation) -> Self {
let fix = Fix {
position: observation.position(),
at: observation.at(),
};
let mut fixes = Fixes::new(fix);
let _ = fixes.push(fix);
Self {
target: observation.target(),
fixes,
latest: fix,
reported: observation.ground_track(),
heading: observation.heading(),
status: TrackStatus::Acquiring,
}
}
pub(crate) fn extend(&mut self, observation: &TargetObservation) {
let fix = Fix {
position: observation.position(),
at: observation.at(),
};
if self.fixes.push(fix).is_err() {
let mut kept = Fixes::new(fix);
for &old in self.fixes.iter().skip(1) {
let _ = kept.push(old);
}
let _ = kept.push(fix);
self.fixes = kept;
}
self.latest = fix;
if observation.ground_track().is_some() {
self.reported = observation.ground_track();
}
if observation.heading().is_some() {
self.heading = observation.heading();
}
}
pub(crate) fn acquire(&mut self) {
self.status = TrackStatus::Tracking;
}
#[must_use]
pub const fn target(&self) -> TargetId {
self.target
}
#[must_use]
pub const fn status(&self) -> TrackStatus {
self.status
}
#[must_use]
pub const fn fix_count(&self) -> usize {
self.fixes.len()
}
#[must_use]
pub fn last_seen(&self) -> Instant<Utc> {
self.last().at
}
#[must_use]
pub fn last_position(&self) -> Position {
self.last().position
}
#[must_use]
pub fn first_seen(&self) -> Instant<Utc> {
self.fixes
.first()
.map_or_else(|| self.last().at, |fix| fix.at)
}
#[must_use]
pub const fn reported_ground_track(&self) -> Option<GroundTrack> {
self.reported
}
#[must_use]
pub const fn heading(&self) -> Option<TrueCourse> {
self.heading
}
#[must_use]
pub fn age(&self, now: Instant<Utc>) -> Duration {
now.checked_duration_since(self.last_seen())
.unwrap_or_default()
}
#[must_use]
pub fn motion(&self) -> Option<GroundTrack> {
self.reported.or_else(|| self.fitted_motion())
}
#[must_use]
pub fn fitted_motion(&self) -> Option<GroundTrack> {
let fit = self.fit()?;
let speed = math::hypot(fit.north_knots, fit.east_knots);
if !speed.is_finite() || speed <= 0.0 {
return None;
}
Some(GroundTrack {
course_over_ground: Direction::<True>::from_degrees_wrapped(math::to_degrees(
math::atan2(fit.east_knots, fit.north_knots),
)),
speed_over_ground: Speed::from_knots_unchecked(speed),
})
}
pub fn position_at(&self, when: Instant<Utc>) -> Result<Position> {
let base = self.smoothed_position()?;
let Some(motion) = self.motion() else {
return Ok(base);
};
let last = self.last_seen();
let hours = match when.checked_duration_since(last) {
Some(ahead) => ahead.as_secs_f64() / 3600.0,
None => {
-last
.checked_duration_since(when)
.unwrap_or_default()
.as_secs_f64()
/ 3600.0
}
};
let run = Distance::from_nautical_miles(motion.speed_over_ground.knots() * hours)?;
rhumb_destination(base, motion.course_over_ground, run)
}
#[must_use]
pub fn as_vessel(&self) -> Option<Vessel> {
self.motion().map(|motion| Vessel {
course: motion.course_over_ground,
speed: motion.speed_over_ground,
})
}
const fn last(&self) -> Fix {
self.latest
}
fn smoothed_position(&self) -> Result<Position> {
let last = self.last();
let Some(fit) = self.fit() else {
return Ok(last.position);
};
let latitude = last.position.latitude().degrees() + fit.north_offset / 60.0;
let stretch = math::cos(last.position.latitude().radians());
if stretch < 1e-6 {
return Ok(last.position);
}
let longitude = last.position.longitude().degrees() + fit.east_offset / (60.0 * stretch);
if !(-90.0..=90.0).contains(&latitude) {
return Ok(last.position);
}
Ok(Position::from_degrees(latitude, longitude)?)
}
fn fit(&self) -> Option<Fit> {
if self.fixes.len() < 2 {
return None;
}
let last = self.last();
let count = math::count_to_f64(self.fixes.len());
let stretch = math::cos(last.position.latitude().radians());
let mut sum_t = 0.0;
let mut sum_n = 0.0;
let mut sum_e = 0.0;
for fix in self.fixes.iter() {
let (t, n, e) = local(fix, last, stretch);
sum_t += t;
sum_n += n;
sum_e += e;
}
let (mean_t, mean_n, mean_e) = (sum_t / count, sum_n / count, sum_e / count);
let mut s_tt = 0.0;
let mut s_tn = 0.0;
let mut s_te = 0.0;
for fix in self.fixes.iter() {
let (t, n, e) = local(fix, last, stretch);
s_tt += (t - mean_t) * (t - mean_t);
s_tn += (t - mean_t) * (n - mean_n);
s_te += (t - mean_t) * (e - mean_e);
}
if s_tt <= 0.0 || !s_tt.is_finite() {
return None;
}
let north_knots = s_tn / s_tt;
let east_knots = s_te / s_tt;
Some(Fit {
north_knots,
east_knots,
north_offset: mean_n - north_knots * mean_t,
east_offset: mean_e - east_knots * mean_t,
})
}
}
fn local(fix: &Fix, last: Fix, stretch: f64) -> (f64, f64, f64) {
let hours = -last
.at
.checked_duration_since(fix.at)
.unwrap_or_default()
.as_secs_f64()
/ 3600.0;
let north = last.position.latitude_difference(fix.position).degrees() * 60.0;
let east = last.position.longitude_difference(fix.position).degrees() * 60.0 * stretch;
(hours, north, east)
}