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:
2026-08-23 15:58:58 +02:00
parent 3fd714eeb2
commit b4ef0107e9
6 changed files with 249 additions and 4 deletions
+1
View File
@@ -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");
+39
View File
@@ -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));
+61
View File
@@ -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;
+2 -2
View File
@@ -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],