feat(routing): RAD-forbidden-segment avoidance + stall-tolerant repair
- Graph gains build_avoiding(): drop segments whose endpoint ident or airway is in an avoid set. plan_route_conn_avoiding / plan_route_best_avoiding thread it through (also from SID/STAR conns). - New PROF204 repair: parse "TRAFFIC VIA <pts> IS ON FORBIDDEN ROUTE", exclude the cited enroute points (never airports), re-plan around them. Runs first (a hard "cannot use" beats splice/level moves) and the avoid set accumulates across iterations. Other re-plans are now avoid-aware. - Repair loop tolerates MAX_STALL non-improving steps before reverting, so it can cross a valley where excluding one forbidden point forces a temporarily worse route en route to a fix. best-so-far still guards the result: this can only find an equal-or-better route, never a worse one. LSZH-LFBZ: 13 -> 2 errors (FRA-border problem gone; remainder is one Annex-2B-forbidden Swiss departure segment needing real SID modelling). LFRS-LFST / LFRN-LFMN / LSGG-LFPO still pass, 0 iterations. 49 tests green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -129,6 +129,24 @@ fn parse_valid_range(msg: &str) -> Option<(i32, i32)> {
|
||||
Some((num(lo)?, num(hi)?))
|
||||
}
|
||||
|
||||
/// Enroute tokens cited by a `PROF204` forbidden-route message, e.g.
|
||||
/// `RS: TRAFFIC VIA WIL IS ON FORBIDDEN ROUTE ...` → `["WIL"]`;
|
||||
/// `... VIA SAPRE Y58 GEVEA IS ON FORBIDDEN ...` → `["SAPRE","Y58","GEVEA"]`.
|
||||
/// Caller keeps only the tokens that are actual route points (never airports),
|
||||
/// so area/airway refs like `LSGGMID` harmlessly no-op.
|
||||
fn parse_forbidden_via(msg: &str) -> Vec<String> {
|
||||
let up = msg.to_uppercase();
|
||||
let Some(after) = up.split(" VIA ").nth(1) else {
|
||||
return Vec::new();
|
||||
};
|
||||
let seg = after.split(" IS ON FORBIDDEN").next().unwrap_or(after);
|
||||
seg.split([' ', '|'])
|
||||
.map(str::trim)
|
||||
.filter(|t| !t.is_empty() && t.chars().all(|c| c.is_ascii_alphanumeric()))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A forbidden flight-level band expressed by a PROF204/205 message.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Band {
|
||||
@@ -192,8 +210,42 @@ fn apply_repairs(
|
||||
route: &Route,
|
||||
fl: i32,
|
||||
errors: &[IfpsErr],
|
||||
avoid: &mut std::collections::HashSet<String>,
|
||||
) -> Result<Option<(Route, i32, String)>> {
|
||||
// 1) PROF195 above an airway's ceiling → the cruise FL is too high for this
|
||||
// 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.
|
||||
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();
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !newly.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)
|
||||
.ok()
|
||||
.filter(|r| r.legs.len() >= 2)
|
||||
{
|
||||
return Ok(Some((r, fl, format!("avoid forbidden {}", newly.join(", ")))));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) PROF195 above an airway's ceiling → the cruise FL is too high for this
|
||||
// route (a short low-level pair whose direct airways cap at, say, FL195,
|
||||
// filed at FL270). Lower to a level where the airways exist and re-plan,
|
||||
// rather than detour around them. `hi` is the airway's top; we drop to the
|
||||
@@ -208,7 +260,7 @@ fn apply_repairs(
|
||||
if let Some(ceil) = prof195_ceiling {
|
||||
let target = (ceil / 10) * 10; // valid cruise level at/below the ceiling
|
||||
if target != fl && target >= 60 {
|
||||
let replanned = super::plan_route_best(conn, from, to, Some(target), dep_fixes, dest_fixes)
|
||||
let replanned = super::plan_route_best_avoiding(conn, from, to, Some(target), dep_fixes, dest_fixes, avoid)
|
||||
.ok()
|
||||
.filter(|r| r.legs.len() >= 2);
|
||||
let (route, how) = match replanned {
|
||||
@@ -290,7 +342,7 @@ fn apply_repairs(
|
||||
if target != fl && target >= 60 {
|
||||
// Re-plan at the new level so airways are valid there (a route
|
||||
// planned at F360 may use airways that don't exist at F190).
|
||||
let replanned = super::plan_route_best(conn, from, to, Some(target), dep_fixes, dest_fixes)
|
||||
let replanned = super::plan_route_best_avoiding(conn, from, to, Some(target), dep_fixes, dest_fixes, avoid)
|
||||
.ok()
|
||||
.filter(|r| r.legs.len() >= 2);
|
||||
let (route, how) = match replanned {
|
||||
@@ -302,7 +354,7 @@ fn apply_repairs(
|
||||
}
|
||||
}
|
||||
|
||||
// 3) unknown designators → drop the token (collapsing the leg chain so we
|
||||
// 4) unknown designators → drop the token (collapsing the leg chain so we
|
||||
// never leave a dangling `AWY AWY`).
|
||||
let drop: std::collections::HashSet<String> = errors
|
||||
.iter()
|
||||
@@ -388,6 +440,11 @@ fn drop_designators(legs: &[Leg], drop: &std::collections::HashSet<String>) -> V
|
||||
/// Maximum validate/repair iterations (each is one live IFPUV round-trip).
|
||||
const MAX_ITERS: usize = 8;
|
||||
|
||||
/// How many consecutive non-improving repair steps to tolerate before reverting
|
||||
/// to the best route seen. Lets the loop cross a short valley (a repair that
|
||||
/// trades one error class for another en route to a fix) without diverging.
|
||||
const MAX_STALL: usize = 2;
|
||||
|
||||
/// Max attempts with different SID/STAR gateways (each is a full inner run).
|
||||
const MAX_GATEWAY_ATTEMPTS: usize = 3;
|
||||
|
||||
@@ -558,7 +615,16 @@ pub fn run_loop(
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = 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<String> = std::collections::HashSet::new();
|
||||
|
||||
// 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),
|
||||
// so we tolerate a few non-improving steps and cross the valley rather than
|
||||
// revert at the first bump. `best` (fewest errors seen) always protects the
|
||||
// result, so this can only find an equal-or-better route, never a worse one.
|
||||
let mut stall = 0;
|
||||
for i in 1..=MAX_ITERS {
|
||||
iterations = i;
|
||||
if verdict.accepted {
|
||||
@@ -566,20 +632,24 @@ pub fn run_loop(
|
||||
best = Some((cur, fl, Vec::new()));
|
||||
break;
|
||||
}
|
||||
if let Some((_, _, berr)) = &best {
|
||||
if verdict.errors.len() >= berr.len() {
|
||||
let improved = best.as_ref().map_or(true, |(_, _, berr)| verdict.errors.len() < berr.len());
|
||||
if improved {
|
||||
best = Some((cur.clone(), fl, verdict.errors.clone()));
|
||||
stall = 0;
|
||||
} else {
|
||||
stall += 1;
|
||||
if stall > MAX_STALL {
|
||||
log.push(format!(
|
||||
"iter {i}: {} → {} err (not better than {}) — reverting",
|
||||
"iter {i}: {} → {} err (no improvement in {MAX_STALL} steps) — reverting to {} err",
|
||||
fl3(fl),
|
||||
verdict.errors.len(),
|
||||
berr.len()
|
||||
best.as_ref().map_or(0, |(_, _, e)| e.len()),
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let codes: Vec<&str> = verdict.errors.iter().map(|e| e.code.as_str()).collect();
|
||||
best = Some((cur.clone(), fl, verdict.errors.clone()));
|
||||
match apply_repairs(conn, from, to, dep_fixes, dest_fixes, &cur, fl, &verdict.errors)? {
|
||||
match apply_repairs(conn, from, to, dep_fixes, dest_fixes, &cur, fl, &verdict.errors, &mut avoid)? {
|
||||
Some((r, nfl, note)) => {
|
||||
log.push(format!("iter {i}: {} → {} err [{}] | fix: {note}", fl3(fl), verdict.errors.len(), codes.join(",")));
|
||||
cur = r;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! In-memory airway graph built from SQLite, used by A* routing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::Connection;
|
||||
@@ -42,6 +42,17 @@ impl RouteGraph {
|
||||
/// position are skipped. When `cruise_fl` is `Some`, only airways valid at
|
||||
/// that flight level (within their `base…top` band) are included.
|
||||
pub fn build(conn: &Connection, cruise_fl: Option<i32>) -> Result<Self> {
|
||||
Self::build_avoiding(conn, cruise_fl, &HashSet::new())
|
||||
}
|
||||
|
||||
/// Like [`build`](Self::build) but skips any segment whose endpoint ident or
|
||||
/// airway name is in `avoid`. Used by the discovery repair loop to route
|
||||
/// around RAD-forbidden points/segments cited by IFPS (`PROF204`/`PROF205`).
|
||||
pub fn build_avoiding(
|
||||
conn: &Connection,
|
||||
cruise_fl: Option<i32>,
|
||||
avoid: &HashSet<String>,
|
||||
) -> Result<Self> {
|
||||
let positions = load_positions(conn)?;
|
||||
let mut g = DiGraph::new();
|
||||
let mut index: HashMap<(String, String), NodeIndex> = HashMap::new();
|
||||
@@ -65,6 +76,9 @@ impl RouteGraph {
|
||||
|
||||
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 let Some(fl) = cruise_fl {
|
||||
if !fl_in_band(fl, base_fl, top_fl) {
|
||||
continue;
|
||||
|
||||
@@ -78,14 +78,32 @@ pub fn plan_route_conn(
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
plan_route_conn_avoiding(conn, from_icao, to_icao, cruise_fl, dep_conn, dest_conn, &std::collections::HashSet::new())
|
||||
}
|
||||
|
||||
/// Like [`plan_route_conn`] but excludes RAD-forbidden points/airways in `avoid`
|
||||
/// from the graph (and from the SID/STAR connector set).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn plan_route_conn_avoiding(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
avoid: &std::collections::HashSet<String>,
|
||||
) -> Result<Route> {
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
|
||||
let mut rg = RouteGraph::build(conn, cruise_fl)?;
|
||||
let entries = resolve_conn(&rg, dep_conn, dep)
|
||||
let mut rg = RouteGraph::build_avoiding(conn, cruise_fl, avoid)?;
|
||||
let keep = |v: Vec<String>| -> Vec<String> { v.into_iter().filter(|f| !avoid.contains(f)).collect() };
|
||||
let dep_conn = keep(dep_conn.to_vec());
|
||||
let dest_conn = keep(dest_conn.to_vec());
|
||||
let entries = resolve_conn(&rg, &dep_conn, dep)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dep, CONNECT_K, CONNECT_MAX_NM));
|
||||
let exits = resolve_conn(&rg, dest_conn, dst)
|
||||
let exits = resolve_conn(&rg, &dest_conn, dst)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dst, CONNECT_K, CONNECT_MAX_NM));
|
||||
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
@@ -197,10 +215,25 @@ pub fn plan_route_best(
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
let full = plan_route_conn(conn, from, to, None, dep_conn, dest_conn)?;
|
||||
plan_route_best_avoiding(conn, from, to, cruise_fl, dep_conn, dest_conn, &std::collections::HashSet::new())
|
||||
}
|
||||
|
||||
/// Like [`plan_route_best`] but routes around the RAD-forbidden points/airways in
|
||||
/// `avoid` (accumulated by the discovery loop from `PROF204`/`PROF205`).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn plan_route_best_avoiding(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
avoid: &std::collections::HashSet<String>,
|
||||
) -> Result<Route> {
|
||||
let full = plan_route_conn_avoiding(conn, from, to, None, dep_conn, dest_conn, avoid)?;
|
||||
match cruise_fl {
|
||||
Some(fl) => {
|
||||
let fl_route = plan_route_conn(conn, from, to, Some(fl), dep_conn, dest_conn)?;
|
||||
let fl_route = plan_route_conn_avoiding(conn, from, to, Some(fl), dep_conn, dest_conn, avoid)?;
|
||||
if fl_route.via_airways && fl_route.total_nm <= FL_ROUTE_MAX_RATIO * full.total_nm.max(1.0) {
|
||||
Ok(fl_route)
|
||||
} else {
|
||||
|
||||
Reference in New Issue
Block a user