//! Eurocontrol RAD (Route Availability Document) parsing. //! //! The RAD is a public per-AIRAC Excel workbook (see the `rad-data-source` //! reference). This crate reads it with `calamine` and turns the annexes into a //! restriction model the routing/validation engine can apply. use anyhow::Result; use calamine::{open_workbook, Data, Reader, Xlsx}; // The RAD data model lives in `core` (so routing/validation can use it without // pulling in `calamine`); this crate produces those types. pub use flightplanner_core::rad::{Area, DctKind, DctRestriction, FraEdge, FraPoint, LevelCap, RadData}; /// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the /// `fra-points-official` note). Sheet `"FRA Points"`, one row per point. pub fn parse_fra_points(path: &str) -> Result> { let rows = rows(path, "FRA Points")?; Ok(rows .iter() .skip(1) // header .filter_map(|r| { let name = cell(r, 2); let lat = parse_lat(&cell(r, 3)); let lon = parse_lon(&cell(r, 4)); let (name, lat, lon) = match (name.is_empty(), lat, lon) { (false, Some(la), Some(lo)) => (name, la, lo), _ => return None, }; let (level_lo, level_hi) = parse_levels(&cell(r, 11)); Some(FraPoint { name, lat, lon, areas: split_amp(&cell(r, 5)), enroute: cell(r, 6).trim_matches('-').trim().to_string(), arrdep: cell(r, 7).trim_matches('-').trim().to_string(), arr_airports: split_ws(&cell(r, 8)), dep_airports: split_ws(&cell(r, 9)), flos: cell(r, 10).trim_matches('-').trim().to_string(), level_lo, level_hi, loc_ind: split_ws(&cell(r, 13)), }) }) .collect()) } /// Latitude `XDDMMSS` (X = N/S) → signed decimal degrees. fn parse_lat(s: &str) -> Option { let s = s.trim(); let b = s.as_bytes(); if b.len() < 7 { return None; } let sign = match b[0] { b'N' => 1.0, b'S' => -1.0, _ => return None, }; let d: f64 = s[1..3].parse().ok()?; let m: f64 = s[3..5].parse().ok()?; let sec: f64 = s[5..7].parse().ok()?; Some(sign * (d + m / 60.0 + sec / 3600.0)) } /// Longitude `XDDDMMSS` (X = E/W) → signed decimal degrees. fn parse_lon(s: &str) -> Option { let s = s.trim(); let b = s.as_bytes(); if b.len() < 8 { return None; } let sign = match b[0] { b'E' => 1.0, b'W' => -1.0, _ => return None, }; let d: f64 = s[1..4].parse().ok()?; let m: f64 = s[4..6].parse().ok()?; let sec: f64 = s[6..8].parse().ok()?; Some(sign * (d + m / 60.0 + sec / 3600.0)) } /// "FL195 / FL660" → (Some(195), Some(660)); "GND / FL245" → (Some(0), Some(245)). fn parse_levels(s: &str) -> (Option, Option) { let up = s.to_uppercase(); let mut fls: Vec = Vec::new(); let mut rest = up.as_str(); while let Some(p) = rest.find("FL") { rest = &rest[p + 2..]; let num: String = rest.chars().take_while(char::is_ascii_digit).collect(); rest = &rest[num.len()..]; if let Ok(n) = num.parse::() { fls.push(n); } } let lo = fls.first().copied().or_else(|| up.contains("GND").then_some(0)); let hi = fls.get(1).copied().or_else(|| if fls.len() == 1 { None } else { fls.first().copied() }); (lo, hi) } fn split_amp(s: &str) -> Vec { s.split('&').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() } fn split_ws(s: &str) -> Vec { s.split([' ', ',', '\n']).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect() } /// Parse the annexes we currently model from the workbook at `path`. pub fn parse(path: &str) -> Result { Ok(RadData { areas: parse_areas(path)?, dct: parse_dct(path)?, fra_edges: parse_fra_edges(path)?, level_caps: parse_level_caps(path)?, fra_points: Vec::new(), // loaded separately via parse_fra_points }) } /// Annex 2A — city-pair flight-level caps. pub fn parse_level_caps(path: &str) -> Result> { let rows = rows(path, "Annex 2A")?; Ok(rows .iter() .skip(1) // header .filter_map(|r| { let from = parse_idents(&cell(r, 4)); let to = parse_idents(&cell(r, 6)); let cap_fl = min_fl(&cell(r, 8)); if from.is_empty() || to.is_empty() || cap_fl.is_none() { return None; } Some(LevelCap { id: cell(r, 3), from, to, condition: cell(r, 7), cap_fl, }) }) .collect()) } /// Lowest flight level mentioned in a capping cell like `FL345` or `FL355FL375`. fn min_fl(s: &str) -> Option { let up = s.to_uppercase(); let mut out: Option = None; let mut rest = up.as_str(); while let Some(pos) = rest.find("FL") { rest = &rest[pos + 2..]; let num: String = rest.chars().take_while(char::is_ascii_digit).collect(); rest = &rest[num.len()..]; if let Ok(fl) = num.parse::() { out = Some(out.map_or(fl, |m| m.min(fl))); } } out } /// Annex 1 — area definitions. pub fn parse_areas(path: &str) -> Result> { let rows = rows(path, "Annex 1")?; Ok(rows .iter() .skip(1) // header .filter_map(|r| { let id = cell(r, 3); let def = cell(r, 4); if id.is_empty() { return None; } Some(Area { id, airports: parse_idents(&def), region: cell(r, 6), }) }) .collect()) } /// Annex 3B — DCT restrictions. pub fn parse_dct(path: &str) -> Result> { let rows = rows(path, "Annex 3B DCT")?; Ok(rows .iter() .skip(1) // header .filter_map(|r| { let from = cell(r, 4); let to = cell(r, 5); if from.is_empty() || to.is_empty() { return None; } let avail = cell(r, 8).to_uppercase(); Some(DctRestriction { id: cell(r, 3), from, to, lower_fl: parse_fl(&cell(r, 6)), upper_fl: parse_fl(&cell(r, 7)), available: avail.starts_with('Y'), utilization: cell(r, 9), direction: cell(r, 13), }) }) .collect()) } /// Scan Annex 2A/2B/2C for `A DCT B` fix pairs and return the de-duplicated set /// of allowed FRA direct edges. This is our FRA connectivity catalog: routing /// through these points/edges is what IFPS accepts in Free Route Airspace. pub fn parse_fra_edges(path: &str) -> Result> { use std::collections::HashSet; let mut seen: HashSet = HashSet::new(); let mut out = Vec::new(); // Annex 2A/2B/2C hold the enroute FRA routings; Annex 3A DEP/ARR hold the // compulsory departure/arrival routings (e.g. LF7352: LFPG deps via // `OPALE DCT KESAX DCT DIMAL DCT ALESO`) — both are needed for the graph. for sheet in ["Annex 2A", "Annex 2B", "Annex 2C", "Annex 3A DEP", "Annex 3A ARR"] { let Ok(rows) = rows(path, sheet) else { continue }; for row in rows { // Normalise separators so `DCT` always stands alone as a token. let text = row.join(" ").replace(['(', ')', ',', '\n'], " "); let toks: Vec<&str> = text.split_whitespace().collect(); for w in toks.windows(3) { if w[1] == "DCT" && is_fix(w[0]) && is_fix(w[2]) { let e = FraEdge { from: w[0].to_string(), to: w[2].to_string() }; if seen.insert(e.clone()) { out.push(e); } } } } } Ok(out) } /// A plausible navaid/waypoint ident: 2–6 chars, letters+digits, ≥1 letter, not /// a RAD keyword. Excludes airway designators would be nice but they rarely sit /// on both sides of a literal `DCT`, so the DCT-pair test already filters them. fn is_fix(s: &str) -> bool { let s = s.trim(); let len = s.len(); if !(2..=6).contains(&len) { return false; } if !s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) { return false; } if !s.chars().any(|c| c.is_ascii_uppercase()) { return false; } !matches!( s, "DCT" | "VIA" | "AND" | "THEN" | "ARR" | "DEP" | "EXC" | "RFL" | "BLW" | "ABV" | "BTN" | "IAW" | "LOA" | "TFC" | "AVBL" | "NOT" | "ONLY" | "H24" | "UFN" | "FL" | "AT" | "IN" | "OR" | "TO" | "VItoken" ) } // ── low-level helpers ────────────────────────────────────────────────────── /// List the sheet (annex) names in the workbook. pub fn sheets(path: &str) -> Result> { let wb: Xlsx<_> = open_workbook(path)?; Ok(wb.sheet_names().to_vec()) } /// Dimensions (rows, cols) of a sheet. pub fn dims(path: &str, sheet: &str) -> Result<(usize, usize)> { let mut wb: Xlsx<_> = open_workbook(path)?; Ok(wb.worksheet_range(sheet)?.get_size()) } /// All rows of `sheet` as trimmed strings. pub fn rows(path: &str, sheet: &str) -> Result>> { let mut wb: Xlsx<_> = open_workbook(path)?; Ok(wb .worksheet_range(sheet)? .rows() .map(|r| r.iter().map(cell_str).collect()) .collect()) } /// First `n` rows of `sheet` (for exploration). pub fn dump(path: &str, sheet: &str, n: usize) -> Result>> { Ok(rows(path, sheet)?.into_iter().take(n).collect()) } fn cell(row: &[String], i: usize) -> String { row.get(i).cloned().unwrap_or_default().trim().to_string() } fn cell_str(c: &Data) -> String { match c { Data::Empty => String::new(), Data::String(s) => s.trim().to_string(), Data::Float(f) => f.to_string(), Data::Int(i) => i.to_string(), Data::Bool(b) => b.to_string(), other => other.to_string(), } } /// First flight level found in a cell like `FL245`, `MEAFL025`, `FL195FL315`. fn parse_fl(s: &str) -> Option { let up = s.to_uppercase(); let pos = up.find("FL")?; let num: String = up[pos + 2..].chars().take_while(char::is_ascii_digit).collect(); num.parse().ok() } /// Split an Annex-1 definition like `(EGBB, EGBE, EGNX)` into idents. fn parse_idents(def: &str) -> Vec { def.trim() .trim_matches(|c| c == '(' || c == ')') .split([',', '\n']) .map(|s| s.trim().trim_matches(|c| c == '(' || c == ')').to_string()) .filter(|s| !s.is_empty()) .collect() } #[cfg(test)] mod tests { use super::*; /// Parses the real RAD workbook when it's present (CWD = crate dir in tests). /// Skips (passes) otherwise so the test stays portable. #[test] fn parses_real_rad_when_present() { let f = "../../rad/RAD_current.xlsx"; if !std::path::Path::new(f).exists() { return; } let rad = parse(f).unwrap(); assert!(rad.areas.len() > 50, "areas = {}", rad.areas.len()); assert!(rad.dct.len() > 1000, "dct = {}", rad.dct.len()); assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Forbidden)); assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Compulsory)); // FL bands parse to plausible values. assert!(rad .dct .iter() .filter_map(|d| d.upper_fl) .all(|fl| (0..=700).contains(&fl))); } #[test] fn parses_fl_variants() { assert_eq!(parse_fl("FL245"), Some(245)); assert_eq!(parse_fl("MEAFL025"), Some(25)); assert_eq!(parse_fl("FL195FL315"), Some(195)); assert_eq!(parse_fl(""), None); } }