fix(routing): kill globe-spanning splices + strip computer-nav fixes

Two correctness fixes exposed by a coverage batch where "1 ROUTE130"
near-misses turned out to be catastrophically broken routes (Paris->
Frankfurt routed via Alaska) hidden by IFPS error-masking:

- airway_path now rejects a spliced sub-path that balloons past
  SPLICE_MAX_RATIO x the direct distance. At a fragmented FL the graph
  could route two nearby fixes the long way around the whole network; that
  garbage then showed only 1 (masked) error and beat sane routes in the
  monotonic best-tracking.
- route_item15 strips computer-navigation fixes (unnamed ARINC waypoints
  whose ident carries a digit, e.g. GT27A) proactively, collapsing
  same-airway legs, so IFPS never sees a ROUTE130 designator it would
  reject and stop evaluating at.

Net: coverage numbers are now honest (masked garbage no longer counts as a
near-miss); the real blocker is RAD PROF204/205 on upper/FRA routes.
49 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 09:54:55 +02:00
parent 481ac241a8
commit 927b7eeea7
2 changed files with 40 additions and 2 deletions
+23 -1
View File
@@ -58,11 +58,33 @@ pub struct DiscoverResult {
pub log: Vec<String>,
}
/// A computer-navigation fix: an unnamed ARINC waypoint our navdata carries on an
/// airway (e.g. `GT27A`, `BS25B`). Their idents contain a digit; real ICAO enroute
/// points are all-letter 5LNCs (`SOVOS`) or letter navaids (`EPL`). IFPS rejects
/// these designators (`ROUTE130`) because the airway already implies them.
fn is_computer_fix(ident: &str) -> bool {
ident.chars().any(|c| c.is_ascii_digit())
}
/// ICAO item-15 (enroute) string for a route: start at the SID exit fix, then
/// `airway to` for each enroute leg, ending at the STAR entry fix. The leading
/// SID and trailing STAR (first/last leg) are omitted — IFPS derives them.
/// Computer-nav fixes are stripped (collapsing same-airway legs) so we never file
/// a designator IFPS would reject.
pub fn route_item15(route: &Route) -> String {
let legs = &route.legs;
let cn: std::collections::HashSet<String> = route
.legs
.iter()
.flat_map(|l| [l.from.clone(), l.to.clone()])
.filter(|id| is_computer_fix(id))
.collect();
let collapsed;
let legs: &[Leg] = if cn.is_empty() {
&route.legs
} else {
collapsed = drop_designators(&route.legs, &cn);
&collapsed
};
match legs.len() {
0 => String::new(),
1 => "DCT".to_owned(),
+17 -1
View File
@@ -23,6 +23,14 @@ use graph::{EdgeData, NodeData, RouteGraph};
const CONNECT_MAX_NM: f64 = 100.0;
const CONNECT_K: usize = 30;
/// A spliced airway sub-path is rejected if it exceeds this multiple of the direct
/// distance between its endpoints (guards against a fragmented FL graph routing
/// two nearby fixes the long way around the network).
const SPLICE_MAX_RATIO: f64 = 3.0;
/// Floor for the splice bound so a short splice (a few nm direct) still has room
/// to route around terminal airspace.
const SPLICE_MIN_NM: f64 = 40.0;
/// One leg of a computed route.
#[derive(Debug, Clone, PartialEq)]
pub struct Leg {
@@ -412,12 +420,20 @@ pub fn airway_path(
|e| e.weight().dist_nm,
|n| rg.g[n].pos.distance_nm(&target),
);
let Some((_, path)) = result else {
let Some((total, path)) = result else {
return Ok(None);
};
if path.len() < 2 {
return Ok(None);
}
// Reject a splice that balloons far beyond the direct distance: at a fragmented
// FL the airway graph can force A* to wander across the network (even around
// the globe) to connect two nearby fixes. Such a path is never the real fix —
// better to leave the original leg for another repair than file garbage.
let direct = rg.g[a].pos.distance_nm(&target);
if total > SPLICE_MAX_RATIO * direct.max(SPLICE_MIN_NM) {
return Ok(None);
}
let mut legs = Vec::with_capacity(path.len() - 1);
for pair in path.windows(2) {
let (x, y) = (pair[0], pair[1]);