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)?))
|
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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user