feat(rad): FRA engine phase 2a — mandatory departure transitions

Parse Annex 3A DEP "COMPULSORY FOR TFC" rows into structured mandatory
departure routings and satisfy PROF205 "… ANNEX3A DEP … MANDATORY ROUTE"
by offering the allowed transitions as discovery candidates:

- core::rad: MandatoryDep + RadData.mandatory_dep +
  mandatory_dep_transitions(adep) -> allowed point-sequences.
- rad crate: parse_mandatory_dep() extracts the VIA(...) clauses, expands
  nested "(A, B)" alternatives into separate branches, and chains
  sub-fragments into full sequences from an entry point (46 rules; LFPG =
  20 transitions incl. NURMO CMB VEKIN ADUTO, OPALE KESAX DIMAL ALESO).
  rad-tool `mandep [ADEP]` to inspect.
- discover: for the departure airport, inject "mandep" candidates that fly
  the allowed transitions nearest the destination (additive — the oracle
  still picks best, so no regression risk).

Cracks LFPG-EDDF (Paris-Frankfurt): coverage 5/10 -> 6/10 on the batch,
no regression. 49 tests green. Phase 2b (mandatory arrivals) is next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:58:58 +02:00
parent 3fd714eeb2
commit b4ef0107e9
6 changed files with 249 additions and 4 deletions
+125 -1
View File
@@ -10,7 +10,7 @@ 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, ForbiddenSeg, FraEdge, FraPoint, LevelCap, RadData,
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, MandatoryDep, RadData,
};
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
@@ -118,9 +118,133 @@ pub fn parse(path: &str) -> Result<RadData> {
level_caps: parse_level_caps(path)?,
fra_points: Vec::new(), // loaded separately via parse_fra_points
forbidden_segs: parse_forbidden_segments(path)?,
mandatory_dep: parse_mandatory_dep(path)?,
})
}
/// Annex 3A DEP — mandatory departure routings (`COMPULSORY FOR TFC`). Extracts
/// the allowed transition point-sequences from the `VIA (...)` clauses, chaining
/// sub-fragments (`d. VIA (OPALE DCT KESAX)` + `ii) VIA (KESAX DCT DIMAL DCT
/// ALESO)` → `OPALE KESAX DIMAL ALESO`) so each result is a full sequence from an
/// entry point (col 5).
pub fn parse_mandatory_dep(path: &str) -> Result<Vec<MandatoryDep>> {
let Ok(rows) = rows(path, "Annex 3A DEP") else {
return Ok(Vec::new());
};
let mut out = Vec::new();
for r in rows.iter().skip(1) {
let up = cell(r, 7).to_uppercase().replace(['\r'], " ");
if !up.contains("COMPULSORY FOR TFC") {
continue;
}
let airports = parse_idents(&cell(r, 4));
if airports.is_empty() {
continue;
}
let entry = parse_idents(&cell(r, 5));
let allowed = chain_transitions(&entry, &via_sequences(&up));
if allowed.is_empty() {
continue;
}
out.push(MandatoryDep { id: cell(r, 3), airports, allowed });
}
Ok(out)
}
/// The point-sequences inside every `VIA (...)` clause (balanced parens), dropping
/// `DCT`/airways/keywords — just the ordered enroute points.
fn via_sequences(up: &str) -> Vec<Vec<String>> {
let bytes = up.as_bytes();
let mut out = Vec::new();
let mut i = 0;
while let Some(p) = up[i..].find("VIA (") {
let start = i + p + 5;
let mut depth = 1;
let mut j = start;
while j < bytes.len() && depth > 0 {
match bytes[j] {
b'(' => depth += 1,
b')' => depth -= 1,
_ => {}
}
j += 1;
}
let inner = &up[start..j.saturating_sub(1)];
out.extend(expand_via(inner));
i = j;
}
out
}
/// Point-sequences from a `VIA (...)` inner string, expanding a nested
/// `(A, B, …)` alternative group into one sequence per branch (`RANUX DCT VALEK
/// DCT (LUTAX, LIPNI)` → `[RANUX,VALEK,LUTAX]`, `[RANUX,VALEK,LIPNI]`).
fn expand_via(inner: &str) -> Vec<Vec<String>> {
let points = |s: &str| -> Vec<String> {
s.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| is_point(t)).map(str::to_owned).collect()
};
if let (Some(op), Some(rel)) = (inner.find('('), inner.find('(').and_then(|o| inner[o..].find(')'))) {
let cp = inner.find('(').unwrap() + rel;
let (before, group, after) = (&inner[..op], &inner[op + 1..cp], &inner[cp + 1..]);
let prefix = points(before);
let tail = points(after);
let mut out = Vec::new();
for alt in group.split(',') {
let mut seq = prefix.clone();
seq.extend(points(alt));
seq.extend(tail.clone());
if !seq.is_empty() {
out.push(seq);
}
}
return out;
}
let pts = points(inner);
if pts.is_empty() { Vec::new() } else { vec![pts] }
}
/// An enroute point token (25 letters, not a RAD keyword or airway designator).
fn is_point(t: &str) -> bool {
(2..=5).contains(&t.len())
&& t.chars().all(|c| c.is_ascii_alphabetic())
&& !matches!(
t,
"DCT" | "VIA" | "EXC" | "ARR" | "DEP" | "AND" | "THEN" | "RFL" | "ABV" | "BLW"
| "FL" | "NM" | "TFC" | "FOR" | "NOT" | "AVBL" | "ONLY" | "ACT" | "EVEN" | "ODD"
)
}
/// Build full transition sequences: keep sequences that start at an entry point,
/// then repeatedly append fragments whose first point continues a sequence's last.
fn chain_transitions(entry: &[String], seqs: &[Vec<String>]) -> Vec<Vec<String>> {
let is_entry = |p: &str| entry.iter().any(|e| e.eq_ignore_ascii_case(p));
let mut result: Vec<Vec<String>> =
seqs.iter().filter(|s| s.first().is_some_and(|f| is_entry(f))).cloned().collect();
let fragments: Vec<&Vec<String>> =
seqs.iter().filter(|s| s.first().is_some_and(|f| !is_entry(f))).collect();
let mut guard = 0;
loop {
guard += 1;
let mut added = false;
for frag in &fragments {
for base in result.clone() {
if base.last().is_some_and(|l| l.eq_ignore_ascii_case(&frag[0])) {
let mut ext = base.clone();
ext.extend(frag[1..].iter().cloned());
if !result.contains(&ext) {
result.push(ext);
added = true;
}
}
}
}
if !added || guard >= 4 {
break;
}
}
result
}
/// Annex 2B — forbidden airway segments. Rows with Airway/From/To (cols 4/5/6)
/// filled and a `NOT AVBL FOR TFC` utilization are structured segment bans (the
/// source of `PROF204 … IS ON FORBIDDEN ROUTE`). We capture the FL band
+21 -1
View File
@@ -166,7 +166,27 @@ fn main() -> Result<()> {
}
}
}
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL]"),
Some("mandep") => {
// mandep [ADEP] — mandatory departure transitions (Annex 3A DEP).
let rad = flightplanner_rad::parse(&path)?;
println!("Mandatory departure routings (Annex 3A DEP): {}", rad.mandatory_dep.len());
match args.get(2) {
Some(a) => {
let a = a.to_uppercase();
let seqs = rad.mandatory_dep_transitions(&a);
println!("{a}: {} allowed transition sequences", seqs.len());
for s in seqs.iter().take(40) {
println!(" {}", s.join(" "));
}
}
None => {
for m in rad.mandatory_dep.iter().take(6) {
println!(" {} {:?}{} seqs (e.g. {:?})", m.id, m.airports, m.allowed.len(), m.allowed.first());
}
}
}
}
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP]"),
}
Ok(())
}