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:
@@ -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<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(
|
||||
&conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator,
|
||||
)?;
|
||||
|
||||
@@ -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<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.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Band {
|
||||
@@ -179,9 +193,36 @@ fn apply_repairs(
|
||||
fl: i32,
|
||||
errors: &[IfpsErr],
|
||||
) -> Result<Option<(Route, i32, String)>> {
|
||||
// 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<String> = errors
|
||||
// 3) unknown designators → drop the token (collapsing the leg chain so we
|
||||
// never leave a dangling `AWY AWY`).
|
||||
let drop: std::collections::HashSet<String> = errors
|
||||
.iter()
|
||||
.filter(|e| e.code == "ROUTE130")
|
||||
.filter_map(|e| parse_unknown(&e.msg))
|
||||
.collect();
|
||||
if !drop.is_empty() {
|
||||
let legs: Vec<Leg> = 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<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 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// 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
|
||||
|
||||
@@ -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
|
||||
/// 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
|
||||
/// compute both and keep the FL-valid one unless it detours by >60 %.
|
||||
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<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:
|
||||
/// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle
|
||||
/// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs.
|
||||
|
||||
Reference in New Issue
Block a user