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>
This commit is contained in:
2026-08-21 11:10:35 +02:00
commit 2cb633e99d
108 changed files with 19460 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
[package]
name = "flightplanner-rad"
version = "0.1.0"
edition = "2021"
[lib]
name = "flightplanner_rad"
path = "src/lib.rs"
[[bin]]
name = "rad-tool"
path = "src/main.rs"
[dependencies]
flightplanner-core = { workspace = true }
calamine = "0.26"
serde = { workspace = true }
anyhow = { workspace = true }
+355
View File
@@ -0,0 +1,355 @@
//! 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);
}
}
+148
View File
@@ -0,0 +1,148 @@
//! `rad-tool` — explore/parse the Eurocontrol RAD workbook.
//!
//! Usage:
//! rad-tool sheets
//! rad-tool dump "<sheet>" [rows]
//! RAD file path from $RAD_FILE, default `rad/RAD_current.xlsx`.
use anyhow::Result;
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let path = std::env::var("RAD_FILE").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
match args.get(1).map(String::as_str) {
Some("sheets") => {
for s in flightplanner_rad::sheets(&path)? {
let (r, c) = flightplanner_rad::dims(&path, &s).unwrap_or((0, 0));
println!("{s} ({r} rows x {c} cols)");
}
}
Some("dump") => {
let sheet = args.get(2).cloned().unwrap_or_default();
let n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(15);
for (i, row) in flightplanner_rad::dump(&path, &sheet, n)?.into_iter().enumerate() {
println!("[{i:>3}] {}", row.join(" | "));
}
}
Some("find") => {
// find "<sheet>" <substr> [max] — print rows containing <substr>.
let sheet = args.get(2).cloned().unwrap_or_default();
let needle = args.get(3).cloned().unwrap_or_default().to_uppercase();
let max: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(20);
let mut hits = 0;
for (i, row) in flightplanner_rad::rows(&path, &sheet)?.into_iter().enumerate() {
let joined = row.join(" | ");
if joined.to_uppercase().contains(&needle) {
println!("[{i:>4}] {joined}");
hits += 1;
if hits >= max {
break;
}
}
}
println!("-- {hits} row(s) matched '{needle}' in {sheet}");
}
Some("summary") => {
use flightplanner_rad::DctKind;
let rad = flightplanner_rad::parse(&path)?;
println!("Parsed RAD: {}", path);
println!(" Areas (Annex 1) : {}", rad.areas.len());
println!(" DCT restrictions (3B) : {}", rad.dct.len());
let forbidden = rad.dct.iter().filter(|d| d.kind() == DctKind::Forbidden).count();
let only = rad.dct.iter().filter(|d| d.kind() == DctKind::ConditionalOnly).count();
let comp = rad.dct.iter().filter(|d| d.kind() == DctKind::Compulsory).count();
println!(" forbidden={forbidden} conditional-only={only} compulsory={comp}");
println!(" sample areas:");
for a in rad.areas.iter().take(3) {
println!(" {} [{}] = {:?}", a.id, a.region, a.airports);
}
println!(" sample DCT restrictions:");
for d in rad.dct.iter().take(4) {
println!(
" {} {}->{} FL{:?}-{:?} avail={} [{:?}] {}",
d.id, d.from, d.to, d.lower_fl, d.upper_fl, d.available, d.kind(),
d.utilization.replace('\n', " ").chars().take(60).collect::<String>()
);
}
}
Some("fra") => {
// fra [point] — extract allowed FRA DCT edges; if a point is given,
// list its neighbours.
let edges = flightplanner_rad::parse_fra_edges(&path)?;
use std::collections::BTreeSet;
let points: BTreeSet<&str> =
edges.iter().flat_map(|e| [e.from.as_str(), e.to.as_str()]).collect();
println!("FRA edges: {} | distinct points: {}", edges.len(), points.len());
match args.get(2) {
Some(p) => {
let p = p.to_uppercase();
let nbrs: BTreeSet<&str> = edges
.iter()
.filter_map(|e| {
if e.from == p {
Some(e.to.as_str())
} else if e.to == p {
Some(e.from.as_str())
} else {
None
}
})
.collect();
println!("{p} present: {} neighbours ({}): {:?}", points.contains(p.as_str()), nbrs.len(), nbrs);
}
None => {
for e in edges.iter().take(25) {
println!(" {} -> {}", e.from, e.to);
}
}
}
}
Some("caps") => {
// caps [FROM TO] — total level caps, or the cap for a city pair.
let rad = flightplanner_rad::parse(&path)?;
println!("Level caps (Annex 2A): {}", rad.level_caps.len());
match (args.get(2), args.get(3)) {
(Some(f), Some(t)) => {
let f = f.to_uppercase();
let t = t.to_uppercase();
match rad.max_cruise_fl(&f, &t) {
Some(cap) => println!("{f}->{t}: max cruise FL{cap:03}"),
None => println!("{f}->{t}: no cap"),
}
}
_ => {
for c in rad.level_caps.iter().take(6) {
println!(" {} {:?}->{:?} FL{:?} [{}]", c.id, c.from, c.to, c.cap_fl, c.condition.replace('\n', " ").chars().take(30).collect::<String>());
}
}
}
}
Some("frapts") => {
// frapts [file] [POINT] — parse the official FRA points list.
let file = args.get(2).cloned().unwrap_or_else(|| "rad/fra-points.xlsx".into());
let pts = flightplanner_rad::parse_fra_points(&file)?;
println!("FRA points: {}", pts.len());
let roles = |r: &str| pts.iter().filter(|p| p.enroute == r).count();
println!(" roles: E={} X={} EX={} I={}", roles("E"), roles("X"), roles("EX"), roles("I"));
match args.get(3) {
Some(name) => {
let name = name.to_uppercase();
for p in pts.iter().filter(|p| p.name.eq_ignore_ascii_case(&name)) {
println!(
" {} @{:.4},{:.4} area={:?} enroute={} arrdep={} FL{:?}-{:?} flos={} loc={:?}",
p.name, p.lat, p.lon, p.areas, p.enroute, p.arrdep, p.level_lo, p.level_hi, p.flos, p.loc_ind
);
}
}
None => {
for p in pts.iter().take(4) {
println!(" {} @{:.3},{:.3} {:?} {} FL{:?}-{:?}", p.name, p.lat, p.lon, p.areas, p.enroute, p.level_lo, p.level_hi);
}
}
}
}
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT]"),
}
Ok(())
}