Skip to main content

thrust/data/faa/
nat.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4use crate::data::faa::nasr::NasrPoint;
5
6#[cfg(feature = "net")]
7const FAA_NAT_URL: &str = "https://notams.aim.faa.gov/nat.html";
8
9#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
10pub enum NatDirection {
11    East,
12    West,
13    Both,
14    Unknown,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize, Default)]
18pub struct NatPoint {
19    pub token: String,
20    pub name: Option<String>,
21    pub latitude: Option<f64>,
22    pub longitude: Option<f64>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize, Default)]
26pub struct NatTrack {
27    pub track_id: String,
28    pub route_points: Vec<NatPoint>,
29    pub east_levels: Vec<u16>,
30    pub west_levels: Vec<u16>,
31    pub nar_routes: Vec<String>,
32    pub validity: Option<String>,
33    pub source_center: Option<String>,
34}
35
36impl NatTrack {
37    pub fn direction(&self) -> NatDirection {
38        match (self.east_levels.is_empty(), self.west_levels.is_empty()) {
39            (false, true) => NatDirection::East,
40            (true, false) => NatDirection::West,
41            (false, false) => NatDirection::Both,
42            (true, true) => NatDirection::Unknown,
43        }
44    }
45}
46
47#[derive(Debug, Clone, Serialize, Deserialize, Default)]
48pub struct NatBulletin {
49    pub tracks: Vec<NatTrack>,
50    pub tmi: Option<String>,
51    pub updated_at: Option<String>,
52}
53
54pub fn fetch_nat_bulletin() -> Result<NatBulletin, Box<dyn std::error::Error>> {
55    #[cfg(not(feature = "net"))]
56    {
57        Err("FAA NAT network fetch is disabled; enable feature 'net'".into())
58    }
59
60    #[cfg(feature = "net")]
61    {
62        let text = reqwest::blocking::Client::new()
63            .get(FAA_NAT_URL)
64            .timeout(std::time::Duration::from_secs(60))
65            .send()?
66            .error_for_status()?
67            .text()?;
68        Ok(parse_nat_bulletin(&text))
69    }
70}
71
72pub fn parse_nat_bulletin(raw: &str) -> NatBulletin {
73    let mut bulletin = NatBulletin::default();
74
75    let normalized = normalize_text(raw);
76    bulletin.updated_at = extract_updated_at(&normalized);
77    bulletin.tmi = extract_tmi(&normalized);
78
79    let mut current_validity: Option<String> = None;
80    let mut current_center: Option<String> = None;
81    let mut current_track: Option<NatTrack> = None;
82
83    for line in normalized.lines().map(|l| l.trim()).filter(|l| !l.is_empty()) {
84        if line.ends_with("ZOZX") || line.ends_with("ZQZX") {
85            let parts: Vec<&str> = line.split_whitespace().collect();
86            if parts.len() >= 2 {
87                current_center = Some(parts[1].to_string());
88            }
89            continue;
90        }
91
92        if line.contains(" TO ") && line.contains('Z') && line.contains('/') {
93            current_validity = Some(line.to_string());
94            continue;
95        }
96
97        if let Some(track) = parse_track_start_line(line) {
98            if let Some(prev) = current_track.take() {
99                bulletin.tracks.push(prev);
100            }
101            let mut track = track;
102            track.validity = current_validity.clone();
103            track.source_center = current_center.clone();
104            current_track = Some(track);
105            continue;
106        }
107
108        if let Some(track) = current_track.as_mut() {
109            if let Some(levels) = line.strip_prefix("EAST LVLS") {
110                track.east_levels = parse_levels(levels);
111                continue;
112            }
113            if let Some(levels) = line.strip_prefix("WEST LVLS") {
114                track.west_levels = parse_levels(levels);
115                continue;
116            }
117            if let Some(nar) = line.strip_prefix("NAR") {
118                track.nar_routes = parse_nar_routes(nar);
119                continue;
120            }
121        }
122    }
123
124    if let Some(prev) = current_track.take() {
125        bulletin.tracks.push(prev);
126    }
127
128    bulletin
129}
130
131pub fn resolve_named_points_with_nasr(bulletin: &mut NatBulletin, points: &[NasrPoint]) -> usize {
132    let lookup = build_point_lookup(points);
133    let mut resolved = 0usize;
134
135    for track in &mut bulletin.tracks {
136        for point in &mut track.route_points {
137            if point.latitude.is_some() && point.longitude.is_some() {
138                continue;
139            }
140            let key = point.token.to_uppercase();
141            if let Some((lat, lon, name)) = lookup.get(&key) {
142                point.latitude = Some(*lat);
143                point.longitude = Some(*lon);
144                if point.name.is_none() {
145                    point.name = Some(name.clone());
146                }
147                resolved += 1;
148            }
149        }
150    }
151
152    resolved
153}
154
155fn build_point_lookup(points: &[NasrPoint]) -> HashMap<String, (f64, f64, String)> {
156    let mut lookup = HashMap::new();
157    for p in points {
158        if p.latitude == 0.0 && p.longitude == 0.0 {
159            continue;
160        }
161
162        let canonical_name = p.name.clone().unwrap_or_else(|| p.identifier.clone());
163        let val = (p.latitude, p.longitude, canonical_name);
164
165        lookup.entry(p.identifier.to_uppercase()).or_insert(val.clone());
166        if let Some(name) = &p.name {
167            lookup.entry(name.to_uppercase()).or_insert(val.clone());
168        }
169
170        let base = p.identifier.split(':').next().unwrap_or(&p.identifier).to_uppercase();
171        lookup.entry(base).or_insert(val);
172    }
173    lookup
174}
175
176fn normalize_text(raw: &str) -> String {
177    raw.replace(['\u{2}', '\u{3}', '\u{b}', '\r'], "\n")
178}
179
180fn extract_updated_at(text: &str) -> Option<String> {
181    text.lines().find_map(|line| {
182        let marker = "Last updated at";
183        if let Some(i) = line.find(marker) {
184            let tail = line[i + marker.len()..].trim();
185            let clean = tail.split('<').next().unwrap_or(tail).trim();
186            if clean.is_empty() {
187                None
188            } else {
189                Some(clean.to_string())
190            }
191        } else {
192            None
193        }
194    })
195}
196
197fn extract_tmi(text: &str) -> Option<String> {
198    text.lines().find_map(|line| {
199        if let Some(idx) = line.find("TMI IS") {
200            let tail = &line[idx + 6..];
201            tail.split(|c: char| !c.is_ascii_alphanumeric())
202                .find(|tok| !tok.is_empty())
203                .map(|s| s.to_string())
204        } else {
205            None
206        }
207    })
208}
209
210fn parse_track_start_line(line: &str) -> Option<NatTrack> {
211    let mut parts = line.split_whitespace();
212    let id = parts.next()?;
213    if id.len() != 1 || !id.chars().all(|c| c.is_ascii_uppercase()) {
214        return None;
215    }
216
217    let route_points = parts
218        .map(|p| p.trim_matches('-'))
219        .filter(|p| !p.is_empty())
220        .map(parse_nat_point)
221        .collect::<Vec<_>>();
222
223    if route_points.len() < 2 {
224        return None;
225    }
226
227    Some(NatTrack {
228        track_id: id.to_string(),
229        route_points,
230        ..Default::default()
231    })
232}
233
234fn parse_nat_point(token: &str) -> NatPoint {
235    let token = token.trim().to_string();
236    if let Some((lat, lon)) = parse_coordinate_token(&token) {
237        NatPoint {
238            token,
239            name: None,
240            latitude: Some(lat),
241            longitude: Some(lon),
242        }
243    } else {
244        NatPoint {
245            token: token.clone(),
246            name: Some(token),
247            latitude: None,
248            longitude: None,
249        }
250    }
251}
252
253fn parse_coordinate_token(token: &str) -> Option<(f64, f64)> {
254    // NAT shorthand like 50/50 means 50N 50W
255    if let Some((lat_s, lon_s)) = token.split_once('/') {
256        let lat = lat_s.parse::<f64>().ok()?;
257        let lon = lon_s.parse::<f64>().ok()?;
258        return Some((lat, -lon));
259    }
260
261    // 50N080W or 56N030W (degrees)
262    if let Some((lat, lon)) = parse_ddn_dddw(token) {
263        return Some((lat, lon));
264    }
265
266    // 5130N07000W or 5530N04000W (degrees+minutes)
267    if let Some((lat, lon)) = parse_ddmmn_dddmmw(token) {
268        return Some((lat, lon));
269    }
270
271    None
272}
273
274fn parse_ddn_dddw(token: &str) -> Option<(f64, f64)> {
275    let b = token.as_bytes();
276    if b.len() < 7 || b.len() > 9 {
277        return None;
278    }
279    let n_pos = token.find('N').or_else(|| token.find('S'))?;
280    let w_pos = token.find('W').or_else(|| token.find('E'))?;
281    if n_pos < 2 || w_pos <= n_pos + 1 {
282        return None;
283    }
284    let lat_deg = token[..n_pos].parse::<f64>().ok()?;
285    let lon_deg = token[n_pos + 1..w_pos].parse::<f64>().ok()?;
286    let lat = if &token[n_pos..=n_pos] == "S" {
287        -lat_deg
288    } else {
289        lat_deg
290    };
291    let lon = if &token[w_pos..=w_pos] == "E" {
292        lon_deg
293    } else {
294        -lon_deg
295    };
296    Some((lat, lon))
297}
298
299fn parse_ddmmn_dddmmw(token: &str) -> Option<(f64, f64)> {
300    let n_pos = token.find('N').or_else(|| token.find('S'))?;
301    let w_pos = token.find('W').or_else(|| token.find('E'))?;
302    if n_pos < 4 || w_pos <= n_pos + 1 {
303        return None;
304    }
305    let lat_raw = &token[..n_pos];
306    let lon_raw = &token[n_pos + 1..w_pos];
307    if lat_raw.len() != 4 || lon_raw.len() != 5 {
308        return None;
309    }
310    let lat_deg = lat_raw[..2].parse::<f64>().ok()?;
311    let lat_min = lat_raw[2..].parse::<f64>().ok()?;
312    let lon_deg = lon_raw[..3].parse::<f64>().ok()?;
313    let lon_min = lon_raw[3..].parse::<f64>().ok()?;
314    let lat = lat_deg + lat_min / 60.0;
315    let lon = lon_deg + lon_min / 60.0;
316    let lat = if &token[n_pos..=n_pos] == "S" { -lat } else { lat };
317    let lon = if &token[w_pos..=w_pos] == "E" { lon } else { -lon };
318    Some((lat, lon))
319}
320
321fn parse_levels(levels_text: &str) -> Vec<u16> {
322    levels_text
323        .split_whitespace()
324        .filter_map(|tok| tok.parse::<u16>().ok())
325        .collect()
326}
327
328fn parse_nar_routes(text: &str) -> Vec<String> {
329    text.split_whitespace()
330        .map(|s| s.trim_matches('-').to_string())
331        .filter(|s| !s.is_empty() && s != "NIL")
332        .collect()
333}