Skip to main content

thrust_py/
airways.rs

1use pyo3::{exceptions::PyOSError, prelude::*, types::PyDict};
2use serde_json::Value;
3use std::fs::File;
4use std::path::PathBuf;
5use thrust::data::eurocontrol::database::{AirwayDatabase, ResolvedPoint, ResolvedRoute};
6use thrust::data::eurocontrol::ddr::routes::parse_routes_path;
7use thrust::data::faa::nasr::parse_field15_data_from_nasr_zip;
8
9fn normalize_name(value: &str) -> String {
10    value
11        .chars()
12        .filter(|c| c.is_ascii_alphanumeric())
13        .collect::<String>()
14        .to_uppercase()
15}
16
17fn normalize_point_code(value: &str) -> String {
18    value.split(':').next().unwrap_or(value).to_uppercase()
19}
20
21#[pyclass(get_all)]
22#[derive(Debug, Clone)]
23pub struct AirwayPointRecord {
24    code: String,
25    raw_code: Option<String>,
26    kind: String,
27    latitude: f64,
28    longitude: f64,
29}
30
31#[pymethods]
32impl AirwayPointRecord {
33    fn to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
34        let d = PyDict::new(py);
35        d.set_item("code", &self.code)?;
36        if let Some(raw_code) = &self.raw_code {
37            d.set_item("raw_code", raw_code)?;
38        }
39        d.set_item("kind", &self.kind)?;
40        d.set_item("latitude", self.latitude)?;
41        d.set_item("longitude", self.longitude)?;
42        Ok(d.into())
43    }
44}
45
46#[pyclass(get_all)]
47#[derive(Debug, Clone)]
48pub struct AirwayRecord {
49    name: String,
50    points: Vec<AirwayPointRecord>,
51    source: String,
52}
53
54#[pymethods]
55impl AirwayRecord {
56    fn to_dict(&self, py: Python<'_>) -> PyResult<Py<PyAny>> {
57        let d = PyDict::new(py);
58        d.set_item("name", &self.name)?;
59        d.set_item("source", &self.source)?;
60        let pts = self
61            .points
62            .iter()
63            .map(|p| p.to_dict(py))
64            .collect::<PyResult<Vec<_>>>()?;
65        d.set_item("points", pts)?;
66        Ok(d.into())
67    }
68}
69
70fn point_to_record(point: &ResolvedPoint) -> Option<AirwayPointRecord> {
71    match point {
72        ResolvedPoint::AirportHeliport(airport) => Some(AirwayPointRecord {
73            code: normalize_point_code(&airport.icao),
74            raw_code: Some(airport.icao.clone()),
75            kind: "airport".to_string(),
76            latitude: airport.latitude,
77            longitude: airport.longitude,
78        }),
79        ResolvedPoint::Navaid(navaid) => navaid.name.clone().map(|name| AirwayPointRecord {
80            code: normalize_point_code(&name),
81            raw_code: Some(name),
82            kind: "navaid".to_string(),
83            latitude: navaid.latitude,
84            longitude: navaid.longitude,
85        }),
86        ResolvedPoint::DesignatedPoint(point) => Some(AirwayPointRecord {
87            code: normalize_point_code(&point.designator),
88            raw_code: Some(point.designator.clone()),
89            kind: "fix".to_string(),
90            latitude: point.latitude,
91            longitude: point.longitude,
92        }),
93        ResolvedPoint::Coordinates { latitude, longitude } => Some(AirwayPointRecord {
94            code: format!("{latitude:.6},{longitude:.6}"),
95            raw_code: None,
96            kind: "coordinates".to_string(),
97            latitude: *latitude,
98            longitude: *longitude,
99        }),
100        ResolvedPoint::None => None,
101    }
102}
103
104fn route_to_record(route: ResolvedRoute, source: &str) -> AirwayRecord {
105    let mut points: Vec<AirwayPointRecord> = Vec::new();
106    for segment in route.segments {
107        if let Some(start) = point_to_record(&segment.start) {
108            if points.last().map(|x| &x.code) != Some(&start.code) {
109                points.push(start);
110            }
111        }
112        if let Some(end) = point_to_record(&segment.end) {
113            if points.last().map(|x| &x.code) != Some(&end.code) {
114                points.push(end);
115            }
116        }
117    }
118    AirwayRecord {
119        name: route.name,
120        points,
121        source: source.to_string(),
122    }
123}
124
125#[pyclass]
126pub struct AixmAirwaysSource {
127    database: AirwayDatabase,
128}
129
130#[pymethods]
131impl AixmAirwaysSource {
132    #[new]
133    fn new(path: PathBuf) -> PyResult<Self> {
134        let database = AirwayDatabase::new(&path).map_err(|e| PyOSError::new_err(e.to_string()))?;
135        Ok(Self { database })
136    }
137
138    fn resolve_airway(&self, name: String) -> Vec<AirwayRecord> {
139        ResolvedRoute::lookup(&name, &self.database)
140            .into_iter()
141            .map(|route| route_to_record(route, "eurocontrol_aixm"))
142            .collect()
143    }
144
145    fn list_airways(&self) -> Vec<String> {
146        // We cannot list all route names cheaply from private internals yet.
147        Vec::new()
148    }
149}
150
151#[pyclass]
152pub struct NasrAirwaysSource {
153    routes: Vec<AirwayRecord>,
154}
155
156#[pymethods]
157impl NasrAirwaysSource {
158    #[new]
159    fn new(path: PathBuf) -> PyResult<Self> {
160        let data = parse_field15_data_from_nasr_zip(path).map_err(|e| PyOSError::new_err(e.to_string()))?;
161
162        let mut point_index: std::collections::HashMap<String, AirwayPointRecord> = std::collections::HashMap::new();
163        for point in &data.points {
164            let kind = match point.kind.as_str() {
165                "FIX" => "fix",
166                "NAVAID" => "navaid",
167                "AIRPORT" => "airport",
168                _ => "point",
169            }
170            .to_string();
171
172            let record = AirwayPointRecord {
173                code: normalize_point_code(&point.identifier),
174                raw_code: Some(point.identifier.to_uppercase()),
175                kind,
176                latitude: point.latitude,
177                longitude: point.longitude,
178            };
179
180            point_index.insert(point.identifier.to_uppercase(), record.clone());
181            if let Some((base, _suffix)) = point.identifier.split_once(':') {
182                point_index.entry(base.to_uppercase()).or_insert(record);
183            }
184        }
185
186        let mut by_name: std::collections::HashMap<String, Vec<AirwayPointRecord>> = std::collections::HashMap::new();
187
188        for seg in data.airways {
189            let route_name = if seg.airway_id.trim().is_empty() {
190                seg.airway_name.clone()
191            } else {
192                seg.airway_id.clone()
193            };
194            let entry = by_name.entry(route_name).or_default();
195            let from_key = seg.from_point.to_uppercase();
196            let to_key = seg.to_point.to_uppercase();
197            let from = point_index.get(&from_key).cloned().unwrap_or(AirwayPointRecord {
198                code: normalize_point_code(&from_key),
199                raw_code: Some(from_key.clone()),
200                kind: "point".to_string(),
201                latitude: 0.0,
202                longitude: 0.0,
203            });
204            let to = point_index.get(&to_key).cloned().unwrap_or(AirwayPointRecord {
205                code: normalize_point_code(&to_key),
206                raw_code: Some(to_key.clone()),
207                kind: "point".to_string(),
208                latitude: 0.0,
209                longitude: 0.0,
210            });
211
212            if entry.last().map(|x| &x.code) != Some(&from.code) {
213                entry.push(from);
214            }
215            if entry.last().map(|x| &x.code) != Some(&to.code) {
216                entry.push(to);
217            }
218        }
219
220        let routes = by_name
221            .into_iter()
222            .map(|(name, points)| AirwayRecord {
223                name,
224                points,
225                source: "faa_nasr".to_string(),
226            })
227            .collect();
228        Ok(Self { routes })
229    }
230
231    fn resolve_airway(&self, name: String) -> Vec<AirwayRecord> {
232        let upper = normalize_name(&name);
233        self.routes
234            .iter()
235            .filter(|route| normalize_name(&route.name) == upper)
236            .cloned()
237            .collect()
238    }
239
240    fn list_airways(&self) -> Vec<AirwayRecord> {
241        self.routes.clone()
242    }
243}
244
245fn value_to_string(v: Option<&Value>) -> Option<String> {
246    v.and_then(|x| x.as_str().map(|s| s.to_string()))
247}
248
249fn read_features(path: &std::path::Path) -> Result<Vec<Value>, Box<dyn std::error::Error>> {
250    let file = File::open(path)?;
251    let payload: Value = serde_json::from_reader(file)?;
252    Ok(payload
253        .get("features")
254        .and_then(|x| x.as_array())
255        .cloned()
256        .unwrap_or_default())
257}
258
259#[pyclass]
260pub struct FaaArcgisAirwaysSource {
261    routes: Vec<AirwayRecord>,
262}
263
264#[pymethods]
265impl FaaArcgisAirwaysSource {
266    #[new]
267    fn new(path: PathBuf) -> PyResult<Self> {
268        let features =
269            read_features(&path.join("faa_ats_routes.json")).map_err(|e| PyOSError::new_err(e.to_string()))?;
270
271        let mut by_name: std::collections::HashMap<String, Vec<AirwayPointRecord>> = std::collections::HashMap::new();
272
273        for feature in features {
274            let props = feature.get("properties").unwrap_or(&Value::Null);
275            let route_name = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
276            if route_name.is_empty() {
277                continue;
278            }
279
280            let geom = feature.get("geometry").unwrap_or(&Value::Null);
281            if geom.get("type").and_then(|x| x.as_str()) != Some("LineString") {
282                continue;
283            }
284            let coordinates = geom
285                .get("coordinates")
286                .and_then(|x| x.as_array())
287                .cloned()
288                .unwrap_or_default();
289
290            let entry = by_name.entry(route_name).or_default();
291            for (idx, point) in coordinates.iter().enumerate() {
292                let arr = match point.as_array() {
293                    Some(v) if v.len() >= 2 => v,
294                    _ => continue,
295                };
296                let lon = arr[0].as_f64().unwrap_or(0.0);
297                let lat = arr[1].as_f64().unwrap_or(0.0);
298                let p = AirwayPointRecord {
299                    code: format!("{}:{}", entry.len() + idx, "PT"),
300                    raw_code: None,
301                    kind: "point".to_string(),
302                    latitude: lat,
303                    longitude: lon,
304                };
305                if entry
306                    .last()
307                    .map(|x| (x.latitude, x.longitude) != (p.latitude, p.longitude))
308                    .unwrap_or(true)
309                {
310                    entry.push(p);
311                }
312            }
313        }
314
315        let routes = by_name
316            .into_iter()
317            .map(|(name, points)| AirwayRecord {
318                name,
319                points,
320                source: "faa_arcgis".to_string(),
321            })
322            .collect();
323
324        Ok(Self { routes })
325    }
326
327    fn resolve_airway(&self, name: String) -> Vec<AirwayRecord> {
328        let upper = normalize_name(&name);
329        self.routes
330            .iter()
331            .filter(|route| normalize_name(&route.name) == upper)
332            .cloned()
333            .collect()
334    }
335
336    fn list_airways(&self) -> Vec<AirwayRecord> {
337        self.routes.clone()
338    }
339}
340
341#[pyclass]
342pub struct DdrAirwaysSource {
343    routes: Vec<AirwayRecord>,
344}
345
346#[pymethods]
347impl DdrAirwaysSource {
348    #[new]
349    fn new(path: PathBuf) -> PyResult<Self> {
350        let parsed = parse_routes_path(path).map_err(|e| PyOSError::new_err(e.to_string()))?;
351        let mut by_name: std::collections::HashMap<String, Vec<AirwayPointRecord>> = std::collections::HashMap::new();
352
353        for point in parsed {
354            let route = point.route.to_uppercase();
355            let entry = by_name.entry(route).or_default();
356            entry.push(AirwayPointRecord {
357                code: point.navaid.to_uppercase(),
358                raw_code: Some(point.navaid.to_uppercase()),
359                kind: if point.point_type.to_uppercase().contains("FIX") {
360                    "fix".to_string()
361                } else {
362                    "navaid".to_string()
363                },
364                latitude: point.latitude.unwrap_or(0.0),
365                longitude: point.longitude.unwrap_or(0.0),
366            });
367        }
368
369        let mut routes: Vec<AirwayRecord> = by_name
370            .into_iter()
371            .map(|(name, points)| AirwayRecord {
372                name,
373                points,
374                source: "eurocontrol_ddr".to_string(),
375            })
376            .collect();
377        routes.sort_by(|a, b| a.name.cmp(&b.name));
378
379        Ok(Self { routes })
380    }
381
382    fn resolve_airway(&self, name: String) -> Vec<AirwayRecord> {
383        let upper = normalize_name(&name);
384        self.routes
385            .iter()
386            .filter(|route| normalize_name(&route.name) == upper)
387            .cloned()
388            .collect()
389    }
390
391    fn list_airways(&self) -> Vec<AirwayRecord> {
392        self.routes.clone()
393    }
394}
395
396pub fn init(py: Python<'_>) -> PyResult<Bound<'_, PyModule>> {
397    let m = PyModule::new(py, "airways")?;
398    m.add_class::<AirwayPointRecord>()?;
399    m.add_class::<AirwayRecord>()?;
400    m.add_class::<AixmAirwaysSource>()?;
401    m.add_class::<NasrAirwaysSource>()?;
402    m.add_class::<FaaArcgisAirwaysSource>()?;
403    m.add_class::<DdrAirwaysSource>()?;
404    Ok(m)
405}