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 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:59:41 +02:00
parent 2cb633e99d
commit 6a3a885c47
3 changed files with 211 additions and 15 deletions
+25
View File
@@ -439,6 +439,31 @@ pub fn discover_route(
None => (Vec::new(), Vec::new()), 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. LFRSLFST,
// 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. EGLLLSZH) 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<String> = dep_sid.iter().map(|(_, f)| f.clone()).collect();
let dest_fixes: Vec<String> = 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( let result = routing::discover::find_valid_route(
&conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator, &conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator,
)?; )?;
+156 -13
View File
@@ -115,6 +115,20 @@ fn parse_fl_gap(msg: &str) -> Option<(String, String)> {
Some((a.to_owned(), b.to_owned())) 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<i32> {
s.trim().trim_start_matches('F').chars().take_while(|c| c.is_ascii_digit()).collect::<String>().parse().ok()
};
Some((num(lo)?, num(hi)?))
}
/// 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 {
@@ -179,9 +193,36 @@ fn apply_repairs(
fl: i32, fl: i32,
errors: &[IfpsErr], errors: &[IfpsErr],
) -> Result<Option<(Route, i32, String)>> { ) -> Result<Option<(Route, i32, String)>> {
// 1) Re-route a broken segment through the FL-filtered airway graph: // 1) PROF195 above an airway's ceiling → the cruise FL is too high for this
// ROUTE165 (DCT too long in a TMA) and PROF195 (airway not valid at this // route (a short low-level pair whose direct airways cap at, say, FL195,
// FL — e.g. we lowered FL for a RAD cap and broke a high-level airway). // 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 let mut segs: Vec<(String, String)> = errors
.iter() .iter()
.filter_map(|e| match e.code.as_str() { .filter_map(|e| match e.code.as_str() {
@@ -261,25 +302,23 @@ fn apply_repairs(
} }
} }
// 3) unknown designators → drop the token. // 3) unknown designators → drop the token (collapsing the leg chain so we
let drop: Vec<String> = errors // never leave a dangling `AWY AWY`).
let drop: std::collections::HashSet<String> = errors
.iter() .iter()
.filter(|e| e.code == "ROUTE130") .filter(|e| e.code == "ROUTE130")
.filter_map(|e| parse_unknown(&e.msg)) .filter_map(|e| parse_unknown(&e.msg))
.collect(); .collect();
if !drop.is_empty() { if !drop.is_empty() {
let legs: Vec<Leg> = route let legs = drop_designators(&route.legs, &drop);
.legs if legs.len() != route.legs.len() && !legs.is_empty() {
.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 nm = total_nm(&legs); let nm = total_nm(&legs);
let mut names: Vec<&str> = drop.iter().map(String::as_str).collect();
names.sort_unstable();
return Ok(Some(( return Ok(Some((
Route { legs, total_nm: nm, via_airways: route.via_airways }, Route { legs, total_nm: nm, via_airways: route.via_airways },
fl, fl,
format!("drop {}", drop.join(", ")), format!("drop {}", names.join(", ")),
))); )));
} }
} }
@@ -287,6 +326,63 @@ fn apply_repairs(
Ok(None) 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<String>) -> Vec<Leg> {
// Pass 1: bad airways → DCT (endpoints preserved).
let staged: Vec<Leg> = 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<Leg> = 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 ──────────────────────────────────────────────────────────────── // ── the loop ────────────────────────────────────────────────────────────────
/// Maximum validate/repair iterations (each is one live IFPUV round-trip). /// 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"), parse_fl_gap("DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245"),
Some(("DIK".into(), "ADUSU".into())) 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 /// A mock validator that replays a scripted sequence of verdicts, so we can
+30 -2
View File
@@ -182,8 +182,11 @@ fn resolve_conn(rg: &RouteGraph, idents: &[String], near: LatLon) -> Option<Vec<
/// Filtering airways to the cruise FL yields an IFPS-coherent route, but on some /// Filtering airways to the cruise FL yields an IFPS-coherent route, but on some
/// pairs the upper network is fragmented and forces an absurd detour; the full /// pairs the upper network is fragmented and forces an absurd detour; the full
/// network gives the short route but may cite airways invalid at the FL. So we /// network gives the short route but may cite airways invalid at the FL. So we
/// compute both and keep the FL-valid one unless it detours by >60 %. /// compute both and keep the FL-valid one only when it's near-shortest — a bigger
const FL_ROUTE_MAX_RATIO: f64 = 1.6; /// 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. /// Best airway route: FL-filtered when it doesn't detour too much, else full.
pub fn plan_route_best( 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<Option<i32>> {
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<i32> = None;
for leg in &route.legs {
if leg.airway == "DCT" || leg.airway.is_empty() {
continue;
}
let top: Option<i32> =
stmt.query_row(params![leg.airway, leg.from, leg.to], |r| r.get::<_, Option<i32>>(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: /// The route to file for modern European airspace, in preference order:
/// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle /// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle
/// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs. /// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs.