feat(routing): off-airway + avoid-aware splice, surgical PROF204 seg-bans

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* X<airway>Y (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 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 10:10:12 +02:00
parent 49dc74ef20
commit 3fd714eeb2
2 changed files with 65 additions and 20 deletions
+52 -19
View File
@@ -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 <airway> 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<String>,
) -> Result<Option<(Route, i32, String)>> {
// 1) RAD-forbidden segments: PROF204 "TRAFFIC VIA <pts> 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 <pts> 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 <airway> 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<String> = Vec::new();
let mut notes: Vec<String> = 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 pointairwaypoint 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());
}
+13 -1
View File
@@ -408,7 +408,19 @@ pub fn airway_path(
to_ident: &str,
cruise_fl: Option<i32>,
) -> Result<Option<Vec<Leg>>> {
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<i32>,
avoid: &std::collections::HashSet<String>,
) -> Result<Option<Vec<Leg>>> {
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);
};