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:
@@ -391,6 +391,7 @@ mod tests {
|
|||||||
fra_edges: vec![],
|
fra_edges: vec![],
|
||||||
level_caps: vec![],
|
level_caps: vec![],
|
||||||
fra_points: vec![],
|
fra_points: vec![],
|
||||||
|
forbidden_segs: vec![],
|
||||||
};
|
};
|
||||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
||||||
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
||||||
|
|||||||
@@ -121,6 +121,27 @@ pub struct LevelCap {
|
|||||||
pub cap_fl: Option<i32>,
|
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).
|
/// The parsed RAD (the parts we currently model).
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct RadData {
|
pub struct RadData {
|
||||||
@@ -135,6 +156,9 @@ pub struct RadData {
|
|||||||
/// Official EUROCONTROL FRA significant points (separate file); empty if not loaded.
|
/// Official EUROCONTROL FRA significant points (separate file); empty if not loaded.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub fra_points: Vec<FraPoint>,
|
pub fra_points: Vec<FraPoint>,
|
||||||
|
/// Forbidden airway segments (Annex 2B); empty if not parsed.
|
||||||
|
#[serde(default)]
|
||||||
|
pub forbidden_segs: Vec<ForbiddenSeg>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RadData {
|
impl RadData {
|
||||||
@@ -179,6 +203,41 @@ impl RadData {
|
|||||||
.min()
|
.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.
|
/// Count of restrictions by kind — for summaries/status.
|
||||||
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
||||||
let mut forbidden = 0;
|
let mut forbidden = 0;
|
||||||
@@ -221,6 +280,7 @@ mod tests {
|
|||||||
fra_edges: vec![],
|
fra_edges: vec![],
|
||||||
level_caps: vec![],
|
level_caps: vec![],
|
||||||
fra_points: 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());
|
||||||
assert!(rad.forbidden_dct("abc", "def", 200).is_some()); // case-insensitive
|
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) },
|
LevelCap { id: "R3".into(), from: vec!["EGLL".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(200) },
|
||||||
],
|
],
|
||||||
fra_points: vec![],
|
fra_points: vec![],
|
||||||
|
forbidden_segs: vec![],
|
||||||
};
|
};
|
||||||
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
||||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
||||||
|
|||||||
@@ -544,6 +544,20 @@ fn attempt_once(
|
|||||||
) -> Result<DiscoverResult> {
|
) -> Result<DiscoverResult> {
|
||||||
let dep_fixes: Vec<String> = dep_sid.iter().map(|(_, f)| f.clone()).collect();
|
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();
|
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();
|
let mut candidates: Vec<(&str, Route)> = Vec::new();
|
||||||
// Official FRA-points corridor first — routes the *right* points (dep/arr/
|
// Official FRA-points corridor first — routes the *right* points (dep/arr/
|
||||||
// intermediate roles + level availability) from the EUROCONTROL FRA list.
|
// 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) {
|
if let Ok(r) = plan_preferred(conn, from, to, dep_sid, dest_star, Some(start_fl), rad) {
|
||||||
candidates.push(("fra", r));
|
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));
|
candidates.push(("airway", r));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -591,7 +605,7 @@ fn attempt_once(
|
|||||||
let (initial, verdict) =
|
let (initial, verdict) =
|
||||||
chosen.ok_or_else(|| crate::error::CoreError::Routing("no route candidate".into()))?;
|
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);
|
pre_log.append(&mut res.log);
|
||||||
res.log = pre_log;
|
res.log = pre_log;
|
||||||
Ok(res)
|
Ok(res)
|
||||||
@@ -629,6 +643,7 @@ pub fn run_loop(
|
|||||||
initial: Route,
|
initial: Route,
|
||||||
initial_verdict: IfpsVerdict,
|
initial_verdict: IfpsVerdict,
|
||||||
start_fl: i32,
|
start_fl: i32,
|
||||||
|
mut avoid: std::collections::HashSet<String>,
|
||||||
validator: &dyn IfpsValidator,
|
validator: &dyn IfpsValidator,
|
||||||
) -> Result<DiscoverResult> {
|
) -> Result<DiscoverResult> {
|
||||||
let mut cur = initial;
|
let mut cur = initial;
|
||||||
@@ -637,9 +652,9 @@ pub fn run_loop(
|
|||||||
let mut log: Vec<String> = Vec::new();
|
let mut log: Vec<String> = Vec::new();
|
||||||
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
|
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
|
||||||
let mut iterations = 0;
|
let mut iterations = 0;
|
||||||
// RAD-forbidden points/airways excluded from the graph, accumulated across
|
// RAD-forbidden points/airways/segments excluded from the graph. Seeded with
|
||||||
// iterations from PROF204 messages (see repair step 3 in `apply_repairs`).
|
// this flight's Annex-2B segment bans and accumulated across iterations from
|
||||||
let mut avoid: std::collections::HashSet<String> = std::collections::HashSet::new();
|
// PROF204 messages (see repair step 1 in `apply_repairs`).
|
||||||
|
|
||||||
// A repair can trade one error class for another (e.g. excluding a forbidden
|
// 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),
|
// 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")],
|
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!(!r.accepted);
|
||||||
assert_eq!(r.fl, 360, "reverted to the pre-fix level");
|
assert_eq!(r.fl, 360, "reverted to the pre-fix level");
|
||||||
assert_eq!(r.errors.len(), 1, "best (fewest-error) state is kept");
|
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 conn = Connection::open_in_memory().unwrap();
|
||||||
let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "LF:F245..F999 IS ON FORBIDDEN ROUTE")] };
|
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 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!(r.accepted, "log: {:?}", r.log);
|
||||||
assert_eq!(r.fl, 240, "lowered below the 245 cap");
|
assert_eq!(r.fl, 240, "lowered below the 245 cap");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,17 @@ use rusqlite::Connection;
|
|||||||
use crate::error::Result;
|
use crate::error::Result;
|
||||||
use crate::model::LatLon;
|
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.
|
/// A routable point (airway endpoint) in the graph.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct NodeData {
|
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 {
|
for row in rows {
|
||||||
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?;
|
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?;
|
||||||
if avoid.contains(&fi) || avoid.contains(&ti) || avoid.contains(&awy) {
|
if avoid.contains(&fi) || avoid.contains(&ti) || avoid.contains(&awy) {
|
||||||
continue;
|
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 let Some(fl) = cruise_fl {
|
||||||
if !fl_in_band(fl, base_fl, top_fl) {
|
if !fl_in_band(fl, base_fl, top_fl) {
|
||||||
continue;
|
continue;
|
||||||
|
|||||||
+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
|
// The RAD data model lives in `core` (so routing/validation can use it without
|
||||||
// pulling in `calamine`); this crate produces those types.
|
// 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
|
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||||
/// `fra-points-official` note). Sheet `"FRA Points"`, one row per point.
|
/// `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)?,
|
fra_edges: parse_fra_edges(path)?,
|
||||||
level_caps: parse_level_caps(path)?,
|
level_caps: parse_level_caps(path)?,
|
||||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
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.
|
/// Annex 2A — city-pair flight-level caps.
|
||||||
pub fn parse_level_caps(path: &str) -> Result<Vec<LevelCap>> {
|
pub fn parse_level_caps(path: &str) -> Result<Vec<LevelCap>> {
|
||||||
let rows = rows(path, "Annex 2A")?;
|
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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user