From 62cee15549c79a36084a87bd7c7ed9ed39d7f2c7 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Sat, 22 Aug 2026 11:13:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(rad):=20FRA=20engine=20phase=201=20?= =?UTF-8?q?=E2=80=94=20Annex-2B=20forbidden-segment=20avoidance?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse the 1135 forbidden airway segments in RAD Annex 2B (rows with Airway/From/To + "NOT AVBL FOR TFC") into structured ForbiddenSeg records (FL band + arrival/departure applicability), and route around the ones that apply to a flight from the start: - core::rad: ForbiddenSeg + RadData.forbidden_segs + forbidden_segments( adep, ades, fl) -> applicable (airway,from,to) triples. - rad crate: parse_forbidden_segments() wired into parse(); rad-tool `forbidden [ADEP ADES FL]` to inspect. - graph: seg_key() encodes a segment ban into the existing avoid channel; build_avoiding skips those segments. discover seeds the avoid set with the flight's Annex-2B bans before generating the airway candidate. Measured: no regression (coverage 4/10 unchanged on the batch) — this is the *forbidden* half. The coverage mover is mandatory routing (PROF205: "must route via X"), which pure avoidance can't provide; that's phase 2. 49 tests green. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/ifps.rs | 1 + crates/core/src/rad.rs | 61 ++++++++++++++++++++++ crates/core/src/routing/discover.rs | 29 ++++++++--- crates/core/src/routing/graph.rs | 19 +++++++ crates/rad/src/lib.rs | 78 ++++++++++++++++++++++++++++- crates/rad/src/main.rs | 26 +++++++++- 6 files changed, 205 insertions(+), 9 deletions(-) diff --git a/crates/core/src/ifps.rs b/crates/core/src/ifps.rs index 5d87022..91f20f0 100644 --- a/crates/core/src/ifps.rs +++ b/crates/core/src/ifps.rs @@ -391,6 +391,7 @@ mod tests { fra_edges: vec![], level_caps: vec![], fra_points: vec![], + forbidden_segs: 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 92e095a..ea20ef6 100644 --- a/crates/core/src/rad.rs +++ b/crates/core/src/rad.rs @@ -121,6 +121,27 @@ pub struct LevelCap { pub cap_fl: Option, } +/// A **forbidden airway segment** (Annex 2B): traffic may not use `airway` +/// between `from` and `to` within the FL band, for the listed arrivals/departures. +/// `arr`/`dep` are airport idents (`LFST`) or ICAO prefixes (`ED`, `LF`); both +/// empty ⇒ the segment is forbidden for all traffic. This is the structured form +/// of the `PROF204 … IS ON FORBIDDEN ROUTE` restrictions. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ForbiddenSeg { + pub id: String, + pub airway: String, + pub from: String, + pub to: String, + /// Forbidden at/above this FL ("ABV FLxxx"); `None` ⇒ no lower bound. + pub min_fl: Option, + /// Forbidden at/below this FL ("BLW FLxxx"); `None` ⇒ no upper bound. + pub max_fl: Option, + /// Arrival airports/prefixes the ban applies to (empty ⇒ any). + pub arr: Vec, + /// Departure airports/prefixes the ban applies to (empty ⇒ any). + pub dep: Vec, +} + /// The parsed RAD (the parts we currently model). #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RadData { @@ -135,6 +156,9 @@ pub struct RadData { /// Official EUROCONTROL FRA significant points (separate file); empty if not loaded. #[serde(default)] pub fra_points: Vec, + /// Forbidden airway segments (Annex 2B); empty if not parsed. + #[serde(default)] + pub forbidden_segs: Vec, } impl RadData { @@ -179,6 +203,41 @@ impl RadData { .min() } + /// Forbidden airway segments (Annex 2B) applicable to a flight `adep`→`ades` + /// at `fl`: the set of `(AIRWAY, FROM, TO)` triples (uppercase) the router must + /// not use. A segment applies when `fl` is in its band **and** it is either + /// unconditional or matches this flight's arrival or departure. + pub fn forbidden_segments( + &self, + adep: &str, + ades: &str, + fl: i32, + ) -> std::collections::HashSet<(String, String, String)> { + // A filter token matches an ICAO by exact 4-letter code or by country/FIR + // prefix (e.g. "ED" matches EDDM); "ED**" is stored as "ED". + let apt_match = |apt: &str, filters: &[String]| -> bool { + let apt = apt.to_uppercase(); + filters.iter().any(|f| { + let f = f.trim_end_matches('*').to_uppercase(); + if f.len() >= 4 { + apt == f + } else { + !f.is_empty() && apt.starts_with(&f) + } + }) + }; + self.forbidden_segs + .iter() + .filter(|s| s.min_fl.map_or(true, |m| fl >= m) && s.max_fl.map_or(true, |m| fl <= m)) + .filter(|s| { + (s.arr.is_empty() && s.dep.is_empty()) + || apt_match(ades, &s.arr) + || apt_match(adep, &s.dep) + }) + .map(|s| (s.airway.to_uppercase(), s.from.to_uppercase(), s.to.to_uppercase())) + .collect() + } + /// Count of restrictions by kind — for summaries/status. pub fn dct_counts(&self) -> (usize, usize, usize) { let mut forbidden = 0; @@ -221,6 +280,7 @@ mod tests { fra_edges: vec![], level_caps: vec![], fra_points: vec![], + forbidden_segs: vec![], }; assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some()); assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive @@ -251,6 +311,7 @@ mod tests { LevelCap { id: "R3".into(), from: vec!["EGLL".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(200) }, ], fra_points: vec![], + forbidden_segs: 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 28b11f4..8e85032 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -544,6 +544,20 @@ fn attempt_once( ) -> Result { let dep_fixes: Vec = dep_sid.iter().map(|(_, f)| f.clone()).collect(); let dest_fixes: Vec = dest_star.iter().map(|(_, f)| f.clone()).collect(); + // Seed the avoid set with the RAD-forbidden airway segments (Annex 2B) that + // apply to this flight, so the airway candidate routes around them from the + // start (proactive, before the oracle would flag PROF204). Both orderings are + // inserted since a ban is direction-insensitive. + let mut avoid: HashSet = rad + .map(|r| { + r.forbidden_segments(from, to, start_fl) + .into_iter() + .flat_map(|(awy, f, t)| { + [super::graph::seg_key(&awy, &f, &t), super::graph::seg_key(&awy, &t, &f)] + }) + .collect() + }) + .unwrap_or_default(); let mut candidates: Vec<(&str, Route)> = Vec::new(); // Official FRA-points corridor first — routes the *right* points (dep/arr/ // intermediate roles + level availability) from the EUROCONTROL FRA list. @@ -570,7 +584,7 @@ fn attempt_once( if let Ok(r) = plan_preferred(conn, from, to, dep_sid, dest_star, Some(start_fl), rad) { candidates.push(("fra", r)); } - if let Ok(r) = super::plan_route_best(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes) { + if let Ok(r) = super::plan_route_best_avoiding(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes, &avoid) { candidates.push(("airway", r)); } @@ -591,7 +605,7 @@ fn attempt_once( 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, validator)?; + let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, std::mem::take(&mut avoid), validator)?; pre_log.append(&mut res.log); res.log = pre_log; Ok(res) @@ -629,6 +643,7 @@ pub fn run_loop( initial: Route, initial_verdict: IfpsVerdict, start_fl: i32, + mut avoid: std::collections::HashSet, validator: &dyn IfpsValidator, ) -> Result { let mut cur = initial; @@ -637,9 +652,9 @@ pub fn run_loop( let mut log: Vec = Vec::new(); let mut best: Option<(Route, i32, Vec)> = None; let mut iterations = 0; - // RAD-forbidden points/airways excluded from the graph, accumulated across - // iterations from PROF204 messages (see repair step 3 in `apply_repairs`). - let mut avoid: std::collections::HashSet = std::collections::HashSet::new(); + // RAD-forbidden points/airways/segments excluded from the graph. Seeded with + // this flight's Annex-2B segment bans and accumulated across iterations from + // PROF204 messages (see repair step 1 in `apply_repairs`). // A repair can trade one error class for another (e.g. excluding a forbidden // Swiss departure point forces a temporarily worse route before it resolves), @@ -824,7 +839,7 @@ mod tests { errors: vec![err("X1", "a"), err("X2", "b"), err("X3", "c")], }]), }; - let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap(); + let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, HashSet::new(), &mock).unwrap(); assert!(!r.accepted); assert_eq!(r.fl, 360, "reverted to the pre-fix level"); assert_eq!(r.errors.len(), 1, "best (fewest-error) state is kept"); @@ -835,7 +850,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "LF:F245..F999 IS ON FORBIDDEN ROUTE")] }; let mock = Mock { script: RefCell::new(vec![IfpsVerdict { accepted: true, errors: vec![] }]) }; - let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap(); + let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, HashSet::new(), &mock).unwrap(); assert!(r.accepted, "log: {:?}", r.log); assert_eq!(r.fl, 240, "lowered below the 245 cap"); } diff --git a/crates/core/src/routing/graph.rs b/crates/core/src/routing/graph.rs index 3848dae..a36891a 100644 --- a/crates/core/src/routing/graph.rs +++ b/crates/core/src/routing/graph.rs @@ -8,6 +8,17 @@ use rusqlite::Connection; use crate::error::Result; use crate::model::LatLon; +/// Marker prefix for a forbidden-segment key in the `avoid` set (a control char +/// that never appears in an ident/airway), so one `HashSet` carries both +/// point/airway idents and specific `(airway, from, to)` segment bans. +const SEG_PREFIX: &str = "\u{1}SEG"; + +/// Encode a forbidden airway segment as an `avoid`-set key. Callers insert both +/// orderings to make the ban direction-insensitive. +pub fn seg_key(airway: &str, from: &str, to: &str) -> String { + format!("{SEG_PREFIX}\u{1}{airway}\u{1}{from}\u{1}{to}") +} + /// A routable point (airway endpoint) in the graph. #[derive(Debug, Clone)] pub struct NodeData { @@ -74,11 +85,19 @@ impl RouteGraph { )) })?; + // Are any forbidden-segment keys present? (Avoids building the SEG key + // string for every one of the ~120k segments when there are none.) + let has_segs = avoid.iter().any(|k| k.starts_with(SEG_PREFIX)); for row in rows { let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?; if avoid.contains(&fi) || avoid.contains(&ti) || avoid.contains(&awy) { continue; } + if has_segs + && (avoid.contains(&seg_key(&awy, &fi, &ti)) || avoid.contains(&seg_key(&awy, &ti, &fi))) + { + continue; // RAD-forbidden airway segment (Annex 2B) + } if let Some(fl) = cruise_fl { if !fl_in_band(fl, base_fl, top_fl) { continue; diff --git a/crates/rad/src/lib.rs b/crates/rad/src/lib.rs index 62f8417..e313341 100644 --- a/crates/rad/src/lib.rs +++ b/crates/rad/src/lib.rs @@ -9,7 +9,9 @@ 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}; +pub use flightplanner_core::rad::{ + Area, DctKind, DctRestriction, ForbiddenSeg, 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. @@ -115,9 +117,83 @@ pub fn parse(path: &str) -> Result { fra_edges: parse_fra_edges(path)?, level_caps: parse_level_caps(path)?, fra_points: Vec::new(), // loaded separately via parse_fra_points + forbidden_segs: parse_forbidden_segments(path)?, }) } +/// 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 +/// (`ABV/BLW FLxxx`) and the arrival/departure airports/prefixes the ban targets. +pub fn parse_forbidden_segments(path: &str) -> Result> { + let mut out = Vec::new(); + let Ok(rows) = rows(path, "Annex 2B") else { + return Ok(out); + }; + for r in rows.iter().skip(1) { + let airway = cell(r, 4); + let from = cell(r, 5); + let to = cell(r, 6); + if airway.is_empty() || from.is_empty() || to.is_empty() { + continue; // point/airspace restriction, not a segment ban — skip in v1 + } + let util = cell(r, 8).to_uppercase().replace(['\n', '\t'], " "); + if !util.contains("NOT AVBL FOR TFC") { + continue; // ONLY-AVBL / COMPULSORY handled elsewhere + } + let (min_fl, max_fl) = fl_band(&util); + out.push(ForbiddenSeg { + id: cell(r, 3), + airway, + from, + to, + min_fl, + max_fl, + arr: apt_after(&util, "ARR"), + dep: apt_after(&util, "DEP"), + }); + } + Ok(out) +} + +/// `(min_fl, max_fl)` from "ABV FLxxx" / "BLW FLxxx" in a utilization string. +fn fl_band(up: &str) -> (Option, Option) { + let grab = |kw: &str| -> Option { + let p = up.find(kw)?; + let after = up[p + kw.len()..].trim_start().strip_prefix("FL").map(str::trim_start); + let after = after?; + let num: String = after.chars().take_while(char::is_ascii_digit).collect(); + num.parse().ok() + }; + (grab("ABV "), grab("BLW ")) +} + +/// Airport idents / country prefixes named right after `kw` ("ARR"/"DEP"), e.g. +/// `ARR LFST` → ["LFST"], `ARR (ED** EXC …)` → ["ED"] (the EXC list is ignored in +/// v1, over-applying the ban slightly — conservative). Stops at the first +/// non-airport token (EXC/VIA/AT/&/number). +fn apt_after(up: &str, kw: &str) -> Vec { + let needle = format!("{kw} "); + let mut out = Vec::new(); + let mut rest = up; + while let Some(p) = rest.find(&needle) { + rest = &rest[p + needle.len()..]; + for raw in rest.split_whitespace() { + let base = raw + .trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '*') + .trim_end_matches('*'); + if (base.len() == 2 || base.len() == 4) && base.chars().all(|c| c.is_ascii_alphabetic()) { + out.push(base.to_string()); + } else { + break; // EXC / VIA / AT / number / … → end of this ARR|DEP clause + } + } + } + out.sort(); + out.dedup(); + out +} + /// Annex 2A — city-pair flight-level caps. pub fn parse_level_caps(path: &str) -> Result> { let rows = rows(path, "Annex 2A")?; diff --git a/crates/rad/src/main.rs b/crates/rad/src/main.rs index 11206b6..611a88f 100644 --- a/crates/rad/src/main.rs +++ b/crates/rad/src/main.rs @@ -142,7 +142,31 @@ fn main() -> Result<()> { } } } - _ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT]"), + Some("forbidden") => { + // forbidden [ADEP ADES FL] — total forbidden segments, or those that + // apply to a given flight. + let rad = flightplanner_rad::parse(&path)?; + println!("Forbidden airway segments (Annex 2B): {}", rad.forbidden_segs.len()); + let with_band = rad.forbidden_segs.iter().filter(|s| s.min_fl.is_some() || s.max_fl.is_some()).count(); + let unconditional = rad.forbidden_segs.iter().filter(|s| s.arr.is_empty() && s.dep.is_empty()).count(); + println!(" with FL band: {with_band} unconditional (no ARR/DEP): {unconditional}"); + match (args.get(2), args.get(3), args.get(4)) { + (Some(a), Some(b), Some(fl)) => { + let fl: i32 = fl.parse().unwrap_or(360); + let segs = rad.forbidden_segments(&a.to_uppercase(), &b.to_uppercase(), fl); + println!("{a}->{b} @FL{fl}: {} applicable forbidden segments", segs.len()); + for (awy, f, t) in segs.iter().take(30) { + println!(" {f} {awy} {t}"); + } + } + _ => { + for s in rad.forbidden_segs.iter().take(8) { + println!(" {} {} {} {} FL[{:?}..{:?}] arr={:?} dep={:?}", s.id, s.from, s.airway, s.to, s.min_fl, s.max_fl, s.arr, s.dep); + } + } + } + } + _ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL]"), } Ok(()) }