1use core::fmt;
10use core::time::Duration;
11
12use crate::angle::TrueCourse;
13use crate::error::{ensure_range, Result};
14use crate::event::{NavigationIntegrity, PositionSource};
15use crate::math;
16use crate::observation::Observed;
17use crate::position::Position;
18use crate::units::{Angle, Distance, Speed};
19
20#[derive(Debug, Clone, Copy, PartialEq)]
22#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
23pub struct GroundTrack {
24 pub course_over_ground: TrueCourse,
26 pub speed_over_ground: Speed,
28}
29
30#[derive(Debug, Clone, Copy, PartialEq)]
36#[cfg_attr(
37 feature = "serde",
38 derive(serde::Serialize, serde::Deserialize),
39 serde(try_from = "StoredErrorEllipse", into = "StoredErrorEllipse")
40)]
41pub struct ErrorEllipse {
42 semi_major: Distance,
43 semi_minor: Distance,
44 orientation: TrueCourse,
45}
46
47impl ErrorEllipse {
48 #[must_use]
53 pub fn from_covariance(north: f64, north_east: f64, east: f64) -> Option<Self> {
54 if !(north.is_finite() && north_east.is_finite() && east.is_finite())
55 || north < 0.0
56 || east < 0.0
57 || north_east * north_east > north * east * (1.0 + 1e-9)
58 {
59 return None;
60 }
61 let half_trace = f64::midpoint(north, east);
63 let half_difference = 0.5 * (north - east);
64 let radius = math::hypot(half_difference, north_east);
65 let major = (half_trace + radius).max(0.0);
66 let minor = (half_trace - radius).max(0.0);
67 let orientation = if radius < f64::MIN_POSITIVE {
69 0.0
70 } else {
71 0.5 * math::atan2(2.0 * north_east, north - east)
72 };
73 Some(Self {
74 semi_major: Distance::from_metres(math::sqrt(major)).ok()?,
75 semi_minor: Distance::from_metres(math::sqrt(minor)).ok()?,
76 orientation: TrueCourse::wrap(math::to_degrees(orientation)).ok()?,
77 })
78 }
79
80 pub fn new(
87 semi_major: Distance,
88 semi_minor: Distance,
89 orientation: TrueCourse,
90 ) -> Result<Self> {
91 ensure_range("semi-major axis", semi_major.metres(), 0.0, f64::MAX)?;
92 ensure_range(
93 "semi-minor axis",
94 semi_minor.metres(),
95 0.0,
96 semi_major.metres(),
97 )?;
98 Ok(Self {
99 semi_major,
100 semi_minor,
101 orientation,
102 })
103 }
104
105 #[must_use]
107 pub fn circular(radius: Distance) -> Self {
108 let radius = Distance::from_metres(math::abs(radius.metres())).unwrap_or(radius);
109 Self {
110 semi_major: radius,
111 semi_minor: radius,
112 orientation: TrueCourse::NORTH,
113 }
114 }
115
116 #[must_use]
118 pub const fn semi_major(&self) -> Distance {
119 self.semi_major
120 }
121
122 #[must_use]
124 pub const fn semi_minor(&self) -> Distance {
125 self.semi_minor
126 }
127
128 #[must_use]
130 pub fn orientation(&self) -> TrueCourse {
131 let degrees = self.orientation.degrees();
132 if degrees >= 180.0 {
133 TrueCourse::wrap(degrees - 180.0).unwrap_or(self.orientation)
134 } else {
135 self.orientation
136 }
137 }
138
139 #[must_use]
142 pub fn equivalent_radius(&self) -> Distance {
143 Distance::from_metres(math::sqrt(
144 self.semi_major.metres() * self.semi_minor.metres(),
145 ))
146 .unwrap_or(Distance::ZERO)
147 }
148}
149
150#[cfg(feature = "serde")]
152#[derive(serde::Serialize, serde::Deserialize)]
153struct StoredErrorEllipse {
154 semi_major: Distance,
155 semi_minor: Distance,
156 orientation: TrueCourse,
157}
158
159#[cfg(feature = "serde")]
160impl TryFrom<StoredErrorEllipse> for ErrorEllipse {
161 type Error = crate::error::KernelError;
162
163 fn try_from(stored: StoredErrorEllipse) -> Result<Self> {
164 Self::new(stored.semi_major, stored.semi_minor, stored.orientation)
165 }
166}
167
168#[cfg(feature = "serde")]
169impl From<ErrorEllipse> for StoredErrorEllipse {
170 fn from(ellipse: ErrorEllipse) -> Self {
171 Self {
172 semi_major: ellipse.semi_major,
173 semi_minor: ellipse.semi_minor,
174 orientation: ellipse.orientation,
175 }
176 }
177}
178
179impl fmt::Display for ErrorEllipse {
180 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
181 write!(
182 f,
183 "{:.1} m × {:.1} m at {:.0}",
184 self.semi_major.metres(),
185 self.semi_minor.metres(),
186 self.orientation()
187 )
188 }
189}
190
191#[derive(Debug, Clone, Copy, PartialEq)]
197pub struct NavigationSnapshot {
198 position: Option<Observed<Position, Distance>>,
199 source: Option<PositionSource>,
200 ground_track: Option<GroundTrack>,
201 heading: Option<TrueCourse>,
202 heading_sigma: Option<Angle>,
203 horizontal_error: Option<ErrorEllipse>,
204 integrity: Option<NavigationIntegrity>,
205 age: Option<Duration>,
206 stale: bool,
207}
208
209impl NavigationSnapshot {
210 pub const EMPTY: Self = Self {
212 position: None,
213 source: None,
214 ground_track: None,
215 heading: None,
216 heading_sigma: None,
217 horizontal_error: None,
218 integrity: None,
219 age: None,
220 stale: false,
221 };
222
223 #[must_use]
225 pub const fn with_position(
226 mut self,
227 position: Observed<Position, Distance>,
228 source: PositionSource,
229 ) -> Self {
230 self.position = Some(position);
231 self.source = Some(source);
232 self
233 }
234
235 #[must_use]
237 pub const fn with_ground_track(mut self, track: GroundTrack) -> Self {
238 self.ground_track = Some(track);
239 self
240 }
241
242 #[must_use]
244 pub const fn with_heading(mut self, heading: TrueCourse, sigma: Option<Angle>) -> Self {
245 self.heading = Some(heading);
246 self.heading_sigma = sigma;
247 self
248 }
249
250 #[must_use]
252 pub const fn with_horizontal_error(mut self, ellipse: ErrorEllipse) -> Self {
253 self.horizontal_error = Some(ellipse);
254 self
255 }
256
257 #[must_use]
259 pub const fn with_integrity(mut self, integrity: NavigationIntegrity) -> Self {
260 self.integrity = Some(integrity);
261 self
262 }
263
264 #[must_use]
266 pub const fn with_age(mut self, age: Option<Duration>, stale: bool) -> Self {
267 self.age = age;
268 self.stale = stale;
269 self
270 }
271
272 #[must_use]
274 pub const fn position(&self) -> Option<&Observed<Position, Distance>> {
275 self.position.as_ref()
276 }
277
278 #[must_use]
280 pub const fn source(&self) -> Option<PositionSource> {
281 self.source
282 }
283
284 #[must_use]
286 pub const fn ground_track(&self) -> Option<GroundTrack> {
287 self.ground_track
288 }
289
290 #[must_use]
292 pub const fn heading(&self) -> Option<TrueCourse> {
293 self.heading
294 }
295
296 #[must_use]
298 pub const fn heading_sigma(&self) -> Option<Angle> {
299 self.heading_sigma
300 }
301
302 #[must_use]
304 pub const fn horizontal_error(&self) -> Option<ErrorEllipse> {
305 self.horizontal_error
306 }
307
308 #[must_use]
311 pub const fn integrity(&self) -> Option<NavigationIntegrity> {
312 self.integrity
313 }
314
315 #[must_use]
318 pub const fn age(&self) -> Option<Duration> {
319 self.age
320 }
321
322 #[must_use]
327 pub const fn is_stale(&self) -> bool {
328 self.stale
329 }
330}
331
332#[cfg(test)]
333#[allow(clippy::unwrap_used, clippy::float_cmp)]
334mod tests {
335 use super::*;
336 use alloc::format;
337
338 #[test]
339 fn a_diagonal_covariance_gives_axes_along_north_and_east() {
340 let ellipse = ErrorEllipse::from_covariance(16.0, 0.0, 4.0).unwrap();
341 assert_eq!(ellipse.semi_major().metres(), 4.0);
342 assert_eq!(ellipse.semi_minor().metres(), 2.0);
343 assert_eq!(ellipse.orientation().degrees(), 0.0);
344 let across = ErrorEllipse::from_covariance(4.0, 0.0, 16.0).unwrap();
345 assert_eq!(across.orientation().degrees(), 90.0);
346 assert!((across.equivalent_radius().metres() - math::sqrt(8.0)).abs() < 1e-12);
347 assert_eq!(format!("{across}"), "4.0 m × 2.0 m at 090°T");
348 }
349
350 #[test]
351 fn a_correlated_covariance_tilts_the_ellipse() {
352 let ellipse = ErrorEllipse::from_covariance(1.0, 1.0, 1.0).unwrap();
354 assert!((ellipse.semi_major().metres() - math::sqrt(2.0)).abs() < 1e-12);
355 assert!(ellipse.semi_minor().metres() < 1e-9);
356 assert!((ellipse.orientation().degrees() - 45.0).abs() < 1e-9);
357 let other = ErrorEllipse::from_covariance(1.0, -0.5, 1.0).unwrap();
359 assert!((other.orientation().degrees() - 135.0).abs() < 1e-9);
360 }
361
362 #[test]
363 fn what_is_not_a_covariance_is_refused() {
364 assert!(ErrorEllipse::from_covariance(-1.0, 0.0, 1.0).is_none());
365 assert!(ErrorEllipse::from_covariance(1.0, 2.0, 1.0).is_none());
366 assert!(ErrorEllipse::from_covariance(f64::NAN, 0.0, 1.0).is_none());
367 assert!(ErrorEllipse::from_covariance(0.0, 0.0, 0.0).is_some());
368 }
369
370 #[test]
371 fn an_empty_snapshot_knows_nothing_and_is_not_stale() {
372 let empty = NavigationSnapshot::EMPTY;
373 assert!(empty.position().is_none());
374 assert!(empty.source().is_none());
375 assert!(empty.ground_track().is_none());
376 assert!(empty.heading().is_none());
377 assert!(empty.horizontal_error().is_none());
378 assert!(empty.integrity().is_none());
379 assert_eq!(empty.age(), None);
380 assert!(!empty.is_stale());
381 }
382}