Files
flightplanner/crates/rad/src/lib.rs
T
Alexandre 2cb633e99d Initial commit: offline flight planner (Rust, PFPX-class)
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>
2026-08-21 11:10:35 +02:00

356 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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: 26 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);
}
}