From 3fd714eeb26d23b4f62bc73d02e725939612b02a Mon Sep 17 00:00:00 2001 From: Alexandre Date: Sun, 23 Aug 2026 10:10:12 +0200 Subject: [PATCH] feat(routing): off-airway + avoid-aware splice, surgical PROF204 seg-bans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cracks EDDM-LOWW (coverage 4/10 -> 5/10) and generalises DCT-too-long (ROUTE165) repair: - airway_path_avoiding(): the splice now builds the avoid-aware graph, so re-routing a too-long DCT never re-introduces a RAD-forbidden airway. - Off-airway absorb: when a broken segment's from-point isn't on any airway (a SID/terminal fix, e.g. ALUTU), splice from the previous on-airway point through to the target, dropping the DCT-only detour (EDDM …BIBAG DCT ALUTU DCT AGNAV -> BIBAG airway AGNAV). - PROF204 repair now bans the *segment* XY (seg_key) instead of excluding the points, so a forbidden airway leg out of a SID/STAR gateway (BIBAG) no longer nukes the gateway itself. Batch: 5/10, no regression. 49 tests green. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/routing/discover.rs | 71 +++++++++++++++++++++-------- crates/core/src/routing/mod.rs | 14 +++++- 2 files changed, 65 insertions(+), 20 deletions(-) diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index 8e85032..ee07fe7 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -21,7 +21,7 @@ use std::collections::HashSet; use crate::error::Result; -use super::{airway_path, plan_preferred, Leg, Route}; +use super::{plan_preferred, Leg, Route}; /// One IFPS message (code + text). #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -66,6 +66,13 @@ fn is_computer_fix(ident: &str) -> bool { ident.chars().any(|c| c.is_ascii_digit()) } +/// A route token that looks like an airway designator (contains a digit) — used to +/// distinguish an "X Y" segment ban from a bare point ban in a PROF204 +/// forbidden-route message. +fn is_airwayish(tok: &str) -> bool { + tok.chars().any(|c| c.is_ascii_digit()) +} + /// ICAO item-15 (enroute) string for a route: start at the SID exit fix, then /// `airway to` for each enroute leg, ending at the STAR entry fix. The leading /// SID and trailing STAR (first/last leg) are omitted — IFPS derives them. @@ -234,36 +241,48 @@ fn apply_repairs( errors: &[IfpsErr], avoid: &mut std::collections::HashSet, ) -> Result> { - // 1) RAD-forbidden segments: PROF204 "TRAFFIC VIA IS ON FORBIDDEN ROUTE" - // that isn't a level band → a hard "cannot use" constraint, so handle it - // before splicing/level moves. Exclude the cited enroute points from the - // graph and re-plan around them. Only points actually on the current route - // are excluded (never the airports); the avoid set accumulates so earlier - // exclusions stick across iterations. + // 1) RAD-forbidden routings: PROF204 "TRAFFIC VIA IS ON FORBIDDEN ROUTE" + // that isn't a level band → a hard "cannot use" constraint, handled before + // splicing/level moves. When the ban names a segment `X Y`, ban that + // *segment* (surgical — keeps the points usable via other airways, so we + // never exclude a SID/STAR gateway like BIBAG). A bare `VIA X` bans the point. + // The avoid set accumulates so earlier bans stick across iterations. let route_pts: std::collections::HashSet<&str> = route.legs.iter().flat_map(|l| [l.from.as_str(), l.to.as_str()]).collect(); - let mut newly: Vec = Vec::new(); + let mut notes: Vec = Vec::new(); for e in errors { if e.code != "PROF204" || parse_band(&e.msg).is_some() { continue; // level-band PROF204s are handled by the window repair below } - for tok in parse_forbidden_via(&e.msg) { - if tok != from && tok != to && route_pts.contains(tok.as_str()) && !avoid.contains(&tok) { - newly.push(tok); + let toks = parse_forbidden_via(&e.msg); + // Segment bans: consecutive point–airway–point triples on the route. + let mut banned_seg = false; + for w in toks.windows(3) { + let (a, awy, b) = (&w[0], &w[1], &w[2]); + if is_airwayish(awy) && !is_airwayish(a) && !is_airwayish(b) { + let k1 = super::graph::seg_key(awy, a, b); + if avoid.insert(k1) { + avoid.insert(super::graph::seg_key(awy, b, a)); + notes.push(format!("{a} {awy} {b}")); + } + banned_seg = true; + } + } + // Otherwise a bare point ban (only points on the current route, not airports). + if !banned_seg { + for tok in toks { + if !is_airwayish(&tok) && tok != from && tok != to && route_pts.contains(tok.as_str()) && avoid.insert(tok.clone()) { + notes.push(tok); + } } } } - if !newly.is_empty() { - newly.sort_unstable(); - newly.dedup(); - for t in &newly { - avoid.insert(t.clone()); - } + if !notes.is_empty() { if let Some(r) = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, dest_fixes, avoid) .ok() .filter(|r| r.legs.len() >= 2) { - return Ok(Some((r, fl, format!("avoid forbidden {}", newly.join(", "))))); + return Ok(Some((r, fl, format!("avoid forbidden {}", notes.join(", "))))); } } @@ -314,11 +333,25 @@ fn apply_repairs( // Any leg matching a broken segment (DCT or airway) is re-routed. let hit = segs.iter().any(|(a, b)| a == &leg.from && b == &leg.to); if hit { - if let Some(path) = airway_path(conn, &leg.from, &leg.to, Some(fl))? { + if let Some(path) = super::airway_path_avoiding(conn, &leg.from, &leg.to, Some(fl), avoid)? { spliced.push(format!("{}..{}", leg.from, leg.to)); legs.extend(path); continue; } + // `from` may be an off-airway terminal/SID fix (a too-long DCT out + // of it can't be spliced directly). Absorb it: splice from the + // previous on-airway point through to `to`, dropping the DCT-only + // detour (e.g. EDDM …BIBAG DCT ALUTU DCT AGNAV → BIBAG airway AGNAV). + if let Some(pfrom) = legs.last().map(|l| l.from.clone()) { + if pfrom != leg.from { + if let Some(path) = super::airway_path_avoiding(conn, &pfrom, &leg.to, Some(fl), avoid)? { + spliced.push(format!("{pfrom}..{}", leg.to)); + legs.pop(); + legs.extend(path); + continue; + } + } + } } legs.push(leg.clone()); } diff --git a/crates/core/src/routing/mod.rs b/crates/core/src/routing/mod.rs index 34fb32f..a0a425a 100644 --- a/crates/core/src/routing/mod.rs +++ b/crates/core/src/routing/mod.rs @@ -408,7 +408,19 @@ pub fn airway_path( to_ident: &str, cruise_fl: Option, ) -> Result>> { - let rg = RouteGraph::build(conn, cruise_fl)?; + airway_path_avoiding(conn, from_ident, to_ident, cruise_fl, &std::collections::HashSet::new()) +} + +/// Like [`airway_path`] but excludes the RAD-forbidden points/airways/segments in +/// `avoid` from the graph, so a splice never re-introduces a forbidden airway. +pub fn airway_path_avoiding( + conn: &Connection, + from_ident: &str, + to_ident: &str, + cruise_fl: Option, + avoid: &std::collections::HashSet, +) -> Result>> { + let rg = RouteGraph::build_avoiding(conn, cruise_fl, avoid)?; let (Some(a), Some(b)) = (rg.find_ident(from_ident), rg.find_ident(to_ident)) else { return Ok(None); };