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 crate::error::Result;
|
||||||
|
|
||||||
use super::{airway_path, plan_preferred, Leg, Route};
|
use super::{plan_preferred, Leg, Route};
|
||||||
|
|
||||||
/// One IFPS message (code + text).
|
/// One IFPS message (code + text).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
#[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())
|
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
|
/// 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
|
/// `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.
|
/// SID and trailing STAR (first/last leg) are omitted — IFPS derives them.
|
||||||
@@ -234,36 +241,48 @@ fn apply_repairs(
|
|||||||
errors: &[IfpsErr],
|
errors: &[IfpsErr],
|
||||||
avoid: &mut std::collections::HashSet<String>,
|
avoid: &mut std::collections::HashSet<String>,
|
||||||
) -> Result<Option<(Route, i32, String)>> {
|
) -> Result<Option<(Route, i32, String)>> {
|
||||||
// 1) RAD-forbidden segments: PROF204 "TRAFFIC VIA <pts> IS ON FORBIDDEN ROUTE"
|
// 1) RAD-forbidden routings: PROF204 "TRAFFIC VIA <pts> IS ON FORBIDDEN ROUTE"
|
||||||
// that isn't a level band → a hard "cannot use" constraint, so handle it
|
// that isn't a level band → a hard "cannot use" constraint, handled before
|
||||||
// before splicing/level moves. Exclude the cited enroute points from the
|
// splicing/level moves. When the ban names a segment `X <airway> Y`, ban that
|
||||||
// graph and re-plan around them. Only points actually on the current route
|
// *segment* (surgical — keeps the points usable via other airways, so we
|
||||||
// are excluded (never the airports); the avoid set accumulates so earlier
|
// never exclude a SID/STAR gateway like BIBAG). A bare `VIA X` bans the point.
|
||||||
// exclusions stick across iterations.
|
// The avoid set accumulates so earlier bans stick across iterations.
|
||||||
let route_pts: std::collections::HashSet<&str> =
|
let route_pts: std::collections::HashSet<&str> =
|
||||||
route.legs.iter().flat_map(|l| [l.from.as_str(), l.to.as_str()]).collect();
|
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 {
|
for e in errors {
|
||||||
if e.code != "PROF204" || parse_band(&e.msg).is_some() {
|
if e.code != "PROF204" || parse_band(&e.msg).is_some() {
|
||||||
continue; // level-band PROF204s are handled by the window repair below
|
continue; // level-band PROF204s are handled by the window repair below
|
||||||
}
|
}
|
||||||
for tok in parse_forbidden_via(&e.msg) {
|
let toks = parse_forbidden_via(&e.msg);
|
||||||
if tok != from && tok != to && route_pts.contains(tok.as_str()) && !avoid.contains(&tok) {
|
// Segment bans: consecutive point–airway–point triples on the route.
|
||||||
newly.push(tok);
|
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() {
|
if !notes.is_empty() {
|
||||||
newly.sort_unstable();
|
|
||||||
newly.dedup();
|
|
||||||
for t in &newly {
|
|
||||||
avoid.insert(t.clone());
|
|
||||||
}
|
|
||||||
if let Some(r) = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, dest_fixes, avoid)
|
if let Some(r) = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, dest_fixes, avoid)
|
||||||
.ok()
|
.ok()
|
||||||
.filter(|r| r.legs.len() >= 2)
|
.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.
|
// 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);
|
let hit = segs.iter().any(|(a, b)| a == &leg.from && b == &leg.to);
|
||||||
if hit {
|
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));
|
spliced.push(format!("{}..{}", leg.from, leg.to));
|
||||||
legs.extend(path);
|
legs.extend(path);
|
||||||
continue;
|
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());
|
legs.push(leg.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -408,7 +408,19 @@ pub fn airway_path(
|
|||||||
to_ident: &str,
|
to_ident: &str,
|
||||||
cruise_fl: Option<i32>,
|
cruise_fl: Option<i32>,
|
||||||
) -> Result<Option<Vec<Leg>>> {
|
) -> 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 {
|
let (Some(a), Some(b)) = (rg.find_ident(from_ident), rg.find_ident(to_ident)) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user