Skip to main content

thrust/data/faa/
arcgis.rs

1use crate::error::ThrustError;
2use serde::{Deserialize, Serialize};
3use serde_json::Value;
4
5#[cfg(feature = "net")]
6const OPENDATA_BASE: &str = "https://opendata.arcgis.com/datasets";
7
8const ATS_ROUTE_DATASET: &str = "acf64966af5f48a1a40fdbcb31238ba7_0";
9const DESIGNATED_POINTS_DATASET: &str = "861043a88ff4486c97c3789e7dcdccc6_0";
10const NAVAID_COMPONENTS_DATASET: &str = "c9254c171b6741d3a5e494860761443a_0";
11const AIRSPACE_BOUNDARY_DATASET: &str = "67885972e4e940b2aa6d74024901c561_0";
12const CLASS_AIRSPACE_DATASET: &str = "c6a62360338e408cb1512366ad61559e_0";
13const SPECIAL_USE_AIRSPACE_DATASET: &str = "dd0d1b726e504137ab3c41b21835d05b_0";
14const ROUTE_AIRSPACE_DATASET: &str = "8bf861bb9b414f4ea9f0ff2ca0f1a851_0";
15const PROHIBITED_AIRSPACE_DATASET: &str = "354ee0c77484461198ebf728a2fca50c_0";
16
17/// A GeoJSON feature from FAA's ArcGIS Open Data platform.
18///
19/// Features represent geographic entities published by the FAA (e.g., ATS routes, airspace boundaries).
20/// Each feature contains properties (metadata) and geometry (spatial shape in GeoJSON format).
21/// Feature structures vary by dataset; properties are stored as JSON values for flexibility.
22///
23/// # Fields
24/// - `properties`: Metadata fields specific to the feature type (name, identifier, regulations, etc.)
25/// - `geometry`: GeoJSON geometry object (Point, LineString, Polygon, or MultiPolygon)
26///
27/// # Example
28/// ```ignore
29/// let routes = parse_faa_ats_routes()?;
30/// for route in routes {
31///     if let Some(name) = route.properties.get("name") {
32///         println!("Route: {}", name);
33///     }
34/// }
35/// ```
36#[derive(Debug, Clone, Serialize, Deserialize, Default)]
37pub struct FaaFeature {
38    pub properties: Value,
39    pub geometry: Value,
40}
41
42/// Complete collection of FAA OpenData from ArcGIS, including routes and airspace.
43///
44/// This struct aggregates all major FAA navigational and airspace datasets from the ArcGIS Open Data platform,
45/// which are sourced from the National Airspace System (NAS) and published by the FAA. Each field contains
46/// GeoJSON features for a specific category of navigational or regulatory entity.
47///
48/// # Fields
49/// - `ats_routes`: Automatic Terminal System (ATS) routes (airways like "J500", "L738")
50/// - `designated_points`: Published waypoints and fixes (e.g., "NERTY", "ELCOB")
51/// - `navaid_components`: Radio navigation aids (VOR, NDB, etc.)
52/// - `airspace_boundary`: Boundaries of Class A–D airspace, TRSAs, MOAs
53/// - `class_airspace`: Controlled airspace classification zones
54/// - `special_use_airspace`: Military Operations Areas (MOAs), restricted areas, etc.
55/// - `route_airspace`: Airspace corridors and flight corridors
56/// - `prohibited_airspace`: No-fly zones around sensitive locations
57///
58/// # Example
59/// ```ignore
60/// let all_data = parse_all_faa_open_data()?;
61/// println!("ATS routes: {}", all_data.ats_routes.len());
62/// println!("Designated points: {}", all_data.designated_points.len());
63/// ```
64#[derive(Debug, Clone, Serialize, Deserialize, Default)]
65pub struct FaaOpenData {
66    pub ats_routes: Vec<FaaFeature>,
67    pub designated_points: Vec<FaaFeature>,
68    pub navaid_components: Vec<FaaFeature>,
69    pub airspace_boundary: Vec<FaaFeature>,
70    pub class_airspace: Vec<FaaFeature>,
71    pub special_use_airspace: Vec<FaaFeature>,
72    pub route_airspace: Vec<FaaFeature>,
73    pub prohibited_airspace: Vec<FaaFeature>,
74}
75
76fn fetch_geojson(dataset_id: &str) -> Result<Vec<FaaFeature>, ThrustError> {
77    #[cfg(not(feature = "net"))]
78    {
79        let _ = dataset_id;
80        Err("FAA ArcGIS network fetch is disabled; enable feature 'net'".into())
81    }
82
83    #[cfg(feature = "net")]
84    {
85        let url = format!("{OPENDATA_BASE}/{dataset_id}.geojson");
86        let payload = reqwest::blocking::get(url)?.error_for_status()?.json::<Value>()?;
87
88        let features = payload
89            .get("features")
90            .and_then(|x| x.as_array())
91            .map(|arr| {
92                arr.iter()
93                    .map(|feature| FaaFeature {
94                        properties: feature.get("properties").cloned().unwrap_or(Value::Null),
95                        geometry: feature.get("geometry").cloned().unwrap_or(Value::Null),
96                    })
97                    .collect::<Vec<_>>()
98            })
99            .unwrap_or_default();
100
101        Ok(features)
102    }
103}
104
105pub fn parse_faa_ats_routes() -> Result<Vec<FaaFeature>, ThrustError> {
106    fetch_geojson(ATS_ROUTE_DATASET)
107}
108
109pub fn parse_faa_designated_points() -> Result<Vec<FaaFeature>, ThrustError> {
110    fetch_geojson(DESIGNATED_POINTS_DATASET)
111}
112
113pub fn parse_faa_navaid_components() -> Result<Vec<FaaFeature>, ThrustError> {
114    fetch_geojson(NAVAID_COMPONENTS_DATASET)
115}
116
117pub fn parse_faa_airspace_boundary() -> Result<Vec<FaaFeature>, ThrustError> {
118    fetch_geojson(AIRSPACE_BOUNDARY_DATASET)
119}
120
121pub fn parse_faa_class_airspace() -> Result<Vec<FaaFeature>, ThrustError> {
122    fetch_geojson(CLASS_AIRSPACE_DATASET)
123}
124
125pub fn parse_faa_special_use_airspace() -> Result<Vec<FaaFeature>, ThrustError> {
126    fetch_geojson(SPECIAL_USE_AIRSPACE_DATASET)
127}
128
129pub fn parse_faa_route_airspace() -> Result<Vec<FaaFeature>, ThrustError> {
130    fetch_geojson(ROUTE_AIRSPACE_DATASET)
131}
132
133pub fn parse_faa_prohibited_airspace() -> Result<Vec<FaaFeature>, ThrustError> {
134    fetch_geojson(PROHIBITED_AIRSPACE_DATASET)
135}
136
137pub fn parse_all_faa_open_data() -> Result<FaaOpenData, ThrustError> {
138    Ok(FaaOpenData {
139        ats_routes: parse_faa_ats_routes()?,
140        designated_points: parse_faa_designated_points()?,
141        navaid_components: parse_faa_navaid_components()?,
142        airspace_boundary: parse_faa_airspace_boundary()?,
143        class_airspace: parse_faa_class_airspace()?,
144        special_use_airspace: parse_faa_special_use_airspace()?,
145        route_airspace: parse_faa_route_airspace()?,
146        prohibited_airspace: parse_faa_prohibited_airspace()?,
147    })
148}
149
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ArcgisAirportRecord {
152    pub code: String,
153    pub iata: Option<String>,
154    pub icao: Option<String>,
155    pub name: Option<String>,
156    pub latitude: f64,
157    pub longitude: f64,
158    pub region: Option<String>,
159    pub source: String,
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct ArcgisNavpointRecord {
164    pub code: String,
165    pub identifier: String,
166    pub kind: String,
167    pub name: Option<String>,
168    pub latitude: f64,
169    pub longitude: f64,
170    pub description: Option<String>,
171    pub frequency: Option<f64>,
172    pub point_type: Option<String>,
173    pub region: Option<String>,
174    pub source: String,
175}
176
177#[derive(Debug, Clone, Serialize, Deserialize)]
178pub struct ArcgisAirwayPointRecord {
179    pub code: String,
180    pub raw_code: String,
181    pub kind: String,
182    pub latitude: f64,
183    pub longitude: f64,
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
187pub struct ArcgisAirwayRecord {
188    pub name: String,
189    pub source: String,
190    pub route_class: Option<String>,
191    pub points: Vec<ArcgisAirwayPointRecord>,
192}
193
194#[derive(Debug, Clone, Serialize, Deserialize)]
195pub struct ArcgisAirspaceRecord {
196    pub designator: String,
197    pub name: Option<String>,
198    pub type_: Option<String>,
199    pub lower: Option<f64>,
200    pub upper: Option<f64>,
201    pub coordinates: Vec<(f64, f64)>,
202    pub source: String,
203}
204
205#[derive(Debug, Clone, Serialize, Deserialize, Default)]
206pub struct ArcgisDataset {
207    pub airports: Vec<ArcgisAirportRecord>,
208    pub navaids: Vec<ArcgisNavpointRecord>,
209    pub airways: Vec<ArcgisAirwayRecord>,
210    pub airspaces: Vec<ArcgisAirspaceRecord>,
211}
212
213pub fn parse_arcgis_features(features: &[Value]) -> ArcgisDataset {
214    let airports = arcgis_features_to_airports(features);
215    let airspaces = arcgis_features_to_airspaces(features);
216    let (fixes, mut navaids) = arcgis_features_to_navpoints(features);
217    navaids.extend(fixes.iter().cloned());
218    navaids.sort_by(|a, b| a.code.cmp(&b.code).then(a.point_type.cmp(&b.point_type)));
219    navaids.dedup_by(|a, b| {
220        a.code == b.code && a.point_type == b.point_type && a.latitude == b.latitude && a.longitude == b.longitude
221    });
222    let airways = arcgis_features_to_airways(features);
223
224    ArcgisDataset {
225        airports,
226        navaids,
227        airways,
228        airspaces,
229    }
230}
231
232fn value_to_f64(v: Option<&Value>) -> Option<f64> {
233    v.and_then(|x| x.as_f64().or_else(|| x.as_i64().map(|n| n as f64)))
234}
235
236fn parse_coord(value: Option<&Value>) -> Option<f64> {
237    let value = value?;
238    if let Some(v) = value.as_f64() {
239        return Some(v);
240    }
241    let s = value.as_str()?.trim();
242    let hemi = s.chars().last()?;
243    let sign = match hemi {
244        'N' | 'E' => 1.0,
245        'S' | 'W' => -1.0,
246        _ => 1.0,
247    };
248    let core = s.strip_suffix(hemi).unwrap_or(s);
249    let parts: Vec<&str> = core.split('-').collect();
250    if parts.len() != 3 {
251        return core.parse::<f64>().ok();
252    }
253    let deg = parts[0].parse::<f64>().ok()?;
254    let min = parts[1].parse::<f64>().ok()?;
255    let sec = parts[2].parse::<f64>().ok()?;
256    Some(sign * (deg + min / 60.0 + sec / 3600.0))
257}
258
259fn value_to_string(v: Option<&Value>) -> Option<String> {
260    v.and_then(|x| x.as_str().map(|s| s.to_string()))
261}
262
263fn value_to_i64(v: Option<&Value>) -> Option<i64> {
264    v.and_then(|x| x.as_i64().or_else(|| x.as_f64().map(|n| n as i64)))
265}
266
267fn geometry_to_polygons(geometry: &Value) -> Vec<Vec<(f64, f64)>> {
268    let gtype = geometry.get("type").and_then(|v| v.as_str());
269    let coords = geometry.get("coordinates");
270
271    match (gtype, coords) {
272        (Some("Polygon"), Some(c)) => c
273            .as_array()
274            .and_then(|rings| rings.first())
275            .and_then(|ring| ring.as_array())
276            .map(|ring| {
277                ring.iter()
278                    .filter_map(|pt| {
279                        let arr = pt.as_array()?;
280                        if arr.len() < 2 {
281                            return None;
282                        }
283                        let lon = arr[0].as_f64()?;
284                        let lat = arr[1].as_f64()?;
285                        Some((lon, lat))
286                    })
287                    .collect::<Vec<_>>()
288            })
289            .into_iter()
290            .collect(),
291        (Some("MultiPolygon"), Some(c)) => c
292            .as_array()
293            .map(|polys| {
294                polys
295                    .iter()
296                    .filter_map(|poly| {
297                        let ring = poly.as_array()?.first()?.as_array()?;
298                        Some(
299                            ring.iter()
300                                .filter_map(|pt| {
301                                    let arr = pt.as_array()?;
302                                    if arr.len() < 2 {
303                                        return None;
304                                    }
305                                    let lon = arr[0].as_f64()?;
306                                    let lat = arr[1].as_f64()?;
307                                    Some((lon, lat))
308                                })
309                                .collect::<Vec<_>>(),
310                        )
311                    })
312                    .collect::<Vec<_>>()
313            })
314            .unwrap_or_default(),
315        _ => vec![],
316    }
317}
318
319fn arcgis_features_to_airspaces(features: &[Value]) -> Vec<ArcgisAirspaceRecord> {
320    let mut out = Vec::new();
321    for feature in features {
322        let properties = feature.get("properties").unwrap_or(&Value::Null);
323        let geometry = feature.get("geometry").unwrap_or(&Value::Null);
324        let polygons = geometry_to_polygons(geometry);
325        if polygons.is_empty() {
326            continue;
327        }
328
329        let designator = value_to_string(properties.get("IDENT"))
330            .or_else(|| value_to_string(properties.get("NAME")))
331            .unwrap_or_else(|| "UNKNOWN".to_string());
332        let name = value_to_string(properties.get("NAME"));
333        let type_ = value_to_string(properties.get("TYPE_CODE"));
334        let lower =
335            value_to_f64(properties.get("LOWER_VAL")).map(|v| if (v + 9998.0).abs() < f64::EPSILON { 0.0 } else { v });
336        let upper = value_to_f64(properties.get("UPPER_VAL")).map(|v| {
337            if (v + 9998.0).abs() < f64::EPSILON {
338                f64::INFINITY
339            } else {
340                v
341            }
342        });
343
344        for coords in polygons {
345            if coords.len() < 3 {
346                continue;
347            }
348            out.push(ArcgisAirspaceRecord {
349                designator: designator.clone(),
350                name: name.clone(),
351                type_: type_.clone(),
352                lower,
353                upper,
354                coordinates: coords,
355                source: "faa_arcgis".to_string(),
356            });
357        }
358    }
359    out
360}
361
362fn arcgis_features_to_navpoints(features: &[Value]) -> (Vec<ArcgisNavpointRecord>, Vec<ArcgisNavpointRecord>) {
363    let mut fixes = Vec::new();
364    let mut navaid_groups: std::collections::HashMap<String, ArcgisNavpointRecord> = std::collections::HashMap::new();
365    let mut navaid_components: std::collections::HashMap<String, (bool, bool, bool, bool)> =
366        std::collections::HashMap::new();
367
368    for feature in features {
369        let props = feature.get("properties").unwrap_or(&Value::Null);
370        let ident = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
371        if ident.is_empty() {
372            continue;
373        }
374
375        if props.get("NAV_TYPE").is_some() || props.get("FREQUENCY").is_some() {
376            let latitude = parse_coord(props.get("LATITUDE")).unwrap_or(0.0);
377            let longitude = parse_coord(props.get("LONGITUDE")).unwrap_or(0.0);
378            let group_key = value_to_string(props.get("NAVSYS_ID"))
379                .filter(|s| !s.is_empty())
380                .unwrap_or_else(|| ident.clone());
381
382            navaid_groups
383                .entry(group_key.clone())
384                .or_insert_with(|| ArcgisNavpointRecord {
385                    code: ident.clone(),
386                    identifier: ident,
387                    kind: "navaid".to_string(),
388                    name: value_to_string(props.get("NAME")),
389                    latitude,
390                    longitude,
391                    description: value_to_string(props.get("NAME")),
392                    frequency: value_to_f64(props.get("FREQUENCY")),
393                    point_type: value_to_string(props.get("TYPE_CODE")),
394                    region: value_to_string(props.get("US_AREA")),
395                    source: "faa_arcgis".to_string(),
396                });
397
398            let entry = navaid_components
399                .entry(group_key)
400                .or_insert((false, false, false, false));
401            match value_to_i64(props.get("NAV_TYPE")) {
402                Some(1) => entry.0 = true,
403                Some(2) => entry.1 = true,
404                Some(3) => entry.2 = true,
405                Some(4) => entry.3 = true,
406                _ => {}
407            }
408        } else {
409            let latitude = parse_coord(props.get("LATITUDE")).unwrap_or(0.0);
410            let longitude = parse_coord(props.get("LONGITUDE")).unwrap_or(0.0);
411            fixes.push(ArcgisNavpointRecord {
412                code: ident.clone(),
413                identifier: ident.clone(),
414                kind: "fix".to_string(),
415                name: Some(ident),
416                latitude,
417                longitude,
418                description: value_to_string(props.get("REMARKS")),
419                frequency: None,
420                point_type: value_to_string(props.get("TYPE_CODE")).map(|s| s.to_uppercase()),
421                region: value_to_string(props.get("US_AREA")).or_else(|| value_to_string(props.get("STATE"))),
422                source: "faa_arcgis".to_string(),
423            });
424        }
425    }
426
427    let mut navaids: Vec<ArcgisNavpointRecord> = navaid_groups
428        .into_iter()
429        .map(|(group_key, mut record)| {
430            if let Some((has_ndb, has_dme, has_vor, has_tacan)) = navaid_components.get(&group_key).copied() {
431                record.point_type = Some(
432                    if has_vor && has_tacan {
433                        "VORTAC"
434                    } else if has_vor && has_dme {
435                        "VOR_DME"
436                    } else if has_vor {
437                        "VOR"
438                    } else if has_tacan {
439                        "TACAN"
440                    } else if has_dme {
441                        "DME"
442                    } else if has_ndb {
443                        "NDB"
444                    } else {
445                        record.point_type.as_deref().unwrap_or("NAVAID")
446                    }
447                    .to_string(),
448                );
449            }
450            record
451        })
452        .collect();
453    navaids.sort_by(|a, b| a.code.cmp(&b.code));
454
455    (fixes, navaids)
456}
457
458fn arcgis_features_to_airports(features: &[Value]) -> Vec<ArcgisAirportRecord> {
459    let mut airports = Vec::new();
460
461    for feature in features {
462        let props = feature.get("properties").unwrap_or(&Value::Null);
463        let ident = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
464        let icao = value_to_string(props.get("ICAO_ID")).map(|x| x.to_uppercase());
465        if ident.is_empty() && icao.is_none() {
466            continue;
467        }
468
469        let latitude = parse_coord(props.get("LATITUDE"));
470        let longitude = parse_coord(props.get("LONGITUDE"));
471        let (latitude, longitude) = match (latitude, longitude) {
472            (Some(lat), Some(lon)) => (lat, lon),
473            _ => continue,
474        };
475
476        let code = if ident.is_empty() {
477            icao.clone().unwrap_or_default()
478        } else {
479            ident.clone()
480        };
481        if code.is_empty() {
482            continue;
483        }
484
485        airports.push(ArcgisAirportRecord {
486            code,
487            iata: if ident.len() == 3 { Some(ident) } else { None },
488            icao,
489            name: value_to_string(props.get("NAME")),
490            latitude,
491            longitude,
492            region: value_to_string(props.get("STATE")).or_else(|| value_to_string(props.get("US_AREA"))),
493            source: "faa_arcgis".to_string(),
494        });
495    }
496
497    airports
498}
499
500fn arcgis_features_to_airways(features: &[Value]) -> Vec<ArcgisAirwayRecord> {
501    let mut grouped: std::collections::HashMap<String, Vec<ArcgisAirwayPointRecord>> = std::collections::HashMap::new();
502    let mut point_id_to_ident: std::collections::HashMap<String, String> = std::collections::HashMap::new();
503
504    for feature in features {
505        let props = feature.get("properties").unwrap_or(&Value::Null);
506        let global_id = value_to_string(props.get("GLOBAL_ID")).map(|s| s.to_uppercase());
507        let ident = value_to_string(props.get("IDENT")).map(|s| s.to_uppercase());
508        if let (Some(gid), Some(idt)) = (global_id, ident) {
509            if !gid.is_empty() && !idt.is_empty() {
510                point_id_to_ident.entry(gid).or_insert(idt);
511            }
512        }
513    }
514
515    for feature in features {
516        let props = feature.get("properties").unwrap_or(&Value::Null);
517        let name = value_to_string(props.get("IDENT")).unwrap_or_default().to_uppercase();
518        if name.is_empty() {
519            continue;
520        }
521
522        let geom = feature.get("geometry").unwrap_or(&Value::Null);
523        if geom.get("type").and_then(|x| x.as_str()) != Some("LineString") {
524            continue;
525        }
526        let coords = geom
527            .get("coordinates")
528            .and_then(|x| x.as_array())
529            .cloned()
530            .unwrap_or_default();
531
532        let start_id = value_to_string(props.get("STARTPT_ID")).map(|s| s.to_uppercase());
533        let end_id = value_to_string(props.get("ENDPT_ID")).map(|s| s.to_uppercase());
534        let start_code = start_id
535            .as_ref()
536            .and_then(|id| point_id_to_ident.get(id).cloned())
537            .or(start_id.clone());
538        let end_code = end_id
539            .as_ref()
540            .and_then(|id| point_id_to_ident.get(id).cloned())
541            .or(end_id.clone());
542
543        let entry = grouped.entry(name).or_default();
544        let coord_len = coords.len();
545        for (idx, p) in coords.into_iter().enumerate() {
546            let arr = match p.as_array() {
547                Some(v) if v.len() >= 2 => v,
548                _ => continue,
549            };
550            let lon = arr[0].as_f64().unwrap_or(0.0);
551            let lat = arr[1].as_f64().unwrap_or(0.0);
552            if entry
553                .last()
554                .map(|x| (x.latitude, x.longitude) == (lat, lon))
555                .unwrap_or(false)
556            {
557                continue;
558            }
559
560            let raw_code = if idx == 0 {
561                start_code.clone().unwrap_or_default()
562            } else if idx + 1 == coord_len {
563                end_code.clone().unwrap_or_default()
564            } else {
565                String::new()
566            };
567            let code = if raw_code.is_empty() {
568                format!("{},{}", lat, lon)
569            } else {
570                raw_code.clone()
571            };
572
573            entry.push(ArcgisAirwayPointRecord {
574                code,
575                raw_code,
576                kind: "point".to_string(),
577                latitude: lat,
578                longitude: lon,
579            });
580        }
581    }
582
583    grouped
584        .into_iter()
585        .map(|(name, points)| ArcgisAirwayRecord {
586            name,
587            source: "faa_arcgis".to_string(),
588            route_class: None,
589            points,
590        })
591        .collect()
592}