feat(rad): preserve airways in mandatory transitions + anchor-on-entry

The transition parser dropped airways, so mandate splices filed all-DCT
(MOVUM DCT HAREM) — wrong routing, and the arrival splice self-looped
(ROUTE49) when the enroute already crossed the arrival area.

- rad: expand_via keeps airway tokens (is_airway_tok); a transition is now
  a point/airway token sequence (DCT where no airway), e.g.
  [MOVUM, T109, HAREM, T104, WLD].
- discover: transition_legs() builds legs with the interspersed airways.
  route_via_arrival_transition anchors on the transition ENTRY (first
  on-airway point) and flies the transition through once — the enroute leg
  stops before the arrival area, so no self-loop. route_via_transition and
  the est/near sorts use the last/first *point* (skip airways).

Files the mandate correctly now (… MOVUM T109 HAREM T104 WLD DCT EDDM).
Coverage 6/10 held, no regression (LFPG-EDDF still passes); LSZH-EGLL
6→4 PROF204; EGLL-EDDM's fake 1-err self-loop is replaced by its real
residuals (CPT dep forbidden, forbidden points on the mandated T104, WLD
STAR limit). 49 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-27 12:24:03 +02:00
parent 29c3f1c185
commit 63e90bbedd
2 changed files with 101 additions and 46 deletions
+81 -40
View File
@@ -561,10 +561,57 @@ pub fn find_valid_route(
Ok(r)
}
/// The last point (non-airway token) of a transition sequence.
fn last_point(transition: &[String]) -> Option<&String> {
transition.iter().rev().find(|t| !is_airwayish(t))
}
/// Is `ident` an endpoint of some airway segment (a routable graph node)?
fn on_airway(conn: &Connection, ident: &str) -> bool {
conn.query_row(
"SELECT 1 FROM airway_segments WHERE from_ident = ?1 OR to_ident = ?1 LIMIT 1",
[ident],
|_| Ok(()),
)
.is_ok()
}
/// Build legs for a transition token sequence (points with airways interspersed,
/// DCT where none) starting from `start`/`start_pos`. Returns the legs and the
/// final point + position. `near` disambiguates repeated idents.
fn transition_legs(
conn: &Connection,
start: &str,
start_pos: crate::model::LatLon,
transition: &[String],
near: crate::model::LatLon,
) -> Option<(Vec<Leg>, String, crate::model::LatLon)> {
let mut legs = Vec::new();
let mut prev = start.to_uppercase();
let mut prevpos = start_pos;
let mut awy = String::from("DCT");
for tok in transition {
if is_airwayish(tok) {
awy = tok.to_uppercase();
continue;
}
let pos = super::ident_pos(conn, tok, near)?;
legs.push(Leg {
from: prev.clone(),
to: tok.to_uppercase(),
airway: std::mem::replace(&mut awy, "DCT".to_owned()),
dist_nm: prevpos.distance_nm(&pos),
});
prev = tok.to_uppercase();
prevpos = pos;
}
Some((legs, prev, prevpos))
}
/// Build a candidate that departs via a mandatory transition sequence (Annex 3A
/// DEP): DCT from `from` through the transition points, then the best airway route
/// from the last transition point to `to`. `None` if positions/routing don't
/// resolve. This is how we satisfy `PROF205 … ANNEX3A DEP … MANDATORY ROUTE`.
/// DEP): fly `from` through the transition (with its airways, DCT to the first
/// point), then the best route from the last transition point to `to`. `None` if
/// positions/routing don't resolve. Satisfies `PROF205 … ANNEX3A DEP … MANDATORY`.
fn route_via_transition(
conn: &Connection,
from: &str,
@@ -574,8 +621,7 @@ fn route_via_transition(
dest_fixes: &[String],
avoid: &std::collections::HashSet<String>,
) -> Result<Option<Route>> {
let Some(last) = transition.last() else { return Ok(None) };
// Route from `from` into the network at `last`, on to `to`.
let Some(last) = last_point(transition) else { return Ok(None) };
let tail = super::plan_route_best_avoiding(conn, from, to, Some(fl), std::slice::from_ref(last), dest_fixes, avoid)?;
let Some(idx) = tail.legs.iter().position(|l| l.to.eq_ignore_ascii_case(last)) else {
return Ok(None);
@@ -584,27 +630,22 @@ fn route_via_transition(
if rest.is_empty() {
return Ok(None);
}
// Prefix: from → t0 → … → last as DCT legs (IFPS derives the SID to t0).
let dep = super::airport_pos(conn, from)?;
let mut pts: Vec<(String, crate::model::LatLon)> = vec![(from.to_uppercase(), dep)];
for p in transition {
let Some(pos) = super::ident_pos(conn, p, dep) else { return Ok(None) };
pts.push((p.to_uppercase(), pos));
}
let mut legs: Vec<Leg> = Vec::with_capacity(pts.len() - 1 + rest.len());
for w in pts.windows(2) {
let d = w[0].1.distance_nm(&w[1].1);
legs.push(Leg { from: w[0].0.clone(), to: w[1].0.clone(), airway: "DCT".into(), dist_nm: d });
}
let dst = super::airport_pos(conn, to)?;
let Some((mut legs, _, _)) = transition_legs(conn, from, dep, transition, dst) else {
return Ok(None);
};
legs.extend(rest.iter().cloned());
let total = total_nm(&legs);
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
}
/// Build a candidate that arrives via a mandatory transition sequence (Annex 2B):
/// route from `from` into the network at the transition's first point, then DCT
/// through the transition points to `to`. `None` if positions/routing don't
/// resolve. Satisfies `PROF205 … VIA <arrival pt> IS OFF MANDATORY ROUTE`.
/// route from `from` to the transition's **entry** (its first on-airway point),
/// then fly the transition (with its airways) through to `to`. Anchoring on the
/// entry — not the exit gateway — means the enroute leg stops *before* the arrival
/// area, so the transition traverses it once (no self-loop → `ROUTE49`). `None` if
/// positions/routing don't resolve.
fn route_via_arrival_transition(
conn: &Connection,
from: &str,
@@ -614,28 +655,27 @@ fn route_via_arrival_transition(
dep_fixes: &[String],
avoid: &std::collections::HashSet<String>,
) -> Result<Option<Route>> {
// Anchor on the transition's LAST point — arrival transitions end at an
// on-airway gateway (e.g. KALMO), while the earlier points may be off-airway
// FRA fixes (e.g. KARDU) that can't be graph connectors. Route to the anchor,
// then splice the whole transition in via DCT ahead of it.
let Some(anchor) = transition.last() else { return Ok(None) };
let head = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, std::slice::from_ref(anchor), avoid)?;
let Some(idx) = head.legs.iter().position(|l| l.to.eq_ignore_ascii_case(anchor)) else {
// Entry = the first transition point that's an on-airway node (earlier
// off-airway FRA fixes are DCT'd in from the enroute point before it).
let anchor = transition
.iter()
.find(|p| !is_airwayish(p) && on_airway(conn, p))
.or_else(|| transition.iter().find(|p| !is_airwayish(p)))
.cloned();
let Some(anchor) = anchor else { return Ok(None) };
let head = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, std::slice::from_ref(&anchor), avoid)?;
let Some(idx) = head.legs.iter().position(|l| l.to.eq_ignore_ascii_case(&anchor)) else {
return Ok(None);
};
let dst = super::airport_pos(conn, to)?;
let x = head.legs[idx].from.clone(); // on-airway point just before the anchor
let mut prev = x.clone();
let mut prevpos = super::ident_pos(conn, &x, dst).unwrap_or(dst);
// Replace the `x → anchor` leg with `x → t0 → … → anchor` (all DCT).
let x = head.legs[idx].from.clone(); // enroute point just before the entry
let xpos = super::ident_pos(conn, &x, dst).unwrap_or(dst);
let mut legs: Vec<Leg> = head.legs[..idx].to_vec();
for p in transition {
let Some(pos) = super::ident_pos(conn, p, dst) else { return Ok(None) };
legs.push(Leg { from: prev.clone(), to: p.to_uppercase(), airway: "DCT".into(), dist_nm: prevpos.distance_nm(&pos) });
prev = p.to_uppercase();
prevpos = pos;
}
legs.extend_from_slice(&head.legs[idx + 1..]); // anchor → … → to
let Some((mid, lastp, lastpos)) = transition_legs(conn, &x, xpos, transition, dst) else {
return Ok(None);
};
legs.extend(mid); // x → t0 → … → tlast (with airways)
legs.push(Leg { from: lastp, to: to.to_uppercase(), airway: "DCT".into(), dist_nm: lastpos.distance_nm(&dst) });
let total = total_nm(&legs);
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
}
@@ -707,7 +747,7 @@ fn attempt_once(
if let Ok(dst) = super::airport_pos(conn, to) {
trans.sort_by(|a, b| {
let d = |s: &Vec<String>| {
s.last().and_then(|p| super::ident_pos(conn, p, dst)).map_or(f64::MAX, |p| p.distance_nm(&dst))
last_point(s).and_then(|p| super::ident_pos(conn, p, dst)).map_or(f64::MAX, |p| p.distance_nm(&dst))
};
d(a).total_cmp(&d(b))
});
@@ -730,9 +770,10 @@ fn attempt_once(
// this pushes the over-associated transitions that don't end near the
// destination to the back without a hard cut-off.
let est = |s: &Vec<String>| -> f64 {
let first = s.iter().find(|t| !is_airwayish(t));
match (
s.first().and_then(|p| super::ident_pos(conn, p, dep)),
s.last().and_then(|p| super::ident_pos(conn, p, dst)),
first.and_then(|p| super::ident_pos(conn, p, dep)),
last_point(s).and_then(|p| super::ident_pos(conn, p, dst)),
) {
(Some(f), Some(l)) => dep.distance_nm(&f) + f.distance_nm(&l) + l.distance_nm(&dst),
_ => f64::MAX,