Skip to main content

thrust/data/eurocontrol/
database.rs

1//! EUROCONTROL airway database handling.
2//!
3//! This module provides functionality to load and query an airway database
4
5use std::hash::Hash;
6use std::{collections::HashMap, path};
7
8use geodesy::prelude::*;
9use once_cell::sync::Lazy;
10use serde::Serialize;
11
12use crate::data::field15::{Connector, Field15Element, Modifier, Point};
13use crate::data::{
14    eurocontrol::aixm::{
15        airport_heliport::{parse_airport_heliport_zip_file, AirportHeliport},
16        arrival_leg::{parse_arrival_leg_zip_file, ArrivalLeg},
17        departure_leg::{parse_departure_leg_zip_file, DepartureLeg},
18        designated_point::{parse_designated_point_zip_file, DesignatedPoint},
19        navaid::{parse_navaid_zip_file, Navaid},
20        route::{parse_route_zip_file, Route},
21        route_segment::{parse_route_segment_zip_file, PointReference, RouteSegment},
22        standard_instrument_arrival::{parse_standard_instrument_arrival_zip_file, StandardInstrumentArrival},
23        standard_instrument_departure::{parse_standard_instrument_departure_zip_file, StandardInstrumentDeparture},
24    },
25    field15::{Altitude, Speed},
26};
27use crate::error::ThrustError;
28
29/// A compiled EUROCONTROL navigational database for querying routes and procedures.
30///
31/// This struct provides fast lookups of airports, navaids, designated points, routes, and
32/// instrument procedures (SIDs/STARs) loaded from AIXM data files. The database is typically
33/// loaded once and used to resolve route segments and procedure exit/entry points.
34///
35/// # Internals
36/// Data is stored in HashMaps keyed by identifier for efficient lookup.
37/// Private fields ensure data consistency and enable future optimizations.
38///
39/// # Example
40/// ```ignore
41/// use std::path::Path;
42/// use thrust::data::eurocontrol::database::AirwayDatabase;
43///
44/// let db = AirwayDatabase::new(Path::new("/path/to/eurocontrol/data"))?;
45/// let exit_points = db.resolve_sid_points("RCKT2");
46/// ```
47#[derive(Debug)]
48pub struct AirwayDatabase {
49    airports: HashMap<String, AirportHeliport>,
50    navaids: HashMap<String, Navaid>,
51    designated_points: HashMap<String, DesignatedPoint>,
52    route_segments: HashMap<String, RouteSegment>,
53    routes: HashMap<String, Route>,
54    arrival_legs: HashMap<String, ArrivalLeg>,
55    departure_legs: HashMap<String, DepartureLeg>,
56    standard_instrument_arrivals: HashMap<String, StandardInstrumentArrival>,
57    standard_instrument_departures: HashMap<String, StandardInstrumentDeparture>,
58}
59
60impl AirwayDatabase {
61    /// Load the airway database from the specified directory path.
62    pub fn new(path: &path::Path) -> Result<Self, ThrustError> {
63        Ok(AirwayDatabase {
64            airports: parse_airport_heliport_zip_file(path.join("AirportHeliport.BASELINE.zip"))?,
65            navaids: parse_navaid_zip_file(path.join("Navaid.BASELINE.zip"))?,
66            designated_points: parse_designated_point_zip_file(path.join("DesignatedPoint.BASELINE.zip"))?,
67            route_segments: parse_route_segment_zip_file(path.join("RouteSegment.BASELINE.zip"))?,
68            routes: parse_route_zip_file(path.join("Route.BASELINE.zip"))?,
69            arrival_legs: if path.join("ArrivalLeg.BASELINE.zip").exists() {
70                parse_arrival_leg_zip_file(path.join("ArrivalLeg.BASELINE.zip"))?
71            } else {
72                HashMap::new()
73            },
74            departure_legs: if path.join("DepartureLeg.BASELINE.zip").exists() {
75                parse_departure_leg_zip_file(path.join("DepartureLeg.BASELINE.zip"))?
76            } else {
77                HashMap::new()
78            },
79            standard_instrument_arrivals: if path.join("StandardInstrumentArrival.BASELINE.zip").exists() {
80                parse_standard_instrument_arrival_zip_file(path.join("StandardInstrumentArrival.BASELINE.zip"))?
81            } else {
82                HashMap::new()
83            },
84            standard_instrument_departures: if path.join("StandardInstrumentDeparture.BASELINE.zip").exists() {
85                parse_standard_instrument_departure_zip_file(path.join("StandardInstrumentDeparture.BASELINE.zip"))?
86            } else {
87                HashMap::new()
88            },
89        })
90    }
91
92    /// Resolve SID connecting points by procedure designator.
93    pub fn resolve_sid_points(&self, name: &str) -> Vec<ResolvedPoint> {
94        let sid_ids = self
95            .standard_instrument_departures
96            .values()
97            .filter(|sid| sid.designator.trim().eq_ignore_ascii_case(name))
98            .map(|sid| sid.identifier.clone())
99            .collect::<std::collections::HashSet<_>>();
100
101        let fallback_points = self
102            .standard_instrument_departures
103            .values()
104            .filter(|sid| sid.designator.trim().eq_ignore_ascii_case(name))
105            .flat_map(|sid| sid.connecting_points.iter().cloned())
106            .collect::<Vec<_>>();
107
108        let exit_points = self.procedure_exit_points(&sid_ids, true);
109        let points_to_resolve = if exit_points.is_empty() {
110            fallback_points
111        } else {
112            exit_points
113        };
114
115        let mut points = points_to_resolve
116            .iter()
117            .map(|p| ResolvedPoint::from_db(p, self))
118            .filter(|p| !matches!(p, ResolvedPoint::None))
119            .collect::<Vec<_>>();
120        points.sort_by_key(|p| format!("{p:?}"));
121        points.dedup();
122        points
123    }
124
125    /// Resolve STAR connecting points by procedure designator.
126    pub fn resolve_star_points(&self, name: &str) -> Vec<ResolvedPoint> {
127        let star_ids = self
128            .standard_instrument_arrivals
129            .values()
130            .filter(|star| star.designator.trim().eq_ignore_ascii_case(name))
131            .map(|star| star.identifier.clone())
132            .collect::<std::collections::HashSet<_>>();
133
134        let fallback_points = self
135            .standard_instrument_arrivals
136            .values()
137            .filter(|star| star.designator.trim().eq_ignore_ascii_case(name))
138            .flat_map(|star| star.connecting_points.iter().cloned())
139            .collect::<Vec<_>>();
140
141        let entry_points = self.procedure_entry_points(&star_ids, false);
142        let points_to_resolve = if entry_points.is_empty() {
143            fallback_points
144        } else {
145            entry_points
146        };
147
148        let mut points = points_to_resolve
149            .iter()
150            .map(|p| ResolvedPoint::from_db(p, self))
151            .filter(|p| !matches!(p, ResolvedPoint::None))
152            .collect::<Vec<_>>();
153        points.sort_by_key(|p| format!("{p:?}"));
154        points.dedup();
155        points
156    }
157
158    /// Resolve SID procedures by designator as route-like segments.
159    pub fn resolve_sid_routes(&self, name: &str) -> Vec<ResolvedRoute> {
160        self.standard_instrument_departures
161            .values()
162            .filter(|sid| sid.designator.trim().eq_ignore_ascii_case(name))
163            .map(|sid| {
164                let segments = self
165                    .departure_legs
166                    .values()
167                    .filter(|leg| leg.departure.as_ref().is_some_and(|id| id == &sid.identifier))
168                    .filter_map(|leg| {
169                        let start = ResolvedPoint::from_db(&leg.start, self);
170                        let end = ResolvedPoint::from_db(&leg.end, self);
171                        if matches!(start, ResolvedPoint::None) || matches!(end, ResolvedPoint::None) {
172                            None
173                        } else {
174                            Some(ResolvedRouteSegment {
175                                start,
176                                end,
177                                name: Some(sid.designator.clone()),
178                                altitude: None,
179                                speed: None,
180                            })
181                        }
182                    })
183                    .collect::<Vec<_>>();
184                ResolvedRoute {
185                    segments: order_route_segments(segments),
186                    name: sid.designator.clone(),
187                }
188            })
189            .collect()
190    }
191
192    /// Resolve STAR procedures by designator as route-like segments.
193    pub fn resolve_star_routes(&self, name: &str) -> Vec<ResolvedRoute> {
194        self.standard_instrument_arrivals
195            .values()
196            .filter(|star| star.designator.trim().eq_ignore_ascii_case(name))
197            .map(|star| {
198                let segments = self
199                    .arrival_legs
200                    .values()
201                    .filter(|leg| leg.arrival.as_ref().is_some_and(|id| id == &star.identifier))
202                    .filter_map(|leg| {
203                        let start = ResolvedPoint::from_db(&leg.start, self);
204                        let end = ResolvedPoint::from_db(&leg.end, self);
205                        if matches!(start, ResolvedPoint::None) || matches!(end, ResolvedPoint::None) {
206                            None
207                        } else {
208                            Some(ResolvedRouteSegment {
209                                start,
210                                end,
211                                name: Some(star.designator.clone()),
212                                altitude: None,
213                                speed: None,
214                            })
215                        }
216                    })
217                    .collect::<Vec<_>>();
218                ResolvedRoute {
219                    segments: order_route_segments(segments),
220                    name: star.designator.clone(),
221                }
222            })
223            .collect()
224    }
225
226    fn procedure_exit_points(
227        &self,
228        procedure_ids: &std::collections::HashSet<String>,
229        is_departure: bool,
230    ) -> Vec<PointReference> {
231        let legs: Vec<(PointReference, PointReference)> = if is_departure {
232            self.departure_legs
233                .values()
234                .filter(|leg| leg.departure.as_ref().is_some_and(|id| procedure_ids.contains(id)))
235                .map(|leg| (leg.start.clone(), leg.end.clone()))
236                .collect()
237        } else {
238            self.arrival_legs
239                .values()
240                .filter(|leg| leg.arrival.as_ref().is_some_and(|id| procedure_ids.contains(id)))
241                .map(|leg| (leg.start.clone(), leg.end.clone()))
242                .collect()
243        };
244
245        let mut indegree: HashMap<String, usize> = HashMap::new();
246        let mut outdegree: HashMap<String, usize> = HashMap::new();
247        let mut refs: HashMap<String, PointReference> = HashMap::new();
248
249        for (start, end) in &legs {
250            let s = start.name();
251            let e = end.name();
252
253            if !s.is_empty() {
254                refs.insert(s.clone(), start.clone());
255                outdegree.entry(s).and_modify(|v| *v += 1).or_insert(1);
256            }
257            if !e.is_empty() {
258                refs.insert(e.clone(), end.clone());
259                indegree.entry(e).and_modify(|v| *v += 1).or_insert(1);
260            }
261        }
262
263        refs.into_iter()
264            .filter_map(|(name, point_ref)| {
265                if point_ref.is_airport_heliport() {
266                    return None;
267                }
268                let in_d = *indegree.get(&name).unwrap_or(&0);
269                let out_d = *outdegree.get(&name).unwrap_or(&0);
270                if out_d == 0 && in_d > 0 {
271                    Some(point_ref)
272                } else {
273                    None
274                }
275            })
276            .collect()
277    }
278
279    fn procedure_entry_points(
280        &self,
281        procedure_ids: &std::collections::HashSet<String>,
282        is_departure: bool,
283    ) -> Vec<PointReference> {
284        let legs: Vec<(PointReference, PointReference)> = if is_departure {
285            self.departure_legs
286                .values()
287                .filter(|leg| leg.departure.as_ref().is_some_and(|id| procedure_ids.contains(id)))
288                .map(|leg| (leg.start.clone(), leg.end.clone()))
289                .collect()
290        } else {
291            self.arrival_legs
292                .values()
293                .filter(|leg| leg.arrival.as_ref().is_some_and(|id| procedure_ids.contains(id)))
294                .map(|leg| (leg.start.clone(), leg.end.clone()))
295                .collect()
296        };
297
298        let mut indegree: HashMap<String, usize> = HashMap::new();
299        let mut outdegree: HashMap<String, usize> = HashMap::new();
300        let mut refs: HashMap<String, PointReference> = HashMap::new();
301
302        for (start, end) in &legs {
303            let s = start.name();
304            let e = end.name();
305
306            if !s.is_empty() {
307                refs.insert(s.clone(), start.clone());
308                outdegree.entry(s).and_modify(|v| *v += 1).or_insert(1);
309            }
310            if !e.is_empty() {
311                refs.insert(e.clone(), end.clone());
312                indegree.entry(e).and_modify(|v| *v += 1).or_insert(1);
313            }
314        }
315
316        refs.into_iter()
317            .filter_map(|(name, point_ref)| {
318                if point_ref.is_airport_heliport() {
319                    return None;
320                }
321                let in_d = *indegree.get(&name).unwrap_or(&0);
322                let out_d = *outdegree.get(&name).unwrap_or(&0);
323                if in_d == 0 && out_d > 0 {
324                    Some(point_ref)
325                } else {
326                    None
327                }
328            })
329            .collect()
330    }
331}
332
333fn order_route_segments(segments: Vec<ResolvedRouteSegment>) -> Vec<ResolvedRouteSegment> {
334    let mut out_map: HashMap<ResolvedPoint, Vec<ResolvedRouteSegment>> = HashMap::new();
335    let mut indegree: HashMap<ResolvedPoint, usize> = HashMap::new();
336
337    for segment in segments {
338        out_map.entry(segment.start.clone()).or_default().push(segment.clone());
339        indegree.entry(segment.start.clone()).or_insert(0);
340        indegree.entry(segment.end.clone()).and_modify(|v| *v += 1).or_insert(1);
341    }
342
343    for edges in out_map.values_mut() {
344        edges.sort_by_key(|e| format!("{}", e.end));
345    }
346
347    let mut starts = indegree
348        .iter()
349        .filter_map(|(point, deg)| if *deg == 0 { Some(point.clone()) } else { None })
350        .collect::<Vec<_>>();
351    starts.sort_by_key(|p| format!("{p}"));
352
353    let mut ordered = Vec::new();
354
355    let walk_from = |start: ResolvedPoint,
356                     out_map: &mut HashMap<ResolvedPoint, Vec<ResolvedRouteSegment>>,
357                     ordered: &mut Vec<ResolvedRouteSegment>| {
358        let mut current = start;
359        loop {
360            let next = out_map
361                .get_mut(&current)
362                .and_then(|edges| if edges.is_empty() { None } else { Some(edges.remove(0)) });
363            if let Some(segment) = next {
364                current = segment.end.clone();
365                ordered.push(segment);
366            } else {
367                break;
368            }
369        }
370    };
371
372    for start in starts {
373        walk_from(start, &mut out_map, &mut ordered);
374    }
375
376    loop {
377        let mut keys = out_map
378            .iter()
379            .filter_map(|(k, v)| if v.is_empty() { None } else { Some(k.clone()) })
380            .collect::<Vec<_>>();
381        if keys.is_empty() {
382            break;
383        }
384        keys.sort_by_key(|p| format!("{p}"));
385        walk_from(keys.remove(0), &mut out_map, &mut ordered);
386    }
387
388    ordered
389}
390
391const VALID_ROUTE_PREFIXES: [&str; 32] = [
392    "UN", "UM", "UL", "UT", "UZ", "UY", "UP", "UA", "UB", "UG", "UH", "UJ", "UQ", "UR", "UV", "UW", "L", "A", "B", "G",
393    "H", "J", "Q", "R", "T", "V", "W", "Y", "Z", "M", "N", "P",
394];
395
396/// The WGS84 ellipsoid.
397static WGS84: Lazy<Ellipsoid> = Lazy::new(|| Ellipsoid::named("WGS84").unwrap());
398
399/// A complete flight route decomposed into segments with geographic and altitude/speed constraints.
400///
401/// Represents a resolved or enriched route with waypoints and metadata about altitude and speed
402/// restrictions at each segment. Routes are typically populated by resolving field15 routes through
403/// the EUROCONTROL database.
404///
405/// # Fields
406/// - `segments`: Ordered list of route segments from origin to destination
407/// - `name`: Route identifier or description (e.g., "Q400 RCKT2 SOLEN")
408#[derive(Debug, Clone, Serialize)]
409pub struct ResolvedRoute {
410    pub segments: Vec<ResolvedRouteSegment>,
411    pub name: String,
412}
413
414/// A single segment of a resolved route between two waypoints.
415///
416/// Each segment connects two navigation points and may include altitude and speed constraints
417/// (e.g., "climb to FL350", "maintain 280 knots").
418///
419/// # Fields
420/// - `start`: Origin point of this segment (airport, navaid, designated point, or coordinates)
421/// - `end`: Destination point of this segment
422/// - `name`: Optional identifier for the segment (e.g., "Q400", route number)
423/// - `altitude`: Altitude constraint if specified in the procedure or route definition
424/// - `speed`: Speed constraint if specified in the procedure or route definition
425#[derive(Debug, Clone, Serialize)]
426pub struct ResolvedRouteSegment {
427    pub start: ResolvedPoint,
428    pub end: ResolvedPoint,
429    #[serde(skip_serializing_if = "Option::is_none")]
430    pub name: Option<String>,
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub altitude: Option<Altitude>,
433    #[serde(skip_serializing_if = "Option::is_none")]
434    pub speed: Option<Speed>,
435}
436
437/// A resolved waypoint on a flight route, resolved to a specific geographic type.
438///
439/// Represents the result of looking up a point reference in the EUROCONTROL database.
440/// A point can be an airport, navaid, designated point, or a latitude/longitude coordinate.
441/// The `None` variant indicates the point could not be resolved.
442///
443/// # Variants
444/// - `AirportHeliport`: Arrival/departure airport or aerodrome
445/// - `Navaid`: Radio navigation aid (VOR, NDB, etc.)
446/// - `DesignatedPoint`: Published waypoint or fix
447/// - `Coordinates`: Raw latitude/longitude pair (typically for procedural segments)
448/// - `None`: Point reference could not be resolved to a known location
449#[derive(Debug, Clone, Serialize)]
450#[serde(untagged)]
451pub enum ResolvedPoint {
452    AirportHeliport(AirportHeliport),
453    Navaid(Navaid),
454    DesignatedPoint(DesignatedPoint),
455    Coordinates { latitude: f64, longitude: f64 },
456    None,
457}
458
459impl std::fmt::Display for ResolvedPoint {
460    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        match self {
462            ResolvedPoint::AirportHeliport(airport) => {
463                write!(
464                    f,
465                    "AirportHeliport({}: {:.3},{:.3})",
466                    airport.icao, airport.latitude, airport.longitude
467                )
468            }
469            ResolvedPoint::Navaid(navaid) => write!(
470                f,
471                "Navaid({}: {:.3},{:.3})",
472                navaid.name.as_ref().unwrap(),
473                navaid.latitude,
474                navaid.longitude
475            ),
476            ResolvedPoint::DesignatedPoint(dp) => write!(
477                f,
478                "DesignatedPoint({}: {:.3}, {:.3})",
479                dp.designator, dp.latitude, dp.longitude
480            ),
481            ResolvedPoint::Coordinates { latitude, longitude } => {
482                write!(f, "Coordinates(lat: {}, lon: {})", latitude, longitude)
483            }
484            ResolvedPoint::None => write!(f, "None"),
485        }
486    }
487}
488
489impl From<&ResolvedPoint> for Coor2D {
490    fn from(val: &ResolvedPoint) -> Self {
491        match val {
492            ResolvedPoint::AirportHeliport(airport) => Coor2D::geo(airport.latitude, airport.longitude),
493            ResolvedPoint::Navaid(navaid) => Coor2D::geo(navaid.latitude, navaid.longitude),
494            ResolvedPoint::DesignatedPoint(dp) => Coor2D::geo(dp.latitude, dp.longitude),
495            ResolvedPoint::Coordinates { latitude, longitude } => Coor2D::geo(*latitude, *longitude),
496            ResolvedPoint::None => Coor2D::default(),
497        }
498    }
499}
500
501impl PartialEq for ResolvedPoint {
502    fn eq(&self, other: &Self) -> bool {
503        match (self, other) {
504            (ResolvedPoint::AirportHeliport(a), ResolvedPoint::AirportHeliport(b)) => a.identifier == b.identifier,
505            (ResolvedPoint::Navaid(a), ResolvedPoint::Navaid(b)) => a.identifier == b.identifier,
506            (ResolvedPoint::DesignatedPoint(a), ResolvedPoint::DesignatedPoint(b)) => a.identifier == b.identifier,
507            (
508                ResolvedPoint::Coordinates {
509                    latitude: a_lat,
510                    longitude: a_lon,
511                },
512                ResolvedPoint::Coordinates {
513                    latitude: b_lat,
514                    longitude: b_lon,
515                },
516            ) => (a_lat - b_lat).abs() < f64::EPSILON && (a_lon - b_lon).abs() < f64::EPSILON,
517            (ResolvedPoint::None, ResolvedPoint::None) => true,
518            _ => false,
519        }
520    }
521}
522
523impl Eq for ResolvedPoint {}
524
525// The hash trait implementation is needed for the DFS algorithm to reconstruct paths
526impl Hash for ResolvedPoint {
527    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
528        match self {
529            ResolvedPoint::AirportHeliport(airport) => {
530                airport.identifier.hash(state);
531            }
532            ResolvedPoint::Navaid(navaid) => {
533                navaid.identifier.hash(state);
534            }
535            ResolvedPoint::DesignatedPoint(dp) => {
536                dp.identifier.hash(state);
537            }
538            ResolvedPoint::Coordinates { latitude, longitude } => {
539                latitude.to_bits().hash(state);
540                longitude.to_bits().hash(state);
541            }
542            ResolvedPoint::None => {
543                0.hash(state);
544            }
545        }
546    }
547}
548
549impl ResolvedPoint {
550    /// Resolve a point from the database.
551    pub fn from_db(point: &PointReference, db: &AirwayDatabase) -> Self {
552        match point {
553            PointReference::AirportHeliport(id) => {
554                if let Some(airport) = db.airports.get(id) {
555                    ResolvedPoint::AirportHeliport(airport.clone())
556                } else {
557                    ResolvedPoint::None
558                }
559            }
560            PointReference::Navaid(id) => {
561                if let Some(navaid) = db.navaids.get(id) {
562                    ResolvedPoint::Navaid(navaid.clone())
563                } else {
564                    ResolvedPoint::None
565                }
566            }
567            PointReference::DesignatedPoint(id) => {
568                if let Some(dp) = db.designated_points.get(id) {
569                    ResolvedPoint::DesignatedPoint(dp.clone())
570                } else {
571                    ResolvedPoint::None
572                }
573            }
574            PointReference::None => ResolvedPoint::None,
575        }
576    }
577    /// Resolve a point by its name from the database.
578    pub fn lookup(name: &str, db: &AirwayDatabase) -> Vec<Self> {
579        let candidates = db
580            .navaids
581            .values()
582            .filter(|n| {
583                n.name
584                    .as_deref()
585                    .is_some_and(|n_name| n_name.trim().eq_ignore_ascii_case(name))
586            })
587            .collect::<Vec<_>>();
588        if !candidates.is_empty() {
589            return candidates.iter().map(|n| ResolvedPoint::Navaid((*n).clone())).collect();
590        }
591        let candidates = db
592            .designated_points
593            .values()
594            .filter(|dp| dp.designator.trim().eq_ignore_ascii_case(name))
595            .collect::<Vec<_>>();
596        if !candidates.is_empty() {
597            return candidates
598                .iter()
599                .map(|dp| ResolvedPoint::DesignatedPoint((*dp).clone()))
600                .collect();
601        }
602        vec![]
603    }
604}
605
606impl ResolvedRouteSegment {
607    /// Resolve a route segment from the database.
608    pub fn from_db(segment: &RouteSegment, db: &AirwayDatabase) -> Self {
609        ResolvedRouteSegment {
610            start: ResolvedPoint::from_db(&segment.start, db),
611            end: ResolvedPoint::from_db(&segment.end, db),
612            name: None,
613            altitude: None,
614            speed: None,
615        }
616    }
617}
618
619impl ResolvedRoute {
620    /// Resolve a route from the database.
621    pub fn from_db(route: &Route, db: &AirwayDatabase) -> Self {
622        let segments = db
623            .route_segments
624            .values()
625            .filter(|segment| segment.route_formed.as_deref() == Some(&route.identifier))
626            .map(|segment| ResolvedRouteSegment::from_db(segment, db))
627            .collect::<Vec<_>>();
628        ResolvedRoute {
629            segments,
630            name: format!(
631                "{}{}{}",
632                route.prefix.as_deref().unwrap_or(""),
633                route.second_letter.as_deref().unwrap_or(""),
634                route.number.as_deref().unwrap_or("")
635            ),
636        }
637    }
638
639    /// Lookup routes by their name from the database.
640    pub fn lookup(name: &str, db: &AirwayDatabase) -> Vec<Self> {
641        if VALID_ROUTE_PREFIXES.iter().any(|prefix| name.starts_with(prefix)) {
642            // First decompose the name into its components
643            // Another approach would be to make a single string match,
644            // but this serves as sanity check as well.
645            let last = name.chars().last().unwrap();
646            let (name, multiple) = if last.is_alphabetic() {
647                (&name[..name.len() - 1], Some(last.to_string()))
648            } else {
649                (name, None)
650            };
651            let (prefix, second_letter, number) = if name.starts_with('U') && name.len() >= 3 {
652                (
653                    Some("U".to_string()),
654                    name.chars().nth(1).unwrap().to_string(),
655                    name[2..].to_string(),
656                )
657            } else if name.len() >= 2 {
658                (None, name.chars().next().unwrap().to_string(), name[1..].to_string())
659            } else {
660                (None, "".to_string(), "".to_string())
661            };
662            let candidates = db
663                .routes
664                .values()
665                .filter(|route| {
666                    route.prefix.as_deref() == prefix.as_deref()
667                        && route.second_letter.as_deref() == Some(&second_letter)
668                        && route.number.as_deref() == Some(&number)
669                        && route.multiple_identifier.as_deref() == multiple.as_deref()
670                })
671                .collect::<Vec<_>>();
672            return candidates
673                .iter()
674                .map(|route| ResolvedRoute::from_db(route, db))
675                .collect();
676        }
677        vec![]
678    }
679
680    /// Check if the route contains the specified point.
681    pub fn contains(&self, point: &ResolvedPoint) -> bool {
682        self.segments
683            .iter()
684            .any(|segment| &segment.start == point || &segment.end == point)
685    }
686
687    /// Find a sub-route between two points, if it exists.
688    /// The implementation uses a depth-first search (DFS) algorithm to find a path
689    /// between the start and end points within the route segments.
690    pub fn between(&self, start: &ResolvedPoint, end: &ResolvedPoint) -> Option<ResolvedRoute> {
691        // Build adjacency map: point -> list of (next_point, segment_index, is_forward)
692        let mut graph: HashMap<&ResolvedPoint, Vec<(&ResolvedPoint, usize, bool)>> = HashMap::new();
693
694        for (i, segment) in self.segments.iter().enumerate() {
695            // Forward direction: start -> end
696            graph.entry(&segment.start).or_default().push((&segment.end, i, true));
697
698            // Backward direction: end -> start
699            graph.entry(&segment.end).or_default().push((&segment.start, i, false));
700        }
701
702        // Try DFS from start to end
703        if let Some(path) = Self::dfs_helper(
704            &graph,
705            start,
706            end,
707            &mut Vec::new(),
708            &mut std::collections::HashSet::new(),
709        ) {
710            return Some(self.build_route_from_path(path));
711        }
712
713        None
714    }
715
716    fn dfs_helper<'a>(
717        graph: &HashMap<&'a ResolvedPoint, Vec<(&'a ResolvedPoint, usize, bool)>>,
718        current: &'a ResolvedPoint,
719        target: &'a ResolvedPoint,
720        path: &mut Vec<(usize, bool)>,
721        visited: &mut std::collections::HashSet<usize>,
722    ) -> Option<Vec<(usize, bool)>> {
723        if current == target {
724            return Some(path.clone());
725        }
726
727        if let Some(neighbors) = graph.get(current) {
728            for (next_point, segment_idx, is_forward) in neighbors {
729                if !visited.contains(segment_idx) {
730                    visited.insert(*segment_idx);
731                    path.push((*segment_idx, *is_forward));
732
733                    if let Some(result) = Self::dfs_helper(graph, next_point, target, path, visited) {
734                        return Some(result);
735                    }
736
737                    path.pop();
738                    visited.remove(segment_idx);
739                }
740            }
741        }
742
743        None
744    }
745
746    fn build_route_from_path(&self, path: Vec<(usize, bool)>) -> ResolvedRoute {
747        let mut segments = Vec::new();
748
749        for (segment_idx, is_forward) in path {
750            let segment = &self.segments[segment_idx];
751            if is_forward {
752                segments.push(segment.clone());
753            } else {
754                // Reverse the segment
755                segments.push(ResolvedRouteSegment {
756                    start: segment.end.clone(),
757                    end: segment.start.clone(),
758                    name: Some(self.name.clone()),
759                    altitude: segment.altitude.clone(),
760                    speed: segment.speed.clone(),
761                });
762            }
763        }
764
765        ResolvedRoute {
766            segments,
767            name: self.name.clone(),
768        }
769    }
770}
771
772#[derive(Debug)]
773enum EnrichedCandidates {
774    Point((Vec<ResolvedPoint>, Option<Altitude>, Option<Speed>)),
775    PointCoords((ResolvedPoint, Option<Altitude>, Option<Speed>)),
776    Airway((Vec<ResolvedRoute>, String, Option<Altitude>, Option<Speed>)),
777    Direct(),
778}
779
780impl AirwayDatabase {
781    /// Enrich a sequence of Field15Elements into resolved route segments.
782    /// A resolved route segment consists of start and end points,
783    /// along with optional altitude and speed constraints.
784    /// All points and airways are resolved against the database.
785    pub fn enrich_route(&self, elements: Vec<Field15Element>) -> Vec<ResolvedRouteSegment> {
786        let mut altitude = None;
787        let mut speed = None;
788
789        // First, resolve all candidates
790        let mut resolved: Vec<EnrichedCandidates> = Vec::new();
791        for element in &elements {
792            match element {
793                Field15Element::Modifier(m) => {
794                    let Modifier {
795                        speed: s, altitude: a, ..
796                    } = m;
797                    altitude = a.clone();
798                    speed = s.clone();
799                }
800                Field15Element::Point(Point::Waypoint(name)) => {
801                    let lookup = ResolvedPoint::lookup(name, self);
802                    if lookup.is_empty() {
803                        tracing::warn!("No point found for identifier '{}'", name);
804                    }
805                    resolved.push(EnrichedCandidates::Point((
806                        ResolvedPoint::lookup(name, self),
807                        altitude.clone(),
808                        speed.clone(),
809                    )));
810                }
811                Field15Element::Point(Point::Coordinates((lat, lon))) => {
812                    resolved.push(EnrichedCandidates::PointCoords((
813                        ResolvedPoint::Coordinates {
814                            latitude: *lat,
815                            longitude: *lon,
816                        },
817                        altitude.clone(),
818                        speed.clone(),
819                    )));
820                }
821                Field15Element::Connector(Connector::Airway(name)) => {
822                    let lookup = ResolvedRoute::lookup(name, self);
823                    if lookup.is_empty() {
824                        tracing::warn!("No airway found for identifier '{}'", name);
825                        resolved.push(EnrichedCandidates::Direct());
826                    } else {
827                        resolved.push(EnrichedCandidates::Airway((
828                            lookup,
829                            name.to_string(),
830                            altitude.clone(),
831                            speed.clone(),
832                        )));
833                    }
834                }
835                Field15Element::Connector(Connector::Sid(name)) => {
836                    let lookup = self.resolve_sid_routes(name);
837                    if lookup.is_empty() {
838                        tracing::warn!("No SID found for identifier '{}'", name);
839                        resolved.push(EnrichedCandidates::Direct());
840                    } else {
841                        resolved.push(EnrichedCandidates::Airway((
842                            lookup,
843                            name.to_string(),
844                            altitude.clone(),
845                            speed.clone(),
846                        )));
847                    }
848                }
849                Field15Element::Connector(Connector::Star(name)) => {
850                    let lookup = self.resolve_star_routes(name);
851                    if lookup.is_empty() {
852                        tracing::warn!("No STAR found for identifier '{}'", name);
853                        resolved.push(EnrichedCandidates::Direct());
854                    } else {
855                        resolved.push(EnrichedCandidates::Airway((
856                            lookup,
857                            name.to_string(),
858                            altitude.clone(),
859                            speed.clone(),
860                        )));
861                    }
862                }
863                Field15Element::Connector(Connector::Direct) => {
864                    resolved.push(EnrichedCandidates::Direct());
865                }
866                Field15Element::Connector(Connector::Nat(_)) | Field15Element::Connector(Connector::Pts(_)) => {
867                    // NAT and PTS are not handled yet
868                    resolved.push(EnrichedCandidates::Direct());
869                }
870                _ => {}
871            }
872        }
873
874        // 1. For each candidate airway, retain only those that contain both the previous and next point.
875        for i in 1..resolved.len() - 1 {
876            let (before_i, i_and_after) = &mut resolved.split_at_mut(i);
877            if let (EnrichedCandidates::Airway((routes, _, _, _)), after_i) = i_and_after.split_first_mut().unwrap() {
878                tracing::debug!("Filtering airway candidates: {:?}", routes);
879                if let Some(EnrichedCandidates::Point((points, _, _))) = before_i.last() {
880                    routes.retain(|r| points.iter().any(|p| r.contains(p)));
881                    tracing::debug!("Filtering airway candidates with point {:?}: {:?}", points, routes);
882                }
883                if let Some(EnrichedCandidates::Point((points, _, _))) = after_i.first() {
884                    routes.retain(|r| points.iter().any(|p| r.contains(p)));
885                    tracing::debug!("Filtering airway candidates with point {:?}: {:?}", points, routes);
886                }
887            }
888        }
889
890        // 2. Transform now empty airway candidates to Direct
891        for candidate in resolved.iter_mut() {
892            if let EnrichedCandidates::Airway((routes, name, _, _)) = candidate {
893                if routes.is_empty() {
894                    tracing::warn!("No valid airway remaining for '{}'", name);
895                    *candidate = EnrichedCandidates::Direct();
896                }
897            }
898        }
899
900        // 3. For each point, retain only those that are present in the adjacent airway segments.
901        for i in 0..resolved.len() {
902            let (before_i, i_and_after) = &mut resolved.split_at_mut(i);
903            if let (EnrichedCandidates::Point((points, _, _)), after_i) = i_and_after.split_first_mut().unwrap() {
904                tracing::debug!("Filtering point candidates: {:?}", points);
905                if let Some(EnrichedCandidates::Airway((routes, _, _, _))) = before_i.last() {
906                    points.retain(|p| routes.iter().any(|r| r.contains(p)));
907                    tracing::debug!("Filtering point candidates with airway {:?}: {:?}", routes, points);
908                }
909                if let Some(EnrichedCandidates::Airway((routes, _, _, _))) = after_i.first() {
910                    points.retain(|p| routes.iter().any(|r| r.contains(p)));
911                    tracing::debug!("Filtering point candidates with airway {:?}: {:?}", routes, points);
912                }
913            }
914        }
915
916        // 4. Trim airways to the segments between the before and after points.
917        for i in 1..resolved.len() - 1 {
918            let (before_i, i_and_after) = &mut resolved.split_at_mut(i);
919            if let (EnrichedCandidates::Airway((routes, _, _, _)), after_i) = i_and_after.split_first_mut().unwrap() {
920                if let Some(EnrichedCandidates::Point((before, _, _))) = before_i.last() {
921                    if let Some(EnrichedCandidates::Point((after, _, _))) = after_i.first() {
922                        if let Some(before) = before.first() {
923                            if let Some(after) = after.first() {
924                                for route in routes.iter_mut() {
925                                    if let Some(trimmed) = route.between(before, after) {
926                                        *route = trimmed;
927                                        tracing::debug!(
928                                            "Trimmed airway '{}' between points {} and {}: {:?}",
929                                            route.name,
930                                            before,
931                                            after,
932                                            *route
933                                        );
934                                    }
935                                }
936                            }
937                        }
938                    }
939                }
940            }
941        }
942
943        // 5. Replace empty routes with Direct
944        for candidate in resolved.iter_mut() {
945            if let EnrichedCandidates::Airway((routes, name, _, _)) = candidate {
946                if routes.iter().all(|r| r.segments.is_empty()) {
947                    tracing::warn!("No valid segments remaining for airway '{}'", name);
948                    *candidate = EnrichedCandidates::Direct();
949                }
950            }
951        }
952
953        // 6. Break the tie for remaining multiple candidate points
954        let mut last_known: Option<ResolvedPoint> = None;
955
956        for i in 0..resolved.len() {
957            if let EnrichedCandidates::Point((points, _, _)) = &resolved[i] {
958                if points.len() > 1 {
959                    // Find the next definitive point ahead
960                    let mut next_definitive: Option<&ResolvedPoint> = None;
961                    for candidate in resolved[i..].iter() {
962                        match candidate {
963                            EnrichedCandidates::Point((pts, _, _)) if pts.len() == 1 => {
964                                next_definitive = pts.first();
965                                break;
966                            }
967                            EnrichedCandidates::PointCoords((pt, _, _)) => {
968                                next_definitive = Some(pt);
969                                break;
970                            }
971                            _ => {}
972                        }
973                    }
974
975                    match (&last_known, next_definitive) {
976                        (None, None) => {
977                            tracing::warn!("Cannot disambiguate point {:?}: no reference points available", points);
978                        }
979                        (None, Some(_)) => {
980                            tracing::info!("Disambiguating point {:?} using only next definitive point", points);
981                        }
982                        (Some(a), None) => {
983                            tracing::info!("Disambiguating point {:?} using only last known point", points);
984
985                            // Only last known point is available, pick the closest candidate
986                            let mut best_idx = 0;
987                            let mut best_distance = f64::INFINITY;
988
989                            for (idx, candidate) in points.iter().enumerate() {
990                                let distance =
991                                    WGS84.distance(&Into::<Coor2D>::into(a), &Into::<Coor2D>::into(candidate));
992                                if distance < best_distance {
993                                    best_distance = distance;
994                                    best_idx = idx;
995                                }
996                            }
997
998                            // Keep only the best candidate
999                            if let EnrichedCandidates::Point((points, _, _)) = &mut resolved[i] {
1000                                let best = points[best_idx].clone();
1001                                points.clear();
1002                                points.push(best);
1003                            }
1004                        }
1005                        (Some(a), Some(b)) => {
1006                            tracing::info!("Disambiguating point {:?} using both reference points", points);
1007
1008                            let mut best_idx = 0;
1009                            let mut best_score = f64::INFINITY;
1010
1011                            for (idx, candidate) in points.iter().enumerate() {
1012                                tracing::info!("Scoring candidate {}: {} ({}-{})", idx, candidate, a, b);
1013                                let score = score_hybrid(&a.into(), &b.into(), &candidate.into());
1014                                if score < best_score {
1015                                    best_score = score;
1016                                    best_idx = idx;
1017                                }
1018                            }
1019
1020                            // Keep only the best candidate
1021                            if let EnrichedCandidates::Point((points, _, _)) = &mut resolved[i] {
1022                                let best = points[best_idx].clone();
1023                                points.clear();
1024                                points.push(best);
1025                            }
1026                        }
1027                    }
1028                }
1029
1030                // Update last_known point
1031                if let EnrichedCandidates::Point((pts, _, _)) = &resolved[i] {
1032                    if let Some(pt) = pts.first() {
1033                        last_known = Some(pt.clone());
1034                    }
1035                }
1036            } else if let EnrichedCandidates::PointCoords((pt, _, _)) = &resolved[i] {
1037                last_known = Some(pt.clone());
1038            }
1039        }
1040
1041        // 7. Build the final sequence of resolved route segments.
1042        let mut segments = Vec::new();
1043        let mut previous_point: Option<ResolvedPoint> = None;
1044
1045        for enriched in resolved {
1046            match enriched {
1047                EnrichedCandidates::Point((points, alt, spd)) => {
1048                    if let Some(point) = points.first() {
1049                        if let Some(prev) = &previous_point {
1050                            if prev == point {
1051                                continue;
1052                            }
1053                            segments.push(ResolvedRouteSegment {
1054                                start: prev.clone(),
1055                                end: point.clone(),
1056                                name: None,
1057                                altitude: alt,
1058                                speed: spd,
1059                            });
1060                        }
1061                        previous_point = Some(point.clone());
1062                    }
1063                }
1064                EnrichedCandidates::PointCoords((point, alt, spd)) => {
1065                    if let Some(prev) = previous_point {
1066                        segments.push(ResolvedRouteSegment {
1067                            start: prev,
1068                            end: point.clone(),
1069                            name: None,
1070                            altitude: alt,
1071                            speed: spd,
1072                        });
1073                    }
1074                    previous_point = Some(point.clone());
1075                }
1076                EnrichedCandidates::Airway((routes, name, alt, spd)) => {
1077                    if let Some(route) = routes.first() {
1078                        for segment in &route.segments {
1079                            segments.push(ResolvedRouteSegment {
1080                                start: segment.start.clone(),
1081                                end: segment.end.clone(),
1082                                name: Some(name.clone()),
1083                                altitude: alt.clone(),
1084                                speed: spd.clone(),
1085                            });
1086                        }
1087                        previous_point = Some(route.segments.last().unwrap().end.clone());
1088                    }
1089                }
1090                EnrichedCandidates::Direct() => {
1091                    // Direct segments are handled by just carrying forward the previous point
1092                }
1093            }
1094        }
1095        segments
1096    }
1097}
1098
1099fn score_hybrid(a: &Coor2D, b: &Coor2D, x: &Coor2D) -> f64 {
1100    // Ideally gap_ration is close to 1.0 and the bearing difference close to 0.0
1101    let ab = WGS84.geodesic_inv(a, b).to_degrees();
1102    let ax = WGS84.geodesic_inv(a, x).to_degrees();
1103    let xb = WGS84.geodesic_inv(x, b).to_degrees();
1104
1105    // Think about triangular inequality, we want x to be "between" a and b
1106    let gap_ratio = (ax[2] + xb[2]) / ab[2].max(1e-9);
1107
1108    let delta_a = (ax[0] - ab[0]).abs().min(360.0 - (ax[0] - ab[0]).abs());
1109    let delta_b = (xb[0] - ab[0]).abs().min(360.0 - (xb[0] - ab[0]).abs());
1110    let bearing_diff = (delta_a + delta_b) / 2.0; // Normalize to [0,1]
1111
1112    tracing::info!(
1113        "Scoring point: {} = {} + {}; bearing_diff = {:.3}, gap_ratio = {:.3}",
1114        ab[2],
1115        ax[2],
1116        xb[2],
1117        bearing_diff,
1118        gap_ratio
1119    );
1120    // Combine the two metrics into a score
1121    bearing_diff / 180. + (gap_ratio - 1.0).max(0.)
1122}