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:
@@ -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 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());
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user