From 29c3f1c185443b56687049e37decdb31314f1029 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Wed, 26 Aug 2026 10:07:19 +0200 Subject: [PATCH] =?UTF-8?q?feat(rad):=20FRA=20engine=20phase=202b=20?= =?UTF-8?q?=E2=80=94=20mandatory=20arrival=20transitions=20+=20top-2=20rep?= =?UTF-8?q?air?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse Annex 2B mandatory arrival routings and repair the best few candidates (not just the lowest-error one): - core::rad: MandatoryArr + RadData.mandatory_arr + mandatory_arr_transitions(ades) -> allowed point-sequences. - rad crate: parse_mandatory_arr() (VIA-clauses in NOT-AVBL-EXC rows, scoped to ARR airports). via_sequences() now scans all parenthesised transition groups (contain DCT/airway), not just contiguous "VIA (", because sub-option markers separate them ("VIA i) (KARDU DCT KALMO)"). rad-tool `manarr [ADES]`. LIML now yields KARDU KALMO etc. - discover: "manarr" candidates route to the transition's on-airway anchor then splice the (often off-airway) transition points in via DCT. - attempt_once repairs the top-2 candidates and keeps the best result: the lowest-raw-error candidate isn't always the most repairable (a mandate-satisfying route may carry more mechanical errors than a route stuck on an unfixable mandatory). Collapses the dense-FRA near-misses: EGLL-EDDM 7->1 err, EDDF-LIRF 3->1 (mandatory PROF205 resolved; residual is a single ROUTE49). Coverage 6/10, no regression (passing pairs short-circuit on accept). 49 tests green. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/ifps.rs | 1 + crates/core/src/rad.rs | 37 ++++++++++ crates/core/src/routing/discover.rs | 101 +++++++++++++++++++++++++--- crates/rad/src/lib.rs | 53 +++++++++++++-- crates/rad/src/main.rs | 15 ++++- 5 files changed, 190 insertions(+), 17 deletions(-) diff --git a/crates/core/src/ifps.rs b/crates/core/src/ifps.rs index 38291d0..7c786ee 100644 --- a/crates/core/src/ifps.rs +++ b/crates/core/src/ifps.rs @@ -393,6 +393,7 @@ mod tests { fra_points: vec![], forbidden_segs: vec![], mandatory_dep: vec![], + mandatory_arr: vec![], }; let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap(); assert!(!r.accepted, "RAD should reject the forbidden direct"); diff --git a/crates/core/src/rad.rs b/crates/core/src/rad.rs index 38bedf8..fe44cb4 100644 --- a/crates/core/src/rad.rs +++ b/crates/core/src/rad.rs @@ -156,6 +156,20 @@ pub struct MandatoryDep { pub allowed: Vec>, } +/// A **mandatory arrival routing** (Annex 2B): traffic arriving at `airports` +/// must approach via one of the `allowed` transition point-sequences (each ending +/// near the destination). The structured form of `PROF205 … VIA IS OFF +/// MANDATORY ROUTE` where the point is an arrival gateway. Within a row the DEP +/// gating is dropped in v1 (candidates are additive, the oracle filters). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MandatoryArr { + pub id: String, + /// Arrival airports/groups the rule applies to. + pub airports: Vec, + /// Allowed arrival transition point-sequences, e.g. `[KARDU,KALMO]`. + pub allowed: Vec>, +} + /// The parsed RAD (the parts we currently model). #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RadData { @@ -176,6 +190,9 @@ pub struct RadData { /// Mandatory departure routings (Annex 3A DEP COMPULSORY); empty if not parsed. #[serde(default)] pub mandatory_dep: Vec, + /// Mandatory arrival routings (Annex 2B); empty if not parsed. + #[serde(default)] + pub mandatory_arr: Vec, } impl RadData { @@ -275,6 +292,24 @@ impl RadData { .collect() } + /// Allowed mandatory arrival transition point-sequences for `ades` (Annex 2B). + pub fn mandatory_arr_transitions(&self, ades: &str) -> Vec> { + let applies = |set: &[String]| -> bool { + set.iter().any(|s| { + s.eq_ignore_ascii_case(ades) + || self.areas.iter().any(|a| { + a.id.eq_ignore_ascii_case(s) + && a.airports.iter().any(|ap| ap.eq_ignore_ascii_case(ades)) + }) + }) + }; + self.mandatory_arr + .iter() + .filter(|m| applies(&m.airports)) + .flat_map(|m| m.allowed.iter().cloned()) + .collect() + } + /// Count of restrictions by kind — for summaries/status. pub fn dct_counts(&self) -> (usize, usize, usize) { let mut forbidden = 0; @@ -319,6 +354,7 @@ mod tests { fra_points: vec![], forbidden_segs: vec![], mandatory_dep: vec![], + mandatory_arr: vec![], }; assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some()); assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive @@ -351,6 +387,7 @@ mod tests { fra_points: vec![], forbidden_segs: vec![], mandatory_dep: vec![], + mandatory_arr: vec![], }; // LFPG→LSGG matches R1 (via group) and R2 → min cap 295. assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295)); diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index 9ca0520..d73f5f9 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -601,6 +601,45 @@ fn route_via_transition( Ok(Some(Route { legs, total_nm: total, via_airways: true })) } +/// Build a candidate that arrives via a mandatory transition sequence (Annex 2B): +/// route from `from` into the network at the transition's first point, then DCT +/// through the transition points to `to`. `None` if positions/routing don't +/// resolve. Satisfies `PROF205 … VIA IS OFF MANDATORY ROUTE`. +fn route_via_arrival_transition( + conn: &Connection, + from: &str, + to: &str, + transition: &[String], + fl: i32, + dep_fixes: &[String], + avoid: &std::collections::HashSet, +) -> Result> { + // Anchor on the transition's LAST point — arrival transitions end at an + // on-airway gateway (e.g. KALMO), while the earlier points may be off-airway + // FRA fixes (e.g. KARDU) that can't be graph connectors. Route to the anchor, + // then splice the whole transition in via DCT ahead of it. + let Some(anchor) = transition.last() else { return Ok(None) }; + let head = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, std::slice::from_ref(anchor), avoid)?; + let Some(idx) = head.legs.iter().position(|l| l.to.eq_ignore_ascii_case(anchor)) else { + return Ok(None); + }; + let dst = super::airport_pos(conn, to)?; + let x = head.legs[idx].from.clone(); // on-airway point just before the anchor + let mut prev = x.clone(); + let mut prevpos = super::ident_pos(conn, &x, dst).unwrap_or(dst); + // Replace the `x → anchor` leg with `x → t0 → … → anchor` (all DCT). + let mut legs: Vec = head.legs[..idx].to_vec(); + for p in transition { + let Some(pos) = super::ident_pos(conn, p, dst) else { return Ok(None) }; + legs.push(Leg { from: prev.clone(), to: p.to_uppercase(), airway: "DCT".into(), dist_nm: prevpos.distance_nm(&pos) }); + prev = p.to_uppercase(); + prevpos = pos; + } + legs.extend_from_slice(&head.legs[idx + 1..]); // anchor → … → to + let total = total_nm(&legs); + Ok(Some(Route { legs, total_nm: total, via_airways: true })) +} + /// One discovery attempt with a fixed set of allowed gateways: generate the seed /// / FRA / airway candidates, start the repair loop from whichever the oracle /// rates best. @@ -680,26 +719,68 @@ fn attempt_once( } } } + // Mandatory arrival transitions (Annex 2B): offer candidates that arrive + // via an allowed transition. The parser over-associates (all VIA-seqs in a + // row with every ARR airport), so keep only transitions that actually end + // near the destination, then pick the shortest overall. Additive. + let mut arr = rad.mandatory_arr_transitions(to); + arr.dedup(); + if let (Ok(dep), Ok(dst)) = (super::airport_pos(conn, from), super::airport_pos(conn, to)) { + // Rank by estimated total length (dep→first + first→last + last→dest); + // this pushes the over-associated transitions that don't end near the + // destination to the back without a hard cut-off. + let est = |s: &Vec| -> f64 { + match ( + s.first().and_then(|p| super::ident_pos(conn, p, dep)), + s.last().and_then(|p| super::ident_pos(conn, p, dst)), + ) { + (Some(f), Some(l)) => dep.distance_nm(&f) + f.distance_nm(&l) + l.distance_nm(&dst), + _ => f64::MAX, + } + }; + arr.sort_by(|a, b| est(a).total_cmp(&est(b))); + } + for t in arr.iter().take(4) { + if let Ok(Some(r)) = route_via_arrival_transition(conn, from, to, t, start_fl, &dep_fixes, &avoid) { + if r.legs.len() >= 2 { + candidates.push(("manarr", r)); + } + } + } } let mut pre_log: Vec = Vec::new(); - let mut chosen: Option<(Route, IfpsVerdict)> = None; + let mut scored: Vec<(Route, IfpsVerdict)> = Vec::new(); for (tag, cand) in candidates { let v = validator.validate(from, to, &route_item15(&cand), start_fl)?; pre_log.push(format!("candidate {tag}: {} err{}", v.errors.len(), if v.accepted { " (ACCEPTED)" } else { "" })); - let better = chosen.as_ref().map(|(_, cv)| v.errors.len() < cv.errors.len()).unwrap_or(true); - let accepted = v.accepted; - if better { - chosen = Some((cand, v)); + if v.accepted { + let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, cand, v, start_fl, std::mem::take(&mut avoid), validator)?; + pre_log.append(&mut res.log); + res.log = pre_log; + return Ok(res); } - if accepted { + scored.push((cand, v)); + } + if scored.is_empty() { + return Err(crate::error::CoreError::Routing("no route candidate".into())); + } + // The lowest-error candidate isn't always the most repairable: a mandate- + // satisfying route (mandep/manarr) may carry more mechanical errors than a + // shorter route that is stuck on an unfixable mandatory. So repair the best + // few candidates and keep whichever result is best. + scored.sort_by_key(|(_, v)| v.errors.len()); + let mut best: Option = None; + for (cand, verdict) in scored.into_iter().take(2) { + let res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, cand, verdict, start_fl, avoid.clone(), validator)?; + if best.as_ref().map_or(true, |b| res.accepted || res.errors.len() < b.errors.len()) { + best = Some(res); + } + if best.as_ref().is_some_and(|b| b.accepted) { break; } } - let (initial, verdict) = - chosen.ok_or_else(|| crate::error::CoreError::Routing("no route candidate".into()))?; - - let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, std::mem::take(&mut avoid), validator)?; + let mut res = best.expect("scored is non-empty"); pre_log.append(&mut res.log); res.log = pre_log; Ok(res) diff --git a/crates/rad/src/lib.rs b/crates/rad/src/lib.rs index fd09a57..139fd0b 100644 --- a/crates/rad/src/lib.rs +++ b/crates/rad/src/lib.rs @@ -10,7 +10,8 @@ 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, MandatoryDep, RadData, + Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, MandatoryArr, + MandatoryDep, RadData, }; /// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the @@ -119,9 +120,35 @@ pub fn parse(path: &str) -> Result { fra_points: Vec::new(), // loaded separately via parse_fra_points forbidden_segs: parse_forbidden_segments(path)?, mandatory_dep: parse_mandatory_dep(path)?, + mandatory_arr: parse_mandatory_arr(path)?, }) } +/// Annex 2B — mandatory arrival routings. Rows whose utilization lists allowed +/// `VIA (...)` transitions as exceptions to a `NOT AVBL FOR TFC` ban, scoped to +/// one or more `ARR `s (e.g. LI2137: `ARR LIML VIA (KARDU DCT KALMO)`). +/// v1 associates every VIA-sequence in a row with every ARR airport in it (the +/// DEP gating is dropped — candidates are additive, so the oracle filters). +pub fn parse_mandatory_arr(path: &str) -> Result> { + let Ok(rows) = rows(path, "Annex 2B") else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for r in rows.iter().skip(1) { + let up = cell(r, 8).to_uppercase().replace(['\r'], " "); + if !up.contains("NOT AVBL FOR TFC") || !up.contains("ARR ") || !up.contains("VIA (") { + continue; + } + let airports = apt_after(&up, "ARR"); + let allowed: Vec> = via_sequences(&up).into_iter().filter(|s| !s.is_empty()).collect(); + if airports.is_empty() || allowed.is_empty() { + continue; + } + out.push(MandatoryArr { id: cell(r, 3), airports, allowed }); + } + Ok(out) +} + /// 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 @@ -151,14 +178,20 @@ pub fn parse_mandatory_dep(path: &str) -> Result> { Ok(out) } -/// The point-sequences inside every `VIA (...)` clause (balanced parens), dropping -/// `DCT`/airways/keywords — just the ordered enroute points. +/// Point-sequences from every parenthesised transition clause (balanced parens +/// that contain a `DCT` or an airway token — which skips airport lists like +/// `(LIMJ, LIRP)` and engine-type notes like `(J)`). We can't anchor on `VIA (` +/// because sub-option markers separate them (`VIA i) (KARDU DCT KALMO)`). fn via_sequences(up: &str) -> Vec> { 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; + while i < bytes.len() { + if bytes[i] != b'(' { + i += 1; + continue; + } + let start = i + 1; let mut depth = 1; let mut j = start; while j < bytes.len() && depth > 0 { @@ -170,7 +203,15 @@ fn via_sequences(up: &str) -> Vec> { j += 1; } let inner = &up[start..j.saturating_sub(1)]; - out.extend(expand_via(inner)); + let is_transition = inner.split(|c: char| !c.is_ascii_alphanumeric()).any(|t| { + t == "DCT" + || (t.len() >= 2 + && t.chars().next().is_some_and(|c| c.is_ascii_alphabetic()) + && t.chars().any(|c| c.is_ascii_digit())) + }); + if is_transition { + out.extend(expand_via(inner)); + } i = j; } out diff --git a/crates/rad/src/main.rs b/crates/rad/src/main.rs index b104018..0ff4ad3 100644 --- a/crates/rad/src/main.rs +++ b/crates/rad/src/main.rs @@ -186,7 +186,20 @@ fn main() -> Result<()> { } } } - _ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP]"), + Some("manarr") => { + // manarr [ADES] — mandatory arrival transitions (Annex 2B). + let rad = flightplanner_rad::parse(&path)?; + println!("Mandatory arrival routings (Annex 2B): {}", rad.mandatory_arr.len()); + if let Some(a) = args.get(2) { + let a = a.to_uppercase(); + let seqs = rad.mandatory_arr_transitions(&a); + println!("{a}: {} allowed transition sequences", seqs.len()); + for s in seqs.iter().take(40) { + println!(" {}", s.join(" ")); + } + } + } + _ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP] | manarr [ADES]"), } Ok(()) }