2cb633e99d
Clean-room reproduction of PFPX: route generation + IFPS validation + OFP. Independent design — official ICAO/EUROCONTROL data only (RAD, FRA points, IFPUV oracle); no community FPL sources. Workspace crates: core (routing/discover/rad/navdata/perf/export), cli, server, rad (Annex parser), gui (Tauri v2 + React + MapLibre). Route discovery: oracle-in-the-loop repair against Eurocontrol IFPUV. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
356 lines
12 KiB
Rust
356 lines
12 KiB
Rust
//! 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<Vec<FraPoint>> {
|
||
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<f64> {
|
||
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<f64> {
|
||
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<i32>, Option<i32>) {
|
||
let up = s.to_uppercase();
|
||
let mut fls: Vec<i32> = 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::<i32>() {
|
||
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<String> {
|
||
s.split('&').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()
|
||
}
|
||
|
||
fn split_ws(s: &str) -> Vec<String> {
|
||
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<RadData> {
|
||
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<Vec<LevelCap>> {
|
||
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<i32> {
|
||
let up = s.to_uppercase();
|
||
let mut out: Option<i32> = 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::<i32>() {
|
||
out = Some(out.map_or(fl, |m| m.min(fl)));
|
||
}
|
||
}
|
||
out
|
||
}
|
||
|
||
/// Annex 1 — area definitions.
|
||
pub fn parse_areas(path: &str) -> Result<Vec<Area>> {
|
||
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<Vec<DctRestriction>> {
|
||
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<Vec<FraEdge>> {
|
||
use std::collections::HashSet;
|
||
let mut seen: HashSet<FraEdge> = 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<Vec<String>> {
|
||
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<Vec<Vec<String>>> {
|
||
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<Vec<Vec<String>>> {
|
||
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<i32> {
|
||
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<String> {
|
||
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);
|
||
}
|
||
}
|