feat(rad): FRA engine phase 2a — mandatory departure transitions
Parse Annex 3A DEP "COMPULSORY FOR TFC" rows into structured mandatory departure routings and satisfy PROF205 "… ANNEX3A DEP … MANDATORY ROUTE" by offering the allowed transitions as discovery candidates: - core::rad: MandatoryDep + RadData.mandatory_dep + mandatory_dep_transitions(adep) -> allowed point-sequences. - rad crate: parse_mandatory_dep() extracts the VIA(...) clauses, expands nested "(A, B)" alternatives into separate branches, and chains sub-fragments into full sequences from an entry point (46 rules; LFPG = 20 transitions incl. NURMO CMB VEKIN ADUTO, OPALE KESAX DIMAL ALESO). rad-tool `mandep [ADEP]` to inspect. - discover: for the departure airport, inject "mandep" candidates that fly the allowed transitions nearest the destination (additive — the oracle still picks best, so no regression risk). Cracks LFPG-EDDF (Paris-Frankfurt): coverage 5/10 -> 6/10 on the batch, no regression. 49 tests green. Phase 2b (mandatory arrivals) is next. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -392,6 +392,7 @@ mod tests {
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
forbidden_segs: vec![],
|
||||
mandatory_dep: vec![],
|
||||
};
|
||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
||||
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
||||
|
||||
@@ -142,6 +142,20 @@ pub struct ForbiddenSeg {
|
||||
pub dep: Vec<String>,
|
||||
}
|
||||
|
||||
/// A **mandatory departure routing** (Annex 3A DEP, `COMPULSORY FOR TFC`):
|
||||
/// traffic from `airports` must leave via one of the `allowed` transition
|
||||
/// point-sequences (each an ordered list of enroute points starting at an entry
|
||||
/// point). The structured form of `PROF205 … ANNEX3A DEP … IS OFF MANDATORY
|
||||
/// ROUTE`. Conditional/ARR-gated sub-options are flattened in v1.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MandatoryDep {
|
||||
pub id: String,
|
||||
/// Departure airports/groups the rule applies to (Annex-1 groups expandable).
|
||||
pub airports: Vec<String>,
|
||||
/// Allowed transition point-sequences, e.g. `[OPALE,KESAX,DIMAL,ALESO]`.
|
||||
pub allowed: Vec<Vec<String>>,
|
||||
}
|
||||
|
||||
/// The parsed RAD (the parts we currently model).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RadData {
|
||||
@@ -159,6 +173,9 @@ pub struct RadData {
|
||||
/// Forbidden airway segments (Annex 2B); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub forbidden_segs: Vec<ForbiddenSeg>,
|
||||
/// Mandatory departure routings (Annex 3A DEP COMPULSORY); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub mandatory_dep: Vec<MandatoryDep>,
|
||||
}
|
||||
|
||||
impl RadData {
|
||||
@@ -238,6 +255,26 @@ impl RadData {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Allowed mandatory departure transition point-sequences for `adep` (Annex 3A
|
||||
/// DEP). Empty ⇒ no mandatory departure routing on record. Group names in a
|
||||
/// rule's `airports` are expanded via Annex-1 areas.
|
||||
pub fn mandatory_dep_transitions(&self, adep: &str) -> Vec<Vec<String>> {
|
||||
let applies = |set: &[String]| -> bool {
|
||||
set.iter().any(|s| {
|
||||
s.eq_ignore_ascii_case(adep)
|
||||
|| self.areas.iter().any(|a| {
|
||||
a.id.eq_ignore_ascii_case(s)
|
||||
&& a.airports.iter().any(|ap| ap.eq_ignore_ascii_case(adep))
|
||||
})
|
||||
})
|
||||
};
|
||||
self.mandatory_dep
|
||||
.iter()
|
||||
.filter(|m| applies(&m.airports))
|
||||
.flat_map(|m| m.allowed.iter().cloned())
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Count of restrictions by kind — for summaries/status.
|
||||
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
||||
let mut forbidden = 0;
|
||||
@@ -281,6 +318,7 @@ mod tests {
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
forbidden_segs: vec![],
|
||||
mandatory_dep: vec![],
|
||||
};
|
||||
assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some());
|
||||
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
|
||||
@@ -312,6 +350,7 @@ mod tests {
|
||||
],
|
||||
fra_points: vec![],
|
||||
forbidden_segs: vec![],
|
||||
mandatory_dep: vec![],
|
||||
};
|
||||
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
||||
|
||||
@@ -561,6 +561,46 @@ pub fn find_valid_route(
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
fn route_via_transition(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
transition: &[String],
|
||||
fl: i32,
|
||||
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 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);
|
||||
};
|
||||
let rest = &tail.legs[idx + 1..];
|
||||
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 });
|
||||
}
|
||||
legs.extend(rest.iter().cloned());
|
||||
let total = total_nm(&legs);
|
||||
Ok(Some(Route { legs, total_nm: total, via_airways: true }))
|
||||
}
|
||||
|
||||
/// One discovery attempt with a fixed set of allowed gateways: generate the seed
|
||||
/// / FRA / airway candidates, start the repair loop from whichever the oracle
|
||||
/// rates best.
|
||||
@@ -620,6 +660,27 @@ fn attempt_once(
|
||||
if let Ok(r) = super::plan_route_best_avoiding(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes, &avoid) {
|
||||
candidates.push(("airway", r));
|
||||
}
|
||||
// Mandatory departure transitions (Annex 3A DEP): when the departure airport
|
||||
// has a compulsory routing, offer candidates that depart via the allowed
|
||||
// transitions nearest the destination. Additive — the oracle still picks best.
|
||||
if let Some(rad) = rad {
|
||||
let mut trans = rad.mandatory_dep_transitions(from);
|
||||
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))
|
||||
};
|
||||
d(a).total_cmp(&d(b))
|
||||
});
|
||||
}
|
||||
for t in trans.iter().take(3) {
|
||||
if let Ok(Some(r)) = route_via_transition(conn, from, to, t, start_fl, &dest_fixes, &avoid) {
|
||||
if r.legs.len() >= 2 {
|
||||
candidates.push(("mandep", r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut pre_log: Vec<String> = Vec::new();
|
||||
let mut chosen: Option<(Route, IfpsVerdict)> = None;
|
||||
|
||||
@@ -340,7 +340,7 @@ fn is_airway(t: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Position of `ident` (waypoint or navaid), nearest `near` when ambiguous.
|
||||
fn ident_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
pub fn ident_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
@@ -479,7 +479,7 @@ fn direct_route(from: &str, to: &str, dep: LatLon, dst: LatLon) -> Route {
|
||||
}
|
||||
}
|
||||
|
||||
fn airport_pos(conn: &Connection, icao: &str) -> Result<LatLon> {
|
||||
pub fn airport_pos(conn: &Connection, icao: &str) -> Result<LatLon> {
|
||||
conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![icao],
|
||||
|
||||
+125
-1
@@ -10,7 +10,7 @@ use calamine::{open_workbook, Data, Reader, Xlsx};
|
||||
// The RAD data model lives in `core` (so routing/validation can use it without
|
||||
// pulling in `calamine`); this crate produces those types.
|
||||
pub use flightplanner_core::rad::{
|
||||
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, RadData,
|
||||
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, MandatoryDep, RadData,
|
||||
};
|
||||
|
||||
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||
@@ -118,9 +118,133 @@ pub fn parse(path: &str) -> Result<RadData> {
|
||||
level_caps: parse_level_caps(path)?,
|
||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
||||
forbidden_segs: parse_forbidden_segments(path)?,
|
||||
mandatory_dep: parse_mandatory_dep(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Annex 3A DEP — mandatory departure routings (`COMPULSORY FOR TFC`). Extracts
|
||||
/// the allowed transition point-sequences from the `VIA (...)` clauses, chaining
|
||||
/// sub-fragments (`d. VIA (OPALE DCT KESAX)` + `ii) VIA (KESAX DCT DIMAL DCT
|
||||
/// ALESO)` → `OPALE KESAX DIMAL ALESO`) so each result is a full sequence from an
|
||||
/// entry point (col 5).
|
||||
pub fn parse_mandatory_dep(path: &str) -> Result<Vec<MandatoryDep>> {
|
||||
let Ok(rows) = rows(path, "Annex 3A DEP") else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows.iter().skip(1) {
|
||||
let up = cell(r, 7).to_uppercase().replace(['\r'], " ");
|
||||
if !up.contains("COMPULSORY FOR TFC") {
|
||||
continue;
|
||||
}
|
||||
let airports = parse_idents(&cell(r, 4));
|
||||
if airports.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let entry = parse_idents(&cell(r, 5));
|
||||
let allowed = chain_transitions(&entry, &via_sequences(&up));
|
||||
if allowed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
out.push(MandatoryDep { id: cell(r, 3), airports, allowed });
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// The point-sequences inside every `VIA (...)` clause (balanced parens), dropping
|
||||
/// `DCT`/airways/keywords — just the ordered enroute points.
|
||||
fn via_sequences(up: &str) -> Vec<Vec<String>> {
|
||||
let bytes = up.as_bytes();
|
||||
let mut out = Vec::new();
|
||||
let mut i = 0;
|
||||
while let Some(p) = up[i..].find("VIA (") {
|
||||
let start = i + p + 5;
|
||||
let mut depth = 1;
|
||||
let mut j = start;
|
||||
while j < bytes.len() && depth > 0 {
|
||||
match bytes[j] {
|
||||
b'(' => depth += 1,
|
||||
b')' => depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
j += 1;
|
||||
}
|
||||
let inner = &up[start..j.saturating_sub(1)];
|
||||
out.extend(expand_via(inner));
|
||||
i = j;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Point-sequences from a `VIA (...)` inner string, expanding a nested
|
||||
/// `(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()
|
||||
};
|
||||
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 mut out = Vec::new();
|
||||
for alt in group.split(',') {
|
||||
let mut seq = prefix.clone();
|
||||
seq.extend(points(alt));
|
||||
seq.extend(tail.clone());
|
||||
if !seq.is_empty() {
|
||||
out.push(seq);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let pts = points(inner);
|
||||
if pts.is_empty() { Vec::new() } else { vec![pts] }
|
||||
}
|
||||
|
||||
/// 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())
|
||||
&& t.chars().all(|c| c.is_ascii_alphabetic())
|
||||
&& !matches!(
|
||||
t,
|
||||
"DCT" | "VIA" | "EXC" | "ARR" | "DEP" | "AND" | "THEN" | "RFL" | "ABV" | "BLW"
|
||||
| "FL" | "NM" | "TFC" | "FOR" | "NOT" | "AVBL" | "ONLY" | "ACT" | "EVEN" | "ODD"
|
||||
)
|
||||
}
|
||||
|
||||
/// Build full transition sequences: keep sequences that start at an entry point,
|
||||
/// then repeatedly append fragments whose first point continues a sequence's last.
|
||||
fn chain_transitions(entry: &[String], seqs: &[Vec<String>]) -> Vec<Vec<String>> {
|
||||
let is_entry = |p: &str| entry.iter().any(|e| e.eq_ignore_ascii_case(p));
|
||||
let mut result: Vec<Vec<String>> =
|
||||
seqs.iter().filter(|s| s.first().is_some_and(|f| is_entry(f))).cloned().collect();
|
||||
let fragments: Vec<&Vec<String>> =
|
||||
seqs.iter().filter(|s| s.first().is_some_and(|f| !is_entry(f))).collect();
|
||||
let mut guard = 0;
|
||||
loop {
|
||||
guard += 1;
|
||||
let mut added = false;
|
||||
for frag in &fragments {
|
||||
for base in result.clone() {
|
||||
if base.last().is_some_and(|l| l.eq_ignore_ascii_case(&frag[0])) {
|
||||
let mut ext = base.clone();
|
||||
ext.extend(frag[1..].iter().cloned());
|
||||
if !result.contains(&ext) {
|
||||
result.push(ext);
|
||||
added = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !added || guard >= 4 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
/// Annex 2B — forbidden airway segments. Rows with Airway/From/To (cols 4/5/6)
|
||||
/// filled and a `NOT AVBL FOR TFC` utilization are structured segment bans (the
|
||||
/// source of `PROF204 … IS ON FORBIDDEN ROUTE`). We capture the FL band
|
||||
|
||||
+21
-1
@@ -166,7 +166,27 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL]"),
|
||||
Some("mandep") => {
|
||||
// mandep [ADEP] — mandatory departure transitions (Annex 3A DEP).
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Mandatory departure routings (Annex 3A DEP): {}", rad.mandatory_dep.len());
|
||||
match args.get(2) {
|
||||
Some(a) => {
|
||||
let a = a.to_uppercase();
|
||||
let seqs = rad.mandatory_dep_transitions(&a);
|
||||
println!("{a}: {} allowed transition sequences", seqs.len());
|
||||
for s in seqs.iter().take(40) {
|
||||
println!(" {}", s.join(" "));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for m in rad.mandatory_dep.iter().take(6) {
|
||||
println!(" {} {:?} → {} seqs (e.g. {:?})", m.id, m.airports, m.allowed.len(), m.allowed.first());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL] | mandep [ADEP]"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user