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>
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
//! Step-5 test: export a fixture route to `.pln`, `.fms` and OFP text.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::{db, export, routing};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_db() -> Connection {
|
||||
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures");
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::import_navdata(&mut conn, &dir).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn exports_pln_fms_and_ofp() {
|
||||
let conn = fixtures_db();
|
||||
let route = routing::plan_route(&conn, "LFPG", "EGLL", None).unwrap();
|
||||
|
||||
let pln = export::pln::to_pln(&conn, &route, 34000).unwrap();
|
||||
assert!(pln.contains("<SimBase.Document"));
|
||||
assert!(pln.contains("<DepartureID>LFPG</DepartureID>"));
|
||||
assert!(pln.contains("<DestinationID>EGLL</DestinationID>"));
|
||||
assert!(pln.contains("<ATCWaypointType>Airport</ATCWaypointType>"));
|
||||
|
||||
let fms = export::fms::to_fms(&conn, &route, 34000).unwrap();
|
||||
assert!(fms.starts_with("I\n1100 Version"));
|
||||
assert!(fms.contains("ADEP LFPG"));
|
||||
assert!(fms.contains("ADES EGLL"));
|
||||
assert!(fms.contains("NUMENR "));
|
||||
|
||||
let ofp = export::ofp::to_ofp(&route, None, Some("A320"), Some(340));
|
||||
assert!(ofp.contains("OPERATIONAL FLIGHT PLAN"));
|
||||
assert!(ofp.contains("LFPG -> EGLL"));
|
||||
assert!(ofp.contains("FL340"));
|
||||
}
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
RWY:RW09L, , ,00079, , ,3, ;N51285200,W000273600,0000;
|
||||
RWY:RW27R, , ,00078, , ,3, ;N51284900,W000260900,0000;
|
||||
+2
@@ -0,0 +1,2 @@
|
||||
RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
RWY:RW26R, , ,00318, ,GAU ,3, ;N48595395,E002360724,1725;
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
I
|
||||
1100 Version - test fixture, not real navdata
|
||||
|
||||
ABEAM LF 11 BEACN LF 11 N 1 35 245 T100
|
||||
BEACN LF 11 CROSS LF 11 N 1 35 245 T100
|
||||
CROSS LF 11 DOVER EG 11 N 2 245 460 U200
|
||||
DOVER EG 11 ENTRY EG 11 N 2 245 460 U200
|
||||
99
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
I
|
||||
1101 Version - test fixture, not real navdata
|
||||
|
||||
49.000000000 2.500000000 ABEAM ENRT LF 1000001
|
||||
49.500000000 2.000000000 BEACN ENRT LF 1000002
|
||||
50.000000000 1.000000000 CROSS ENRT LF 1000003
|
||||
50.500000000 0.000000000 DOVER ENRT EG 1000004
|
||||
51.000000000 -0.500000000 ENTRY ENRT EG 1000005
|
||||
99
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
I
|
||||
1150 Version - test fixture, not real navdata
|
||||
|
||||
3 49.010000000 2.600000000 300 11400 130 1.000 PGV ENRT LF PARIS VOR
|
||||
2 50.900000000 -0.400000000 100 3700 50 0.000 EGN ENRT EG ENTRY NDB
|
||||
4 49.000000000 2.550000000 50 11000 25 0.000 ILP ENRT LF PARIS ILS
|
||||
99
|
||||
@@ -0,0 +1,57 @@
|
||||
//! End-to-end step-2 test: parse the hand-made fixtures and import them into an
|
||||
//! in-memory SQLite database, then assert row counts and a couple of lookups.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::db;
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn count(conn: &Connection, table: &str) -> i64 {
|
||||
conn.query_row(&format!("SELECT COUNT(*) FROM {table}"), [], |r| r.get(0))
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn imports_fixture_navdata() {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
let stats = db::import_navdata(&mut conn, &fixtures_dir()).unwrap();
|
||||
|
||||
// Fixtures: 5 fixes, 1 VOR + 1 NDB (ILS skipped), 4 airway segments, 2 airports.
|
||||
assert_eq!(stats.waypoints, 5);
|
||||
assert_eq!(stats.navaids, 2);
|
||||
assert_eq!(stats.airway_segments, 4);
|
||||
assert_eq!(stats.airports, 2);
|
||||
|
||||
assert_eq!(count(&conn, "waypoints"), 5);
|
||||
assert_eq!(count(&conn, "navaids"), 2);
|
||||
assert_eq!(count(&conn, "airway_segments"), 4);
|
||||
assert_eq!(count(&conn, "airports"), 2);
|
||||
|
||||
// A specific fix carries the right region.
|
||||
let region: String = conn
|
||||
.query_row(
|
||||
"SELECT region FROM waypoints WHERE ident = 'DOVER'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(region, "EG");
|
||||
|
||||
// LFPG reference point is the centroid of its two runway thresholds.
|
||||
let (lat, lon): (f64, f64) = conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = 'LFPG'",
|
||||
[],
|
||||
|r| Ok((r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.unwrap();
|
||||
assert!((lat - 48.99).abs() < 0.05, "lat = {lat}");
|
||||
assert!((lon - 2.56).abs() < 0.05, "lon = {lon}");
|
||||
|
||||
// Distinct airway names were recorded.
|
||||
assert_eq!(count(&conn, "airways"), 2);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! End-to-end step-3 test: import the fixtures, then route LFPG→EGLL through the
|
||||
//! hand-made airway network and check the result is sensible.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use flightplanner_core::routing::graph::RouteGraph;
|
||||
use flightplanner_core::{db, routing};
|
||||
use rusqlite::Connection;
|
||||
|
||||
fn fixtures_dir() -> PathBuf {
|
||||
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures")
|
||||
}
|
||||
|
||||
fn fixtures_db() -> Connection {
|
||||
let mut conn = Connection::open_in_memory().unwrap();
|
||||
db::import_navdata(&mut conn, &fixtures_dir()).unwrap();
|
||||
conn
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn routes_lfpg_to_egll_via_airways() {
|
||||
let conn = fixtures_db();
|
||||
|
||||
let route = routing::plan_route(&conn, "LFPG", "EGLL", None).unwrap();
|
||||
|
||||
assert!(route.via_airways, "expected an airway route, got {route:?}");
|
||||
assert_eq!(route.legs.first().unwrap().from, "LFPG");
|
||||
assert_eq!(route.legs.last().unwrap().to, "EGLL");
|
||||
|
||||
// The fixture network forces travel over the T100/U200 airways.
|
||||
let airways: Vec<&str> = route.legs.iter().map(|l| l.airway.as_str()).collect();
|
||||
assert!(
|
||||
airways.iter().any(|a| *a != "DCT"),
|
||||
"route used no airway: {airways:?}"
|
||||
);
|
||||
|
||||
// Sanity on total distance (direct LFPG-EGLL ≈ 188 nm; via fixtures a bit more).
|
||||
assert!(
|
||||
route.total_nm > 150.0 && route.total_nm < 400.0,
|
||||
"total = {} nm",
|
||||
route.total_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_airport_is_reported() {
|
||||
let conn = fixtures_db();
|
||||
let err = routing::plan_route(&conn, "LFPG", "ZZZZ", None).unwrap_err();
|
||||
assert!(err.to_string().contains("ZZZZ"), "{err}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn graph_filters_airways_by_flight_level() {
|
||||
let conn = fixtures_db();
|
||||
// Fixtures: T100 (low, FL35..245) links ABEAM/BEACN/CROSS,
|
||||
// U200 (high, FL245..460) links CROSS/DOVER/ENTRY.
|
||||
assert_eq!(RouteGraph::build(&conn, None).unwrap().node_count(), 5);
|
||||
assert_eq!(RouteGraph::build(&conn, Some(200)).unwrap().node_count(), 3); // T100 only
|
||||
assert_eq!(RouteGraph::build(&conn, Some(300)).unwrap().node_count(), 3); // U200 only
|
||||
assert_eq!(RouteGraph::build(&conn, Some(500)).unwrap().node_count(), 0); // above both
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fra_produces_a_dct_chain() {
|
||||
let conn = fixtures_db();
|
||||
let route = routing::fra::plan_fra(&conn, "LFPG", "EGLL", &[], &[], 360, None).unwrap();
|
||||
assert!(!route.via_airways, "FRA is DCT-based: {route:?}");
|
||||
assert!(route.legs.iter().all(|l| l.airway == "DCT"), "{route:?}");
|
||||
assert_eq!(route.legs.first().unwrap().from, "LFPG");
|
||||
assert_eq!(route.legs.last().unwrap().to, "EGLL");
|
||||
assert!(
|
||||
route.total_nm > 150.0 && route.total_nm < 400.0,
|
||||
"total = {} nm",
|
||||
route.total_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connects_departure_via_given_fix() {
|
||||
let conn = fixtures_db();
|
||||
// Force LFPG to join the network at CROSS (a SID exit fix, say) rather than
|
||||
// the nearest airway point.
|
||||
let route =
|
||||
routing::plan_route_conn(&conn, "LFPG", "EGLL", None, &["CROSS".to_string()], &[]).unwrap();
|
||||
assert!(route.via_airways, "{route:?}");
|
||||
let first = route.legs.first().unwrap();
|
||||
assert_eq!(first.from, "LFPG");
|
||||
assert_eq!(first.to, "CROSS", "should connect via the given fix: {route:?}");
|
||||
}
|
||||
Reference in New Issue
Block a user