Skip to main content

thrust/data/faa/
nat.rs

1use crate::error::ThrustError;
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4
5use crate::data::faa::nasr::NasrPoint;
6
7#[cfg(feature = "net")]
8const FAA_NAT_URL: &str = "https://notams.aim.faa.gov/nat.html";
9
10/// Direction of flight level assignments for a North Atlantic Track.
11///
12/// North Atlantic Organized Track System (NAT) routes assign different flight levels
13/// for eastbound and westbound traffic to minimize conflicts while optimizing fuel efficiency.
14///
15/// # Variants
16/// - `East`: Track is valid only for eastbound flights (typically 0°-180°)
17/// - `West`: Track is valid only for westbound flights (typically 180°-360°)
18/// - `Both`: Track is valid for both directions (rare; used during special operations)
19/// - `Unknown`: Direction could not be determined from available data
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21pub enum NatDirection {
22    East,
23    West,
24    Both,
25    Unknown,
26}
27
28/// A waypoint or fix on a North Atlantic Track.
29///
30/// This represents a single point in a NAT route. Points may be defined either by:
31/// - **Named fix**: A published navaid or waypoint (e.g., "WEST", "STIRA") with optional coordinates
32/// - **Coordinate**: A latitude/longitude pair in shorthand notation (e.g., "50/50" for 50°N 50°W)
33///
34/// # Fields
35/// - `token`: Raw identifier as parsed from the NAT bulletin (e.g., "STIRA", "50/50")
36/// - `name`: Human-readable name (e.g., "STANDARD INSTRUMENT REPAIR AREA"); None for coordinates
37/// - `latitude`: Decimal latitude if available; None if unresolved
38/// - `longitude`: Decimal longitude if available; None if unresolved
39#[derive(Debug, Clone, Serialize, Deserialize, Default)]
40pub struct NatPoint {
41    pub token: String,
42    pub name: Option<String>,
43    pub latitude: Option<f64>,
44    pub longitude: Option<f64>,
45}
46
47/// A single North Atlantic Track with routing, altitude assignments, and metadata.
48///
49/// NAT tracks are published daily and define high-altitude oceanic air routes between North America
50/// and Europe. Each track specifies a sequence of waypoints and approved flight levels for eastbound
51/// and/or westbound traffic. Tracks are identified by single letters (A–Z).
52///
53/// # Fields
54/// - `track_id`: Single-letter identifier (e.g., "A", "B")
55/// - `route_points`: Ordered sequence of waypoints defining the track path
56/// - `east_levels`: Approved flight levels for eastbound traffic (FL250–FL510)
57/// - `west_levels`: Approved flight levels for westbound traffic (FL250–FL510)
58/// - `nar_routes`: Alternate North American region routes or special routing
59/// - `validity`: Time window during which track is active (e.g., "0000 TO 0600Z")
60/// - `source_center`: Originating ATC center (e.g., "SHANNON", "GANDER")
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
62pub struct NatTrack {
63    pub track_id: String,
64    pub route_points: Vec<NatPoint>,
65    pub east_levels: Vec<u16>,
66    pub west_levels: Vec<u16>,
67    pub nar_routes: Vec<String>,
68    pub validity: Option<String>,
69    pub source_center: Option<String>,
70}
71
72impl NatTrack {
73    pub fn direction(&self) -> NatDirection {
74        match (self.east_levels.is_empty(), self.west_levels.is_empty()) {
75            (false, true) => NatDirection::East,
76            (true, false) => NatDirection::West,
77            (false, false) => NatDirection::Both,
78            (true, true) => NatDirection::Unknown,
79        }
80    }
81}
82
83/// A complete set of North Atlantic Tracks for a given validity period.
84///
85/// This represents the entire NAT bulletin published by the FAA, containing all active tracks
86/// for a specific time window. The bulletin includes metadata about when it was published and
87/// any traffic management initiatives (TMI) in effect.
88///
89/// # Fields
90/// - `tracks`: Collection of all active tracks (typically 6–8 tracks labeled A–G or H)
91/// - `tmi`: Traffic management initiative identifier if active (e.g., "TMI00001")
92/// - `updated_at`: Timestamp when the bulletin was last updated
93///
94/// # Example
95/// ```ignore
96/// let bulletin = parse_nat_bulletin(raw_html);
97/// println!("Active tracks: {:?}", bulletin.tracks.iter().map(|t| &t.track_id).collect::<Vec<_>>());
98/// println!("Valid from: {}", bulletin.updated_at.unwrap_or_default());
99/// ```
100#[derive(Debug, Clone, Serialize, Deserialize, Default)]
101pub struct NatBulletin {
102    pub tracks: Vec<NatTrack>,
103    pub tmi: Option<String>,
104    pub updated_at: Option<String>,
105}
106
107pub fn fetch_nat_bulletin() -> Result<NatBulletin, ThrustError> {
108    #[cfg(not(feature = "net"))]
109    {
110        Err("FAA NAT network fetch is disabled; enable feature 'net'".into())
111    }
112
113    #[cfg(feature = "net")]
114    {
115        let text = reqwest::blocking::Client::new()
116            .get(FAA_NAT_URL)
117            .timeout(std::time::Duration::from_secs(60))
118            .send()?
119            .error_for_status()?
120            .text()?;
121        Ok(parse_nat_bulletin(&text))
122    }
123}
124
125pub fn parse_nat_bulletin(raw: &str) -> NatBulletin {
126    let mut bulletin = NatBulletin::default();
127
128    let normalized = normalize_text(raw);
129    bulletin.updated_at = extract_updated_at(&normalized);
130    bulletin.tmi = extract_tmi(&normalized);
131
132    let mut current_validity: Option<String> = None;
133    let mut current_center: Option<String> = None;
134    let mut current_track: Option<NatTrack> = None;
135
136    for line in normalized.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
137        if line.ends_with("ZOZX") || line.ends_with("ZQZX") {
138            let parts: Vec<&str> = line.split_whitespace().collect();
139            if parts.len() >= 2 {
140                current_center = Some(parts[1].to_string());
141            }
142            continue;
143        }
144
145        if line.contains(" TO ") && line.contains('Z') && line.contains('/') {
146            current_validity = Some(line.to_string());
147            continue;
148        }
149
150        if let Some(track) = parse_track_start_line(line) {
151            if let Some(prev) = current_track.take() {
152                bulletin.tracks.push(prev);
153            }
154            let mut track = track;
155            track.validity = current_validity.clone();
156            track.source_center = current_center.clone();
157            current_track = Some(track);
158            continue;
159        }
160
161        if let Some(track) = current_track.as_mut() {
162            if let Some(levels) = line.strip_prefix("EAST LVLS") {
163                track.east_levels = parse_levels(levels);
164                continue;
165            }
166            if let Some(levels) = line.strip_prefix("WEST LVLS") {
167                track.west_levels = parse_levels(levels);
168                continue;
169            }
170            if let Some(nar) = line.strip_prefix("NAR") {
171                track.nar_routes = parse_nar_routes(nar);
172                continue;
173            }
174        }
175    }
176
177    if let Some(prev) = current_track.take() {
178        bulletin.tracks.push(prev);
179    }
180
181    bulletin
182}
183
184pub fn resolve_named_points_with_nasr(bulletin: &mut NatBulletin, points: &[NasrPoint]) -> usize {
185    let lookup = build_point_lookup(points);
186    let mut resolved = 0usize;
187
188    for track in &mut bulletin.tracks {
189        for point in &mut track.route_points {
190            if point.latitude.is_some() && point.longitude.is_some() {
191                continue;
192            }
193            let key = point.token.to_uppercase();
194            if let Some((lat, lon, name)) = lookup.get(&key) {
195                point.latitude = Some(*lat);
196                point.longitude = Some(*lon);
197                if point.name.is_none() {
198                    point.name = Some(name.clone());
199                }
200                resolved += 1;
201            }
202        }
203    }
204
205    resolved
206}
207
208fn build_point_lookup(points: &[NasrPoint]) -> HashMap<String, (f64, f64, String)> {
209    let mut lookup = HashMap::new();
210    for p in points {
211        if p.latitude == 0.0 && p.longitude == 0.0 {
212            continue;
213        }
214
215        let canonical_name = p.name.clone().unwrap_or_else(|| p.identifier.clone());
216        let val = (p.latitude, p.longitude, canonical_name);
217
218        lookup.entry(p.identifier.to_uppercase()).or_insert(val.clone());
219        if let Some(name) = &p.name {
220            lookup.entry(name.to_uppercase()).or_insert(val.clone());
221        }
222
223        let base = p.identifier.split(':').next().unwrap_or(&p.identifier).to_uppercase();
224        lookup.entry(base).or_insert(val);
225    }
226    lookup
227}
228
229fn normalize_text(raw: &str) -> String {
230    raw.replace(['\u{2}', '\u{3}', '\u{b}', '\r'], "\n")
231}
232
233fn extract_updated_at(text: &str) -> Option<String> {
234    text.lines().find_map(|line| {
235        let marker = "Last updated at";
236        if let Some(i) = line.find(marker) {
237            let tail = line[i + marker.len()..].trim();
238            let clean = tail.split('<').next().unwrap_or(tail).trim();
239            if clean.is_empty() {
240                None
241            } else {
242                Some(clean.to_string())
243            }
244        } else {
245            None
246        }
247    })
248}
249
250fn extract_tmi(text: &str) -> Option<String> {
251    text.lines().find_map(|line| {
252        if let Some(idx) = line.find("TMI IS") {
253            let tail = &line[idx + 6..];
254            tail.split(|c: char| !c.is_ascii_alphanumeric())
255                .find(|tok| !tok.is_empty())
256                .map(|s| s.to_string())
257        } else {
258            None
259        }
260    })
261}
262
263fn parse_track_start_line(line: &str) -> Option<NatTrack> {
264    let mut parts = line.split_whitespace();
265    let id = parts.next()?;
266    if id.len() != 1 || !id.chars().all(|c| c.is_ascii_uppercase()) {
267        return None;
268    }
269
270    let route_points = parts
271        .map(|p| p.trim_matches('-'))
272        .filter(|p| !p.is_empty())
273        .map(parse_nat_point)
274        .collect::<Vec<_>>();
275
276    if route_points.len() < 2 {
277        return None;
278    }
279
280    Some(NatTrack {
281        track_id: id.to_string(),
282        route_points,
283        ..Default::default()
284    })
285}
286
287fn parse_nat_point(token: &str) -> NatPoint {
288    let token = token.trim().to_string();
289    if let Some((lat, lon)) = parse_coordinate_token(&token) {
290        NatPoint {
291            token,
292            name: None,
293            latitude: Some(lat),
294            longitude: Some(lon),
295        }
296    } else {
297        NatPoint {
298            token: token.clone(),
299            name: Some(token),
300            latitude: None,
301            longitude: None,
302        }
303    }
304}
305
306fn parse_coordinate_token(token: &str) -> Option<(f64, f64)> {
307    // NAT shorthand like 50/50 means 50N 50W
308    if let Some((lat_s, lon_s)) = token.split_once('/') {
309        let lat = lat_s.parse::<f64>().ok()?;
310        let lon = lon_s.parse::<f64>().ok()?;
311        return Some((lat, -lon));
312    }
313
314    // 50N080W or 56N030W (degrees)
315    if let Some((lat, lon)) = parse_ddn_dddw(token) {
316        return Some((lat, lon));
317    }
318
319    // 5130N07000W or 5530N04000W (degrees+minutes)
320    if let Some((lat, lon)) = parse_ddmmn_dddmmw(token) {
321        return Some((lat, lon));
322    }
323
324    None
325}
326
327fn parse_ddn_dddw(token: &str) -> Option<(f64, f64)> {
328    let b = token.as_bytes();
329    if b.len() < 7 || b.len() > 9 {
330        return None;
331    }
332    let n_pos = token.find('N').or_else(|| token.find('S'))?;
333    let w_pos = token.find('W').or_else(|| token.find('E'))?;
334    if n_pos < 2 || w_pos <= n_pos + 1 {
335        return None;
336    }
337    let lat_deg = token[..n_pos].parse::<f64>().ok()?;
338    let lon_deg = token[n_pos + 1..w_pos].parse::<f64>().ok()?;
339    let lat = if &token[n_pos..=n_pos] == "S" {
340        -lat_deg
341    } else {
342        lat_deg
343    };
344    let lon = if &token[w_pos..=w_pos] == "E" {
345        lon_deg
346    } else {
347        -lon_deg
348    };
349    Some((lat, lon))
350}
351
352fn parse_ddmmn_dddmmw(token: &str) -> Option<(f64, f64)> {
353    let n_pos = token.find('N').or_else(|| token.find('S'))?;
354    let w_pos = token.find('W').or_else(|| token.find('E'))?;
355    if n_pos < 4 || w_pos <= n_pos + 1 {
356        return None;
357    }
358    let lat_raw = &token[..n_pos];
359    let lon_raw = &token[n_pos + 1..w_pos];
360    if lat_raw.len() != 4 || lon_raw.len() != 5 {
361        return None;
362    }
363    let lat_deg = lat_raw[..2].parse::<f64>().ok()?;
364    let lat_min = lat_raw[2..].parse::<f64>().ok()?;
365    let lon_deg = lon_raw[..3].parse::<f64>().ok()?;
366    let lon_min = lon_raw[3..].parse::<f64>().ok()?;
367    let lat = lat_deg + lat_min / 60.0;
368    let lon = lon_deg + lon_min / 60.0;
369    let lat = if &token[n_pos..=n_pos] == "S" { -lat } else { lat };
370    let lon = if &token[w_pos..=w_pos] == "E" { lon } else { -lon };
371    Some((lat, lon))
372}
373
374fn parse_levels(levels_text: &str) -> Vec<u16> {
375    levels_text
376        .split_whitespace()
377        .filter_map(|tok| tok.parse::<u16>().ok())
378        .collect()
379}
380
381fn parse_nar_routes(text: &str) -> Vec<String> {
382    text.split_whitespace()
383        .map(|s| s.trim_matches('-').to_string())
384        .filter(|s| !s.is_empty() && s != "NIL")
385        .collect()
386}