From 6a3a885c47825aee57d0b63dd3941e374b77a502 Mon Sep 17 00:00:00 2001 From: Alexandre Date: Fri, 21 Aug 2026 11:59:41 +0200 Subject: [PATCH] fix(routing): make low-level pairs pass IFPS (LFRS-LFST) Three router bugs surfaced by testing real filed pairs against the Eurocontrol IFPUV oracle: - ROUTE130 repair now collapses the leg chain when dropping an unknown designator (a computer-nav fix like GT27A on an airway), instead of leaving a dangling `AWY AWY`. Same airway on both sides -> merge; else DCT. - FL detour ratio 1.6 -> 1.25: the FL-filtered graph fragments the conventional (<=FL195) network and forced an absurd London detour for a domestic French hop; the short full route now wins. - FL ceiling-cap: file a short low-level pair at its airways' natural ceiling instead of an optimistic FL360 (which yields PROF195 on every leg). Gated on upper-network fragmentation so genuine high-level long-haul (EGLL-LSZH) is never wrongly lowered. - New PROF195 repair lowers the cruise FL to where the cited airway exists. LFRS-LFST now no-error at FL080 (0 iterations); LFRN-LFMN and LSGG-LFPO still pass and now converge in 0 iterations. 4 new unit tests; 49 green. Co-Authored-By: Claude Opus 4.8 --- crates/core/src/api.rs | 25 ++++ crates/core/src/routing/discover.rs | 169 +++++++++++++++++++++++++--- crates/core/src/routing/mod.rs | 32 +++++- 3 files changed, 211 insertions(+), 15 deletions(-) diff --git a/crates/core/src/api.rs b/crates/core/src/api.rs index ba898e2..ee9ac15 100644 --- a/crates/core/src/api.rs +++ b/crates/core/src/api.rs @@ -439,6 +439,31 @@ pub fn discover_route( None => (Vec::new(), Vec::new()), }; + // Cap the start FL to the airway route's natural ceiling *only when the upper + // network fragments* for this pair. A short low-level pair (e.g. LFRS–LFST, + // direct airways cap at FL195) fragments: its FL-filtered route detours badly + // vs. the full route, so we fly low at the full route's ceiling. A normal + // high-level pair (e.g. EGLL–LSZH) routes cleanly on the upper network, so we + // must NOT lower it just because its shortest full path dips through one + // low-cap airway. This is the same detour test as `plan_route_best`. + const FRAG_RATIO: f64 = 1.25; + let dep_fixes: Vec = dep_sid.iter().map(|(_, f)| f.clone()).collect(); + let dest_fixes: Vec = dest_star.iter().map(|(_, f)| f.clone()).collect(); + let full = routing::plan_route_conn(&conn, &from, &to, None, &dep_fixes, &dest_fixes).ok(); + let fl_route = routing::plan_route_conn(&conn, &from, &to, Some(start_fl), &dep_fixes, &dest_fixes).ok(); + let fragmented = match (&full, &fl_route) { + (Some(full), Some(fl)) => !(fl.via_airways && fl.total_nm <= FRAG_RATIO * full.total_nm.max(1.0)), + _ => false, + }; + let start_fl = match full + .as_ref() + .filter(|_| fragmented) + .and_then(|r| routing::route_fl_ceiling(&conn, r).ok().flatten()) + { + Some(ceil) if ceil < start_fl => (ceil / 10) * 10, + _ => start_fl, + }; + let result = routing::discover::find_valid_route( &conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator, )?; diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index bf16956..32ec1da 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -115,6 +115,20 @@ fn parse_fl_gap(msg: &str) -> Option<(String, String)> { Some((a.to_owned(), b.to_owned())) } +/// The valid `(lo, hi)` FL band from a PROF195 message, e.g. +/// `DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245` → `(0, 245)`. +/// The airway exists only inside this band, so a cruise FL above `hi` (or below +/// `lo`) is the fault. +fn parse_valid_range(msg: &str) -> Option<(i32, i32)> { + let up = msg.to_uppercase(); + let tail = up.split("FL RANGE ").nth(1)?.trim(); + let (lo, hi) = tail.split_once("..")?; + let num = |s: &str| -> Option { + s.trim().trim_start_matches('F').chars().take_while(|c| c.is_ascii_digit()).collect::().parse().ok() + }; + Some((num(lo)?, num(hi)?)) +} + /// A forbidden flight-level band expressed by a PROF204/205 message. #[derive(Default, Clone, Copy)] struct Band { @@ -179,9 +193,36 @@ fn apply_repairs( fl: i32, errors: &[IfpsErr], ) -> Result> { - // 1) Re-route a broken segment through the FL-filtered airway graph: - // ROUTE165 (DCT too long in a TMA) and PROF195 (airway not valid at this - // FL — e.g. we lowered FL for a RAD cap and broke a high-level airway). + // 1) 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 + // highest 10s FL at/below the lowest violated ceiling. + let prof195_ceiling = errors + .iter() + .filter(|e| e.code == "PROF195") + .filter_map(|e| parse_valid_range(&e.msg)) + .filter(|&(_lo, hi)| fl > hi) + .map(|(_lo, hi)| hi) + .min(); + 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) + .ok() + .filter(|r| r.legs.len() >= 2); + let (route, how) = match replanned { + Some(r) => (r, "re-planned"), + None => (route.clone(), "same route"), + }; + return Ok(Some((route, target, format!("PROF195 ceiling → {} ({how})", fl3(target))))); + } + } + + // 2) Re-route a broken segment through the FL-filtered airway graph: + // ROUTE165 (DCT too long in a TMA) and PROF195 where lowering didn't apply + // (airway invalid because we're *below* its base — e.g. we over-lowered for + // a RAD cap and broke a high-level airway). let mut segs: Vec<(String, String)> = errors .iter() .filter_map(|e| match e.code.as_str() { @@ -261,25 +302,23 @@ fn apply_repairs( } } - // 3) unknown designators → drop the token. - let drop: Vec = errors + // 3) unknown designators → drop the token (collapsing the leg chain so we + // never leave a dangling `AWY AWY`). + let drop: std::collections::HashSet = errors .iter() .filter(|e| e.code == "ROUTE130") .filter_map(|e| parse_unknown(&e.msg)) .collect(); if !drop.is_empty() { - let legs: Vec = route - .legs - .iter() - .filter(|l| !drop.iter().any(|d| d == &l.to || d == &l.airway)) - .cloned() - .collect(); - if legs.len() != route.legs.len() && legs.len() >= 1 { + let legs = drop_designators(&route.legs, &drop); + if legs.len() != route.legs.len() && !legs.is_empty() { let nm = total_nm(&legs); + let mut names: Vec<&str> = drop.iter().map(String::as_str).collect(); + names.sort_unstable(); return Ok(Some(( Route { legs, total_nm: nm, via_airways: route.via_airways }, fl, - format!("drop {}", drop.join(", ")), + format!("drop {}", names.join(", ")), ))); } } @@ -287,6 +326,63 @@ fn apply_repairs( Ok(None) } +/// Remove unknown designators from a leg chain without breaking it. +/// +/// * An **airway** designator in `drop` → that leg becomes a `DCT` between its +/// endpoints (keep the waypoints, drop the bad airway). +/// * A **waypoint** designator in `drop` (a leg's `to`) → merge the leg into the +/// next one: `A --awy1--> W --awy2--> B` becomes `A --awy--> B` where `awy` is +/// the shared airway if `awy1 == awy2`, else `DCT`. Consecutive dropped +/// waypoints are absorbed in one pass. Distances are summed (consecutive +/// segments), which is exact for the same-airway case and a safe over-estimate +/// otherwise. +fn drop_designators(legs: &[Leg], drop: &std::collections::HashSet) -> Vec { + // Pass 1: bad airways → DCT (endpoints preserved). + let staged: Vec = legs + .iter() + .map(|l| { + if drop.contains(&l.airway) { + Leg { airway: "DCT".to_owned(), ..l.clone() } + } else { + l.clone() + } + }) + .collect(); + + // Pass 2: merge out legs whose `to` is a dropped waypoint. + let mut out: Vec = Vec::with_capacity(staged.len()); + let mut i = 0; + while i < staged.len() { + let leg = &staged[i]; + if drop.contains(&leg.to) && i + 1 < staged.len() { + let from = leg.from.clone(); + let start_awy = leg.airway.clone(); + let mut acc = leg.dist_nm; + let mut j = i + 1; + acc += staged[j].dist_nm; + let mut same = staged[j].airway == start_awy; + let mut end_to = staged[j].to.clone(); + while drop.contains(&end_to) && j + 1 < staged.len() { + j += 1; + acc += staged[j].dist_nm; + same &= staged[j].airway == start_awy; + end_to = staged[j].to.clone(); + } + let airway = if same { start_awy } else { "DCT".to_owned() }; + out.push(Leg { from, to: end_to, airway, dist_nm: acc }); + i = j + 1; + } else if drop.contains(&leg.to) { + // Trailing leg ends at a dropped waypoint (no next leg to merge into): + // drop this leg so the route ends at the previous point. + i += 1; + } else { + out.push(leg.clone()); + i += 1; + } + } + out +} + // ── the loop ──────────────────────────────────────────────────────────────── /// Maximum validate/repair iterations (each is one live IFPUV round-trip). @@ -550,6 +646,53 @@ mod tests { parse_fl_gap("DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245"), Some(("DIK".into(), "ADUSU".into())) ); + assert_eq!( + parse_valid_range("DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245"), + Some((0, 245)) + ); + assert_eq!( + parse_valid_range("ANG H34 SABLE DOES NOT EXIST IN FL RANGE F055..F195"), + Some((55, 195)) + ); + } + + #[test] + fn drop_designator_collapses_same_airway() { + // SUTAL N852 GT27A N852 AKELU → drop GT27A → SUTAL N852 AKELU (not `N852 N852`). + let leg = |f: &str, t: &str, a: &str| Leg { + from: f.into(), to: t.into(), airway: a.into(), dist_nm: 10.0, + }; + let legs = vec![ + leg("SUTAL", "GT27A", "N852"), + leg("GT27A", "AKELU", "N852"), + leg("AKELU", "GTQ", "N852"), + ]; + let drop = std::collections::HashSet::from(["GT27A".to_owned()]); + let out = drop_designators(&legs, &drop); + let s: Vec<(String, String, String)> = + out.iter().map(|l| (l.from.clone(), l.airway.clone(), l.to.clone())).collect(); + assert_eq!( + s, + vec![ + ("SUTAL".into(), "N852".into(), "AKELU".into()), + ("AKELU".into(), "N852".into(), "GTQ".into()), + ] + ); + // Distance of the merged leg = sum of the two collapsed segments. + assert_eq!(out[0].dist_nm, 20.0); + } + + #[test] + fn drop_designator_different_airways_becomes_dct() { + let leg = |f: &str, t: &str, a: &str| Leg { + from: f.into(), to: t.into(), airway: a.into(), dist_nm: 10.0, + }; + // A UN867 W UM184 B → drop W → A DCT B (can't stay on one airway). + let legs = vec![leg("A", "W", "UN867"), leg("W", "B", "UM184")]; + let drop = std::collections::HashSet::from(["W".to_owned()]); + let out = drop_designators(&legs, &drop); + assert_eq!(out.len(), 1); + assert_eq!((out[0].from.as_str(), out[0].airway.as_str(), out[0].to.as_str()), ("A", "DCT", "B")); } /// A mock validator that replays a scripted sequence of verdicts, so we can diff --git a/crates/core/src/routing/mod.rs b/crates/core/src/routing/mod.rs index 5229c9e..c03cec4 100644 --- a/crates/core/src/routing/mod.rs +++ b/crates/core/src/routing/mod.rs @@ -182,8 +182,11 @@ fn resolve_conn(rg: &RouteGraph, idents: &[String], near: LatLon) -> Option60 %. -const FL_ROUTE_MAX_RATIO: f64 = 1.6; +/// compute both and keep the FL-valid one only when it's near-shortest — a bigger +/// detour means the FL graph fragmented (e.g. a short low-level pair whose direct +/// airways cap at FL195), so we take the short route and let the oracle's PROF195 +/// repair lower the cruise level to where those airways are valid. +const FL_ROUTE_MAX_RATIO: f64 = 1.25; /// Best airway route: FL-filtered when it doesn't detour too much, else full. pub fn plan_route_best( @@ -208,6 +211,31 @@ pub fn plan_route_best( } } +/// The highest cruise FL at which every airway leg of `route` stays valid — the +/// min `top_fl` over its bounded airway segments (DCT/unlimited legs ignored). +/// `None` when nothing constrains it. Lets the planner file a short low-level +/// route (whose conventional airways cap at, e.g., FL195) at a coherent level +/// instead of an optimistic FL360 that every leg would reject with `PROF195`. +pub fn route_fl_ceiling(conn: &Connection, route: &Route) -> Result> { + let mut stmt = conn.prepare( + "SELECT MAX(top_fl) FROM airway_segments \ + WHERE airway_name = ?1 AND top_fl > 0 \ + AND ((from_ident = ?2 AND to_ident = ?3) OR (from_ident = ?3 AND to_ident = ?2))", + )?; + let mut ceiling: Option = None; + for leg in &route.legs { + if leg.airway == "DCT" || leg.airway.is_empty() { + continue; + } + let top: Option = + stmt.query_row(params![leg.airway, leg.from, leg.to], |r| r.get::<_, Option>(0))?; + if let Some(t) = top { + ceiling = Some(ceiling.map_or(t, |c| c.min(t))); + } + } + Ok(ceiling) +} + /// The route to file for modern European airspace, in preference order: /// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle /// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs.