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)
|
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
|
/// 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
|
/// DEP): fly `from` through the transition (with its airways, DCT to the first
|
||||||
/// from the last transition point to `to`. `None` if positions/routing don't
|
/// point), then the best route from the last transition point to `to`. `None` if
|
||||||
/// resolve. This is how we satisfy `PROF205 … ANNEX3A DEP … MANDATORY ROUTE`.
|
/// positions/routing don't resolve. Satisfies `PROF205 … ANNEX3A DEP … MANDATORY`.
|
||||||
fn route_via_transition(
|
fn route_via_transition(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
from: &str,
|
from: &str,
|
||||||
@@ -574,8 +621,7 @@ fn route_via_transition(
|
|||||||
dest_fixes: &[String],
|
dest_fixes: &[String],
|
||||||
avoid: &std::collections::HashSet<String>,
|
avoid: &std::collections::HashSet<String>,
|
||||||
) -> Result<Option<Route>> {
|
) -> Result<Option<Route>> {
|
||||||
let Some(last) = transition.last() else { return Ok(None) };
|
let Some(last) = last_point(transition) else { return Ok(None) };
|
||||||
// Route from `from` into the network at `last`, on to `to`.
|
|
||||||
let tail = super::plan_route_best_avoiding(conn, from, to, Some(fl), std::slice::from_ref(last), dest_fixes, avoid)?;
|
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 {
|
let Some(idx) = tail.legs.iter().position(|l| l.to.eq_ignore_ascii_case(last)) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
@@ -584,27 +630,22 @@ fn route_via_transition(
|
|||||||
if rest.is_empty() {
|
if rest.is_empty() {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
}
|
}
|
||||||
// Prefix: from → t0 → … → last as DCT legs (IFPS derives the SID to t0).
|
|
||||||
let dep = super::airport_pos(conn, from)?;
|
let dep = super::airport_pos(conn, from)?;
|
||||||
let mut pts: Vec<(String, crate::model::LatLon)> = vec![(from.to_uppercase(), dep)];
|
let dst = super::airport_pos(conn, to)?;
|
||||||
for p in transition {
|
let Some((mut legs, _, _)) = transition_legs(conn, from, dep, transition, dst) else {
|
||||||
let Some(pos) = super::ident_pos(conn, p, dep) else { return Ok(None) };
|
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 });
|
|
||||||
}
|
|
||||||
legs.extend(rest.iter().cloned());
|
legs.extend(rest.iter().cloned());
|
||||||
let total = total_nm(&legs);
|
let total = total_nm(&legs);
|
||||||
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a candidate that arrives via a mandatory transition sequence (Annex 2B):
|
/// 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
|
/// route from `from` to the transition's **entry** (its first on-airway point),
|
||||||
/// through the transition points to `to`. `None` if positions/routing don't
|
/// then fly the transition (with its airways) through to `to`. Anchoring on the
|
||||||
/// resolve. Satisfies `PROF205 … VIA <arrival pt> IS OFF MANDATORY ROUTE`.
|
/// 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(
|
fn route_via_arrival_transition(
|
||||||
conn: &Connection,
|
conn: &Connection,
|
||||||
from: &str,
|
from: &str,
|
||||||
@@ -614,28 +655,27 @@ fn route_via_arrival_transition(
|
|||||||
dep_fixes: &[String],
|
dep_fixes: &[String],
|
||||||
avoid: &std::collections::HashSet<String>,
|
avoid: &std::collections::HashSet<String>,
|
||||||
) -> Result<Option<Route>> {
|
) -> Result<Option<Route>> {
|
||||||
// Anchor on the transition's LAST point — arrival transitions end at an
|
// Entry = the first transition point that's an on-airway node (earlier
|
||||||
// on-airway gateway (e.g. KALMO), while the earlier points may be off-airway
|
// off-airway FRA fixes are DCT'd in from the enroute point before it).
|
||||||
// FRA fixes (e.g. KARDU) that can't be graph connectors. Route to the anchor,
|
let anchor = transition
|
||||||
// then splice the whole transition in via DCT ahead of it.
|
.iter()
|
||||||
let Some(anchor) = transition.last() else { return Ok(None) };
|
.find(|p| !is_airwayish(p) && on_airway(conn, p))
|
||||||
let head = super::plan_route_best_avoiding(conn, from, to, Some(fl), dep_fixes, std::slice::from_ref(anchor), avoid)?;
|
.or_else(|| transition.iter().find(|p| !is_airwayish(p)))
|
||||||
let Some(idx) = head.legs.iter().position(|l| l.to.eq_ignore_ascii_case(anchor)) else {
|
.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);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let dst = super::airport_pos(conn, to)?;
|
let dst = super::airport_pos(conn, to)?;
|
||||||
let x = head.legs[idx].from.clone(); // on-airway point just before the anchor
|
let x = head.legs[idx].from.clone(); // enroute point just before the entry
|
||||||
let mut prev = x.clone();
|
let xpos = super::ident_pos(conn, &x, dst).unwrap_or(dst);
|
||||||
let mut prevpos = super::ident_pos(conn, &x, dst).unwrap_or(dst);
|
|
||||||
// Replace the `x → anchor` leg with `x → t0 → … → anchor` (all DCT).
|
|
||||||
let mut legs: Vec<Leg> = head.legs[..idx].to_vec();
|
let mut legs: Vec<Leg> = head.legs[..idx].to_vec();
|
||||||
for p in transition {
|
let Some((mid, lastp, lastpos)) = transition_legs(conn, &x, xpos, transition, dst) else {
|
||||||
let Some(pos) = super::ident_pos(conn, p, dst) else { return Ok(None) };
|
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();
|
legs.extend(mid); // x → t0 → … → tlast (with airways)
|
||||||
prevpos = pos;
|
legs.push(Leg { from: lastp, to: to.to_uppercase(), airway: "DCT".into(), dist_nm: lastpos.distance_nm(&dst) });
|
||||||
}
|
|
||||||
legs.extend_from_slice(&head.legs[idx + 1..]); // anchor → … → to
|
|
||||||
let total = total_nm(&legs);
|
let total = total_nm(&legs);
|
||||||
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
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) {
|
if let Ok(dst) = super::airport_pos(conn, to) {
|
||||||
trans.sort_by(|a, b| {
|
trans.sort_by(|a, b| {
|
||||||
let d = |s: &Vec<String>| {
|
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))
|
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
|
// this pushes the over-associated transitions that don't end near the
|
||||||
// destination to the back without a hard cut-off.
|
// destination to the back without a hard cut-off.
|
||||||
let est = |s: &Vec<String>| -> f64 {
|
let est = |s: &Vec<String>| -> f64 {
|
||||||
|
let first = s.iter().find(|t| !is_airwayish(t));
|
||||||
match (
|
match (
|
||||||
s.first().and_then(|p| super::ident_pos(conn, p, dep)),
|
first.and_then(|p| super::ident_pos(conn, p, dep)),
|
||||||
s.last().and_then(|p| super::ident_pos(conn, p, dst)),
|
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),
|
(Some(f), Some(l)) => dep.distance_nm(&f) + f.distance_nm(&l) + l.distance_nm(&dst),
|
||||||
_ => f64::MAX,
|
_ => 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
|
/// `(A, B, …)` alternative group into one sequence per branch (`RANUX DCT VALEK
|
||||||
/// DCT (LUTAX, LIPNI)` → `[RANUX,VALEK,LUTAX]`, `[RANUX,VALEK,LIPNI]`).
|
/// DCT (LUTAX, LIPNI)` → `[RANUX,VALEK,LUTAX]`, `[RANUX,VALEK,LIPNI]`).
|
||||||
fn expand_via(inner: &str) -> Vec<Vec<String>> {
|
fn expand_via(inner: &str) -> Vec<Vec<String>> {
|
||||||
let points = |s: &str| -> Vec<String> {
|
// Keep points AND airway designators (in order); `DCT`/keywords are dropped so
|
||||||
s.split(|c: char| !c.is_ascii_alphanumeric()).filter(|t| is_point(t)).map(str::to_owned).collect()
|
// 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(')'))) {
|
if let (Some(op), Some(rel)) = (inner.find('('), inner.find('(').and_then(|o| inner[o..].find(')'))) {
|
||||||
let cp = inner.find('(').unwrap() + rel;
|
let cp = inner.find('(').unwrap() + rel;
|
||||||
let (before, group, after) = (&inner[..op], &inner[op + 1..cp], &inner[cp + 1..]);
|
let (before, group, after) = (&inner[..op], &inner[op + 1..cp], &inner[cp + 1..]);
|
||||||
let prefix = points(before);
|
let prefix = tokens(before);
|
||||||
let tail = points(after);
|
let tail = tokens(after);
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
for alt in group.split(',') {
|
for alt in group.split(',') {
|
||||||
let mut seq = prefix.clone();
|
let mut seq = prefix.clone();
|
||||||
seq.extend(points(alt));
|
seq.extend(tokens(alt));
|
||||||
seq.extend(tail.clone());
|
seq.extend(tail.clone());
|
||||||
if !seq.is_empty() {
|
if !seq.is_empty() {
|
||||||
out.push(seq);
|
out.push(seq);
|
||||||
@@ -240,10 +246,18 @@ fn expand_via(inner: &str) -> Vec<Vec<String>> {
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
let pts = points(inner);
|
let pts = tokens(inner);
|
||||||
if pts.is_empty() { Vec::new() } else { vec![pts] }
|
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).
|
/// An enroute point token (2–5 letters, not a RAD keyword or airway designator).
|
||||||
fn is_point(t: &str) -> bool {
|
fn is_point(t: &str) -> bool {
|
||||||
(2..=5).contains(&t.len())
|
(2..=5).contains(&t.len())
|
||||||
|
|||||||
Reference in New Issue
Block a user