use crate::coordinates::centers::Geodetic;
use crate::coordinates::frames::ECEF;
use crate::qtty::unit::{Meter, Second};
use crate::qtty::{Degrees, Meters, Per, Quantity};
use crate::targets::Trackable;
use crate::time::JulianDate;
use std::borrow::Cow;
pub type MetersPerSecond = Quantity<Per<Meter, Second>>;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Aircraft<'a> {
pub icao24: u32,
pub callsign: Cow<'a, str>,
pub wake_category: Option<WakeCategory>,
}
impl<'a> Aircraft<'a> {
pub fn new(icao24: u32, callsign: impl Into<Cow<'a, str>>) -> Self {
Self {
icao24,
callsign: callsign.into(),
wake_category: None,
}
}
#[must_use]
pub fn with_wake_category(mut self, cat: WakeCategory) -> Self {
self.wake_category = Some(cat);
self
}
#[inline]
pub fn icao24(&self) -> u32 {
self.icao24
}
#[inline]
pub fn callsign(&self) -> &str {
&self.callsign
}
}
#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
pub enum WakeCategory {
Light,
Medium,
Heavy,
Super,
}
#[derive(Clone, Debug, PartialEq)]
pub struct AircraftState {
pub position: Geodetic<ECEF>,
pub ground_speed: MetersPerSecond,
pub track_angle: Degrees,
pub vertical_rate: MetersPerSecond,
}
impl AircraftState {
pub fn new(position: Geodetic<ECEF>, track_angle: Degrees) -> Self {
Self {
position,
ground_speed: MetersPerSecond::new(0.0),
track_angle,
vertical_rate: MetersPerSecond::new(0.0),
}
}
#[inline]
pub fn barometric_altitude_m(&self) -> Meters {
self.position.height.to()
}
}
#[derive(Clone, Debug, PartialEq)]
pub struct AircraftTrack {
pub state: AircraftState,
pub epoch: JulianDate,
}
impl AircraftTrack {
pub fn new(state: AircraftState, epoch: JulianDate) -> Self {
Self { state, epoch }
}
}
impl Trackable for AircraftTrack {
type Coords = AircraftState;
fn track(&self, jd: JulianDate) -> AircraftState {
const R_EARTH_M: f64 = 6_371_000.0;
const DEG_PER_RAD: f64 = 180.0 / std::f64::consts::PI;
let dt_s = (jd.raw().value() - self.epoch.raw().value()) * 86_400.0;
let gs = self.state.ground_speed.value(); let vr = self.state.vertical_rate.value(); let track_rad = self
.state
.track_angle
.to::<crate::qtty::unit::Radian>()
.value();
let lat_rad = self
.state
.position
.lat
.to::<crate::qtty::unit::Radian>()
.value();
let ds_m = gs * dt_s;
let dlat_deg = ds_m * track_rad.cos() / R_EARTH_M * DEG_PER_RAD;
let cos_lat = lat_rad.cos().max(1e-9);
let dlon_deg = ds_m * track_rad.sin() / (R_EARTH_M * cos_lat) * DEG_PER_RAD;
let dalt_m = vr * dt_s;
AircraftState {
position: Geodetic::<ECEF>::new(
Degrees::new(self.state.position.lon.value() + dlon_deg),
Degrees::new(self.state.position.lat.value() + dlat_deg),
Meters::new(self.state.position.height.value() + dalt_m),
),
ground_speed: self.state.ground_speed,
track_angle: self.state.track_angle,
vertical_rate: self.state.vertical_rate,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn aircraft_identity_roundtrip() {
let ac = Aircraft::new(0x4CA2B5_u32, "EIN104");
assert_eq!(ac.icao24(), 0x4CA2B5);
assert_eq!(ac.callsign(), "EIN104");
assert!(ac.wake_category.is_none());
}
#[test]
fn aircraft_with_wake_category() {
let ac = Aircraft::new(0x400000_u32, "BAW1").with_wake_category(WakeCategory::Heavy);
assert_eq!(ac.wake_category, Some(WakeCategory::Heavy));
}
#[test]
fn aircraft_state_new_zeroed_kinematics() {
let state = AircraftState::new(
Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(0.0), Meters::new(10_000.0)),
Degrees::new(180.0),
);
assert_eq!(state.ground_speed.value(), 0.0);
assert_eq!(state.vertical_rate.value(), 0.0);
assert_eq!(state.track_angle.value(), 180.0);
}
#[test]
fn barometric_altitude_m_returns_ellipsoidal_height() {
let h = Meters::new(9_144.0);
let state = AircraftState::new(
Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(0.0), h),
Degrees::new(0.0),
);
assert!((state.barometric_altitude_m().value() - 9_144.0).abs() < 1e-9);
}
#[test]
fn dead_reckoning_due_east() {
use crate::targets::Trackable;
use crate::time::JulianDate;
let state = AircraftState {
position: Geodetic::<ECEF>::new(Degrees::new(0.0), Degrees::new(0.0), Meters::new(0.0)),
ground_speed: MetersPerSecond::new(100.0),
track_angle: Degrees::new(90.0),
vertical_rate: MetersPerSecond::new(0.0),
};
let epoch = JulianDate::new(2_451_545.0);
let track = AircraftTrack::new(state, epoch);
let t1 = JulianDate::new(2_451_545.0 + 60.0 / 86_400.0);
let s1 = track.track(t1);
let expected_dlon = 6_000.0 / 6_371_000.0 * (180.0 / std::f64::consts::PI);
assert!(
(s1.position.lon.value() - expected_dlon).abs() < 1e-6,
"lon = {}",
s1.position.lon.value()
);
assert!(
s1.position.lat.value().abs() < 1e-9,
"lat should not change"
);
assert!(
s1.position.height.value().abs() < 1e-9,
"alt should not change"
);
}
#[test]
fn dead_reckoning_climbing_north() {
use crate::targets::Trackable;
use crate::time::JulianDate;
let state = AircraftState {
position: Geodetic::<ECEF>::new(
Degrees::new(0.0),
Degrees::new(0.0),
Meters::new(1_000.0),
),
ground_speed: MetersPerSecond::new(200.0),
track_angle: Degrees::new(0.0),
vertical_rate: MetersPerSecond::new(5.0),
};
let epoch = JulianDate::new(2_451_545.0);
let track = AircraftTrack::new(state, epoch);
let t1 = JulianDate::new(2_451_545.0 + 120.0 / 86_400.0); let s1 = track.track(t1);
let expected_dlat = 24_000.0 / 6_371_000.0 * (180.0 / std::f64::consts::PI);
assert!((s1.position.lat.value() - expected_dlat).abs() < 1e-6);
assert!(s1.position.lon.value().abs() < 1e-9);
assert!(
(s1.position.height.value() - 1_600.0).abs() < 0.01,
"height = {}",
s1.position.height.value()
);
}
}