diff --git a/crates/core/src/ifps.rs b/crates/core/src/ifps.rs index 91f20f0..38291d0 100644 --- a/crates/core/src/ifps.rs +++ b/crates/core/src/ifps.rs @@ -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"); diff --git a/crates/core/src/rad.rs b/crates/core/src/rad.rs index ea20ef6..38bedf8 100644 --- a/crates/core/src/rad.rs +++ b/crates/core/src/rad.rs @@ -142,6 +142,20 @@ pub struct ForbiddenSeg { pub dep: Vec, } +/// 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, + /// Allowed transition point-sequences, e.g. `[OPALE,KESAX,DIMAL,ALESO]`. + pub allowed: Vec>, +} + /// 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, + /// Mandatory departure routings (Annex 3A DEP COMPULSORY); empty if not parsed. + #[serde(default)] + pub mandatory_dep: Vec, } 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> { + 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)); diff --git a/crates/core/src/routing/discover.rs b/crates/core/src/routing/discover.rs index ee07fe7..9ca0520 100644 --- a/crates/core/src/routing/discover.rs +++ b/crates/core/src/routing/discover.rs @@ -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, +) -> Result> { + 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 = 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| { + 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 = Vec::new(); let mut chosen: Option<(Route, IfpsVerdict)> = None; diff --git a/crates/core/src/routing/mod.rs b/crates/core/src/routing/mod.rs index a0a425a..69655f3 100644 --- a/crates/core/src/routing/mod.rs +++ b/crates/core/src/routing/mod.rs @@ -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 { +pub fn ident_pos(conn: &Connection, ident: &str, near: LatLon) -> Option { let mut cands: Vec = 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 { +pub fn airport_pos(conn: &Connection, icao: &str) -> Result { conn.query_row( "SELECT lat, lon FROM airports WHERE icao = ?1", params![icao], diff --git a/crates/rad/src/lib.rs b/crates/rad/src/lib.rs index e313341..fd09a57 100644 --- a/crates/rad/src/lib.rs +++ b/crates/rad/src/lib.rs @@ -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 { 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> { + 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> { + 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> { + let points = |s: &str| -> Vec { + 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]) -> Vec> { + let is_entry = |p: &str| entry.iter().any(|e| e.eq_ignore_ascii_case(p)); + let mut result: Vec> = + seqs.iter().filter(|s| s.first().is_some_and(|f| is_entry(f))).cloned().collect(); + let fragments: Vec<&Vec> = + 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 diff --git a/crates/rad/src/main.rs b/crates/rad/src/main.rs index 611a88f..b104018 100644 --- a/crates/rad/src/main.rs +++ b/crates/rad/src/main.rs @@ -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(()) }