feat(rad): FRA engine phase 1 — Annex-2B forbidden-segment avoidance

Parse the 1135 forbidden airway segments in RAD Annex 2B (rows with
Airway/From/To + "NOT AVBL FOR TFC") into structured ForbiddenSeg records
(FL band + arrival/departure applicability), and route around the ones
that apply to a flight from the start:

- core::rad: ForbiddenSeg + RadData.forbidden_segs + forbidden_segments(
  adep, ades, fl) -> applicable (airway,from,to) triples.
- rad crate: parse_forbidden_segments() wired into parse(); rad-tool
  `forbidden [ADEP ADES FL]` to inspect.
- graph: seg_key() encodes a segment ban into the existing avoid channel;
  build_avoiding skips those segments. discover seeds the avoid set with
  the flight's Annex-2B bans before generating the airway candidate.

Measured: no regression (coverage 4/10 unchanged on the batch) — this is
the *forbidden* half. The coverage mover is mandatory routing (PROF205:
"must route via X"), which pure avoidance can't provide; that's phase 2.
49 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-08-22 11:13:54 +02:00
parent 927b7eeea7
commit 62cee15549
6 changed files with 205 additions and 9 deletions
+1
View File
@@ -391,6 +391,7 @@ mod tests {
fra_edges: vec![],
level_caps: vec![],
fra_points: vec![],
forbidden_segs: vec![],
};
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
assert!(!r.accepted, "RAD should reject the forbidden direct");
+61
View File
@@ -121,6 +121,27 @@ pub struct LevelCap {
pub cap_fl: Option<i32>,
}
/// A **forbidden airway segment** (Annex 2B): traffic may not use `airway`
/// between `from` and `to` within the FL band, for the listed arrivals/departures.
/// `arr`/`dep` are airport idents (`LFST`) or ICAO prefixes (`ED`, `LF`); both
/// empty ⇒ the segment is forbidden for all traffic. This is the structured form
/// of the `PROF204 … IS ON FORBIDDEN ROUTE` restrictions.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ForbiddenSeg {
pub id: String,
pub airway: String,
pub from: String,
pub to: String,
/// Forbidden at/above this FL ("ABV FLxxx"); `None` ⇒ no lower bound.
pub min_fl: Option<i32>,
/// Forbidden at/below this FL ("BLW FLxxx"); `None` ⇒ no upper bound.
pub max_fl: Option<i32>,
/// Arrival airports/prefixes the ban applies to (empty ⇒ any).
pub arr: Vec<String>,
/// Departure airports/prefixes the ban applies to (empty ⇒ any).
pub dep: Vec<String>,
}
/// The parsed RAD (the parts we currently model).
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct RadData {
@@ -135,6 +156,9 @@ pub struct RadData {
/// Official EUROCONTROL FRA significant points (separate file); empty if not loaded.
#[serde(default)]
pub fra_points: Vec<FraPoint>,
/// Forbidden airway segments (Annex 2B); empty if not parsed.
#[serde(default)]
pub forbidden_segs: Vec<ForbiddenSeg>,
}
impl RadData {
@@ -179,6 +203,41 @@ impl RadData {
.min()
}
/// Forbidden airway segments (Annex 2B) applicable to a flight `adep`→`ades`
/// at `fl`: the set of `(AIRWAY, FROM, TO)` triples (uppercase) the router must
/// not use. A segment applies when `fl` is in its band **and** it is either
/// unconditional or matches this flight's arrival or departure.
pub fn forbidden_segments(
&self,
adep: &str,
ades: &str,
fl: i32,
) -> std::collections::HashSet<(String, String, String)> {
// A filter token matches an ICAO by exact 4-letter code or by country/FIR
// prefix (e.g. "ED" matches EDDM); "ED**" is stored as "ED".
let apt_match = |apt: &str, filters: &[String]| -> bool {
let apt = apt.to_uppercase();
filters.iter().any(|f| {
let f = f.trim_end_matches('*').to_uppercase();
if f.len() >= 4 {
apt == f
} else {
!f.is_empty() && apt.starts_with(&f)
}
})
};
self.forbidden_segs
.iter()
.filter(|s| s.min_fl.map_or(true, |m| fl >= m) && s.max_fl.map_or(true, |m| fl <= m))
.filter(|s| {
(s.arr.is_empty() && s.dep.is_empty())
|| apt_match(ades, &s.arr)
|| apt_match(adep, &s.dep)
})
.map(|s| (s.airway.to_uppercase(), s.from.to_uppercase(), s.to.to_uppercase()))
.collect()
}
/// Count of restrictions by kind — for summaries/status.
pub fn dct_counts(&self) -> (usize, usize, usize) {
let mut forbidden = 0;
@@ -221,6 +280,7 @@ mod tests {
fra_edges: vec![],
level_caps: vec![],
fra_points: vec![],
forbidden_segs: vec![],
};
assert!(rad.forbidden_dct("ABC", "DEF", 200).is_some());
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
@@ -251,6 +311,7 @@ mod tests {
LevelCap { id: "R3".into(), from: vec!["EGLL".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(200) },
],
fra_points: vec![],
forbidden_segs: vec![],
};
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
+22 -7
View File
@@ -544,6 +544,20 @@ fn attempt_once(
) -> Result<DiscoverResult> {
let dep_fixes: Vec<String> = dep_sid.iter().map(|(_, f)| f.clone()).collect();
let dest_fixes: Vec<String> = dest_star.iter().map(|(_, f)| f.clone()).collect();
// Seed the avoid set with the RAD-forbidden airway segments (Annex 2B) that
// apply to this flight, so the airway candidate routes around them from the
// start (proactive, before the oracle would flag PROF204). Both orderings are
// inserted since a ban is direction-insensitive.
let mut avoid: HashSet<String> = rad
.map(|r| {
r.forbidden_segments(from, to, start_fl)
.into_iter()
.flat_map(|(awy, f, t)| {
[super::graph::seg_key(&awy, &f, &t), super::graph::seg_key(&awy, &t, &f)]
})
.collect()
})
.unwrap_or_default();
let mut candidates: Vec<(&str, Route)> = Vec::new();
// Official FRA-points corridor first — routes the *right* points (dep/arr/
// intermediate roles + level availability) from the EUROCONTROL FRA list.
@@ -570,7 +584,7 @@ fn attempt_once(
if let Ok(r) = plan_preferred(conn, from, to, dep_sid, dest_star, Some(start_fl), rad) {
candidates.push(("fra", r));
}
if let Ok(r) = super::plan_route_best(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes) {
if let Ok(r) = super::plan_route_best_avoiding(conn, from, to, Some(start_fl), &dep_fixes, &dest_fixes, &avoid) {
candidates.push(("airway", r));
}
@@ -591,7 +605,7 @@ fn attempt_once(
let (initial, verdict) =
chosen.ok_or_else(|| crate::error::CoreError::Routing("no route candidate".into()))?;
let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, validator)?;
let mut res = run_loop(conn, from, to, &dep_fixes, &dest_fixes, initial, verdict, start_fl, std::mem::take(&mut avoid), validator)?;
pre_log.append(&mut res.log);
res.log = pre_log;
Ok(res)
@@ -629,6 +643,7 @@ pub fn run_loop(
initial: Route,
initial_verdict: IfpsVerdict,
start_fl: i32,
mut avoid: std::collections::HashSet<String>,
validator: &dyn IfpsValidator,
) -> Result<DiscoverResult> {
let mut cur = initial;
@@ -637,9 +652,9 @@ pub fn run_loop(
let mut log: Vec<String> = Vec::new();
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
let mut iterations = 0;
// RAD-forbidden points/airways excluded from the graph, accumulated across
// iterations from PROF204 messages (see repair step 3 in `apply_repairs`).
let mut avoid: std::collections::HashSet<String> = std::collections::HashSet::new();
// RAD-forbidden points/airways/segments excluded from the graph. Seeded with
// this flight's Annex-2B segment bans and accumulated across iterations from
// PROF204 messages (see repair step 1 in `apply_repairs`).
// A repair can trade one error class for another (e.g. excluding a forbidden
// Swiss departure point forces a temporarily worse route before it resolves),
@@ -824,7 +839,7 @@ mod tests {
errors: vec![err("X1", "a"), err("X2", "b"), err("X3", "c")],
}]),
};
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap();
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, HashSet::new(), &mock).unwrap();
assert!(!r.accepted);
assert_eq!(r.fl, 360, "reverted to the pre-fix level");
assert_eq!(r.errors.len(), 1, "best (fewest-error) state is kept");
@@ -835,7 +850,7 @@ mod tests {
let conn = Connection::open_in_memory().unwrap();
let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "LF:F245..F999 IS ON FORBIDDEN ROUTE")] };
let mock = Mock { script: RefCell::new(vec![IfpsVerdict { accepted: true, errors: vec![] }]) };
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &mock).unwrap();
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, HashSet::new(), &mock).unwrap();
assert!(r.accepted, "log: {:?}", r.log);
assert_eq!(r.fl, 240, "lowered below the 245 cap");
}
+19
View File
@@ -8,6 +8,17 @@ use rusqlite::Connection;
use crate::error::Result;
use crate::model::LatLon;
/// Marker prefix for a forbidden-segment key in the `avoid` set (a control char
/// that never appears in an ident/airway), so one `HashSet<String>` carries both
/// point/airway idents and specific `(airway, from, to)` segment bans.
const SEG_PREFIX: &str = "\u{1}SEG";
/// Encode a forbidden airway segment as an `avoid`-set key. Callers insert both
/// orderings to make the ban direction-insensitive.
pub fn seg_key(airway: &str, from: &str, to: &str) -> String {
format!("{SEG_PREFIX}\u{1}{airway}\u{1}{from}\u{1}{to}")
}
/// A routable point (airway endpoint) in the graph.
#[derive(Debug, Clone)]
pub struct NodeData {
@@ -74,11 +85,19 @@ impl RouteGraph {
))
})?;
// Are any forbidden-segment keys present? (Avoids building the SEG key
// string for every one of the ~120k segments when there are none.)
let has_segs = avoid.iter().any(|k| k.starts_with(SEG_PREFIX));
for row in rows {
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?;
if avoid.contains(&fi) || avoid.contains(&ti) || avoid.contains(&awy) {
continue;
}
if has_segs
&& (avoid.contains(&seg_key(&awy, &fi, &ti)) || avoid.contains(&seg_key(&awy, &ti, &fi)))
{
continue; // RAD-forbidden airway segment (Annex 2B)
}
if let Some(fl) = cruise_fl {
if !fl_in_band(fl, base_fl, top_fl) {
continue;