Files
flightplanner/crates/core/src/routing/hybrid.rs
T
Alexandre 2cb633e99d Initial commit: offline flight planner (Rust, PFPX-class)
Clean-room reproduction of PFPX: route generation + IFPS validation + OFP.
Independent design — official ICAO/EUROCONTROL data only (RAD, FRA points,
IFPUV oracle); no community FPL sources.

Workspace crates: core (routing/discover/rad/navdata/perf/export),
cli, server, rad (Annex parser), gui (Tauri v2 + React + MapLibre).
Route discovery: oracle-in-the-loop repair against Eurocontrol IFPUV.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-21 11:10:35 +02:00

204 lines
7.7 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Dense free-route (DCT) routing over the navdata point cloud.
//!
//! Modern European enroute is Free Route Airspace: you may fly DCT between
//! (almost) any two published points, subject to length limits. Our RAD-derived
//! FRA graph ([`super::fra_graph`]) only has the *explicitly published* VIA pairs,
//! so it's far too sparse to find real routes (e.g. ParisNice `LATRA DCT LAMUT
//! … NISAR`). This router instead builds a **k-nearest-neighbour DCT graph** over
//! every waypoint/navaid in the dep→dest corridor and runs A* — yielding the
//! near-great-circle path through real points (the optimal free-route shape).
//! RAD-forbidden directs are skipped; the IFPUV oracle repairs residual issues
//! (e.g. TMA segments where DCT is banned → airway splice) in [`super::discover`].
use petgraph::algo::astar;
use petgraph::graph::{DiGraph, NodeIndex};
use rusqlite::{params, Connection};
use crate::error::Result;
use crate::model::LatLon;
use crate::rad::RadData;
use super::{airport_pos, Leg, Route};
/// Max length of a single DCT edge (nm). Most FRA areas allow long directs; TMAs
/// don't, but the oracle repairs those. Keeps the kNN graph sparse.
const MAX_DCT_NM: f64 = 220.0;
/// Neighbours per node in the DCT graph.
const K: usize = 10;
/// How many nearest corridor points an airport connects to (when no SID/STAR).
const CONNECT_K: usize = 12;
/// Plan a dense free-route (DCT) path from `from` to `to`. `dep_conn`/`dest_conn`
/// are SID exit / STAR entry fix idents used to anchor the terminal connection.
pub fn plan_hybrid(
conn: &Connection,
from: &str,
to: &str,
dep_conn: &[String],
dest_conn: &[String],
cruise_fl: i32,
rad: Option<&RadData>,
) -> Result<Option<Route>> {
let dep = airport_pos(conn, from)?;
let dst = airport_pos(conn, to)?;
let pts = load_corridor(conn, dep, dst, 2.5)?;
if pts.len() < 2 {
return Ok(None);
}
let mut g: DiGraph<(String, LatLon), f64> = DiGraph::new();
let node: Vec<NodeIndex> = pts.iter().map(|(id, p)| g.add_node((id.clone(), *p))).collect();
// kNN DCT edges (bidirectional), skipping RAD-forbidden directs at this FL.
for i in 0..pts.len() {
let mut nbrs: Vec<(f64, usize)> = (0..pts.len())
.filter(|&j| j != i)
.map(|j| (pts[i].1.distance_nm(&pts[j].1), j))
.filter(|(d, _)| *d <= MAX_DCT_NM)
.collect();
nbrs.sort_by(|a, b| a.0.total_cmp(&b.0));
for (d, j) in nbrs.into_iter().take(K) {
if let Some(rad) = rad {
if rad.forbidden_dct(&pts[i].0, &pts[j].0, cruise_fl).is_some() {
continue;
}
}
g.add_edge(node[i], node[j], d);
}
}
// Terminal connection: airport → SID exit fixes (or nearest corridor points).
let dep_ix = g.add_node((from.to_uppercase(), dep));
let dst_ix = g.add_node((to.to_uppercase(), dst));
connect(&mut g, &pts, &node, dep_ix, dep, dep_conn, true);
connect(&mut g, &pts, &node, dst_ix, dst, dest_conn, false);
let result = astar(
&g,
dep_ix,
|n| n == dst_ix,
|e| *e.weight(),
|n| g[n].1.distance_nm(&dst),
);
let Some((total_nm, path)) = result else {
return Ok(None);
};
if path.len() < 2 {
return Ok(None);
}
let mut legs = Vec::with_capacity(path.len() - 1);
for w in path.windows(2) {
let (a, b) = (&g[w[0]], &g[w[1]]);
legs.push(Leg {
from: a.0.clone(),
to: b.0.clone(),
airway: "DCT".to_owned(),
dist_nm: a.1.distance_nm(&b.1),
});
}
Ok(Some(Route { legs, total_nm, via_airways: false }))
}
/// Wire an airport node to its SID/STAR connector fixes (matched by ident), or to
/// the nearest corridor points when none resolve. `outbound` = airport→fix.
fn connect(
g: &mut DiGraph<(String, LatLon), f64>,
pts: &[(String, LatLon)],
node: &[NodeIndex],
apt_ix: NodeIndex,
apt: LatLon,
conn_fixes: &[String],
outbound: bool,
) {
let mut targets: Vec<usize> = conn_fixes
.iter()
.filter_map(|f| pts.iter().position(|(id, _)| id.eq_ignore_ascii_case(f)))
.collect();
if targets.is_empty() {
let mut near: Vec<(f64, usize)> =
pts.iter().enumerate().map(|(i, (_, p))| (apt.distance_nm(p), i)).collect();
near.sort_by(|a, b| a.0.total_cmp(&b.0));
targets = near.into_iter().take(CONNECT_K).map(|(_, i)| i).collect();
}
for i in targets {
let d = apt.distance_nm(&pts[i].1);
if outbound {
g.add_edge(apt_ix, node[i], d);
} else {
g.add_edge(node[i], apt_ix, d);
}
}
}
/// A filable **enroute** waypoint ident: a 5-letter name code (5LNC), all
/// alphabetic. Terminal/procedure fixes (e.g. `AT410`, `MA13`, `DE27R`, `PG271`)
/// contain digits and are NOT valid enroute points — IFPS rejects them.
fn is_enroute_wpt(ident: &str) -> bool {
ident.len() == 5 && ident.chars().all(|c| c.is_ascii_uppercase())
}
/// Navdata points inside the dep/dest bounding box (+`margin`°) usable for
/// enroute DCT: 5LNC waypoints + all navaids. One per ident (nearest the corridor
/// mid when repeated).
fn load_corridor(conn: &Connection, a: LatLon, b: LatLon, margin: f64) -> Result<Vec<(String, LatLon)>> {
let mid = LatLon::new((a.lat + b.lat) / 2.0, (a.lon + b.lon) / 2.0);
let (min_lat, max_lat) = (a.lat.min(b.lat) - margin, a.lat.max(b.lat) + margin);
let (min_lon, max_lon) = (a.lon.min(b.lon) - margin, a.lon.max(b.lon) + margin);
let mut best: std::collections::HashMap<String, LatLon> = std::collections::HashMap::new();
for table in ["waypoints", "navaids"] {
let enroute_only = table == "waypoints";
let mut stmt = conn.prepare(&format!(
"SELECT ident, lat, lon FROM {table} WHERE lat BETWEEN ?1 AND ?2 AND lon BETWEEN ?3 AND ?4"
))?;
let rows = stmt.query_map(params![min_lat, max_lat, min_lon, max_lon], |r| {
Ok((r.get::<_, String>(0)?, LatLon::new(r.get(1)?, r.get(2)?)))
})?;
for row in rows.flatten() {
let (ident, p) = row;
if enroute_only && !is_enroute_wpt(&ident) {
continue;
}
best.entry(ident)
.and_modify(|cur| {
if p.distance_nm(&mid) < cur.distance_nm(&mid) {
*cur = p;
}
})
.or_insert(p);
}
}
Ok(best.into_iter().collect())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enroute_ident_filter() {
assert!(is_enroute_wpt("KESAX") && is_enroute_wpt("LATRA") && is_enroute_wpt("NISAR"));
assert!(!is_enroute_wpt("AT410") && !is_enroute_wpt("MA13") && !is_enroute_wpt("DE27R"));
assert!(!is_enroute_wpt("RBT")); // navaids come from the navaids table
}
/// Prints the offline hybrid route for a few pairs (needs real.db). Ignored.
/// `cargo test -p flightplanner-core prints_hybrid -- --ignored --nocapture`
#[test]
#[ignore = "needs real.db"]
fn prints_hybrid_routes() {
let Ok(conn) = Connection::open("../../real.db") else { return };
for (f, t) in [("LFPG", "EGLL"), ("LFPG", "LFMN"), ("LFPG", "EDDF")] {
match plan_hybrid(&conn, f, t, &[], &[], 360, None) {
Ok(Some(r)) => {
let n = r.legs.len();
let item15: Vec<String> =
r.legs.iter().take(n.saturating_sub(1)).map(|l| l.to.clone()).collect();
eprintln!("{f}->{t} ({:.0} nm, {n} legs): {}", r.total_nm, item15.join(" DCT "));
}
other => eprintln!("{f}->{t}: {other:?}"),
}
}
}
}