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:
@@ -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: 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user