diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index 32ec1da..71a12b6 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -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 { + 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, ) -> Result> { - // 1) PROF195 above an airway's ceiling → the cruise FL is too high for this + // 1) RAD-forbidden segments: PROF204 "TRAFFIC VIA 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 = 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 = errors .iter() @@ -388,6 +440,11 @@ fn drop_designators(legs: &[Leg], drop: &std::collections::HashSet) -> 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 = Vec::new(); let mut best: Option<(Route, i32, Vec)> = 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 = 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; diff --git a/crates/core/src/routing/graph.rs b/crates/core/src/routing/graph.rs index ded58a5..3848dae 100644 --- a/crates/core/src/routing/graph.rs +++ b/crates/core/src/routing/graph.rs @@ -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) -> Result { + 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, + avoid: &HashSet, + ) -> Result { 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; diff --git a/crates/core/src/routing/mod.rs b/crates/core/src/routing/mod.rs index c03cec4..34e5e88 100644 --- a/crates/core/src/routing/mod.rs +++ b/crates/core/src/routing/mod.rs @@ -78,14 +78,32 @@ pub fn plan_route_conn( cruise_fl: Option, dep_conn: &[String], dest_conn: &[String], +) -> Result { + 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, + dep_conn: &[String], + dest_conn: &[String], + avoid: &std::collections::HashSet, ) -> Result { 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| -> Vec { 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 { - 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, + dep_conn: &[String], + dest_conn: &[String], + avoid: &std::collections::HashSet, +) -> Result { + 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 {