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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user