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:
+77
-1
@@ -9,7 +9,9 @@ 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, FraEdge, FraPoint, LevelCap, RadData};
|
||||
pub use flightplanner_core::rad::{
|
||||
Area, DctKind, DctRestriction, ForbiddenSeg, FraEdge, FraPoint, LevelCap, RadData,
|
||||
};
|
||||
|
||||
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||
/// `fra-points-official` note). Sheet `"FRA Points"`, one row per point.
|
||||
@@ -115,9 +117,83 @@ pub fn parse(path: &str) -> Result<RadData> {
|
||||
fra_edges: parse_fra_edges(path)?,
|
||||
level_caps: parse_level_caps(path)?,
|
||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
||||
forbidden_segs: parse_forbidden_segments(path)?,
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// (`ABV/BLW FLxxx`) and the arrival/departure airports/prefixes the ban targets.
|
||||
pub fn parse_forbidden_segments(path: &str) -> Result<Vec<ForbiddenSeg>> {
|
||||
let mut out = Vec::new();
|
||||
let Ok(rows) = rows(path, "Annex 2B") else {
|
||||
return Ok(out);
|
||||
};
|
||||
for r in rows.iter().skip(1) {
|
||||
let airway = cell(r, 4);
|
||||
let from = cell(r, 5);
|
||||
let to = cell(r, 6);
|
||||
if airway.is_empty() || from.is_empty() || to.is_empty() {
|
||||
continue; // point/airspace restriction, not a segment ban — skip in v1
|
||||
}
|
||||
let util = cell(r, 8).to_uppercase().replace(['\n', '\t'], " ");
|
||||
if !util.contains("NOT AVBL FOR TFC") {
|
||||
continue; // ONLY-AVBL / COMPULSORY handled elsewhere
|
||||
}
|
||||
let (min_fl, max_fl) = fl_band(&util);
|
||||
out.push(ForbiddenSeg {
|
||||
id: cell(r, 3),
|
||||
airway,
|
||||
from,
|
||||
to,
|
||||
min_fl,
|
||||
max_fl,
|
||||
arr: apt_after(&util, "ARR"),
|
||||
dep: apt_after(&util, "DEP"),
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// `(min_fl, max_fl)` from "ABV FLxxx" / "BLW FLxxx" in a utilization string.
|
||||
fn fl_band(up: &str) -> (Option<i32>, Option<i32>) {
|
||||
let grab = |kw: &str| -> Option<i32> {
|
||||
let p = up.find(kw)?;
|
||||
let after = up[p + kw.len()..].trim_start().strip_prefix("FL").map(str::trim_start);
|
||||
let after = after?;
|
||||
let num: String = after.chars().take_while(char::is_ascii_digit).collect();
|
||||
num.parse().ok()
|
||||
};
|
||||
(grab("ABV "), grab("BLW "))
|
||||
}
|
||||
|
||||
/// Airport idents / country prefixes named right after `kw` ("ARR"/"DEP"), e.g.
|
||||
/// `ARR LFST` → ["LFST"], `ARR (ED** EXC …)` → ["ED"] (the EXC list is ignored in
|
||||
/// v1, over-applying the ban slightly — conservative). Stops at the first
|
||||
/// non-airport token (EXC/VIA/AT/&/number).
|
||||
fn apt_after(up: &str, kw: &str) -> Vec<String> {
|
||||
let needle = format!("{kw} ");
|
||||
let mut out = Vec::new();
|
||||
let mut rest = up;
|
||||
while let Some(p) = rest.find(&needle) {
|
||||
rest = &rest[p + needle.len()..];
|
||||
for raw in rest.split_whitespace() {
|
||||
let base = raw
|
||||
.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '*')
|
||||
.trim_end_matches('*');
|
||||
if (base.len() == 2 || base.len() == 4) && base.chars().all(|c| c.is_ascii_alphabetic()) {
|
||||
out.push(base.to_string());
|
||||
} else {
|
||||
break; // EXC / VIA / AT / number / … → end of this ARR|DEP clause
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out
|
||||
}
|
||||
|
||||
/// Annex 2A — city-pair flight-level caps.
|
||||
pub fn parse_level_caps(path: &str) -> Result<Vec<LevelCap>> {
|
||||
let rows = rows(path, "Annex 2A")?;
|
||||
|
||||
+25
-1
@@ -142,7 +142,31 @@ fn main() -> Result<()> {
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT]"),
|
||||
Some("forbidden") => {
|
||||
// forbidden [ADEP ADES FL] — total forbidden segments, or those that
|
||||
// apply to a given flight.
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Forbidden airway segments (Annex 2B): {}", rad.forbidden_segs.len());
|
||||
let with_band = rad.forbidden_segs.iter().filter(|s| s.min_fl.is_some() || s.max_fl.is_some()).count();
|
||||
let unconditional = rad.forbidden_segs.iter().filter(|s| s.arr.is_empty() && s.dep.is_empty()).count();
|
||||
println!(" with FL band: {with_band} unconditional (no ARR/DEP): {unconditional}");
|
||||
match (args.get(2), args.get(3), args.get(4)) {
|
||||
(Some(a), Some(b), Some(fl)) => {
|
||||
let fl: i32 = fl.parse().unwrap_or(360);
|
||||
let segs = rad.forbidden_segments(&a.to_uppercase(), &b.to_uppercase(), fl);
|
||||
println!("{a}->{b} @FL{fl}: {} applicable forbidden segments", segs.len());
|
||||
for (awy, f, t) in segs.iter().take(30) {
|
||||
println!(" {f} {awy} {t}");
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for s in rad.forbidden_segs.iter().take(8) {
|
||||
println!(" {} {} {} {} FL[{:?}..{:?}] arr={:?} dep={:?}", s.id, s.from, s.airway, s.to, s.min_fl, s.max_fl, s.arr, s.dep);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT] | forbidden [ADEP ADES FL]"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user