diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index d73f5f9..0fe4965 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -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, 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, ) -> Result> { - 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 = 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 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, ) -> Result> { - // 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 = 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| { - 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| -> 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, diff --git a/crates/rad/src/lib.rs b/crates/rad/src/lib.rs index 139fd0b..f5df518 100644 --- a/crates/rad/src/lib.rs +++ b/crates/rad/src/lib.rs @@ -221,18 +221,24 @@ fn via_sequences(up: &str) -> Vec> { /// `(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> { - let points = |s: &str| -> Vec { - 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 { + 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> { } 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())