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,
|
||||
|
||||
+20
-6
@@ -221,18 +221,24 @@ fn via_sequences(up: &str) -> Vec<Vec<String>> {
|
||||
/// `(A, B, …)` alternative group into one sequence per branch (`RANUX DCT VALEK
|
||||
/// DCT (LUTAX, LIPNI)` → `[RANUX,VALEK,LUTAX]`, `[RANUX,VALEK,LIPNI]`).
|
||||
fn expand_via(inner: &str) -> Vec<Vec<String>> {
|
||||
let points = |s: &str| -> Vec<String> {
|
||||
s.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| is_point(t)).map(str::to_owned).collect()
|
||||
// Keep points AND airway designators (in order); `DCT`/keywords are dropped so
|
||||
// consecutive points with no airway between them are an implicit DCT. This lets
|
||||
// the splice file `MOVUM T109 HAREM T104 WLD` with its airways, not all-DCT.
|
||||
let tokens = |s: &str| -> Vec<String> {
|
||||
s.split(|c: char| !c.is_ascii_alphanumeric())
|
||||
.filter(|t| is_point(t) || is_airway_tok(t))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
};
|
||||
if let (Some(op), Some(rel)) = (inner.find('('), inner.find('(').and_then(|o| inner[o..].find(')'))) {
|
||||
let cp = inner.find('(').unwrap() + rel;
|
||||
let (before, group, after) = (&inner[..op], &inner[op + 1..cp], &inner[cp + 1..]);
|
||||
let prefix = points(before);
|
||||
let tail = points(after);
|
||||
let prefix = tokens(before);
|
||||
let tail = tokens(after);
|
||||
let mut out = Vec::new();
|
||||
for alt in group.split(',') {
|
||||
let mut seq = prefix.clone();
|
||||
seq.extend(points(alt));
|
||||
seq.extend(tokens(alt));
|
||||
seq.extend(tail.clone());
|
||||
if !seq.is_empty() {
|
||||
out.push(seq);
|
||||
@@ -240,10 +246,18 @@ fn expand_via(inner: &str) -> Vec<Vec<String>> {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let pts = points(inner);
|
||||
let pts = tokens(inner);
|
||||
if pts.is_empty() { Vec::new() } else { vec![pts] }
|
||||
}
|
||||
|
||||
/// An airway designator token: starts with a letter and contains a digit (`N871`,
|
||||
/// `T109`, `UL607`, `Z66`). Distinguishes airways from points in a transition seq.
|
||||
fn is_airway_tok(t: &str) -> bool {
|
||||
(2..=6).contains(&t.len())
|
||||
&& t.chars().next().is_some_and(|c| c.is_ascii_alphabetic())
|
||||
&& t.chars().any(|c| c.is_ascii_digit())
|
||||
}
|
||||
|
||||
/// An enroute point token (2–5 letters, not a RAD keyword or airway designator).
|
||||
fn is_point(t: &str) -> bool {
|
||||
(2..=5).contains(&t.len())
|
||||
|
||||
Reference in New Issue
Block a user