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:
2026-08-21 12:38:29 +02:00
parent c7177cc3c2
commit 481ac241a8
3 changed files with 133 additions and 16 deletions
+80 -10
View File
@@ -129,6 +129,24 @@ fn parse_valid_range(msg: &str) -> Option<(i32, i32)> {
Some((num(lo)?, num(hi)?)) 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. /// A forbidden flight-level band expressed by a PROF204/205 message.
#[derive(Default, Clone, Copy)] #[derive(Default, Clone, Copy)]
struct Band { struct Band {
@@ -192,8 +210,42 @@ fn apply_repairs(
route: &Route, route: &Route,
fl: i32, fl: i32,
errors: &[IfpsErr], errors: &[IfpsErr],
avoid: &mut std::collections::HashSet<String>,
) -> Result<Option<(Route, i32, 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, // 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, // 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 // 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 { if let Some(ceil) = prof195_ceiling {
let target = (ceil / 10) * 10; // valid cruise level at/below the ceiling let target = (ceil / 10) * 10; // valid cruise level at/below the ceiling
if target != fl && target >= 60 { 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() .ok()
.filter(|r| r.legs.len() >= 2); .filter(|r| r.legs.len() >= 2);
let (route, how) = match replanned { let (route, how) = match replanned {
@@ -290,7 +342,7 @@ fn apply_repairs(
if target != fl && target >= 60 { if target != fl && target >= 60 {
// Re-plan at the new level so airways are valid there (a route // 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). // 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() .ok()
.filter(|r| r.legs.len() >= 2); .filter(|r| r.legs.len() >= 2);
let (route, how) = match replanned { 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`). // never leave a dangling `AWY AWY`).
let drop: std::collections::HashSet<String> = errors let drop: std::collections::HashSet<String> = errors
.iter() .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). /// Maximum validate/repair iterations (each is one live IFPUV round-trip).
const MAX_ITERS: usize = 8; 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). /// Max attempts with different SID/STAR gateways (each is a full inner run).
const MAX_GATEWAY_ATTEMPTS: usize = 3; const MAX_GATEWAY_ATTEMPTS: usize = 3;
@@ -558,7 +615,16 @@ pub fn run_loop(
let mut log: Vec<String> = Vec::new(); let mut log: Vec<String> = Vec::new();
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None; let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
let mut iterations = 0; 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 { for i in 1..=MAX_ITERS {
iterations = i; iterations = i;
if verdict.accepted { if verdict.accepted {
@@ -566,20 +632,24 @@ pub fn run_loop(
best = Some((cur, fl, Vec::new())); best = Some((cur, fl, Vec::new()));
break; break;
} }
if let Some((_, _, berr)) = &best { let improved = best.as_ref().map_or(true, |(_, _, berr)| verdict.errors.len() < berr.len());
if 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!( log.push(format!(
"iter {i}: {}{} err (not better than {}) — reverting", "iter {i}: {}{} err (no improvement in {MAX_STALL} steps) — reverting to {} err",
fl3(fl), fl3(fl),
verdict.errors.len(), verdict.errors.len(),
berr.len() best.as_ref().map_or(0, |(_, _, e)| e.len()),
)); ));
break; break;
} }
} }
let codes: Vec<&str> = verdict.errors.iter().map(|e| e.code.as_str()).collect(); 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, &mut avoid)? {
match apply_repairs(conn, from, to, dep_fixes, dest_fixes, &cur, fl, &verdict.errors)? {
Some((r, nfl, note)) => { Some((r, nfl, note)) => {
log.push(format!("iter {i}: {}{} err [{}] | fix: {note}", fl3(fl), verdict.errors.len(), codes.join(","))); log.push(format!("iter {i}: {}{} err [{}] | fix: {note}", fl3(fl), verdict.errors.len(), codes.join(",")));
cur = r; cur = r;
+15 -1
View File
@@ -1,6 +1,6 @@
//! In-memory airway graph built from SQLite, used by A* routing. //! 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 petgraph::graph::{DiGraph, NodeIndex};
use rusqlite::Connection; use rusqlite::Connection;
@@ -42,6 +42,17 @@ impl RouteGraph {
/// position are skipped. When `cruise_fl` is `Some`, only airways valid at /// position are skipped. When `cruise_fl` is `Some`, only airways valid at
/// that flight level (within their `base…top` band) are included. /// that flight level (within their `base…top` band) are included.
pub fn build(conn: &Connection, cruise_fl: Option<i32>) -> Result<Self> { 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 positions = load_positions(conn)?;
let mut g = DiGraph::new(); let mut g = DiGraph::new();
let mut index: HashMap<(String, String), NodeIndex> = HashMap::new(); let mut index: HashMap<(String, String), NodeIndex> = HashMap::new();
@@ -65,6 +76,9 @@ impl RouteGraph {
for row in rows { for row in rows {
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?; 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 let Some(fl) = cruise_fl {
if !fl_in_band(fl, base_fl, top_fl) { if !fl_in_band(fl, base_fl, top_fl) {
continue; continue;
+38 -5
View File
@@ -78,14 +78,32 @@ pub fn plan_route_conn(
cruise_fl: Option<i32>, cruise_fl: Option<i32>,
dep_conn: &[String], dep_conn: &[String],
dest_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> { ) -> Result<Route> {
let dep = airport_pos(conn, from_icao)?; let dep = airport_pos(conn, from_icao)?;
let dst = airport_pos(conn, to_icao)?; let dst = airport_pos(conn, to_icao)?;
let mut rg = RouteGraph::build(conn, cruise_fl)?; let mut rg = RouteGraph::build_avoiding(conn, cruise_fl, avoid)?;
let entries = resolve_conn(&rg, dep_conn, dep) 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)); .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)); .unwrap_or_else(|| rg.nearest_nodes(dst, CONNECT_K, CONNECT_MAX_NM));
if entries.is_empty() || exits.is_empty() { if entries.is_empty() || exits.is_empty() {
@@ -197,10 +215,25 @@ pub fn plan_route_best(
dep_conn: &[String], dep_conn: &[String],
dest_conn: &[String], dest_conn: &[String],
) -> Result<Route> { ) -> 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 { match cruise_fl {
Some(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) { if fl_route.via_airways && fl_route.total_nm <= FL_ROUTE_MAX_RATIO * full.total_nm.max(1.0) {
Ok(fl_route) Ok(fl_route)
} else { } else {