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>
@@ -0,0 +1,27 @@
|
||||
# Rust build artifacts (root workspace + nested Tauri crate)
|
||||
/target/
|
||||
**/target/
|
||||
|
||||
# Local SQLite databases (generated by `import-navdata`)
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
|
||||
# Full X-Plane navdata (large, licensed separately — see README)
|
||||
/navdata/
|
||||
|
||||
# Generated flight plans at the repo root (demo/output artifacts)
|
||||
/*.pln
|
||||
/*.fms
|
||||
|
||||
# Node / frontend
|
||||
node_modules/
|
||||
/gui/dist/
|
||||
|
||||
# Local agent config (machine-specific permissions)
|
||||
/.claude/settings.local.json
|
||||
|
||||
# Editor / OS
|
||||
*.rs.bk
|
||||
.DS_Store
|
||||
@@ -0,0 +1,26 @@
|
||||
[workspace]
|
||||
resolver = "2"
|
||||
members = ["crates/core", "crates/cli", "crates/rad", "crates/server", "gui/src-tauri"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT OR Apache-2.0"
|
||||
rust-version = "1.74"
|
||||
|
||||
# Shared dependency versions — pinned once here, referenced with
|
||||
# `dep = { workspace = true }` from each crate.
|
||||
[workspace.dependencies]
|
||||
flightplanner-core = { path = "crates/core" }
|
||||
rusqlite = { version = "0.32", features = ["bundled"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "1.0"
|
||||
anyhow = "1.0"
|
||||
clap = { version = "4.5", features = ["derive"] }
|
||||
petgraph = "0.6"
|
||||
geo = "0.28"
|
||||
geographiclib-rs = "0.2"
|
||||
|
||||
[profile.release]
|
||||
lto = "thin"
|
||||
@@ -0,0 +1,134 @@
|
||||
# IDEAS / Vision — flightplanner
|
||||
|
||||
Cible produit à long terme : une appli de **briefing de vol** complète, dans
|
||||
l'esprit de SkyNexus / SimBrief / PFPX (voir maquettes fournies). Ce document
|
||||
capture les idées **au-delà du MVP** ; il ne remet pas en cause l'ordre de
|
||||
travail défini dans [`TODO.md`].
|
||||
|
||||
> ⚠️ **Tension à assumer.** Le MVP est *offline-first* (navdata X-Plane locale,
|
||||
> zéro dépendance cloud). La plupart des écrans ci-dessous reposent sur des
|
||||
> **données temps réel / online** (météo, NOTAM, réseau VATSIM, cartes ChartFox).
|
||||
> Ils appartiennent donc à une **phase post-MVP**, et seront branchés via une
|
||||
> couche *providers* **optionnelle** : chaque source a une implémentation
|
||||
> « offline / vide » par défaut, et une implémentation « online » activable.
|
||||
> Le cœur (parsing, routing, perf, export) reste utilisable sans réseau.
|
||||
|
||||
## Impact architecture (prévoir tôt, coder plus tard)
|
||||
|
||||
- **`crates/providers`** — traits abstraits + implémentations :
|
||||
`WeatherProvider` (METAR/TAF/SIGMET/WAFS), `NotamProvider`, `ChartProvider`
|
||||
(ChartFox), `NetworkProvider` (VATSIM online ATC/traffic). Chaque trait a une
|
||||
impl `Null`/offline. Aucune de ces deps ne remonte dans `core`.
|
||||
- **`crates/web`** — UI locale (Axum + front simple) qui héberge les écrans
|
||||
Briefing / DEP-ARR-ALT / Enroute / Charts. Consomme `core` + `providers`.
|
||||
- **`OFP builder`** dans `core` : agrège route + perf + météo en un objet
|
||||
« briefing » sérialisable (base commune CLI ↔ web ↔ export PDF/texte).
|
||||
- **Modèle de vol enrichi** : callsign, type OACI (ex. BCS3/A220), CI, step
|
||||
climbs, ZFW/TOW/LDW, bloc carburant détaillé, alternates multiples.
|
||||
|
||||
---
|
||||
|
||||
## 1. Écran BRIEFING (OFP / dashboard)
|
||||
|
||||
- **Bandeau global** (toutes pages) : callsign, type avion + moteur, dep→arr,
|
||||
heures OFF/ON + temps bloc, cycle AIRAC, version OFP, sync multi-appareils,
|
||||
actions PAD / OFP / REFRESH.
|
||||
- **Couverture ATC** : timeline des FIR traversés avec état online (VATSIM).
|
||||
- **Weights** : ZFW / TOW / LDW.
|
||||
- **Fuel** : bloc total + décomposition TAXI / TRIP / CONT / ALTN / FINRES
|
||||
(barre empilée visuelle).
|
||||
- **Load** : PAX, cargo, payload, Cost Index, CRZ FL / temp, Mach.
|
||||
- **Step climbs** : profil d'altitude par étape.
|
||||
- **Cartes départ / arrivée** condensées : RWY, SID/STAR, QNH, vent, METAR.
|
||||
- **Route** : chaîne complète SID … DCT/airways … STAR, + bouton « File on VATSIM ».
|
||||
- **Alternates** : FL, distance, fuel, ETE, route de déroutement.
|
||||
- **Cruise level impact** : Δ trip fuel / Δ temps pour FL ±2000/4000/6000 vs FL choisi.
|
||||
|
||||
## 2. Écrans DEP / ARR / ALT (briefing terrain)
|
||||
|
||||
- **En-tête terrain** : nom, ELEV, TA (transition alt), TL (transition level),
|
||||
RWY active + « change RWY », sélecteur SID/STAR.
|
||||
- **Météo** : rose des vents (orientation piste + vecteur vent + composantes),
|
||||
tuiles WIND / VIS / QNH / TEMP-DEW, METAR + TAF (mise en évidence des
|
||||
changements/éléments importants), refresh.
|
||||
- **ATIS** décodé, avec source (SayIntentions / VATSIM / réel).
|
||||
- **NOTAMs** : compteur total + CRITICAL/IMPORTANT, filtre « n'affectant que mon
|
||||
vol », regroupement (YOUR FLIGHT / TAXIWAYS & GROUND / NAVAIDS & LIGHTING /
|
||||
GENERAL), tags CHANGED / NOT AVAILABLE, dépliables.
|
||||
- **NADP** (profil anti-bruit) + **EOSID** (engine-out SID).
|
||||
- **Table pistes** : HDG, type d'approche, dimensions, THR ELEV, TORA/TODA/ASDA/LDA, marge.
|
||||
- **Operational notes** : infos arrivée/départ/taxi spécifiques au terrain.
|
||||
- **Crew briefing** : catégorie d'approche (CAT), threats (TEM).
|
||||
- **ALT (déroutement)** : Divert plan — niveau, distance, fuel, ETE, route.
|
||||
|
||||
## 3. Écran ENROUTE (carte + profil vertical)
|
||||
|
||||
- **Carte mobile** avec la route tracée et les waypoints.
|
||||
- **Couches** :
|
||||
- Flight plan (route + waypoints)
|
||||
- Météo : METARs, SIGMET, conditions de vol, vents ≥15kt, **radar de précip**
|
||||
- Prévision WAFS : jet stream, turbulence, givrage, fronts
|
||||
- Réseau online : ATC VATSIM, trafic, ma position
|
||||
- Catégories de vol (VFR / MVFR / IFR / LIFR) avec compteurs + légende
|
||||
- Vent surface (légende barbules)
|
||||
- **Time scrubber** : LIVE + prévision (+6h … +36h).
|
||||
- **Badges météo aéroports** (dispo GA/TAF/ATIS).
|
||||
- **Profil vertical (type EWAS)** : bandes FL, vent/temp le long de la route,
|
||||
terrain, waypoints avec distances cumulées, axe temps, FL de croisière surligné.
|
||||
|
||||
## 4. Écran CHARTS
|
||||
|
||||
- Intégration **ChartFox** (ou équivalent).
|
||||
- Par terrain (DEP/ARR/ALT + ajout ICAO) : cartes groupées TAXI / SID / STAR /
|
||||
APPROACH avec alternatives, tags « ON PLAN » / « GEO » (géo-référencé), épinglage.
|
||||
- **Visionneuse** : zoom/fit/rotation/luminosité, overlay position live,
|
||||
download, ouverture externe, barre d'onglets des cartes ouvertes.
|
||||
|
||||
---
|
||||
|
||||
## Validation IFPS (Eurocontrol) — « check IFPS fiable »
|
||||
|
||||
Objectif : savoir si une route serait **acceptée par l'IFPS** (Eurocontrol NM,
|
||||
espace IFPZ européen). C'est demandé comme fonctionnalité *fiable*.
|
||||
|
||||
**Réalité à assumer :** un check IFPS *fiable* est **forcément online et
|
||||
autoritatif**. Les règles à respecter incluent la connectivité point/airway, le
|
||||
sens et la bande de FL des airways, les **RAD** (Route Availability Document :
|
||||
routings obligatoires, DCT interdits, restrictions de flux, contraintes de
|
||||
niveau par zone/horaire — change à chaque cycle AIRAC), les limites DCT en FRA,
|
||||
la connexion SID/STAR au réseau, RVSM, etc. Reproduire tout cela **hors ligne**
|
||||
ne peut pas être « fiable » : on courrait derrière le ruleset Eurocontrol.
|
||||
|
||||
**Conception en deux couches :**
|
||||
|
||||
1. **Validateur autoritatif (online, post-MVP)** — la seule source *fiable* :
|
||||
- **Eurocontrol NM B2B** service `flightPlanValidation` (nécessite un compte
|
||||
NM + certificat, réservé AO/CFSP) — référence absolue.
|
||||
- Alternative : **Autorouter** (fait une vraie validation IFPS) ou portail NOP
|
||||
(manuel). Renvoie accepté/refusé + messages IFPS (ERROUTxxx, PROF…).
|
||||
- Exposé via un trait `providers::IfpsValidator` ; impl online activable.
|
||||
|
||||
2. **Pré-validateur structurel (offline, faisable dans `core`)** — *best-effort,
|
||||
non autoritatif*, pour attraper les erreurs évidentes avant soumission :
|
||||
- chaque point de la route existe et est bien sur l'airway cité ;
|
||||
- sens d'airway (N/F/B) respecté ;
|
||||
- FL de croisière dans la bande `base_fl…top_fl` du segment ;
|
||||
- DCT « raisonnables » (longueur, hors FRA) ;
|
||||
- continuité de la séquence (pas de trou entre segments).
|
||||
Résultat étiqueté clairement « pré-check local, non IFPS-officiel ».
|
||||
|
||||
> Décision requise : cible du validateur autoritatif (NM B2B vs Autorouter) et
|
||||
> accès/credentials disponibles. En attendant, le pré-validateur offline apporte
|
||||
> déjà de la valeur et alimentera la même API `IfpsResult { accepted, errors,
|
||||
> warnings }`.
|
||||
|
||||
## Priorisation suggérée (post-MVP)
|
||||
|
||||
1. **OFP builder + écran Briefing texte** (n'a besoin que de core : route + perf).
|
||||
Faisable offline, prolonge directement l'étape 5 (OFP).
|
||||
2. **UI web locale** (Axum) réutilisant l'OFP builder — pages statiques d'abord.
|
||||
3. **Providers online optionnels** : météo (METAR/TAF) → NOTAM → WAFS → réseau
|
||||
VATSIM → ChartFox, dans cet ordre de valeur/effort.
|
||||
4. **Écran Enroute (carte + profil vertical)** — le plus lourd, en dernier.
|
||||
|
||||
[`TODO.md`]: ./TODO.md
|
||||
@@ -0,0 +1,91 @@
|
||||
# flightplanner
|
||||
|
||||
Outil de *flight planning* **local et offline** pour la simulation de vol
|
||||
(MSFS / P3D / X-Plane), dans l'esprit de PFPX en plus simple. Aucune dépendance
|
||||
cloud : navdata X-Plane importée en base SQLite locale, routing par réseau
|
||||
d'airways, plan carburant, export `.pln` / `.fms` / OFP texte.
|
||||
|
||||
> État : **MVP en construction**. Voir [`TODO.md`](TODO.md) pour l'avancement,
|
||||
> et [`IDEAS.md`](IDEAS.md) pour la vision produit long terme (briefing complet
|
||||
> type SkyNexus/SimBrief : météo, NOTAM, carte enroute, charts…).
|
||||
|
||||
## Architecture
|
||||
|
||||
Workspace Cargo à deux crates :
|
||||
|
||||
| Crate | Rôle |
|
||||
|-----------------------|------------------------------------------------------------------|
|
||||
| `flightplanner-core` | Logique métier : parsing navdata, base SQLite, routing, perf, export |
|
||||
| `flightplanner-cli` | Binaire `flightplanner` (interface `clap`) |
|
||||
|
||||
Une interface web locale (Axum) pourra s'ajouter plus tard sans toucher au cœur.
|
||||
|
||||
## Prérequis
|
||||
|
||||
- **Rust stable** (toolchain MSVC sous Windows) — https://rustup.rs
|
||||
- Un **compilateur C** : `rusqlite` est utilisé en mode `bundled` (SQLite compilé
|
||||
depuis la source). Sous Windows, installer les *Visual Studio Build Tools*
|
||||
(workload « Développement Desktop en C++ »).
|
||||
|
||||
## Récupérer la navdata X-Plane (gratuit)
|
||||
|
||||
L'outil lit le format texte X-Plane. La donnée par défaut, **libre**, est fournie
|
||||
avec X-Plane 11/12 dans :
|
||||
|
||||
```
|
||||
<dossier X-Plane>/Resources/default data/
|
||||
```
|
||||
|
||||
Fichiers utilisés :
|
||||
|
||||
| Fichier | Contenu |
|
||||
|------------------|------------------------------------------|
|
||||
| `earth_fix.dat` | Waypoints / fixes |
|
||||
| `earth_nav.dat` | Navaids (VOR, DME, NDB, ILS...) |
|
||||
| `earth_awy.dat` | Airways (segments entre waypoints) |
|
||||
|
||||
Copier ces fichiers dans un dossier local, p. ex. `./navdata/` (ignoré par git).
|
||||
|
||||
> Pour une navdata à jour (cycle AIRAC courant), une souscription Navigraph est
|
||||
> possible, mais **hors périmètre du MVP** : ici on part de la donnée X-Plane
|
||||
> gratuite, importée manuellement.
|
||||
|
||||
## Utilisation
|
||||
|
||||
```sh
|
||||
# 1) Importer la navdata en base SQLite locale
|
||||
flightplanner import-navdata --source ./navdata/ --db flightplanner.db
|
||||
|
||||
# 2) Router (FL-aware si --cruise-fl ou profil avion fourni)
|
||||
flightplanner route --from LFPG --to EGLL --db flightplanner.db
|
||||
|
||||
# 3) Route + carburant + pré-check IFPS + exports
|
||||
flightplanner route --from LFPG --to EGLL --db flightplanner.db \
|
||||
--aircraft A320 --cruise-fl 340 \
|
||||
--check-ifps --ofp \
|
||||
--pln LFPG-EGLL.pln --fms LFPG-EGLL.fms
|
||||
|
||||
# Pré-check IFPS d'une chaîne de route arbitraire
|
||||
flightplanner ifps-check --route "LFPG DCT PON UT300 ... MODMI DCT EGLL" --fl 340 --db flightplanner.db
|
||||
```
|
||||
|
||||
> **Aéroports** : les coordonnées viennent des seuils de piste des fichiers
|
||||
> `CIFP/<ICAO>.dat`. Pour router au-delà de la paire testée, extraire tout le
|
||||
> dossier `CIFP/` de l'archive navdata dans `./navdata/CIFP/` avant l'import.
|
||||
|
||||
> **Check IFPS** : le `--check-ifps` est un **pré-check local best-effort** (non
|
||||
> officiel). Un vrai check IFPS *fiable* nécessite un validateur online
|
||||
> (Autorouter / Eurocontrol NM) — voir [`IDEAS.md`](IDEAS.md).
|
||||
|
||||
## Développement
|
||||
|
||||
```sh
|
||||
cargo build
|
||||
cargo test
|
||||
cargo fmt && cargo clippy
|
||||
```
|
||||
|
||||
## Licence
|
||||
|
||||
MIT OR Apache-2.0. La navdata X-Plane est soumise à sa propre licence et n'est
|
||||
pas redistribuée dans ce dépôt.
|
||||
@@ -0,0 +1,91 @@
|
||||
# TODO — flightplanner
|
||||
|
||||
État d'avancement entre sessions. Cocher au fur et à mesure.
|
||||
|
||||
## Étape 1 — Workspace Cargo ✅ (en cours de validation)
|
||||
- [x] Workspace `Cargo.toml` (members core + cli, deps partagées)
|
||||
- [x] Crate `flightplanner-core` (lib) : `error`, `model`, stubs `navdata/db/routing/perf/export`
|
||||
- [x] Crate `flightplanner-cli` (bin `flightplanner`) : clap, sous-commandes `import-navdata` / `route` (stub)
|
||||
- [x] Profils avions `data/aircraft/{a320,b738}.json`
|
||||
- [x] README, TODO, .gitignore, rust-toolchain
|
||||
- [x] `cargo build` + `cargo test` verts, `clippy -D warnings` propre, `fmt --check` OK
|
||||
- [ ] **Point d'arrêt : validation utilisateur**
|
||||
|
||||
## Étape 2 — Parsing navdata + import SQLite ✅ (en cours de validation)
|
||||
- [x] Format figé depuis les vrais fichiers (fix 1101, nav 1150, awy 1100 + coords CIFP)
|
||||
- [x] Parsers streaming ligne-à-ligne (`navdata/{fix,nav,airway,airport}.rs`)
|
||||
- [x] Schéma SQLite : `airports`, `waypoints`, `navaids`, `airways`, `airway_segments`
|
||||
- [x] Import bulk transactionnel (`db::import_navdata`)
|
||||
- [x] Aéroports dérivés des seuils de piste CIFP (centroïde = point de référence)
|
||||
- [x] Fixtures faites main + tests unitaires parsing + test intégration end-to-end
|
||||
- [x] Câbler `import-navdata` dans le CLI
|
||||
- [x] Import réel vérifié : 250 762 wpt / 7 923 navaids / 121 884 segments awy
|
||||
- [ ] **Point d'arrêt : validation utilisateur**
|
||||
|
||||
> Note : n'ont été extraits du CIFP que `LFPG.dat`/`EGLL.dat` (→ 2 aéroports).
|
||||
> Pour tous les aéroports, extraire tout le dossier `CIFP/` de l'archive.
|
||||
|
||||
## Étape 3 — Routing ✅ (en cours de validation)
|
||||
- [x] Construction graphe `petgraph` depuis SQLite (`routing/graph.rs`)
|
||||
- [x] A* airways (heuristique great-circle) + connexion dep/dest aux points proches (DCT)
|
||||
- [x] Fallback direct great-circle si pas de chemin
|
||||
- [x] Test bout-en-bout LFPG→EGLL sur fixtures + gestion aéroport inconnu
|
||||
- [x] Câbler `route` dans le CLI
|
||||
- [x] Vérifié sur vraies données : `LFPG DCT PON UT300 … MID UM185 MODMI DCT EGLL` (12 legs, 236 nm)
|
||||
- [x] **FL-aware** : filtrage des airways par bande base/top selon le FL de croisière (`build(conn, Some(fl))`) → routes cohérentes qui passent le pré-check IFPS
|
||||
- [ ] **Point d'arrêt : validation utilisateur**
|
||||
- [ ] (idée) collapse des legs consécutifs d'un même airway à l'affichage
|
||||
|
||||
## Étape 4 — Performance / carburant ✅ (en cours de validation)
|
||||
- [x] Struct `AircraftProfile` (serde) + chargement `data/aircraft/<icao>.json`
|
||||
- [x] Modèle 3 phases (montée/croisière/descente) réparti sur la distance
|
||||
- [x] Temps/carburant par segment + cumul + réserves (taxi/contingence/finale/alternate)
|
||||
- [x] `route --aircraft A320 [--alternate] [--cruise-fl]` dans le CLI
|
||||
- [x] Tests (couverture distance, block>trip, vol court sans croisière, chargement profil)
|
||||
- [ ] **Point d'arrêt : validation utilisateur**
|
||||
|
||||
## Étape 5 — Export ✅ (MVP bouclé)
|
||||
- [x] `.pln` (MSFS/P3D, AceXML) avec ATCWaypoint + ATCAirway
|
||||
- [x] `.fms` (X-Plane 11, v1100) avec ADEP/ADES/NUMENR
|
||||
- [x] OFP texte (route, table legs dist/ETE/fuel/cumul, réserves, block)
|
||||
- [x] Câblé au CLI : `route … --pln f.pln --fms f.fms --ofp`
|
||||
- [x] Test end-to-end export (pln/fms/ofp)
|
||||
|
||||
## Étape 6 — Moteur de perf physique OpenAP 🔧 (en cours — cap : remplaçant PFPX)
|
||||
Objectif : sortir du `fuel_flow_kgph` fixe et calculer la conso par la physique
|
||||
(atmosphère ISA + polaire de traînée + TSFC), comme BADA/OpenAP.
|
||||
- [x] `perf/openap.rs` : atmosphère ISA, polaire `CD = CD0 + k·CL²`, débit = TSFC·traînée
|
||||
- [x] Coefficients réels OpenAP embarqués (A320/CFM56-5B4, B738/CFM56-7B26) dans les JSON avion (`openap{}`)
|
||||
- [x] Débit **fonction de la masse et du FL** ; câblé dans `compute_fuel_plan` (non-cassant, fallback si pas d'`openap`)
|
||||
- [x] Tests : ISA vs valeurs connues, croisière A320 réaliste (~2000 kg/h @FL350), + lourd = + conso, + haut = - conso, plancher idle en descente
|
||||
- [x] Vérifié sur route réelle : courbe d'altitude optimale (min conso ~FL360, remonte à FL390)
|
||||
- [x] **Masse réelle** : `--payload` au CLI, ZFW/TOW/LDW calculés, itération point-fixe du block-fuel (masse ← OEW+payload+carburant), alertes MTOW/MLW ; masses au print + OFP. Vérifié : trip fuel monte avec la charge (LFPG→EGLL : 1736→1959 kg de 8t à 20t)
|
||||
- [ ] Profil de montée/descente intégré par tranches d'altitude (au lieu d'une altitude médiane)
|
||||
- [ ] Traînée de compressibilité (wave drag) près du Mach de croisière
|
||||
- [ ] TSFC calibrée par phase (idle/approche/climb depuis les points ICAO du moteur)
|
||||
- [ ] (option) brancher les datasets BADA 4 derrière la même interface `PerfModel` si licence EUROCONTROL obtenue
|
||||
|
||||
## Étape 7 — GUI desktop (Tauri + React + Fluent UI) 🖥️ (en cours)
|
||||
Fenêtre native (WebView2, PAS un navigateur). **Règle : aucun CSS/style écrit à la
|
||||
main — on utilise le design system Fluent UI (Microsoft, look Windows 11).**
|
||||
`gui/` = frontend React/TypeScript (Vite) ; `gui/src-tauri/` = backend Rust
|
||||
(`flightplanner-gui`, membre du workspace). Lancer : `cd gui ; npm run tauri dev`.
|
||||
- [x] Scaffold Tauri v2 (react-ts) intégré au workspace, WebView2 présent
|
||||
- [x] **Fluent UI** (`@fluentui/react-components`) : `FluentProvider` suit le thème clair/sombre de l'OS ; composants standards (Field/Input/Select/Card/Table/Badge/MessageBar) → zéro style maison, juste de la mise en page via les tokens
|
||||
- [x] Commande `plan` : pont vers `core` (route + fuel plan) via DTO sérialisables
|
||||
- [x] Formulaire : dep/dest, avion, cruise FL, payload, alternate, sources data (DB/profils)
|
||||
- [x] Résultats : route string, cartes (trip/block/réserves), bandeau masses (payload/ZFW/TOW/LDW + alertes MTOW/MLW), table des legs (dist/ETE/fuel/cum)
|
||||
- [x] `cargo check` propre sur `flightplanner-gui`, frontend `npm run build` (tsc) OK
|
||||
- [ ] Carte (MapLibre) avec tracé de la route + waypoints
|
||||
- [ ] Sélecteurs d'aéroport avec autocomplétion depuis la DB (Combobox Fluent)
|
||||
- [ ] Boutons export (.pln/.fms/OFP) + copier route
|
||||
- [ ] Écran réglages (chemin DB, cycle AIRAC, unités kg/lb)
|
||||
- [ ] Bundler une DB navdata + packaging installeur (`tauri build`)
|
||||
|
||||
## Plus tard (hors MVP)
|
||||
- ~~UI web locale (Axum)~~ → remplacé par GUI Tauri (étape 7)
|
||||
- Météo, cycles AIRAC automatiques
|
||||
- **Check IFPS** :
|
||||
- [x] Pré-validateur offline (`core::ifps`) : point existe / sur airway, sens, bande FL, DCT, continuité + CLI `ifps-check` et `route --check-ifps`
|
||||
- [ ] Validateur autoritatif online **Autorouter** (le vrai « fiable ») — voir [`IDEAS.md`](IDEAS.md)
|
||||
- **Vision produit complète (briefing type SkyNexus/SimBrief) → voir [`IDEAS.md`](IDEAS.md)**
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "flightplanner-cli"
|
||||
description = "Command-line interface for the local flight planner."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[[bin]]
|
||||
name = "flightplanner"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
flightplanner-core = { workspace = true }
|
||||
flightplanner-rad = { path = "../rad" }
|
||||
clap = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,386 @@
|
||||
//! `flightplanner` CLI — thin `clap` layer over `flightplanner-core`.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "flightplanner",
|
||||
version,
|
||||
about = "Local, offline flight planner (X-Plane navdata, MSFS/P3D/X-Plane export)"
|
||||
)]
|
||||
struct Cli {
|
||||
#[command(subcommand)]
|
||||
command: Command,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Command {
|
||||
/// Import X-Plane navdata (.dat) into the local SQLite database.
|
||||
ImportNavdata {
|
||||
/// Directory containing earth_fix.dat, earth_nav.dat, earth_awy.dat.
|
||||
#[arg(long)]
|
||||
source: PathBuf,
|
||||
/// SQLite database file to create/update.
|
||||
#[arg(long, default_value = "flightplanner.db")]
|
||||
db: PathBuf,
|
||||
},
|
||||
/// Import PFPX `.route` files as unvalidated seeds into our route DB.
|
||||
ImportPfpx {
|
||||
/// PFPX `Routes` directory (e.g. `C:\Users\Public\Documents\PFPX Data\Routes`).
|
||||
#[arg(long)]
|
||||
source: PathBuf,
|
||||
/// SQLite database file to update.
|
||||
#[arg(long, default_value = "flightplanner.db")]
|
||||
db: PathBuf,
|
||||
},
|
||||
/// Compute a route between two waypoints/airports.
|
||||
Route {
|
||||
/// Departure identifier (e.g. LFPG).
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Destination identifier (e.g. EGLL).
|
||||
#[arg(long)]
|
||||
to: String,
|
||||
/// SQLite database file produced by `import-navdata`.
|
||||
#[arg(long, default_value = "flightplanner.db")]
|
||||
db: PathBuf,
|
||||
/// Aircraft type (e.g. A320) — enables the fuel plan.
|
||||
#[arg(long)]
|
||||
aircraft: Option<String>,
|
||||
/// Directory holding `<icao>.json` aircraft profiles.
|
||||
#[arg(long, default_value = "data/aircraft")]
|
||||
aircraft_dir: PathBuf,
|
||||
/// Cruise flight level (×100 ft); defaults to the profile's value.
|
||||
#[arg(long)]
|
||||
cruise_fl: Option<i32>,
|
||||
/// Alternate airport identifier — adds alternate fuel.
|
||||
#[arg(long)]
|
||||
alternate: Option<String>,
|
||||
/// Payload (pax + cargo) in kg; defaults to a medium load factor.
|
||||
#[arg(long)]
|
||||
payload: Option<f64>,
|
||||
/// Run the offline IFPS pre-check on the computed route.
|
||||
#[arg(long)]
|
||||
check_ifps: bool,
|
||||
/// Write an MSFS/P3D `.pln` to this path.
|
||||
#[arg(long)]
|
||||
pln: Option<PathBuf>,
|
||||
/// Write an X-Plane `.fms` to this path.
|
||||
#[arg(long)]
|
||||
fms: Option<PathBuf>,
|
||||
/// Print a text OFP to stdout.
|
||||
#[arg(long)]
|
||||
ofp: bool,
|
||||
},
|
||||
/// Discover an IFPS-valid route via the oracle loop (live Eurocontrol IFPUV).
|
||||
Discover {
|
||||
/// Departure ICAO (e.g. LFRN).
|
||||
#[arg(long)]
|
||||
from: String,
|
||||
/// Destination ICAO (e.g. LFMN).
|
||||
#[arg(long)]
|
||||
to: String,
|
||||
/// SQLite database (navdata).
|
||||
#[arg(long, default_value = "real.db")]
|
||||
db: PathBuf,
|
||||
/// Aircraft type (for the cruise FL default).
|
||||
#[arg(long, default_value = "A320")]
|
||||
aircraft: String,
|
||||
/// Aircraft profiles dir.
|
||||
#[arg(long, default_value = "data/aircraft")]
|
||||
aircraft_dir: PathBuf,
|
||||
/// CIFP directory (SID/STAR).
|
||||
#[arg(long, default_value = "navdata/CIFP")]
|
||||
cifp: PathBuf,
|
||||
/// Requested cruise FL (×100 ft); capped by RAD.
|
||||
#[arg(long, default_value_t = 360)]
|
||||
fl: i32,
|
||||
},
|
||||
/// Offline IFPS pre-check of a route string (best-effort, non-authoritative).
|
||||
IfpsCheck {
|
||||
/// Route string, e.g. "LFPG DCT PON UT300 ELCOB ... EGLL".
|
||||
#[arg(long)]
|
||||
route: String,
|
||||
/// Cruise flight level (×100 ft).
|
||||
#[arg(long)]
|
||||
fl: i32,
|
||||
/// SQLite database file produced by `import-navdata`.
|
||||
#[arg(long, default_value = "flightplanner.db")]
|
||||
db: PathBuf,
|
||||
},
|
||||
}
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
match cli.command {
|
||||
Command::ImportNavdata { source, db } => {
|
||||
use flightplanner_core::db;
|
||||
let mut conn = db::open(&db)?;
|
||||
let stats = db::import_navdata(&mut conn, &source)?;
|
||||
println!(
|
||||
"Imported navdata from {} into {}:",
|
||||
source.display(),
|
||||
db.display()
|
||||
);
|
||||
println!(" waypoints: {}", stats.waypoints);
|
||||
println!(" navaids: {}", stats.navaids);
|
||||
println!(" airway segments: {}", stats.airway_segments);
|
||||
println!(" airports: {}", stats.airports);
|
||||
}
|
||||
Command::ImportPfpx { source, db } => {
|
||||
use flightplanner_core::{db, pfpx};
|
||||
let mut conn = db::open(&db)?;
|
||||
let (parsed, inserted) = pfpx::import_dir(&mut conn, &source)?;
|
||||
println!(
|
||||
"Imported PFPX routes from {} into {}:",
|
||||
source.display(),
|
||||
db.display()
|
||||
);
|
||||
println!(" parsed: {parsed}");
|
||||
println!(" inserted: {inserted} (new seeds; existing rows kept)");
|
||||
}
|
||||
Command::Discover { from, to, db, aircraft, aircraft_dir, cifp, fl } => {
|
||||
use flightplanner_core::api::{self, PlanRequest};
|
||||
let rad = load_rad_full();
|
||||
let req = PlanRequest {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
db_path: db.to_string_lossy().into(),
|
||||
aircraft: Some(aircraft),
|
||||
aircraft_dir: aircraft_dir.to_string_lossy().into(),
|
||||
cifp_dir: Some(cifp.to_string_lossy().into()),
|
||||
cruise_fl: Some(fl),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
};
|
||||
let validator = CliIfps;
|
||||
let r = api::discover_route(&req, rad.as_ref(), &validator)?;
|
||||
println!("\n{from} → {to} {}", if r.accepted { "✓ IFPS ACCEPTED" } else { "✗ not accepted" });
|
||||
println!(" FL{:03} {:.0} nm ({} iterations)", r.fl, r.total_nm, r.iterations);
|
||||
println!(" route : {}", r.route_string);
|
||||
println!(" log:");
|
||||
for l in &r.log { println!(" {l}"); }
|
||||
if !r.accepted {
|
||||
println!(" remaining errors:");
|
||||
for e in &r.errors { println!(" {} {}", e.code, e.msg); }
|
||||
}
|
||||
}
|
||||
Command::Route {
|
||||
from,
|
||||
to,
|
||||
db,
|
||||
aircraft,
|
||||
aircraft_dir,
|
||||
cruise_fl,
|
||||
alternate,
|
||||
payload,
|
||||
check_ifps,
|
||||
pln,
|
||||
fms,
|
||||
ofp,
|
||||
} => {
|
||||
use flightplanner_core::{db as database, export, ifps, perf, routing};
|
||||
let conn = database::open(&db)?;
|
||||
let profile = match &aircraft {
|
||||
Some(icao) => Some(perf::AircraftProfile::load(&aircraft_dir, icao)?),
|
||||
None => None,
|
||||
};
|
||||
// Effective cruise FL: explicit flag, else the aircraft's default.
|
||||
let cruise_fl = cruise_fl.or_else(|| profile.as_ref().map(|p| p.default_cruise_fl));
|
||||
|
||||
let route =
|
||||
routing::plan_route(&conn, &from.to_uppercase(), &to.to_uppercase(), cruise_fl)?;
|
||||
println!("{}", route.route_string());
|
||||
println!(
|
||||
"{} legs, {:.0} nm ({})",
|
||||
route.legs.len(),
|
||||
route.total_nm,
|
||||
if route.via_airways {
|
||||
"via airways"
|
||||
} else {
|
||||
"direct great-circle fallback"
|
||||
}
|
||||
);
|
||||
|
||||
let fuel_plan = if let Some(profile) = &profile {
|
||||
let fl = cruise_fl.unwrap_or(profile.default_cruise_fl);
|
||||
let alternate_nm = match &alternate {
|
||||
Some(alt) => Some(
|
||||
routing::plan_route(
|
||||
&conn,
|
||||
&to.to_uppercase(),
|
||||
&alt.to_uppercase(),
|
||||
cruise_fl,
|
||||
)?
|
||||
.total_nm,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let plan = perf::compute_fuel_plan(profile, &route, fl, alternate_nm, payload);
|
||||
print_fuel_plan(profile, &plan, alternate.as_deref());
|
||||
Some(plan)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
if check_ifps {
|
||||
let ifps_fl = cruise_fl.unwrap_or(350);
|
||||
let report = ifps::prevalidate(&conn, &route.route_string(), ifps_fl, None)?;
|
||||
print_ifps_report(&report, ifps_fl);
|
||||
}
|
||||
|
||||
let cruise_alt_ft = cruise_fl.unwrap_or(350) * 100;
|
||||
if let Some(path) = &pln {
|
||||
std::fs::write(path, export::pln::to_pln(&conn, &route, cruise_alt_ft)?)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
if let Some(path) = &fms {
|
||||
std::fs::write(path, export::fms::to_fms(&conn, &route, cruise_alt_ft)?)?;
|
||||
println!("wrote {}", path.display());
|
||||
}
|
||||
if ofp {
|
||||
print!(
|
||||
"{}",
|
||||
export::ofp::to_ofp(&route, fuel_plan.as_ref(), aircraft.as_deref(), cruise_fl)
|
||||
);
|
||||
}
|
||||
}
|
||||
Command::IfpsCheck { route, fl, db } => {
|
||||
use flightplanner_core::{db as database, ifps};
|
||||
let conn = database::open(&db)?;
|
||||
let report = ifps::prevalidate(&conn, &route, fl, None)?;
|
||||
print_ifps_report(&report, fl);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Format minutes as `H:MM`.
|
||||
fn hm(minutes: f64) -> String {
|
||||
let total = minutes.round() as i64;
|
||||
format!("{}:{:02}", total / 60, total % 60)
|
||||
}
|
||||
|
||||
/// Load the RAD workbook + official FRA points (env `FP_RAD` / `FP_FRA_POINTS`).
|
||||
fn load_rad_full() -> Option<flightplanner_core::rad::RadData> {
|
||||
let path = std::env::var("FP_RAD").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
|
||||
let mut rad = flightplanner_rad::parse(&path).ok()?;
|
||||
let fp = std::env::var("FP_FRA_POINTS").unwrap_or_else(|_| "rad/fra-points.xlsx".into());
|
||||
rad.fra_points = flightplanner_rad::parse_fra_points(&fp).unwrap_or_default();
|
||||
eprintln!("RAD: {} level caps, {} FRA points", rad.level_caps.len(), rad.fra_points.len());
|
||||
Some(rad)
|
||||
}
|
||||
|
||||
/// IFPS validator spawning the IFPUV scraper (`tools/ifpuv/validate.mjs`).
|
||||
struct CliIfps;
|
||||
impl flightplanner_core::routing::discover::IfpsValidator for CliIfps {
|
||||
fn validate(
|
||||
&self,
|
||||
adep: &str,
|
||||
ades: &str,
|
||||
route: &str,
|
||||
fl: i32,
|
||||
) -> flightplanner_core::error::Result<flightplanner_core::routing::discover::IfpsVerdict> {
|
||||
use flightplanner_core::error::CoreError;
|
||||
use flightplanner_core::routing::discover::{IfpsErr, IfpsVerdict};
|
||||
eprint!(" · checking @ FL{fl:03} … ");
|
||||
let dir = std::env::var("FP_IFPUV_DIR").unwrap_or_else(|_| "tools/ifpuv".into());
|
||||
let payload = serde_json::json!({
|
||||
"adep": adep, "ades": ades, "route": route, "level": format!("F{:03}", fl),
|
||||
});
|
||||
let out = std::process::Command::new("node")
|
||||
.arg("validate.mjs")
|
||||
.arg(payload.to_string())
|
||||
.current_dir(&dir)
|
||||
.output()?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.ok_or_else(|| CoreError::Other(format!("validator: no result. stderr: {}", String::from_utf8_lossy(&out.stderr).trim())))?;
|
||||
let v: serde_json::Value = serde_json::from_str(line)?;
|
||||
let errors: Vec<IfpsErr> = v["errors"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
.filter_map(|e| Some(IfpsErr { code: e["code"].as_str()?.to_string(), msg: e["msg"].as_str()?.to_string() }))
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
eprintln!("{} err", errors.len());
|
||||
Ok(IfpsVerdict { accepted: v["accepted"].as_bool().unwrap_or(false), errors })
|
||||
}
|
||||
}
|
||||
|
||||
fn print_fuel_plan(
|
||||
profile: &flightplanner_core::perf::AircraftProfile,
|
||||
plan: &flightplanner_core::perf::FuelPlan,
|
||||
alternate: Option<&str>,
|
||||
) {
|
||||
println!();
|
||||
println!(
|
||||
"Aircraft {} ({}) CRZ FL{:03}",
|
||||
profile.icao, profile.name, plan.cruise_fl
|
||||
);
|
||||
println!(
|
||||
"Trip: {:.0} nm {} {:.0} kg",
|
||||
plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm,
|
||||
hm(plan.trip_time_min),
|
||||
plan.trip_fuel_kg
|
||||
);
|
||||
for (label, p) in [
|
||||
("climb ", &plan.climb),
|
||||
("cruise ", &plan.cruise),
|
||||
("descent", &plan.descent),
|
||||
] {
|
||||
println!(
|
||||
" {label}: {:>5.0} nm {:>5} {:>6.0} kg",
|
||||
p.dist_nm,
|
||||
hm(p.time_min),
|
||||
p.fuel_kg
|
||||
);
|
||||
}
|
||||
println!(
|
||||
"Reserves: taxi {:.0} contingency {:.0} alternate{} {:.0} final {:.0}",
|
||||
plan.taxi_kg,
|
||||
plan.contingency_kg,
|
||||
alternate.map(|a| format!(" ({a})")).unwrap_or_default(),
|
||||
plan.alternate_kg,
|
||||
plan.final_reserve_kg
|
||||
);
|
||||
println!("Block fuel: {:.0} kg", plan.block_fuel_kg);
|
||||
if let Some(m) = &plan.masses {
|
||||
println!(
|
||||
"Mass: payload {:.0} ZFW {:.0} TOW {:.0} LDW {:.0} kg",
|
||||
m.payload_kg, m.zfw_kg, m.takeoff_kg, m.landing_kg
|
||||
);
|
||||
if m.over_mtow {
|
||||
println!(" ! TOW exceeds MTOW — reduce payload or fuel");
|
||||
}
|
||||
if m.over_mlw {
|
||||
println!(" ! LDW exceeds MLW");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn print_ifps_report(report: &flightplanner_core::ifps::IfpsReport, fl: i32) {
|
||||
println!();
|
||||
println!("IFPS pre-check (local, non-authoritative) at FL{fl}:");
|
||||
if report.accepted {
|
||||
println!(" ACCEPTED");
|
||||
} else {
|
||||
println!(" REJECTED ({} error(s))", report.errors.len());
|
||||
}
|
||||
for e in &report.errors {
|
||||
println!(" ✗ {e}");
|
||||
}
|
||||
for w in &report.warnings {
|
||||
println!(" ! {w}");
|
||||
}
|
||||
if !report.expanded.is_empty() {
|
||||
println!(" expanded: {}", report.expanded.join(" "));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "flightplanner-core"
|
||||
description = "Core logic for the local flight planner: navdata parsing, routing, performance, export."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
|
||||
[dependencies]
|
||||
rusqlite = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
petgraph = { workspace = true }
|
||||
geo = { workspace = true }
|
||||
geographiclib-rs = { workspace = true }
|
||||
@@ -0,0 +1,492 @@
|
||||
//! Shared planning API — one `plan(request) -> result` used by both the local
|
||||
//! (in-process, Tauri) and the custom-server (HTTP) backends, so the two modes
|
||||
//! run the exact same engine.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::{db, export, perf, routing};
|
||||
|
||||
/// Everything the UI form sends to plan a flight.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanRequest {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub db_path: String,
|
||||
/// Aircraft ICAO (e.g. "A320"); `None`/empty ⇒ route only, no fuel plan.
|
||||
pub aircraft: Option<String>,
|
||||
pub aircraft_dir: String,
|
||||
/// Directory of CIFP airport files (for SID/STAR connectors); `None` ⇒ no
|
||||
/// SID/STAR-aware routing (falls back to nearest-airway-point connection).
|
||||
#[serde(default)]
|
||||
pub cifp_dir: Option<String>,
|
||||
/// Cruise flight level (×100 ft); `None` ⇒ the profile's default.
|
||||
pub cruise_fl: Option<i32>,
|
||||
/// Alternate airport ICAO; adds alternate fuel when set.
|
||||
pub alternate: Option<String>,
|
||||
/// Payload (pax + cargo) in kg; `None` ⇒ a medium default load.
|
||||
pub payload_kg: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LegDto {
|
||||
pub from: String,
|
||||
pub via: String,
|
||||
pub to: String,
|
||||
pub dist_nm: f64,
|
||||
pub cum_dist_nm: f64,
|
||||
pub time_min: Option<f64>,
|
||||
pub cum_time_min: Option<f64>,
|
||||
pub fuel_kg: Option<f64>,
|
||||
pub cum_fuel_kg: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PhaseDto {
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MassDto {
|
||||
pub payload_kg: f64,
|
||||
pub zfw_kg: f64,
|
||||
pub takeoff_kg: f64,
|
||||
pub landing_kg: f64,
|
||||
pub over_mtow: bool,
|
||||
pub over_mlw: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FuelDto {
|
||||
pub aircraft: String,
|
||||
pub cruise_fl: i32,
|
||||
pub climb: PhaseDto,
|
||||
pub cruise: PhaseDto,
|
||||
pub descent: PhaseDto,
|
||||
pub trip_fuel_kg: f64,
|
||||
pub trip_time_min: f64,
|
||||
pub taxi_kg: f64,
|
||||
pub contingency_kg: f64,
|
||||
pub alternate_kg: f64,
|
||||
pub final_reserve_kg: f64,
|
||||
pub block_fuel_kg: f64,
|
||||
pub mass: Option<MassDto>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PointDto {
|
||||
pub ident: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
/// A full SID/STAR procedure track, for drawing on the map.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ProcTrackDto {
|
||||
pub name: String,
|
||||
/// `"SID"` or `"STAR"`.
|
||||
pub kind: String,
|
||||
pub points: Vec<PointDto>,
|
||||
}
|
||||
|
||||
/// Offline IFPS pre-check outcome (best-effort, non-authoritative).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IfpsDto {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
}
|
||||
|
||||
/// A stored flight plan from our own route database.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CachedRouteDto {
|
||||
pub dep: String,
|
||||
pub dest: String,
|
||||
pub cruise_fl: i32,
|
||||
pub route_string: String,
|
||||
pub dist_nm: f64,
|
||||
pub ifps_ok: bool,
|
||||
pub source: String,
|
||||
pub generated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PlanResult {
|
||||
pub route_string: String,
|
||||
pub via_airways: bool,
|
||||
pub total_nm: f64,
|
||||
pub cruise_fl: Option<i32>,
|
||||
pub legs: Vec<LegDto>,
|
||||
pub fuel: Option<FuelDto>,
|
||||
/// Ordered waypoint positions for the map (dep → … → dest).
|
||||
pub geometry: Vec<PointDto>,
|
||||
/// Full SID/STAR procedure tracks (terminal), for the map.
|
||||
pub procedures: Vec<ProcTrackDto>,
|
||||
/// Ready-to-read operational flight plan (plain text).
|
||||
pub ofp: String,
|
||||
/// Offline IFPS pre-check result for the computed route.
|
||||
pub ifps: IfpsDto,
|
||||
}
|
||||
|
||||
fn phase_dto(p: &perf::PhaseResult) -> PhaseDto {
|
||||
PhaseDto {
|
||||
dist_nm: p.dist_nm,
|
||||
time_min: p.time_min,
|
||||
fuel_kg: p.fuel_kg,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve a SID/STAR fix sequence to map points (airport prepended for a SID,
|
||||
/// appended for a STAR), reusing the route-geometry resolver.
|
||||
fn resolve_track(
|
||||
conn: &rusqlite::Connection,
|
||||
airport: &str,
|
||||
fixes: &[String],
|
||||
is_sid: bool,
|
||||
) -> Vec<PointDto> {
|
||||
if fixes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut idents: Vec<String> = Vec::new();
|
||||
if is_sid {
|
||||
idents.push(airport.to_uppercase());
|
||||
}
|
||||
idents.extend(fixes.iter().cloned());
|
||||
if !is_sid {
|
||||
idents.push(airport.to_uppercase());
|
||||
}
|
||||
let legs = idents
|
||||
.windows(2)
|
||||
.map(|w| routing::Leg {
|
||||
from: w[0].clone(),
|
||||
to: w[1].clone(),
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm: 0.0,
|
||||
})
|
||||
.collect();
|
||||
let route = routing::Route {
|
||||
legs,
|
||||
total_nm: 0.0,
|
||||
via_airways: false,
|
||||
};
|
||||
routing::resolve_geometry(conn, &route)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|(ident, pos)| PointDto {
|
||||
ident,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Run the full pipeline (route → fuel plan → geometry → OFP) for `req`.
|
||||
/// When `rad` is supplied, the IFPS pre-check also flags RAD DCT violations.
|
||||
pub fn plan(req: &PlanRequest, rad: Option<&crate::rad::RadData>) -> Result<PlanResult> {
|
||||
let conn = db::open(Path::new(&req.db_path))?;
|
||||
let from = req.from.trim().to_uppercase();
|
||||
let to = req.to.trim().to_uppercase();
|
||||
|
||||
let aircraft = req.aircraft.as_deref().filter(|s| !s.trim().is_empty());
|
||||
let profile = match aircraft {
|
||||
Some(icao) => Some(perf::AircraftProfile::load(
|
||||
Path::new(&req.aircraft_dir),
|
||||
icao,
|
||||
)?),
|
||||
None => None,
|
||||
};
|
||||
let cruise_fl = req
|
||||
.cruise_fl
|
||||
.or_else(|| profile.as_ref().map(|p| p.default_cruise_fl));
|
||||
// Respect RAD city-pair level caps (Annex 2A) up front, so airways are chosen
|
||||
// valid at the filed FL and the route is PROF204-clean by construction.
|
||||
let rad_cap = rad.and_then(|r| r.max_cruise_fl(&from, &to));
|
||||
let cruise_fl = match (cruise_fl, rad_cap) {
|
||||
// Snap to a valid cruise level (multiple of 10 = 1000 ft); an odd cap like
|
||||
// FL235 filed verbatim is rejected by IFPS as SYN101 INVALID LEVEL.
|
||||
(Some(fl), Some(cap)) => Some(fl.min(cap) / 10 * 10),
|
||||
(fl, _) => fl,
|
||||
};
|
||||
|
||||
// SID exits (dep) / STAR entries (dest): (procedure name, connector fix).
|
||||
use crate::navdata::procedure::{connectors, ProcKind};
|
||||
let (dep_sid, dest_star): (Vec<(String, String)>, Vec<(String, String)>) =
|
||||
match req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(cifp) => {
|
||||
let dir = Path::new(cifp);
|
||||
(
|
||||
connectors(dir, &from, ProcKind::Sid).unwrap_or_default(),
|
||||
connectors(dir, &to, ProcKind::Star).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
// Route to file (FRA graph → FRA heuristic → airways); see `plan_preferred`.
|
||||
let route = routing::plan_preferred(&conn, &from, &to, &dep_sid, &dest_star, cruise_fl, rad)?;
|
||||
|
||||
let fuel = if let Some(profile) = &profile {
|
||||
let fl = cruise_fl.unwrap_or(profile.default_cruise_fl);
|
||||
let alternate_nm = match req.alternate.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(alt) => Some(
|
||||
routing::plan_route_best(&conn, &to, &alt.trim().to_uppercase(), cruise_fl, &[], &[])?
|
||||
.total_nm,
|
||||
),
|
||||
None => None,
|
||||
};
|
||||
let p = perf::compute_fuel_plan(profile, &route, fl, alternate_nm, req.payload_kg);
|
||||
Some((profile.clone(), fl, p))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let geometry = routing::resolve_geometry(&conn, &route)?
|
||||
.into_iter()
|
||||
.map(|(ident, pos)| PointDto {
|
||||
ident,
|
||||
lat: pos.lat,
|
||||
lon: pos.lon,
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Full SID/STAR procedure tracks (chosen SID = first non-DCT leg, STAR = last).
|
||||
let mut procedures: Vec<ProcTrackDto> = Vec::new();
|
||||
if let Some(cifp) = req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
let dir = Path::new(cifp);
|
||||
if let Some(first) = route.legs.first() {
|
||||
if first.airway != "DCT" {
|
||||
let fixes = crate::navdata::procedure::procedure_track(
|
||||
dir, &from, ProcKind::Sid, &first.airway, &first.to,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let pts = resolve_track(&conn, &from, &fixes, true);
|
||||
if pts.len() >= 2 {
|
||||
procedures.push(ProcTrackDto {
|
||||
name: first.airway.clone(),
|
||||
kind: "SID".to_owned(),
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(last) = route.legs.last() {
|
||||
if last.airway != "DCT" {
|
||||
let fixes = crate::navdata::procedure::procedure_track(
|
||||
dir, &to, ProcKind::Star, &last.airway, &last.from,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
let pts = resolve_track(&conn, &to, &fixes, false);
|
||||
if pts.len() >= 2 {
|
||||
procedures.push(ProcTrackDto {
|
||||
name: last.airway.clone(),
|
||||
kind: "STAR".to_owned(),
|
||||
points: pts,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ofp = export::ofp::to_ofp(&route, fuel.as_ref().map(|(_, _, p)| p), aircraft, cruise_fl);
|
||||
|
||||
let mut cum_dist = 0.0;
|
||||
let legs = match &fuel {
|
||||
Some((_, _, plan)) => plan
|
||||
.legs
|
||||
.iter()
|
||||
.map(|l| {
|
||||
cum_dist += l.dist_nm;
|
||||
LegDto {
|
||||
from: l.from.clone(),
|
||||
via: l.airway.clone(),
|
||||
to: l.to.clone(),
|
||||
dist_nm: l.dist_nm,
|
||||
cum_dist_nm: cum_dist,
|
||||
time_min: Some(l.time_min),
|
||||
cum_time_min: Some(l.cum_time_min),
|
||||
fuel_kg: Some(l.fuel_kg),
|
||||
cum_fuel_kg: Some(l.cum_fuel_kg),
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
None => route
|
||||
.legs
|
||||
.iter()
|
||||
.map(|l| {
|
||||
cum_dist += l.dist_nm;
|
||||
LegDto {
|
||||
from: l.from.clone(),
|
||||
via: l.airway.clone(),
|
||||
to: l.to.clone(),
|
||||
dist_nm: l.dist_nm,
|
||||
cum_dist_nm: cum_dist,
|
||||
time_min: None,
|
||||
cum_time_min: None,
|
||||
fuel_kg: None,
|
||||
cum_fuel_kg: None,
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let fuel_dto = fuel.map(|(profile, fl, plan)| FuelDto {
|
||||
aircraft: format!("{} ({})", profile.icao, profile.name),
|
||||
cruise_fl: fl,
|
||||
climb: phase_dto(&plan.climb),
|
||||
cruise: phase_dto(&plan.cruise),
|
||||
descent: phase_dto(&plan.descent),
|
||||
trip_fuel_kg: plan.trip_fuel_kg,
|
||||
trip_time_min: plan.trip_time_min,
|
||||
taxi_kg: plan.taxi_kg,
|
||||
contingency_kg: plan.contingency_kg,
|
||||
alternate_kg: plan.alternate_kg,
|
||||
final_reserve_kg: plan.final_reserve_kg,
|
||||
block_fuel_kg: plan.block_fuel_kg,
|
||||
mass: plan.masses.map(|m| MassDto {
|
||||
payload_kg: m.payload_kg,
|
||||
zfw_kg: m.zfw_kg,
|
||||
takeoff_kg: m.takeoff_kg,
|
||||
landing_kg: m.landing_kg,
|
||||
over_mtow: m.over_mtow,
|
||||
over_mlw: m.over_mlw,
|
||||
}),
|
||||
});
|
||||
|
||||
// Offline IFPS pre-check (structural, non-authoritative), then store the plan
|
||||
// as an UNVALIDATED draft. `ifps_ok` is reserved for routes the real IFPUV
|
||||
// oracle has accepted (`discover_route`), so the DB never claims a route is
|
||||
// IFPS-valid on the strength of the offline check (it has false positives).
|
||||
let route_str = route.route_string();
|
||||
let ifps_fl = cruise_fl.unwrap_or(350);
|
||||
let report = crate::ifps::prevalidate(&conn, &route_str, ifps_fl, rad)?;
|
||||
let _ = crate::routes::record(
|
||||
&conn,
|
||||
&from,
|
||||
&to,
|
||||
ifps_fl,
|
||||
&route_str,
|
||||
route.total_nm,
|
||||
route.via_airways,
|
||||
false, // never authoritative — only the oracle sets ifps_ok
|
||||
&report.errors,
|
||||
"draft",
|
||||
);
|
||||
|
||||
Ok(PlanResult {
|
||||
route_string: route_str,
|
||||
via_airways: route.via_airways,
|
||||
total_nm: route.total_nm,
|
||||
cruise_fl,
|
||||
legs,
|
||||
fuel: fuel_dto,
|
||||
geometry,
|
||||
procedures,
|
||||
ofp,
|
||||
ifps: IfpsDto {
|
||||
accepted: report.accepted,
|
||||
errors: report.errors,
|
||||
warnings: report.warnings,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/// Discover an IFPS-valid route via the oracle loop, then store it. `validator`
|
||||
/// is supplied by the app layer (spawns the IFPUV scraper). The accepted route is
|
||||
/// saved to our route DB (`source="ifps"`) so it's reused instantly next time.
|
||||
pub fn discover_route(
|
||||
req: &PlanRequest,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn routing::discover::IfpsValidator,
|
||||
) -> Result<routing::discover::DiscoverResult> {
|
||||
use crate::navdata::procedure::{connectors, ProcKind};
|
||||
let conn = db::open(Path::new(&req.db_path))?;
|
||||
let from = req.from.trim().to_uppercase();
|
||||
let to = req.to.trim().to_uppercase();
|
||||
// Start at the requested FL, capped by any RAD city-pair level cap (Annex 2A).
|
||||
let requested = req.cruise_fl.unwrap_or(360);
|
||||
let start_fl = rad
|
||||
.and_then(|r| r.max_cruise_fl(&from, &to))
|
||||
.map_or(requested, |cap| requested.min(cap) / 10 * 10); // valid cruise level
|
||||
|
||||
// Reuse: instant return if our DB already holds an oracle-validated (no-error)
|
||||
// route for this pair — this is how the self-built IFPS route DB pays off.
|
||||
if let Ok(stored) = crate::routes::recent(&conn, &from, &to, 25) {
|
||||
if let Some(r) = stored.iter().find(|r| r.ifps_ok && r.source == "ifps") {
|
||||
return Ok(routing::discover::DiscoverResult {
|
||||
accepted: true,
|
||||
route_string: r.route_string.clone(),
|
||||
item15: r.route_string.clone(),
|
||||
fl: r.cruise_fl,
|
||||
total_nm: r.dist_nm,
|
||||
errors: Vec::new(),
|
||||
iterations: 0,
|
||||
log: vec![format!("reused stored IFPS-valid route ({})", r.generated_at)],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let (dep_sid, dest_star) = match req.cifp_dir.as_deref().filter(|s| !s.trim().is_empty()) {
|
||||
Some(cifp) => {
|
||||
let dir = Path::new(cifp);
|
||||
(
|
||||
connectors(dir, &from, ProcKind::Sid).unwrap_or_default(),
|
||||
connectors(dir, &to, ProcKind::Star).unwrap_or_default(),
|
||||
)
|
||||
}
|
||||
None => (Vec::new(), Vec::new()),
|
||||
};
|
||||
|
||||
let result = routing::discover::find_valid_route(
|
||||
&conn, &from, &to, &dep_sid, &dest_star, start_fl, rad, validator,
|
||||
)?;
|
||||
|
||||
// Only store routes the oracle actually accepted (no-error) — the DB is the
|
||||
// set of IFPS-valid routes, nothing else.
|
||||
if result.accepted {
|
||||
let _ = crate::routes::record(
|
||||
&conn,
|
||||
&from,
|
||||
&to,
|
||||
result.fl,
|
||||
&result.route_string,
|
||||
result.total_nm,
|
||||
true,
|
||||
true,
|
||||
&[],
|
||||
"ifps",
|
||||
);
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair (our own FPL database), most recent first.
|
||||
pub fn recent_routes(
|
||||
db_path: &str,
|
||||
dep: &str,
|
||||
dest: &str,
|
||||
limit: usize,
|
||||
) -> Result<Vec<CachedRouteDto>> {
|
||||
let conn = db::open(Path::new(db_path))?;
|
||||
Ok(crate::routes::recent(&conn, dep, dest, limit)?
|
||||
.into_iter()
|
||||
.map(|r| CachedRouteDto {
|
||||
dep: r.dep,
|
||||
dest: r.dest,
|
||||
cruise_fl: r.cruise_fl,
|
||||
route_string: r.route_string,
|
||||
dist_nm: r.dist_nm,
|
||||
ifps_ok: r.ifps_ok,
|
||||
source: r.source,
|
||||
generated_at: r.generated_at,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Total number of stored routes in `db_path`.
|
||||
pub fn route_db_count(db_path: &str) -> Result<i64> {
|
||||
let conn = db::open(Path::new(db_path))?;
|
||||
crate::routes::count(&conn)
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
//! SQLite persistence via `rusqlite` (feature `bundled`, no system dependency).
|
||||
//!
|
||||
//! [`import_navdata`] streams the X-Plane `.dat` files from a source directory
|
||||
//! into the local database inside a single transaction.
|
||||
|
||||
pub mod schema;
|
||||
|
||||
use std::fs::File;
|
||||
use std::io::BufReader;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection, Transaction};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::navdata::{
|
||||
airport::parse_airport_from_cifp, airway::parse_awy_line, fix::parse_fix_line,
|
||||
nav::parse_nav_line, stream_file,
|
||||
};
|
||||
|
||||
/// Row counts produced by an import.
|
||||
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct ImportStats {
|
||||
pub waypoints: usize,
|
||||
pub navaids: usize,
|
||||
pub airway_segments: usize,
|
||||
pub airports: usize,
|
||||
}
|
||||
|
||||
/// Open (creating if needed) a database file and ensure the schema exists.
|
||||
pub fn open(path: &Path) -> Result<Connection> {
|
||||
let conn = Connection::open(path)?;
|
||||
init_schema(&conn)?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// Apply the schema (idempotent).
|
||||
pub fn init_schema(conn: &Connection) -> Result<()> {
|
||||
conn.execute_batch(schema::SCHEMA_SQL)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Import `earth_fix.dat`, `earth_nav.dat`, `earth_awy.dat` and (if present) the
|
||||
/// `CIFP/` airports from `source` into `conn`.
|
||||
pub fn import_navdata(conn: &mut Connection, source: &Path) -> Result<ImportStats> {
|
||||
init_schema(conn)?;
|
||||
let tx = conn.transaction()?;
|
||||
let stats = ImportStats {
|
||||
waypoints: import_fixes(&tx, &source.join("earth_fix.dat"))?,
|
||||
navaids: import_navaids(&tx, &source.join("earth_nav.dat"))?,
|
||||
airway_segments: import_airways(&tx, &source.join("earth_awy.dat"))?,
|
||||
airports: import_airports(&tx, &source.join("CIFP"))?,
|
||||
};
|
||||
tx.commit()?;
|
||||
Ok(stats)
|
||||
}
|
||||
|
||||
fn reader(path: &Path) -> Result<BufReader<File>> {
|
||||
Ok(BufReader::new(File::open(path)?))
|
||||
}
|
||||
|
||||
fn import_fixes(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut stmt =
|
||||
tx.prepare("INSERT INTO waypoints (ident, region, lat, lon) VALUES (?1, ?2, ?3, ?4)")?;
|
||||
stream_file(reader(path)?, "earth_fix.dat", parse_fix_line, |wp| {
|
||||
stmt.execute(params![wp.ident, wp.region, wp.pos.lat, wp.pos.lon])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn import_navaids(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut stmt = tx.prepare(
|
||||
"INSERT INTO navaids (ident, region, kind, freq, lat, lon, name) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
)?;
|
||||
stream_file(reader(path)?, "earth_nav.dat", parse_nav_line, |nv| {
|
||||
stmt.execute(params![
|
||||
nv.ident,
|
||||
nv.region,
|
||||
nv.kind.as_str(),
|
||||
nv.freq,
|
||||
nv.pos.lat,
|
||||
nv.pos.lon,
|
||||
nv.name
|
||||
])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn import_airways(tx: &Transaction, path: &Path) -> Result<usize> {
|
||||
let mut seg_stmt = tx.prepare(
|
||||
"INSERT INTO airway_segments \
|
||||
(airway_name, from_ident, from_region, to_ident, to_region, direction, layer, base_fl, top_fl) \
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
|
||||
)?;
|
||||
let mut awy_stmt = tx.prepare("INSERT OR IGNORE INTO airways (name, layer) VALUES (?1, ?2)")?;
|
||||
let mut rows = 0usize;
|
||||
stream_file(reader(path)?, "earth_awy.dat", parse_awy_line, |seg| {
|
||||
let layer = seg.layer.as_str();
|
||||
let dir = seg.direction.to_string();
|
||||
for name in &seg.airways {
|
||||
seg_stmt.execute(params![
|
||||
name,
|
||||
seg.from_ident,
|
||||
seg.from_region,
|
||||
seg.to_ident,
|
||||
seg.to_region,
|
||||
dir,
|
||||
layer,
|
||||
seg.base_fl,
|
||||
seg.top_fl
|
||||
])?;
|
||||
awy_stmt.execute(params![name, layer])?;
|
||||
rows += 1;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(rows)
|
||||
}
|
||||
|
||||
fn import_airports(tx: &Transaction, cifp_dir: &Path) -> Result<usize> {
|
||||
if !cifp_dir.is_dir() {
|
||||
return Ok(0);
|
||||
}
|
||||
let mut stmt =
|
||||
tx.prepare("INSERT OR REPLACE INTO airports (icao, lat, lon) VALUES (?1, ?2, ?3)")?;
|
||||
let mut n = 0usize;
|
||||
for entry in std::fs::read_dir(cifp_dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("dat") {
|
||||
continue;
|
||||
}
|
||||
let icao = path
|
||||
.file_stem()
|
||||
.and_then(|s| s.to_str())
|
||||
.unwrap_or_default()
|
||||
.to_owned();
|
||||
if let Some(ap) = parse_airport_from_cifp(&icao, reader(&path)?)? {
|
||||
stmt.execute(params![ap.icao, ap.pos.lat, ap.pos.lon])?;
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! SQLite schema. Kept in one place so `init_schema` and future migrations stay
|
||||
//! in sync.
|
||||
|
||||
/// DDL applied on every `open`/import (idempotent).
|
||||
pub const SCHEMA_SQL: &str = r#"
|
||||
CREATE TABLE IF NOT EXISTS waypoints (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ident TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_waypoints_ident ON waypoints(ident);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS navaids (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ident TEXT NOT NULL,
|
||||
region TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
freq INTEGER NOT NULL,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL,
|
||||
name TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_navaids_ident ON navaids(ident);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airports (
|
||||
icao TEXT PRIMARY KEY,
|
||||
lat REAL NOT NULL,
|
||||
lon REAL NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airways (
|
||||
name TEXT PRIMARY KEY,
|
||||
layer TEXT NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS airway_segments (
|
||||
id INTEGER PRIMARY KEY,
|
||||
airway_name TEXT NOT NULL,
|
||||
from_ident TEXT NOT NULL,
|
||||
from_region TEXT NOT NULL,
|
||||
to_ident TEXT NOT NULL,
|
||||
to_region TEXT NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
layer TEXT NOT NULL,
|
||||
base_fl INTEGER NOT NULL,
|
||||
top_fl INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_awyseg_from ON airway_segments(from_ident);
|
||||
CREATE INDEX IF NOT EXISTS idx_awyseg_to ON airway_segments(to_ident);
|
||||
|
||||
-- Our own database of flight plans: routes we generated and IFPS-pre-checked.
|
||||
CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
dep TEXT NOT NULL,
|
||||
dest TEXT NOT NULL,
|
||||
cruise_fl INTEGER NOT NULL,
|
||||
route_string TEXT NOT NULL,
|
||||
dist_nm REAL NOT NULL,
|
||||
via_airways INTEGER NOT NULL,
|
||||
ifps_ok INTEGER NOT NULL,
|
||||
ifps_errors TEXT NOT NULL,
|
||||
source TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL,
|
||||
UNIQUE(dep, dest, cruise_fl, route_string)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_routes_pair ON routes(dep, dest);
|
||||
"#;
|
||||
@@ -0,0 +1,37 @@
|
||||
//! Crate-wide error type. Library code returns [`Result`]; the CLI wraps these
|
||||
//! with `anyhow` for user-facing context.
|
||||
|
||||
use thiserror::Error;
|
||||
|
||||
/// All fallible operations in `flightplanner-core` return this error.
|
||||
#[derive(Debug, Error)]
|
||||
pub enum CoreError {
|
||||
#[error("I/O error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
#[error("database error: {0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
|
||||
#[error("JSON error: {0}")]
|
||||
Json(#[from] serde_json::Error),
|
||||
|
||||
/// A navdata line could not be parsed. Carries enough context to locate it.
|
||||
#[error("parse error in {file} (line {line}): {reason}")]
|
||||
Parse {
|
||||
file: String,
|
||||
line: usize,
|
||||
reason: String,
|
||||
},
|
||||
|
||||
#[error("routing error: {0}")]
|
||||
Routing(String),
|
||||
|
||||
#[error("not found: {0}")]
|
||||
NotFound(String),
|
||||
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
/// Convenience alias used throughout the crate.
|
||||
pub type Result<T> = std::result::Result<T, CoreError>;
|
||||
@@ -0,0 +1,40 @@
|
||||
//! X-Plane 11 `.fms` flight plan (version 1100).
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{build_points, PointKind};
|
||||
use crate::error::Result;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Render `route` as an X-Plane `.fms` document, cruising at `cruise_alt_ft`.
|
||||
pub fn to_fms(conn: &Connection, route: &Route, cruise_alt_ft: i32) -> Result<String> {
|
||||
let points = build_points(conn, route)?;
|
||||
let dep = &points[0];
|
||||
let dest = &points[points.len() - 1];
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("I\n");
|
||||
s.push_str("1100 Version\n");
|
||||
s.push_str("CYCLE 2608\n");
|
||||
s.push_str(&format!("ADEP {}\n", dep.ident));
|
||||
s.push_str(&format!("ADES {}\n", dest.ident));
|
||||
s.push_str(&format!("NUMENR {}\n", points.len()));
|
||||
|
||||
for p in &points {
|
||||
let alt = if p.kind == PointKind::Airport {
|
||||
0.0
|
||||
} else {
|
||||
cruise_alt_ft as f64
|
||||
};
|
||||
s.push_str(&format!(
|
||||
"{} {} {} {:.6} {:.6} {:.6}\n",
|
||||
p.kind.fms_type(),
|
||||
p.ident,
|
||||
p.via,
|
||||
alt,
|
||||
p.pos.lat,
|
||||
p.pos.lon
|
||||
));
|
||||
}
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
//! Route/plan exporters: `.pln` (MSFS/P3D), `.fms` (X-Plane) and a text OFP.
|
||||
//!
|
||||
//! The sim formats need a coordinate per point, so [`build_points`] resolves each
|
||||
//! route ident against the local navdata (airports, navaids, waypoints) and
|
||||
//! annotates it with the inbound airway (or `ADEP`/`ADES`/`DCT`).
|
||||
|
||||
pub mod fms;
|
||||
pub mod ofp;
|
||||
pub mod ofp_template;
|
||||
pub mod pln;
|
||||
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::LatLon;
|
||||
use crate::routing::Route;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PointKind {
|
||||
Airport,
|
||||
Vor,
|
||||
Ndb,
|
||||
Fix,
|
||||
}
|
||||
|
||||
impl PointKind {
|
||||
fn pln_type(self) -> &'static str {
|
||||
match self {
|
||||
PointKind::Airport => "Airport",
|
||||
PointKind::Vor => "VOR",
|
||||
PointKind::Ndb => "NDB",
|
||||
PointKind::Fix => "Intersection",
|
||||
}
|
||||
}
|
||||
|
||||
fn fms_type(self) -> i32 {
|
||||
match self {
|
||||
PointKind::Airport => 1,
|
||||
PointKind::Ndb => 2,
|
||||
PointKind::Vor => 3,
|
||||
PointKind::Fix => 11,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A resolved route point ready for export.
|
||||
pub struct ExportPoint {
|
||||
pub ident: String,
|
||||
pub pos: LatLon,
|
||||
pub kind: PointKind,
|
||||
/// Inbound leg: `ADEP`, `ADES`, `DCT`, or an airway name.
|
||||
pub via: String,
|
||||
}
|
||||
|
||||
/// Resolve a route into an ordered list of positioned points (dep first, dest
|
||||
/// last).
|
||||
pub fn build_points(conn: &Connection, route: &Route) -> Result<Vec<ExportPoint>> {
|
||||
let first = route
|
||||
.legs
|
||||
.first()
|
||||
.ok_or_else(|| CoreError::Other("cannot export an empty route".to_owned()))?;
|
||||
|
||||
// (ident, inbound airway) in order.
|
||||
let mut seq: Vec<(String, String)> = vec![(first.from.clone(), "ADEP".to_owned())];
|
||||
for leg in &route.legs {
|
||||
let via = if leg.airway == "DCT" {
|
||||
"DCT".to_owned()
|
||||
} else {
|
||||
leg.airway.clone()
|
||||
};
|
||||
seq.push((leg.to.clone(), via));
|
||||
}
|
||||
|
||||
let n = seq.len();
|
||||
let mut points = Vec::with_capacity(n);
|
||||
for (i, (ident, via)) in seq.into_iter().enumerate() {
|
||||
let (pos, kind) = resolve_point(conn, &ident)?;
|
||||
let via = if i == 0 {
|
||||
"ADEP".to_owned()
|
||||
} else if i == n - 1 {
|
||||
"ADES".to_owned()
|
||||
} else {
|
||||
via
|
||||
};
|
||||
points.push(ExportPoint {
|
||||
ident,
|
||||
pos,
|
||||
kind,
|
||||
via,
|
||||
});
|
||||
}
|
||||
Ok(points)
|
||||
}
|
||||
|
||||
fn resolve_point(conn: &Connection, ident: &str) -> Result<(LatLon, PointKind)> {
|
||||
if let Some(p) = q_airport(conn, ident)? {
|
||||
return Ok((p, PointKind::Airport));
|
||||
}
|
||||
if let Some(pk) = q_navaid(conn, ident)? {
|
||||
return Ok(pk);
|
||||
}
|
||||
if let Some(p) = q_waypoint(conn, ident)? {
|
||||
return Ok((p, PointKind::Fix));
|
||||
}
|
||||
Err(CoreError::NotFound(format!("point '{ident}' for export")))
|
||||
}
|
||||
|
||||
fn q_airport(conn: &Connection, ident: &str) -> Result<Option<LatLon>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
fn q_navaid(conn: &Connection, ident: &str) -> Result<Option<(LatLon, PointKind)>> {
|
||||
let row = conn
|
||||
.query_row(
|
||||
"SELECT lat, lon, kind FROM navaids WHERE ident = ?1 LIMIT 1",
|
||||
params![ident],
|
||||
|r| Ok((LatLon::new(r.get(0)?, r.get(1)?), r.get::<_, String>(2)?)),
|
||||
)
|
||||
.optional()?;
|
||||
Ok(row.map(|(pos, kind)| {
|
||||
let kind = if kind == "NDB" {
|
||||
PointKind::Ndb
|
||||
} else {
|
||||
PointKind::Vor
|
||||
};
|
||||
(pos, kind)
|
||||
}))
|
||||
}
|
||||
|
||||
fn q_waypoint(conn: &Connection, ident: &str) -> Result<Option<LatLon>> {
|
||||
Ok(conn
|
||||
.query_row(
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1 LIMIT 1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.optional()?)
|
||||
}
|
||||
|
||||
/// Degrees/minutes/seconds string like `N49° 0' 34.93"`.
|
||||
pub(crate) fn dms(value: f64, is_lat: bool) -> String {
|
||||
let hemi = match (is_lat, value >= 0.0) {
|
||||
(true, true) => 'N',
|
||||
(true, false) => 'S',
|
||||
(false, true) => 'E',
|
||||
(false, false) => 'W',
|
||||
};
|
||||
let v = value.abs();
|
||||
let deg = v.trunc() as i64;
|
||||
let minutes = (v - deg as f64) * 60.0;
|
||||
let min = minutes.trunc() as i64;
|
||||
let sec = (minutes - min as f64) * 60.0;
|
||||
format!("{hemi}{deg}° {min}' {sec:.2}\"")
|
||||
}
|
||||
|
||||
/// MSFS `WorldPosition` string: `lat,lon,+000000.00`.
|
||||
pub(crate) fn world_position(pos: LatLon, alt_ft: i32) -> String {
|
||||
format!(
|
||||
"{},{},{:+010.2}",
|
||||
dms(pos.lat, true),
|
||||
dms(pos.lon, false),
|
||||
alt_ft as f64
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! Human-readable Operational Flight Plan (plain text).
|
||||
|
||||
use std::fmt::Write;
|
||||
|
||||
use crate::perf::FuelPlan;
|
||||
use crate::routing::Route;
|
||||
|
||||
fn hm(minutes: f64) -> String {
|
||||
let t = minutes.round() as i64;
|
||||
format!("{}:{:02}", t / 60, t % 60)
|
||||
}
|
||||
|
||||
/// Render a text OFP. When `plan` is provided, per-leg time/fuel and a fuel
|
||||
/// summary are included; otherwise only route and distances are shown.
|
||||
pub fn to_ofp(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> String {
|
||||
let dep = route
|
||||
.legs
|
||||
.first()
|
||||
.map(|l| l.from.as_str())
|
||||
.unwrap_or("????");
|
||||
let dest = route.legs.last().map(|l| l.to.as_str()).unwrap_or("????");
|
||||
let bar = "=".repeat(64);
|
||||
let sep = "-".repeat(64);
|
||||
|
||||
let mut s = String::new();
|
||||
let _ = writeln!(s, "{bar}");
|
||||
let _ = writeln!(s, " OPERATIONAL FLIGHT PLAN (unofficial, sim use)");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {dep} -> {dest} {}",
|
||||
aircraft.unwrap_or("(no aircraft)")
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Cruise {} AIRAC 2608 routing: {}",
|
||||
cruise_fl
|
||||
.map(|f| format!("FL{f:03}"))
|
||||
.unwrap_or_else(|| "n/a".to_owned()),
|
||||
if route.via_airways {
|
||||
"airways"
|
||||
} else {
|
||||
"direct"
|
||||
}
|
||||
);
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(s, " ROUTE");
|
||||
let _ = writeln!(s, " {}", route.route_string());
|
||||
let _ = writeln!(s, "{sep}");
|
||||
|
||||
if let Some(plan) = plan {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5} {:>6} {:>7} {:>8}",
|
||||
"FROM", "VIA", "TO", "DIST", "ETE", "FUEL", "CUMFUEL"
|
||||
);
|
||||
for leg in &plan.legs {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5.0} {:>6} {:>7.0} {:>8.0}",
|
||||
leg.from,
|
||||
leg.airway,
|
||||
leg.to,
|
||||
leg.dist_nm,
|
||||
hm(leg.time_min),
|
||||
leg.fuel_kg,
|
||||
leg.cum_fuel_kg
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Trip: {:>6.0} nm {} {:>6.0} kg",
|
||||
plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm,
|
||||
hm(plan.trip_time_min),
|
||||
plan.trip_fuel_kg
|
||||
);
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" Reserves: taxi {:.0} contingency {:.0} alternate {:.0} final {:.0}",
|
||||
plan.taxi_kg, plan.contingency_kg, plan.alternate_kg, plan.final_reserve_kg
|
||||
);
|
||||
let _ = writeln!(s, " BLOCK FUEL: {:.0} kg", plan.block_fuel_kg);
|
||||
if let Some(m) = &plan.masses {
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" MASS payload {:.0} ZFW {:.0} TOW {:.0} LDW {:.0} kg",
|
||||
m.payload_kg, m.zfw_kg, m.takeoff_kg, m.landing_kg
|
||||
);
|
||||
if m.over_mtow {
|
||||
let _ = writeln!(s, " ! TOW exceeds MTOW — infeasible as loaded");
|
||||
}
|
||||
if m.over_mlw {
|
||||
let _ = writeln!(s, " ! LDW exceeds MLW");
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let _ = writeln!(s, " {:<7} {:<6} {:<7} {:>5}", "FROM", "VIA", "TO", "DIST");
|
||||
for leg in &route.legs {
|
||||
let _ = writeln!(
|
||||
s,
|
||||
" {:<7} {:<6} {:<7} {:>5.0}",
|
||||
leg.from, leg.airway, leg.to, leg.dist_nm
|
||||
);
|
||||
}
|
||||
let _ = writeln!(s, "{sep}");
|
||||
let _ = writeln!(s, " Total distance: {:.0} nm", route.total_nm);
|
||||
}
|
||||
let _ = writeln!(s, "{bar}");
|
||||
s
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
//! Token-based OFP template engine (PFPX-style, clean-room).
|
||||
//!
|
||||
//! PFPX renders its Operational Flight Plan from a **text template** full of
|
||||
//! `<&Token>` placeholders and repeating `<&Section_Begin>…<&Section_End>` blocks
|
||||
//! (e.g. the nav-log). This module reproduces that *system* — our own engine and
|
||||
//! our own default template — so the OFP layout is configurable, not hard-coded.
|
||||
//!
|
||||
//! Token grammar:
|
||||
//! * `<&Name>` — the value of `Name`
|
||||
//! * `<&Name:W>` — right-justified in a field of width `W`
|
||||
//! * `<&Name:W:L>` — left-justified in width `W`
|
||||
//! * `<&NavLog_Begin>…<&NavLog_End>` — the inner block repeated once per leg
|
||||
//!
|
||||
//! Values come from an [`OfpContext`] built from a [`Route`] + optional
|
||||
//! [`FuelPlan`]; inside the nav-log section, per-row tokens shadow scalars.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use crate::perf::FuelPlan;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Values available to a template: scalar tokens + repeating nav-log rows.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct OfpContext {
|
||||
scalars: HashMap<String, String>,
|
||||
navlog: Vec<HashMap<String, String>>,
|
||||
}
|
||||
|
||||
impl OfpContext {
|
||||
fn set(&mut self, key: &str, val: impl Into<String>) {
|
||||
self.scalars.insert(key.to_string(), val.into());
|
||||
}
|
||||
}
|
||||
|
||||
fn hm(minutes: f64) -> String {
|
||||
let t = minutes.round() as i64;
|
||||
format!("{}:{:02}", t / 60, t % 60)
|
||||
}
|
||||
|
||||
fn kg(v: f64) -> String {
|
||||
format!("{:.0}", v)
|
||||
}
|
||||
|
||||
fn truncate(s: &str, w: usize) -> String {
|
||||
if s.chars().count() > w {
|
||||
s.chars().take(w).collect()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Replace `<&Name[:W[:L]]>` tokens using `get`. Section markers must already be
|
||||
/// expanded away before this runs.
|
||||
fn render_scalars(s: &str, get: &dyn Fn(&str) -> Option<String>) -> String {
|
||||
let mut out = String::with_capacity(s.len());
|
||||
let mut rest = s;
|
||||
while let Some(i) = rest.find("<&") {
|
||||
out.push_str(&rest[..i]);
|
||||
rest = &rest[i + 2..];
|
||||
let Some(j) = rest.find('>') else {
|
||||
out.push_str("<&");
|
||||
break;
|
||||
};
|
||||
let tok = &rest[..j];
|
||||
rest = &rest[j + 1..];
|
||||
let mut parts = tok.split(':');
|
||||
let name = parts.next().unwrap_or("");
|
||||
let width: Option<usize> = parts.next().and_then(|w| w.parse().ok());
|
||||
let left = parts.next() == Some("L");
|
||||
let val = get(name).unwrap_or_default();
|
||||
match width {
|
||||
Some(w) if left => out.push_str(&format!("{:<w$}", truncate(&val, w), w = w)),
|
||||
Some(w) => out.push_str(&format!("{:>w$}", truncate(&val, w), w = w)),
|
||||
None => out.push_str(&val),
|
||||
}
|
||||
}
|
||||
out.push_str(rest);
|
||||
out
|
||||
}
|
||||
|
||||
/// Expand `<&{name}_Begin>…<&{name}_End>`, repeating the inner block per row.
|
||||
fn expand_section(
|
||||
template: &str,
|
||||
name: &str,
|
||||
rows: &[HashMap<String, String>],
|
||||
scalars: &HashMap<String, String>,
|
||||
) -> String {
|
||||
let begin = format!("<&{name}_Begin>");
|
||||
let end = format!("<&{name}_End>");
|
||||
let (Some(b), Some(e)) = (template.find(&begin), template.find(&end)) else {
|
||||
return template.to_string();
|
||||
};
|
||||
let inner = &template[b + begin.len()..e];
|
||||
let mut body = String::new();
|
||||
for row in rows {
|
||||
let get = |k: &str| row.get(k).cloned().or_else(|| scalars.get(k).cloned());
|
||||
body.push_str(&render_scalars(inner, &get));
|
||||
}
|
||||
format!("{}{}{}", &template[..b], body, &template[e + end.len()..])
|
||||
}
|
||||
|
||||
/// Render `template` with `ctx`.
|
||||
pub fn render(template: &str, ctx: &OfpContext) -> String {
|
||||
let expanded = expand_section(template, "NavLog", &ctx.navlog, &ctx.scalars);
|
||||
let get = |k: &str| ctx.scalars.get(k).cloned();
|
||||
render_scalars(&expanded, &get)
|
||||
}
|
||||
|
||||
/// Build the render context from a computed route/plan.
|
||||
pub fn context(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
airac: &str,
|
||||
) -> OfpContext {
|
||||
let mut c = OfpContext::default();
|
||||
let dep = route.legs.first().map(|l| l.from.as_str()).unwrap_or("????");
|
||||
let dest = route.legs.last().map(|l| l.to.as_str()).unwrap_or("????");
|
||||
c.set("DEP", dep);
|
||||
c.set("DEST", dest);
|
||||
c.set("AIRCRAFT", aircraft.unwrap_or("---"));
|
||||
c.set("CRUISE", cruise_fl.map(|f| format!("FL{f:03}")).unwrap_or_else(|| "---".into()));
|
||||
c.set("AIRAC", airac);
|
||||
c.set("ROUTE", route.route_string());
|
||||
c.set("TOTALDIST", format!("{:.0}", route.total_nm));
|
||||
c.set("ROUTING", if route.via_airways { "airways" } else { "direct" });
|
||||
|
||||
if let Some(p) = plan {
|
||||
for leg in &p.legs {
|
||||
let mut row = HashMap::new();
|
||||
row.insert("FROM".into(), leg.from.clone());
|
||||
row.insert("VIA".into(), leg.airway.clone());
|
||||
row.insert("TO".into(), leg.to.clone());
|
||||
row.insert("DIST".into(), format!("{:.0}", leg.dist_nm));
|
||||
row.insert("ETE".into(), hm(leg.time_min));
|
||||
row.insert("FUEL".into(), kg(leg.fuel_kg));
|
||||
row.insert("CUMFUEL".into(), kg(leg.cum_fuel_kg));
|
||||
c.navlog.push(row);
|
||||
}
|
||||
c.set("TRIPDIST", format!("{:.0}", p.climb.dist_nm + p.cruise.dist_nm + p.descent.dist_nm));
|
||||
c.set("TRIPTIME", hm(p.trip_time_min));
|
||||
c.set("TRIPFUEL", kg(p.trip_fuel_kg));
|
||||
c.set("TAXI", kg(p.taxi_kg));
|
||||
c.set("CONTINGENCY", kg(p.contingency_kg));
|
||||
c.set("ALTERNATE", kg(p.alternate_kg));
|
||||
c.set("FINALRES", kg(p.final_reserve_kg));
|
||||
c.set("BLOCKFUEL", kg(p.block_fuel_kg));
|
||||
if let Some(m) = &p.masses {
|
||||
c.set("PAYLOAD", kg(m.payload_kg));
|
||||
c.set("ZFW", kg(m.zfw_kg));
|
||||
c.set("TOW", kg(m.takeoff_kg));
|
||||
c.set("LDW", kg(m.landing_kg));
|
||||
}
|
||||
}
|
||||
c
|
||||
}
|
||||
|
||||
/// Our own default PFPX-style OFP template.
|
||||
pub const DEFAULT_TEMPLATE: &str = "\
|
||||
================================================================
|
||||
OPERATIONAL FLIGHT PLAN (unofficial, sim use)
|
||||
<&DEP> -> <&DEST> <&AIRCRAFT> <&CRUISE> AIRAC <&AIRAC>
|
||||
routing: <&ROUTING> distance: <&TOTALDIST> nm
|
||||
----------------------------------------------------------------
|
||||
ROUTE
|
||||
<&ROUTE>
|
||||
----------------------------------------------------------------
|
||||
FROM VIA TO DIST ETE FUEL CUMFUEL
|
||||
<&NavLog_Begin> <&FROM:7:L> <&VIA:6:L> <&TO:7:L> <&DIST:5> <&ETE:6> <&FUEL:6> <&CUMFUEL:8>
|
||||
<&NavLog_End>----------------------------------------------------------------
|
||||
TRIP <&TRIPDIST:6> nm <&TRIPTIME:6> <&TRIPFUEL:6> kg
|
||||
RES taxi <&TAXI> / cont <&CONTINGENCY> / altn <&ALTERNATE> / final <&FINALRES>
|
||||
BLOCK FUEL <&BLOCKFUEL> kg
|
||||
MASS payload <&PAYLOAD> ZFW <&ZFW> TOW <&TOW> LDW <&LDW> kg
|
||||
================================================================
|
||||
";
|
||||
|
||||
/// Render the default OFP for a route/plan.
|
||||
pub fn render_default(
|
||||
route: &Route,
|
||||
plan: Option<&FuelPlan>,
|
||||
aircraft: Option<&str>,
|
||||
cruise_fl: Option<i32>,
|
||||
airac: &str,
|
||||
) -> String {
|
||||
render(DEFAULT_TEMPLATE, &context(route, plan, aircraft, cruise_fl, airac))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::{Leg, Route};
|
||||
|
||||
fn leg(from: &str, via: &str, to: &str, nm: f64) -> Leg {
|
||||
Leg { from: from.into(), airway: via.into(), to: to.into(), dist_nm: nm }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scalar_tokens_and_width() {
|
||||
let mut c = OfpContext::default();
|
||||
c.set("A", "LFPG");
|
||||
c.set("N", "1234");
|
||||
assert_eq!(render("<&A> x<&N:6>|", &c), "LFPG x 1234|");
|
||||
assert_eq!(render("<&A:6:L>|", &c), "LFPG |"); // left-justified
|
||||
assert_eq!(render("<&MISSING>!", &c), "!"); // unknown → empty
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn navlog_section_repeats_per_leg() {
|
||||
let route = Route {
|
||||
legs: vec![leg("LFPG", "DCT", "OPALE", 20.0), leg("OPALE", "UN491", "EGLL", 180.0)],
|
||||
total_nm: 200.0,
|
||||
via_airways: true,
|
||||
};
|
||||
let out = render_default(&route, None, Some("A320"), Some(360), "2608");
|
||||
assert!(out.contains("LFPG -> EGLL"));
|
||||
assert!(out.contains("A320"));
|
||||
assert!(out.contains("FL360"));
|
||||
// one nav-log line per leg
|
||||
assert!(out.contains("OPALE"));
|
||||
assert!(out.contains("UN491"));
|
||||
assert!(out.matches("EGLL").count() >= 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
//! MSFS / Prepar3D `.pln` flight plan (AceXML).
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use super::{build_points, world_position, PointKind};
|
||||
use crate::error::Result;
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Render `route` as a `.pln` XML document, cruising at `cruise_alt_ft`.
|
||||
pub fn to_pln(conn: &Connection, route: &Route, cruise_alt_ft: i32) -> Result<String> {
|
||||
let points = build_points(conn, route)?;
|
||||
let dep = &points[0];
|
||||
let dest = &points[points.len() - 1];
|
||||
|
||||
let mut s = String::new();
|
||||
s.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
s.push_str("<SimBase.Document Type=\"AceXML\" version=\"1,0\">\n");
|
||||
s.push_str(" <Descr>AceXML Document</Descr>\n");
|
||||
s.push_str(" <FlightPlan.FlightPlan>\n");
|
||||
s.push_str(&format!(
|
||||
" <Title>{} to {}</Title>\n",
|
||||
dep.ident, dest.ident
|
||||
));
|
||||
s.push_str(" <FPType>IFR</FPType>\n");
|
||||
s.push_str(&format!(" <CruisingAlt>{cruise_alt_ft}</CruisingAlt>\n"));
|
||||
s.push_str(&format!(" <DepartureID>{}</DepartureID>\n", dep.ident));
|
||||
s.push_str(&format!(
|
||||
" <DepartureLLA>{}</DepartureLLA>\n",
|
||||
world_position(dep.pos, 0)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <DestinationID>{}</DestinationID>\n",
|
||||
dest.ident
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <DestinationLLA>{}</DestinationLLA>\n",
|
||||
world_position(dest.pos, 0)
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <Descr>{} to {}</Descr>\n",
|
||||
dep.ident, dest.ident
|
||||
));
|
||||
|
||||
for p in &points {
|
||||
let alt = if p.kind == PointKind::Airport {
|
||||
0
|
||||
} else {
|
||||
cruise_alt_ft
|
||||
};
|
||||
s.push_str(&format!(" <ATCWaypoint id=\"{}\">\n", p.ident));
|
||||
s.push_str(&format!(
|
||||
" <ATCWaypointType>{}</ATCWaypointType>\n",
|
||||
p.kind.pln_type()
|
||||
));
|
||||
s.push_str(&format!(
|
||||
" <WorldPosition>{}</WorldPosition>\n",
|
||||
world_position(p.pos, alt)
|
||||
));
|
||||
if !matches!(p.via.as_str(), "ADEP" | "ADES" | "DCT") {
|
||||
s.push_str(&format!(" <ATCAirway>{}</ATCAirway>\n", p.via));
|
||||
}
|
||||
s.push_str(" <ICAO>\n");
|
||||
s.push_str(&format!(" <ICAOIdent>{}</ICAOIdent>\n", p.ident));
|
||||
s.push_str(" </ICAO>\n");
|
||||
s.push_str(" </ATCWaypoint>\n");
|
||||
}
|
||||
|
||||
s.push_str(" </FlightPlan.FlightPlan>\n");
|
||||
s.push_str("</SimBase.Document>\n");
|
||||
Ok(s)
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
//! Offline, **best-effort** IFPS pre-check.
|
||||
//!
|
||||
//! This is NOT an authoritative IFPS validation — RAD restrictions, traffic-flow
|
||||
//! and FRA rules require Eurocontrol NM (see `IDEAS.md`). It only checks what our
|
||||
//! local navdata can prove about a route string:
|
||||
//!
|
||||
//! - every point exists (waypoint or navaid);
|
||||
//! - each cited airway exists and both endpoints lie on it;
|
||||
//! - the airway is traversable in the requested direction between them;
|
||||
//! - the cruise FL falls within each traversed segment's `base…top` band;
|
||||
//! - the route has no discontinuity (missing connector between two points).
|
||||
//!
|
||||
//! Airport codes at the ends and unrecognised first/last tokens (SID/STAR names)
|
||||
//! are reported as warnings, not errors.
|
||||
|
||||
use std::collections::{HashMap, HashSet, VecDeque};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
|
||||
/// DCT legs longer than this are flagged (a warning, since free-route airspace
|
||||
/// permits long directs while structured airspace/RAD usually restricts them).
|
||||
const MAX_DCT_NM: f64 = 100.0;
|
||||
|
||||
/// Result of a local IFPS pre-check.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct IfpsReport {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<String>,
|
||||
pub warnings: Vec<String>,
|
||||
/// The route expanded to its full point sequence (airway intermediates added).
|
||||
pub expanded: Vec<String>,
|
||||
}
|
||||
|
||||
enum Connector {
|
||||
Dct,
|
||||
Airway(String),
|
||||
}
|
||||
|
||||
/// Pre-validate a route string (e.g. `LFPG DCT PON UT300 ELCOB … EGLL`) at
|
||||
/// `cruise_fl` against the local navdata. When `rad` is provided, DCT legs are
|
||||
/// also checked against the Eurocontrol RAD (forbidden directs at that FL).
|
||||
pub fn prevalidate(
|
||||
conn: &Connection,
|
||||
route: &str,
|
||||
cruise_fl: i32,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<IfpsReport> {
|
||||
let tokens: Vec<String> = route.split_whitespace().map(|t| t.to_uppercase()).collect();
|
||||
let mut errors = Vec::new();
|
||||
let mut warnings = Vec::new();
|
||||
let mut expanded: Vec<String> = Vec::new();
|
||||
let mut last_point: Option<String> = None;
|
||||
let mut last_pos: Option<LatLon> = None;
|
||||
let mut pending: Option<Connector> = None;
|
||||
|
||||
for (i, t) in tokens.iter().enumerate() {
|
||||
let connector_pos = last_point.is_some() && pending.is_none();
|
||||
|
||||
if t == "DCT" {
|
||||
pending = Some(Connector::Dct);
|
||||
continue;
|
||||
}
|
||||
// Aerodrome at either end: anchor, not an enroute point.
|
||||
if (i == 0 || i == tokens.len() - 1) && airport_exists(conn, t)? {
|
||||
let cur_pos = point_pos(conn, t, last_pos)?;
|
||||
if matches!(pending, Some(Connector::Dct)) {
|
||||
if let (Some(a), Some(b), Some(prev)) = (last_pos, cur_pos, last_point.as_deref()) {
|
||||
check_dct_len(&mut warnings, prev, t, a, b);
|
||||
}
|
||||
if let Some(prev) = last_point.as_deref() {
|
||||
check_dct_rad(&mut errors, rad, prev, t, cruise_fl);
|
||||
}
|
||||
}
|
||||
expanded.push(t.clone());
|
||||
last_point = Some(t.clone());
|
||||
last_pos = cur_pos;
|
||||
pending = None;
|
||||
continue;
|
||||
}
|
||||
if connector_pos && airway_exists(conn, t)? {
|
||||
pending = Some(Connector::Airway(t.clone()));
|
||||
continue;
|
||||
}
|
||||
if point_exists(conn, t)? {
|
||||
let cur_pos = point_pos(conn, t, last_pos)?;
|
||||
match (last_point.as_ref(), pending.take()) {
|
||||
(Some(prev), Some(Connector::Airway(awy))) => {
|
||||
let (mut inter, errs) = trace_airway(conn, &awy, prev, t, cruise_fl)?;
|
||||
errors.extend(errs);
|
||||
expanded.append(&mut inter);
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(Some(prev), Some(Connector::Dct)) => {
|
||||
if let (Some(a), Some(b)) = (last_pos, cur_pos) {
|
||||
check_dct_len(&mut warnings, prev, t, a, b);
|
||||
}
|
||||
check_dct_rad(&mut errors, rad, prev, t, cruise_fl);
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(None, _) => {
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
(Some(prev), None) => {
|
||||
errors.push(format!("route discontinuity: {prev} → {t} (no airway/DCT)"));
|
||||
expanded.push(t.clone());
|
||||
}
|
||||
}
|
||||
last_point = Some(t.clone());
|
||||
last_pos = cur_pos;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unrecognised token.
|
||||
if i == 0 || i == tokens.len() - 1 {
|
||||
warnings.push(format!("'{t}' not checked offline (SID/STAR/procedure?)"));
|
||||
} else if airway_exists(conn, t)? {
|
||||
errors.push(format!(
|
||||
"unexpected airway '{t}' (missing point before it?)"
|
||||
));
|
||||
} else {
|
||||
errors.push(format!("unknown point or airway: '{t}'"));
|
||||
}
|
||||
}
|
||||
|
||||
if last_point.is_none() {
|
||||
errors.push("no valid enroute point in route".to_owned());
|
||||
}
|
||||
|
||||
Ok(IfpsReport {
|
||||
accepted: errors.is_empty(),
|
||||
errors,
|
||||
warnings,
|
||||
expanded,
|
||||
})
|
||||
}
|
||||
|
||||
/// Flag a DCT leg that the RAD marks as forbidden at this flight level.
|
||||
fn check_dct_rad(errors: &mut Vec<String>, rad: Option<&RadData>, from: &str, to: &str, fl: i32) {
|
||||
if let Some(rad) = rad {
|
||||
if let Some(r) = rad.forbidden_dct(from, to, fl) {
|
||||
errors.push(format!("RAD: DCT {from} → {to} not available at FL{fl} [{}]", r.id));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Warn when a DCT leg is longer than [`MAX_DCT_NM`].
|
||||
fn check_dct_len(warnings: &mut Vec<String>, from: &str, to: &str, a: LatLon, b: LatLon) {
|
||||
let d = a.distance_nm(&b);
|
||||
if d > MAX_DCT_NM {
|
||||
warnings.push(format!(
|
||||
"long DCT {from} → {to}: {d:.0} nm (restricted outside free-route airspace)"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
/// Position of `ident` (airport, then fix, then navaid). When it repeats across
|
||||
/// regions, the candidate nearest `near` is chosen.
|
||||
fn point_pos(conn: &Connection, ident: &str, near: Option<LatLon>) -> Result<Option<LatLon>> {
|
||||
if let Ok(p) = conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
) {
|
||||
return Ok(Some(p));
|
||||
}
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))?;
|
||||
for row in rows {
|
||||
cands.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(match near {
|
||||
Some(n) => cands.into_iter().min_by(|a, b| {
|
||||
a.distance_nm(&n)
|
||||
.partial_cmp(&b.distance_nm(&n))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
None => cands.into_iter().next(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk `airway` from `from` to `to`, honouring direction and FL band.
|
||||
/// Returns the intermediate points (excluding both endpoints) and any errors.
|
||||
fn trace_airway(
|
||||
conn: &Connection,
|
||||
airway: &str,
|
||||
from: &str,
|
||||
to: &str,
|
||||
fl: i32,
|
||||
) -> Result<(Vec<String>, Vec<String>)> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT from_ident, to_ident, direction, base_fl, top_fl \
|
||||
FROM airway_segments WHERE airway_name = ?1",
|
||||
)?;
|
||||
let rows = stmt.query_map(params![airway], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, i32>(3)?,
|
||||
r.get::<_, i32>(4)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
// Adjacency: node -> Vec<(neighbour, base_fl, top_fl)>, respecting direction.
|
||||
let mut adj: HashMap<String, Vec<(String, i32, i32)>> = HashMap::new();
|
||||
let mut nodes: HashSet<String> = HashSet::new();
|
||||
for row in rows {
|
||||
let (f, t, dir, base, top) = row?;
|
||||
nodes.insert(f.clone());
|
||||
nodes.insert(t.clone());
|
||||
let d = dir.chars().next().unwrap_or('N');
|
||||
if d == 'N' || d == 'F' {
|
||||
adj.entry(f.clone())
|
||||
.or_default()
|
||||
.push((t.clone(), base, top));
|
||||
}
|
||||
if d == 'N' || d == 'B' {
|
||||
adj.entry(t).or_default().push((f, base, top));
|
||||
}
|
||||
}
|
||||
|
||||
let mut errors = Vec::new();
|
||||
if !nodes.contains(from) {
|
||||
errors.push(format!("{from} is not on airway {airway}"));
|
||||
}
|
||||
if !nodes.contains(to) {
|
||||
errors.push(format!("{to} is not on airway {airway}"));
|
||||
}
|
||||
if !errors.is_empty() {
|
||||
return Ok((vec![], errors));
|
||||
}
|
||||
|
||||
// BFS, recording predecessor and the segment's FL band used to reach a node.
|
||||
let mut prev: HashMap<String, (String, i32, i32)> = HashMap::new();
|
||||
let mut visited: HashSet<String> = HashSet::from([from.to_owned()]);
|
||||
let mut queue: VecDeque<String> = VecDeque::from([from.to_owned()]);
|
||||
let mut found = false;
|
||||
while let Some(cur) = queue.pop_front() {
|
||||
if cur == to {
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
if let Some(neigh) = adj.get(&cur) {
|
||||
for (n, base, top) in neigh {
|
||||
if visited.insert(n.clone()) {
|
||||
prev.insert(n.clone(), (cur.clone(), *base, *top));
|
||||
queue.push_back(n.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
errors.push(format!(
|
||||
"no {airway} connection {from} → {to} (wrong direction?)"
|
||||
));
|
||||
return Ok((vec![], errors));
|
||||
}
|
||||
|
||||
// Reconstruct path to..from, checking FL band on each edge (top 0 = unknown).
|
||||
let mut path = Vec::new();
|
||||
let mut node = to.to_owned();
|
||||
while node != *from {
|
||||
let (p, base, top) = match prev.get(&node) {
|
||||
Some(v) => v.clone(),
|
||||
None => break,
|
||||
};
|
||||
if top > 0 && (fl < base || fl > top) {
|
||||
errors.push(format!(
|
||||
"FL{fl} outside {airway} band FL{base}..FL{top} near {node}"
|
||||
));
|
||||
}
|
||||
path.push(node.clone());
|
||||
node = p;
|
||||
}
|
||||
path.reverse();
|
||||
path.pop(); // drop `to`; the caller appends it
|
||||
Ok((path, errors))
|
||||
}
|
||||
|
||||
fn airway_exists(conn: &Connection, name: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM airways WHERE name = ?1)",
|
||||
params![name],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
fn point_exists(conn: &Connection, ident: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT (EXISTS(SELECT 1 FROM waypoints WHERE ident = ?1) \
|
||||
OR EXISTS(SELECT 1 FROM navaids WHERE ident = ?1))",
|
||||
params![ident],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
fn airport_exists(conn: &Connection, icao: &str) -> Result<bool> {
|
||||
let n: i64 = conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM airports WHERE icao = ?1)",
|
||||
params![icao],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(n == 1)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::db;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn db_from_fixtures() -> 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 accepts_a_valid_low_route() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM T100 BEACN T100 CROSS", 200, None).unwrap();
|
||||
assert!(r.accepted, "{:?}", r.errors);
|
||||
assert_eq!(r.expanded, vec!["ABEAM", "BEACN", "CROSS"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_fl_outside_airway_band() {
|
||||
let conn = db_from_fixtures();
|
||||
// U200 band is FL245..FL460; FL100 is below it.
|
||||
let r = prevalidate(&conn, "CROSS U200 DOVER", 100, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
assert!(
|
||||
r.errors.iter().any(|e| e.contains("U200")),
|
||||
"{:?}",
|
||||
r.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_point_not_on_airway() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM T100 DOVER", 200, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
assert!(r.errors.iter().any(|e| e.contains("not on airway")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dct_between_known_points_is_accepted() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, None).unwrap();
|
||||
assert!(r.accepted, "{:?}", r.errors);
|
||||
assert_eq!(r.expanded, vec!["ABEAM", "DOVER"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_token_is_an_error() {
|
||||
let conn = db_from_fixtures();
|
||||
let r = prevalidate(&conn, "ABEAM ZZZ99 BEACN", 200, None).unwrap();
|
||||
assert!(!r.accepted);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn flags_rad_forbidden_dct() {
|
||||
let conn = db_from_fixtures();
|
||||
// Same DCT as `dct_between_known_points_is_accepted`, but the RAD forbids it.
|
||||
let rad = RadData {
|
||||
areas: vec![],
|
||||
dct: vec![crate::rad::DctRestriction {
|
||||
id: "TEST01".into(),
|
||||
from: "ABEAM".into(),
|
||||
to: "DOVER".into(),
|
||||
lower_fl: Some(0),
|
||||
upper_fl: Some(400),
|
||||
available: true,
|
||||
utilization: "NOT AVBL FOR TFC X".into(),
|
||||
direction: String::new(),
|
||||
}],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
};
|
||||
let r = prevalidate(&conn, "ABEAM DCT DOVER", 200, Some(&rad)).unwrap();
|
||||
assert!(!r.accepted, "RAD should reject the forbidden direct");
|
||||
assert!(
|
||||
r.errors
|
||||
.iter()
|
||||
.any(|e| e.contains("RAD") && e.contains("ABEAM") && e.contains("DOVER")),
|
||||
"{:?}",
|
||||
r.errors
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
//! `flightplanner-core` — offline flight planning building blocks.
|
||||
//!
|
||||
//! Module map (implemented incrementally, see `TODO.md`):
|
||||
//! - [`error`] : typed error enum shared across the crate.
|
||||
//! - [`model`] : shared domain types (positions, waypoints, ...).
|
||||
//! - [`navdata`] : X-Plane `.dat` parsers (step 2).
|
||||
//! - [`db`] : SQLite schema + import/query (step 2).
|
||||
//! - [`routing`] : airway graph + A* routing (step 3).
|
||||
//! - [`perf`] : aircraft profiles + fuel/time computation (step 4).
|
||||
//! - [`ifps`] : offline structural IFPS pre-check (best-effort, non-authoritative).
|
||||
//! - [`export`] : `.pln` / `.fms` / OFP writers (step 5).
|
||||
|
||||
pub mod api;
|
||||
pub mod db;
|
||||
pub mod error;
|
||||
pub mod export;
|
||||
pub mod ifps;
|
||||
pub mod model;
|
||||
pub mod navdata;
|
||||
pub mod perf;
|
||||
pub mod pfpx;
|
||||
pub mod rad;
|
||||
pub mod routes;
|
||||
pub mod routing;
|
||||
|
||||
pub use error::{CoreError, Result};
|
||||
@@ -0,0 +1,145 @@
|
||||
//! Shared domain types produced by navdata parsing and consumed by routing,
|
||||
//! performance and export.
|
||||
|
||||
use geographiclib_rs::{Geodesic, InverseGeodesic};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A geographic position in decimal degrees (WGS84).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
|
||||
pub struct LatLon {
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
}
|
||||
|
||||
impl LatLon {
|
||||
pub const fn new(lat: f64, lon: f64) -> Self {
|
||||
Self { lat, lon }
|
||||
}
|
||||
|
||||
/// Geodesic distance to `other` in nautical miles, on the WGS84 ellipsoid
|
||||
/// (Karney's algorithm — sub-millimetre accurate, unlike a spherical model).
|
||||
pub fn distance_nm(&self, other: &LatLon) -> f64 {
|
||||
const METERS_PER_NM: f64 = 1852.0;
|
||||
let geod = Geodesic::wgs84();
|
||||
let s12_m: f64 = geod.inverse(self.lat, self.lon, other.lat, other.lon);
|
||||
s12_m / METERS_PER_NM
|
||||
}
|
||||
}
|
||||
|
||||
/// An enroute/terminal fix from `earth_fix.dat`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Waypoint {
|
||||
pub ident: String,
|
||||
/// 2-letter ICAO region code (matches airway endpoint regions).
|
||||
pub region: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// Kinds of navaid we keep for planning (others in `earth_nav.dat` are skipped).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum NavaidKind {
|
||||
Ndb,
|
||||
Vor,
|
||||
Dme,
|
||||
}
|
||||
|
||||
impl NavaidKind {
|
||||
/// Map an `earth_nav.dat` row code, or `None` for kinds we ignore
|
||||
/// (ILS/LOC/GS/markers, DME collocated with a VOR, SBAS/GBAS, ...).
|
||||
pub fn from_row_code(code: i32) -> Option<Self> {
|
||||
match code {
|
||||
2 => Some(Self::Ndb),
|
||||
3 => Some(Self::Vor),
|
||||
13 => Some(Self::Dme),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Ndb => "NDB",
|
||||
Self::Vor => "VOR",
|
||||
Self::Dme => "DME",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A navaid from `earth_nav.dat`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Navaid {
|
||||
pub ident: String,
|
||||
pub region: String,
|
||||
pub kind: NavaidKind,
|
||||
/// Frequency as stored by X-Plane (VOR: MHz×100, NDB: kHz).
|
||||
pub freq: i64,
|
||||
pub pos: LatLon,
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// An airport reference point (derived from CIFP runway thresholds).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Airport {
|
||||
pub icao: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// Low- (Victor) vs high-altitude (Jet) airway layer.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum AirwayLayer {
|
||||
Low,
|
||||
High,
|
||||
}
|
||||
|
||||
impl AirwayLayer {
|
||||
pub fn from_code(code: i32) -> Option<Self> {
|
||||
match code {
|
||||
1 => Some(Self::Low),
|
||||
2 => Some(Self::High),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Low => "Low",
|
||||
Self::High => "High",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One airway segment between two endpoints, as parsed from `earth_awy.dat`.
|
||||
/// A physical line may belong to several named airways (`A-B`), hence `airways`.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AirwaySegment {
|
||||
pub from_ident: String,
|
||||
pub from_region: String,
|
||||
pub to_ident: String,
|
||||
pub to_region: String,
|
||||
/// Direction of use: `N` (both), `F` (forward), `B` (backward).
|
||||
pub direction: char,
|
||||
pub layer: AirwayLayer,
|
||||
pub base_fl: i32,
|
||||
pub top_fl: i32,
|
||||
pub airways: Vec<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn latlon_roundtrips_through_json() {
|
||||
let p = LatLon::new(49.0097, 2.5479);
|
||||
let json = serde_json::to_string(&p).unwrap();
|
||||
let back: LatLon = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(p, back);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn great_circle_lfpg_egll() {
|
||||
let lfpg = LatLon::new(49.0097, 2.5479);
|
||||
let egll = LatLon::new(51.4706, -0.4619);
|
||||
let d = lfpg.distance_nm(&egll);
|
||||
assert!((d - 188.0).abs() < 5.0, "distance = {d} nm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
//! Airport reference points from CIFP files.
|
||||
//!
|
||||
//! X-Plane `earth_*.dat` carry no airport coordinates, but each `CIFP/<ICAO>.dat`
|
||||
//! file lists runway thresholds as ARINC-packed coordinates:
|
||||
//!
|
||||
//! ```text
|
||||
//! RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
//! ```
|
||||
//!
|
||||
//! We take the centroid of all thresholds as the airport reference point.
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::{Airport, LatLon};
|
||||
|
||||
/// Parse an ARINC-packed coordinate such as `N48594447` (lat) or `E002330988`
|
||||
/// (lon): hemisphere letter, then `D…D MM SS ss` (seconds×100), degrees being
|
||||
/// 2 digits for latitude and 3 for longitude.
|
||||
pub fn parse_arinc_coord(tok: &str) -> std::result::Result<f64, String> {
|
||||
if tok.len() < 8 {
|
||||
return Err(format!("coordinate too short: '{tok}'"));
|
||||
}
|
||||
let (hemi, digits) = tok.split_at(1);
|
||||
let sign = match hemi {
|
||||
"N" | "E" => 1.0,
|
||||
"S" | "W" => -1.0,
|
||||
_ => return Err(format!("bad hemisphere in '{tok}'")),
|
||||
};
|
||||
// Trailing 6 digits are always MM SS ss; the rest are degrees.
|
||||
let deg_len = digits.len() - 6;
|
||||
let field = |range: std::ops::Range<usize>, what: &str| -> std::result::Result<f64, String> {
|
||||
digits
|
||||
.get(range)
|
||||
.ok_or_else(|| format!("truncated {what} in '{tok}'"))?
|
||||
.parse::<f64>()
|
||||
.map_err(|_| format!("invalid {what} in '{tok}'"))
|
||||
};
|
||||
let deg = field(0..deg_len, "degrees")?;
|
||||
let min = field(deg_len..deg_len + 2, "minutes")?;
|
||||
let sec = field(deg_len + 2..deg_len + 4, "seconds")?;
|
||||
let hund = field(deg_len + 4..deg_len + 6, "sub-seconds")?;
|
||||
Ok(sign * (deg + min / 60.0 + (sec + hund / 100.0) / 3600.0))
|
||||
}
|
||||
|
||||
/// Build an [`Airport`] from a CIFP reader, or `None` if it has no runways.
|
||||
pub fn parse_airport_from_cifp(icao: &str, reader: impl BufRead) -> Result<Option<Airport>> {
|
||||
let mut sum_lat = 0.0;
|
||||
let mut sum_lon = 0.0;
|
||||
let mut n = 0u32;
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
if !line.starts_with("RWY:") {
|
||||
continue;
|
||||
}
|
||||
// Coordinates live after the first ';': "N48594447,E002330988,0000".
|
||||
let coords = match line.split(';').nth(1) {
|
||||
Some(s) => s,
|
||||
None => continue,
|
||||
};
|
||||
let mut parts = coords.split(',');
|
||||
let (lat_s, lon_s) = match (parts.next(), parts.next()) {
|
||||
(Some(a), Some(b)) => (a, b),
|
||||
_ => continue,
|
||||
};
|
||||
let to_err = |reason| CoreError::Parse {
|
||||
file: format!("CIFP/{icao}.dat"),
|
||||
line: 0,
|
||||
reason,
|
||||
};
|
||||
sum_lat += parse_arinc_coord(lat_s).map_err(to_err)?;
|
||||
sum_lon += parse_arinc_coord(lon_s).map_err(to_err)?;
|
||||
n += 1;
|
||||
}
|
||||
if n == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(Airport {
|
||||
icao: icao.to_owned(),
|
||||
pos: LatLon::new(sum_lat / n as f64, sum_lon / n as f64),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn decodes_arinc_coordinates() {
|
||||
let lat = parse_arinc_coord("N48594447").unwrap();
|
||||
let lon = parse_arinc_coord("E002330988").unwrap();
|
||||
assert!((lat - (48.0 + 59.0 / 60.0 + 44.47 / 3600.0)).abs() < 1e-6);
|
||||
assert!((lon - (2.0 + 33.0 / 60.0 + 9.88 / 3600.0)).abs() < 1e-6);
|
||||
assert!(parse_arinc_coord("W000273600").unwrap() < 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn centroid_of_thresholds() {
|
||||
let data = "\
|
||||
RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
RWY:RW26R, , ,00318, ,GAU ,3, ;N48595395,E002360724,1725;
|
||||
SID:010,4,AGOP6A,RW27R,DE27R,LF,P,C,EY;
|
||||
";
|
||||
let ap = parse_airport_from_cifp("LFPG", data.as_bytes())
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(ap.icao, "LFPG");
|
||||
assert!((ap.pos.lat - 48.99).abs() < 0.05);
|
||||
assert!((ap.pos.lon - 2.56).abs() < 0.05);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
//! Parser for `earth_awy.dat` (version 1100).
|
||||
//!
|
||||
//! Data columns: `from freg ftype to treg ttype dir layer baseFL topFL name(s)`.
|
||||
//! `name(s)` may join several airways with `-` (e.g. `J121-J584`).
|
||||
|
||||
use super::{col_i32, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{AirwayLayer, AirwaySegment};
|
||||
|
||||
pub fn parse_awy_line(line: &str) -> LineResult<AirwaySegment> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let from_ident = col_str(&cols, 0, "from ident")?;
|
||||
let from_region = col_str(&cols, 1, "from region")?;
|
||||
let to_ident = col_str(&cols, 3, "to ident")?;
|
||||
let to_region = col_str(&cols, 4, "to region")?;
|
||||
let direction = col_str(&cols, 6, "direction")?
|
||||
.chars()
|
||||
.next()
|
||||
.ok_or_else(|| "empty direction".to_owned())?;
|
||||
let layer_code = col_i32(&cols, 7, "layer")?;
|
||||
let layer = AirwayLayer::from_code(layer_code)
|
||||
.ok_or_else(|| format!("invalid layer: '{layer_code}'"))?;
|
||||
let base_fl = col_i32(&cols, 8, "base FL")?;
|
||||
let top_fl = col_i32(&cols, 9, "top FL")?;
|
||||
let airways: Vec<String> = col_str(&cols, 10, "airway name")?
|
||||
.split('-')
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
Ok(Some(AirwaySegment {
|
||||
from_ident: from_ident.to_owned(),
|
||||
from_region: from_region.to_owned(),
|
||||
to_ident: to_ident.to_owned(),
|
||||
to_region: to_region.to_owned(),
|
||||
direction,
|
||||
layer,
|
||||
base_fl,
|
||||
top_fl,
|
||||
airways,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_low_segment() {
|
||||
let s = parse_awy_line("07EBA DT 11 GILEX DT 11 N 1 95 245 G869")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(s.from_ident, "07EBA");
|
||||
assert_eq!(s.to_ident, "GILEX");
|
||||
assert_eq!(s.direction, 'N');
|
||||
assert_eq!(s.layer, AirwayLayer::Low);
|
||||
assert_eq!(s.base_fl, 95);
|
||||
assert_eq!(s.top_fl, 245);
|
||||
assert_eq!(s.airways, vec!["G869".to_owned()]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn splits_multiple_airway_names() {
|
||||
let s = parse_awy_line("A LF 11 B LF 11 N 2 245 460 J121-J584")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(s.layer, AirwayLayer::High);
|
||||
assert_eq!(s.airways, vec!["J121".to_owned(), "J584".to_owned()]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
//! Parser for `earth_fix.dat` (version 1101).
|
||||
//!
|
||||
//! Data columns: `lat lon ident termArea region typecode`.
|
||||
|
||||
use super::{col_f64, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{LatLon, Waypoint};
|
||||
|
||||
pub fn parse_fix_line(line: &str) -> LineResult<Waypoint> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let lat = col_f64(&cols, 0, "latitude")?;
|
||||
let lon = col_f64(&cols, 1, "longitude")?;
|
||||
let ident = col_str(&cols, 2, "ident")?;
|
||||
// cols[3] = terminal area airport ICAO or "ENRT" (unused for now)
|
||||
let region = col_str(&cols, 4, "region")?;
|
||||
Ok(Some(Waypoint {
|
||||
ident: ident.to_owned(),
|
||||
region: region.to_owned(),
|
||||
pos: LatLon::new(lat, lon),
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn skips_header_and_terminator() {
|
||||
assert!(parse_fix_line("I").unwrap().is_none());
|
||||
assert!(parse_fix_line("1101 Version - data cycle 2608")
|
||||
.unwrap()
|
||||
.is_none());
|
||||
assert!(parse_fix_line("").unwrap().is_none());
|
||||
assert!(parse_fix_line("99").unwrap().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_a_fix() {
|
||||
let wp = parse_fix_line(" 33.492513889 9.217400000 07EBA ENRT DT 2118994")
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(wp.ident, "07EBA");
|
||||
assert_eq!(wp.region, "DT");
|
||||
assert!((wp.pos.lat - 33.492513889).abs() < 1e-9);
|
||||
assert!((wp.pos.lon - 9.2174).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn errors_on_bad_latitude() {
|
||||
let err = parse_fix_line("xx 9.2 07EBA ENRT DT 1").unwrap_err();
|
||||
assert!(err.contains("latitude"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
//! X-Plane navdata parsers (`earth_fix.dat`, `earth_nav.dat`, `earth_awy.dat`)
|
||||
//! plus airport reference points from CIFP files.
|
||||
//!
|
||||
//! Parsing is streaming (line-by-line): a multi-MB file is never buffered whole.
|
||||
//! Each line parser is a pure `&str -> LineResult<T>` function so it can be unit
|
||||
//! tested on tiny hand-made inputs; [`stream_file`] drives them over a reader and
|
||||
//! attaches file/line context to any error.
|
||||
|
||||
pub mod airport;
|
||||
pub mod airway;
|
||||
pub mod fix;
|
||||
pub mod nav;
|
||||
pub mod procedure;
|
||||
|
||||
use std::io::BufRead;
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
|
||||
/// Outcome of parsing one physical line: `Some` = a record, `None` = skip
|
||||
/// (header / blank / terminator), `Err(reason)` = malformed data line.
|
||||
pub type LineResult<T> = std::result::Result<Option<T>, String>;
|
||||
|
||||
/// Header / blank / terminator lines shared by every X-Plane `.dat` file.
|
||||
pub(crate) fn is_meta_line(trimmed: &str) -> bool {
|
||||
trimmed.is_empty()
|
||||
|| trimmed == "I"
|
||||
|| trimmed == "A"
|
||||
|| trimmed == "99"
|
||||
|| trimmed.contains("Version")
|
||||
}
|
||||
|
||||
/// Parse a whitespace-separated column as `f64`.
|
||||
pub(crate) fn col_f64(cols: &[&str], i: usize, field: &str) -> std::result::Result<f64, String> {
|
||||
let s = cols
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("missing field: {field}"))?;
|
||||
s.parse::<f64>()
|
||||
.map_err(|_| format!("invalid {field}: '{s}'"))
|
||||
}
|
||||
|
||||
/// Parse a whitespace-separated column as `i32`.
|
||||
pub(crate) fn col_i32(cols: &[&str], i: usize, field: &str) -> std::result::Result<i32, String> {
|
||||
let s = cols
|
||||
.get(i)
|
||||
.ok_or_else(|| format!("missing field: {field}"))?;
|
||||
s.parse::<i32>()
|
||||
.map_err(|_| format!("invalid {field}: '{s}'"))
|
||||
}
|
||||
|
||||
/// Borrow a whitespace-separated column as `&str`.
|
||||
pub(crate) fn col_str<'a>(
|
||||
cols: &[&'a str],
|
||||
i: usize,
|
||||
field: &str,
|
||||
) -> std::result::Result<&'a str, String> {
|
||||
cols.get(i)
|
||||
.copied()
|
||||
.ok_or_else(|| format!("missing field: {field}"))
|
||||
}
|
||||
|
||||
/// Drive a line parser over a reader, invoking `visit` for each parsed record.
|
||||
/// Returns the number of records produced. Parse errors are wrapped with the
|
||||
/// file label and 1-based line number.
|
||||
pub fn stream_file<R, T>(
|
||||
reader: R,
|
||||
file_label: &str,
|
||||
parse: impl Fn(&str) -> LineResult<T>,
|
||||
mut visit: impl FnMut(T) -> Result<()>,
|
||||
) -> Result<usize>
|
||||
where
|
||||
R: BufRead,
|
||||
{
|
||||
let mut count = 0usize;
|
||||
for (i, line) in reader.lines().enumerate() {
|
||||
let line = line?;
|
||||
match parse(&line) {
|
||||
Ok(Some(rec)) => {
|
||||
visit(rec)?;
|
||||
count += 1;
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(reason) => {
|
||||
return Err(CoreError::Parse {
|
||||
file: file_label.to_owned(),
|
||||
line: i + 1,
|
||||
reason,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(count)
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
//! Parser for `earth_nav.dat` (version 1150).
|
||||
//!
|
||||
//! Data columns: `code lat lon elev freq range bearing ident termArea region name…`.
|
||||
//! Only NDB/VOR/DME rows are kept; other codes yield `Ok(None)`.
|
||||
|
||||
use super::{col_f64, col_i32, col_str, is_meta_line, LineResult};
|
||||
use crate::model::{LatLon, Navaid, NavaidKind};
|
||||
|
||||
pub fn parse_nav_line(line: &str) -> LineResult<Navaid> {
|
||||
let t = line.trim();
|
||||
if is_meta_line(t) {
|
||||
return Ok(None);
|
||||
}
|
||||
let cols: Vec<&str> = t.split_whitespace().collect();
|
||||
let code = col_i32(&cols, 0, "row code")?;
|
||||
let kind = match NavaidKind::from_row_code(code) {
|
||||
Some(k) => k,
|
||||
None => return Ok(None),
|
||||
};
|
||||
let lat = col_f64(&cols, 1, "latitude")?;
|
||||
let lon = col_f64(&cols, 2, "longitude")?;
|
||||
// cols[3] elevation, cols[5] range, cols[6] bearing — unused for now.
|
||||
let freq = col_i32(&cols, 4, "frequency")? as i64;
|
||||
let ident = col_str(&cols, 7, "ident")?;
|
||||
let region = col_str(&cols, 9, "region")?;
|
||||
let name = cols.get(10..).map(|r| r.join(" ")).unwrap_or_default();
|
||||
Ok(Some(Navaid {
|
||||
ident: ident.to_owned(),
|
||||
region: region.to_owned(),
|
||||
kind,
|
||||
freq,
|
||||
pos: LatLon::new(lat, lon),
|
||||
name,
|
||||
}))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_a_vor() {
|
||||
let nv = parse_nav_line(
|
||||
" 3 9.037805556 7.285111111 1191 11630 130 -0.000 ABC ENRT DN ABUJA VOR/DME",
|
||||
)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(nv.ident, "ABC");
|
||||
assert_eq!(nv.region, "DN");
|
||||
assert_eq!(nv.kind, NavaidKind::Vor);
|
||||
assert_eq!(nv.freq, 11630);
|
||||
assert_eq!(nv.name, "ABUJA VOR/DME");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skips_ils_and_collocated_dme() {
|
||||
// code 4 = ILS/LOC, code 12 = DME component of a VOR/DME
|
||||
assert!(
|
||||
parse_nav_line(" 4 49.0 2.55 50 11000 25 0.0 ILP ENRT LF PARIS ILS")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
parse_nav_line(" 12 9.0 7.2 1191 11630 130 0.0 ABC ENRT DN ABUJA DME")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
//! CIFP SID/STAR procedure parsing (ARINC 424-derived).
|
||||
//!
|
||||
//! Each airport's CIFP file holds `SID:` and `STAR:` records. A record is one
|
||||
//! leg of a procedure; legs sharing the same procedure name + transition form
|
||||
//! the ordered fix sequence. Field layout after the `SID:`/`STAR:` prefix:
|
||||
//! `seq, routeType, name, transition, fix, fixRegion, …`.
|
||||
//!
|
||||
//! v1 keeps just what routing needs: the ordered enroute **fixes** per
|
||||
//! (name, transition). A SID's last fix is where it joins the airway network
|
||||
//! ([`Procedure::exit_fix`]); a STAR's first fix is where it leaves it
|
||||
//! ([`Procedure::entry_fix`]).
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::fs::File;
|
||||
use std::io::{BufRead, BufReader};
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ProcKind {
|
||||
Sid,
|
||||
Star,
|
||||
}
|
||||
|
||||
/// One departure/arrival procedure variant (a name + transition + fix sequence).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Procedure {
|
||||
pub kind: ProcKind,
|
||||
/// Procedure identifier, e.g. `AGOP6A`.
|
||||
pub name: String,
|
||||
/// Transition: a runway (`RW27L`), `ALL`, or an enroute transition name.
|
||||
pub transition: String,
|
||||
/// Ordered fix idents along the procedure.
|
||||
pub fixes: Vec<String>,
|
||||
}
|
||||
|
||||
impl Procedure {
|
||||
/// Fix where a SID joins the airway network (its last fix).
|
||||
pub fn exit_fix(&self) -> Option<&str> {
|
||||
self.fixes.last().map(String::as_str)
|
||||
}
|
||||
/// Fix where a STAR leaves the airway network (its first fix).
|
||||
pub fn entry_fix(&self) -> Option<&str> {
|
||||
self.fixes.first().map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse all SID/STAR procedures from a CIFP airport file.
|
||||
pub fn parse_procedures<R: BufRead>(reader: R) -> Result<Vec<Procedure>> {
|
||||
type Key = (ProcKind, String, String);
|
||||
let mut order: Vec<Key> = Vec::new();
|
||||
let mut legs: HashMap<Key, Vec<(u32, String)>> = HashMap::new();
|
||||
|
||||
for line in reader.lines() {
|
||||
let line = line?;
|
||||
let (kind, rest) = if let Some(r) = line.strip_prefix("SID:") {
|
||||
(ProcKind::Sid, r)
|
||||
} else if let Some(r) = line.strip_prefix("STAR:") {
|
||||
(ProcKind::Star, r)
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
let f: Vec<&str> = rest.split(',').collect();
|
||||
let name = field(&f, 2);
|
||||
if name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let transition = field(&f, 3);
|
||||
let fix = field(&f, 4);
|
||||
let seq: u32 = field(&f, 0).parse().unwrap_or(0);
|
||||
|
||||
let key = (kind, name, transition);
|
||||
if !legs.contains_key(&key) {
|
||||
order.push(key.clone());
|
||||
}
|
||||
let entry = legs.entry(key).or_default();
|
||||
if !fix.is_empty() {
|
||||
entry.push((seq, fix));
|
||||
}
|
||||
}
|
||||
|
||||
let mut procs = Vec::with_capacity(order.len());
|
||||
for key in order {
|
||||
let mut group = legs.remove(&key).unwrap_or_default();
|
||||
group.sort_by_key(|(s, _)| *s);
|
||||
let mut fixes: Vec<String> = Vec::new();
|
||||
for (_, fix) in group {
|
||||
if fixes.last() != Some(&fix) {
|
||||
fixes.push(fix);
|
||||
}
|
||||
}
|
||||
if fixes.is_empty() {
|
||||
continue;
|
||||
}
|
||||
procs.push(Procedure {
|
||||
kind: key.0,
|
||||
name: key.1,
|
||||
transition: key.2,
|
||||
fixes,
|
||||
});
|
||||
}
|
||||
Ok(procs)
|
||||
}
|
||||
|
||||
fn field(f: &[&str], i: usize) -> String {
|
||||
f.get(i).map(|s| s.trim()).unwrap_or("").to_string()
|
||||
}
|
||||
|
||||
/// Ordered fixes of the procedure named `name` (of `kind`) whose connector fix
|
||||
/// (SID exit / STAR entry) equals `connector_fix` — for drawing the full
|
||||
/// SID/STAR track on the map. Empty if not found.
|
||||
pub fn procedure_track(
|
||||
cifp_dir: &Path,
|
||||
icao: &str,
|
||||
kind: ProcKind,
|
||||
name: &str,
|
||||
connector_fix: &str,
|
||||
) -> Result<Vec<String>> {
|
||||
let path = cifp_dir.join(format!("{}.dat", icao.to_uppercase()));
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file))?;
|
||||
let cf = connector_fix.to_uppercase();
|
||||
for p in procs
|
||||
.iter()
|
||||
.filter(|p| p.kind == kind && p.name.eq_ignore_ascii_case(name))
|
||||
{
|
||||
let matches = match kind {
|
||||
ProcKind::Sid => p.exit_fix() == Some(cf.as_str()),
|
||||
ProcKind::Star => p.entry_fix() == Some(cf.as_str()),
|
||||
};
|
||||
if matches {
|
||||
return Ok(p.fixes.clone());
|
||||
}
|
||||
}
|
||||
Ok(Vec::new())
|
||||
}
|
||||
|
||||
/// Connectors for an airport read from `<cifp_dir>/<ICAO>.dat`: pairs of
|
||||
/// (procedure name, connector fix) — SID **exit** fixes for [`ProcKind::Sid`],
|
||||
/// STAR **entry** fixes for [`ProcKind::Star`], deduplicated by fix. Empty when
|
||||
/// the CIFP file is absent.
|
||||
pub fn connectors(cifp_dir: &Path, icao: &str, kind: ProcKind) -> Result<Vec<(String, String)>> {
|
||||
let path = cifp_dir.join(format!("{}.dat", icao.to_uppercase()));
|
||||
let Ok(file) = File::open(path) else {
|
||||
return Ok(Vec::new());
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file))?;
|
||||
let mut out: Vec<(String, String)> = Vec::new();
|
||||
for p in procs.iter().filter(|p| p.kind == kind) {
|
||||
let fix = match kind {
|
||||
ProcKind::Sid => p.exit_fix(),
|
||||
ProcKind::Star => p.entry_fix(),
|
||||
};
|
||||
if let Some(f) = fix {
|
||||
if !out.iter().any(|(_, x)| x == f) {
|
||||
out.push((p.name.clone(), f.to_string()));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, Cursor};
|
||||
|
||||
#[test]
|
||||
fn groups_sid_legs_into_ordered_fixes() {
|
||||
let data = "\
|
||||
SID:010,4,TEST1,RW27L,AAA,LF,P,C\n\
|
||||
SID:030,4,TEST1,RW27L,CCC,LF\n\
|
||||
SID:020,4,TEST1,RW27L,BBB,LF\n\
|
||||
STAR:010,5,ARR1,ALL,ZZZ,LF\n";
|
||||
let procs = parse_procedures(Cursor::new(data)).unwrap();
|
||||
assert_eq!(procs.len(), 2);
|
||||
let sid = &procs[0];
|
||||
assert_eq!(sid.kind, ProcKind::Sid);
|
||||
assert_eq!(sid.name, "TEST1");
|
||||
assert_eq!(sid.transition, "RW27L");
|
||||
assert_eq!(sid.fixes, vec!["AAA", "BBB", "CCC"]); // sorted by seq
|
||||
assert_eq!(sid.exit_fix(), Some("CCC"));
|
||||
assert_eq!(procs[1].entry_fix(), Some("ZZZ"));
|
||||
}
|
||||
|
||||
/// Parses the real LFPG CIFP when present (skips otherwise).
|
||||
#[test]
|
||||
fn parses_real_lfpg_when_present() {
|
||||
let p = "../../navdata/CIFP/LFPG.dat";
|
||||
let Ok(file) = File::open(p) else {
|
||||
return;
|
||||
};
|
||||
let procs = parse_procedures(BufReader::new(file)).unwrap();
|
||||
let sids = procs.iter().filter(|p| p.kind == ProcKind::Sid).count();
|
||||
let stars = procs.iter().filter(|p| p.kind == ProcKind::Star).count();
|
||||
assert!(sids > 10, "sids = {sids}");
|
||||
assert!(stars > 5, "stars = {stars}");
|
||||
assert!(procs.iter().all(|p| !p.fixes.is_empty()));
|
||||
for s in procs.iter().filter(|p| p.kind == ProcKind::Sid).take(4) {
|
||||
eprintln!("SID {:8} rwy {:6} exits→ {:?}", s.name, s.transition, s.exit_fix());
|
||||
}
|
||||
for s in procs.iter().filter(|p| p.kind == ProcKind::Star).take(4) {
|
||||
eprintln!("STAR {:8} trans {:6} enters→ {:?}", s.name, s.transition, s.entry_fix());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,438 @@
|
||||
//! Aircraft performance & fuel planning over a computed [`Route`].
|
||||
//!
|
||||
//! A simple 3-phase profile (climb / cruise / descent) is laid out along the
|
||||
//! total route distance: climb and descent occupy the ground distance implied by
|
||||
//! their rate of climb/descent and TAS, cruise fills the middle. Each route leg
|
||||
//! is then split across whatever phases it overlaps, giving per-leg time and fuel
|
||||
//! with running totals, plus regulatory reserves and a block fuel figure.
|
||||
|
||||
pub mod openap;
|
||||
pub mod profile;
|
||||
|
||||
pub use openap::PerfModel;
|
||||
pub use profile::AircraftProfile;
|
||||
|
||||
use crate::routing::Route;
|
||||
|
||||
/// Time, distance and fuel for one flight phase.
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq)]
|
||||
pub struct PhaseResult {
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Per-leg time/fuel with cumulative running totals.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LegFuel {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
pub time_min: f64,
|
||||
pub fuel_kg: f64,
|
||||
pub cum_time_min: f64,
|
||||
pub cum_fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Mass breakdown for the flight (only available with a physics/OpenAP profile).
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct Masses {
|
||||
/// Payload (pax + cargo), kg.
|
||||
pub payload_kg: f64,
|
||||
/// Zero-fuel weight = OEW + payload, kg.
|
||||
pub zfw_kg: f64,
|
||||
/// Take-off weight = ZFW + take-off fuel (block − taxi), kg.
|
||||
pub takeoff_kg: f64,
|
||||
/// Landing weight = take-off weight − trip fuel, kg.
|
||||
pub landing_kg: f64,
|
||||
/// True if take-off weight exceeds MTOW (plan is infeasible as loaded).
|
||||
pub over_mtow: bool,
|
||||
/// True if landing weight exceeds MLW.
|
||||
pub over_mlw: bool,
|
||||
}
|
||||
|
||||
/// A complete fuel plan for a route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct FuelPlan {
|
||||
pub cruise_fl: i32,
|
||||
pub climb: PhaseResult,
|
||||
pub cruise: PhaseResult,
|
||||
pub descent: PhaseResult,
|
||||
pub legs: Vec<LegFuel>,
|
||||
pub trip_fuel_kg: f64,
|
||||
pub trip_time_min: f64,
|
||||
pub taxi_kg: f64,
|
||||
pub contingency_kg: f64,
|
||||
pub alternate_kg: f64,
|
||||
pub final_reserve_kg: f64,
|
||||
pub block_fuel_kg: f64,
|
||||
/// Mass breakdown, when the profile carries OpenAP mass data.
|
||||
pub masses: Option<Masses>,
|
||||
}
|
||||
|
||||
/// Result of one fuel-accounting pass over the route at a fixed representative
|
||||
/// mass (fuel flows already resolved for that mass).
|
||||
struct Pass {
|
||||
climb: PhaseResult,
|
||||
cruise: PhaseResult,
|
||||
descent: PhaseResult,
|
||||
legs: Vec<LegFuel>,
|
||||
trip_fuel_kg: f64,
|
||||
trip_time_min: f64,
|
||||
contingency_kg: f64,
|
||||
final_reserve_kg: f64,
|
||||
alternate_kg: f64,
|
||||
block_fuel_kg: f64,
|
||||
}
|
||||
|
||||
/// Compute a fuel plan for `route` using `profile`, cruising at `cruise_fl`
|
||||
/// (×100 ft). `alternate_nm`, if given, adds a cruise-only alternate leg.
|
||||
/// `payload_kg` sets the load (pax + cargo); with an OpenAP profile the block
|
||||
/// fuel is solved by iterating mass (heavier fuel load ⇒ more burn ⇒ more fuel).
|
||||
/// Without OpenAP coefficients, fuel flow is mass-independent and `payload_kg`
|
||||
/// only affects the reported mass breakdown (which is then `None`).
|
||||
pub fn compute_fuel_plan(
|
||||
profile: &AircraftProfile,
|
||||
route: &Route,
|
||||
cruise_fl: i32,
|
||||
alternate_nm: Option<f64>,
|
||||
payload_kg: Option<f64>,
|
||||
) -> FuelPlan {
|
||||
let d_total: f64 = route.legs.iter().map(|l| l.dist_nm).sum();
|
||||
let alt_ft = cruise_fl as f64 * 100.0;
|
||||
let (c, cr, de) = (
|
||||
&profile.phases.climb,
|
||||
&profile.phases.cruise,
|
||||
&profile.phases.descent,
|
||||
);
|
||||
|
||||
// Ground distance covered by climb and descent (top-of-climb / top-of-descent).
|
||||
let mut climb_dist = c.tas_kt * (alt_ft / c.roc_fpm) / 60.0;
|
||||
let mut descent_dist = de.tas_kt * (alt_ft / de.rod_fpm) / 60.0;
|
||||
if climb_dist + descent_dist > d_total && climb_dist + descent_dist > 0.0 {
|
||||
// Short flight: cruise never reached, compress climb/descent to fit.
|
||||
let scale = d_total / (climb_dist + descent_dist);
|
||||
climb_dist *= scale;
|
||||
descent_dist *= scale;
|
||||
}
|
||||
let cruise_dist = (d_total - climb_dist - descent_dist).max(0.0);
|
||||
let taxi_kg = profile.reserves.taxi_kg;
|
||||
|
||||
// One fuel-accounting pass at a given fuel-flow evaluation mass.
|
||||
let run = |ff_mass: f64| -> Pass {
|
||||
let (climb_ff, cruise_ff, descent_ff) =
|
||||
phase_fuel_flows(profile, cruise_fl, alt_ft, ff_mass);
|
||||
// (start_nm, end_nm, tas_kt, fuel_flow_kgph) per phase, in route order.
|
||||
let phases = [
|
||||
(0.0, climb_dist, c.tas_kt, climb_ff),
|
||||
(climb_dist, climb_dist + cruise_dist, cr.tas_kt, cruise_ff),
|
||||
(climb_dist + cruise_dist, d_total, de.tas_kt, descent_ff),
|
||||
];
|
||||
|
||||
let mut totals = [PhaseResult::default(); 3];
|
||||
let mut legs = Vec::with_capacity(route.legs.len());
|
||||
let mut traveled = 0.0;
|
||||
let mut cum_time = 0.0;
|
||||
let mut cum_fuel = 0.0;
|
||||
|
||||
for leg in &route.legs {
|
||||
let (start, end) = (traveled, traveled + leg.dist_nm);
|
||||
let mut leg_time = 0.0;
|
||||
let mut leg_fuel = 0.0;
|
||||
for (i, &(ps, pe, tas, ff)) in phases.iter().enumerate() {
|
||||
let overlap = (end.min(pe) - start.max(ps)).max(0.0);
|
||||
if overlap <= 0.0 || tas <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let time = overlap / tas * 60.0;
|
||||
let fuel = ff * overlap / tas;
|
||||
leg_time += time;
|
||||
leg_fuel += fuel;
|
||||
totals[i].dist_nm += overlap;
|
||||
totals[i].time_min += time;
|
||||
totals[i].fuel_kg += fuel;
|
||||
}
|
||||
cum_time += leg_time;
|
||||
cum_fuel += leg_fuel;
|
||||
legs.push(LegFuel {
|
||||
from: leg.from.clone(),
|
||||
to: leg.to.clone(),
|
||||
airway: leg.airway.clone(),
|
||||
dist_nm: leg.dist_nm,
|
||||
time_min: leg_time,
|
||||
fuel_kg: leg_fuel,
|
||||
cum_time_min: cum_time,
|
||||
cum_fuel_kg: cum_fuel,
|
||||
});
|
||||
traveled = end;
|
||||
}
|
||||
|
||||
let [climb, cruise, descent] = totals;
|
||||
let trip_fuel_kg = climb.fuel_kg + cruise.fuel_kg + descent.fuel_kg;
|
||||
let trip_time_min = climb.time_min + cruise.time_min + descent.time_min;
|
||||
let contingency_kg = profile.reserves.contingency_pct / 100.0 * trip_fuel_kg;
|
||||
let final_reserve_kg = cruise_ff * profile.reserves.final_reserve_min / 60.0;
|
||||
let alternate_kg = alternate_nm
|
||||
.map(|d| cruise_ff * (d / cr.tas_kt))
|
||||
.unwrap_or(0.0);
|
||||
let block_fuel_kg =
|
||||
taxi_kg + trip_fuel_kg + contingency_kg + alternate_kg + final_reserve_kg;
|
||||
Pass {
|
||||
climb,
|
||||
cruise,
|
||||
descent,
|
||||
legs,
|
||||
trip_fuel_kg,
|
||||
trip_time_min,
|
||||
contingency_kg,
|
||||
final_reserve_kg,
|
||||
alternate_kg,
|
||||
block_fuel_kg,
|
||||
}
|
||||
};
|
||||
|
||||
// With an OpenAP profile, solve block fuel by fixed-point iteration on mass:
|
||||
// start empty (ZFW), then feed take-off fuel back into the evaluation mass
|
||||
// until the block figure stops moving. Without it, one mass-agnostic pass.
|
||||
let (pass, masses) = match &profile.openap {
|
||||
None => (run(0.0), None),
|
||||
Some(params) => {
|
||||
let payload = payload_kg.unwrap_or_else(|| params.default_payload_kg());
|
||||
let zfw = params.oew_kg + payload;
|
||||
let mut pass = run(zfw);
|
||||
for _ in 0..12 {
|
||||
let takeoff = zfw + (pass.block_fuel_kg - taxi_kg).max(0.0);
|
||||
// Represent the whole flight by its average all-up mass.
|
||||
let avg = takeoff - pass.trip_fuel_kg / 2.0;
|
||||
let next = run(avg);
|
||||
let converged = (next.block_fuel_kg - pass.block_fuel_kg).abs() < 0.5;
|
||||
pass = next;
|
||||
if converged {
|
||||
break;
|
||||
}
|
||||
}
|
||||
let takeoff = zfw + (pass.block_fuel_kg - taxi_kg).max(0.0);
|
||||
let landing = takeoff - pass.trip_fuel_kg;
|
||||
let masses = Masses {
|
||||
payload_kg: payload,
|
||||
zfw_kg: zfw,
|
||||
takeoff_kg: takeoff,
|
||||
landing_kg: landing,
|
||||
over_mtow: takeoff > params.mtow_kg,
|
||||
over_mlw: landing > params.mlw_kg,
|
||||
};
|
||||
(pass, Some(masses))
|
||||
}
|
||||
};
|
||||
|
||||
FuelPlan {
|
||||
cruise_fl,
|
||||
climb: pass.climb,
|
||||
cruise: pass.cruise,
|
||||
descent: pass.descent,
|
||||
legs: pass.legs,
|
||||
trip_fuel_kg: pass.trip_fuel_kg,
|
||||
trip_time_min: pass.trip_time_min,
|
||||
taxi_kg,
|
||||
contingency_kg: pass.contingency_kg,
|
||||
alternate_kg: pass.alternate_kg,
|
||||
final_reserve_kg: pass.final_reserve_kg,
|
||||
block_fuel_kg: pass.block_fuel_kg,
|
||||
masses,
|
||||
}
|
||||
}
|
||||
|
||||
/// Fuel flow (kg/h) for climb, cruise and descent at all-up mass `mass_kg`. Uses
|
||||
/// the physics model when the profile has OpenAP coefficients (burn depends on
|
||||
/// weight and flight level), otherwise the fixed per-phase JSON figures (and
|
||||
/// `mass_kg` is ignored).
|
||||
fn phase_fuel_flows(
|
||||
profile: &AircraftProfile,
|
||||
cruise_fl: i32,
|
||||
cruise_alt_ft: f64,
|
||||
mass_kg: f64,
|
||||
) -> (f64, f64, f64) {
|
||||
let (c, cr, de) = (
|
||||
&profile.phases.climb,
|
||||
&profile.phases.cruise,
|
||||
&profile.phases.descent,
|
||||
);
|
||||
let Some(params) = &profile.openap else {
|
||||
return (c.fuel_flow_kgph, cr.fuel_flow_kgph, de.fuel_flow_kgph);
|
||||
};
|
||||
|
||||
let model = params.model();
|
||||
// Evaluate climb/descent at a representative mid-altitude of the phase.
|
||||
let mid_ft = cruise_alt_ft / 2.0;
|
||||
|
||||
let cruise_ff = model.cruise_fuel_flow_kgph(mass_kg, cruise_fl, params.cruise_mach);
|
||||
|
||||
let climb_tas = openap::kt_to_ms(c.tas_kt);
|
||||
let climb_gamma = (c.roc_fpm * openap_fpm_to_ms() / climb_tas).asin();
|
||||
let climb_ff = model.fuel_flow_kgs(mass_kg, climb_tas, mid_ft, climb_gamma) * 3600.0;
|
||||
|
||||
let descent_tas = openap::kt_to_ms(de.tas_kt);
|
||||
let descent_gamma = -(de.rod_fpm * openap_fpm_to_ms() / descent_tas).asin();
|
||||
let descent_ff = model.fuel_flow_kgs(mass_kg, descent_tas, mid_ft, descent_gamma) * 3600.0;
|
||||
|
||||
(climb_ff, cruise_ff, descent_ff)
|
||||
}
|
||||
|
||||
/// Feet-per-minute to metres-per-second.
|
||||
fn openap_fpm_to_ms() -> f64 {
|
||||
0.3048 / 60.0
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::routing::{Leg, Route};
|
||||
|
||||
fn a320() -> AircraftProfile {
|
||||
AircraftProfile::from_json_str(
|
||||
r#"{
|
||||
"icao":"A320","name":"t","default_cruise_fl":360,
|
||||
"phases":{
|
||||
"climb":{"ias_kt":290,"mach":0.78,"tas_kt":380,"fuel_flow_kgph":2600,"roc_fpm":2000},
|
||||
"cruise":{"mach":0.78,"tas_kt":450,"fuel_flow_kgph":2400},
|
||||
"descent":{"ias_kt":290,"mach":0.78,"tas_kt":320,"fuel_flow_kgph":1200,"rod_fpm":1800}
|
||||
},
|
||||
"reserves":{"final_reserve_min":30,"contingency_pct":5.0,"taxi_kg":200}
|
||||
}"#,
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn route(total_nm: f64) -> Route {
|
||||
Route {
|
||||
legs: vec![Leg {
|
||||
from: "A".into(),
|
||||
to: "B".into(),
|
||||
airway: "DCT".into(),
|
||||
dist_nm: total_nm,
|
||||
}],
|
||||
total_nm,
|
||||
via_airways: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn phases_cover_total_distance() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(400.0), 360, None, None);
|
||||
let covered = plan.climb.dist_nm + plan.cruise.dist_nm + plan.descent.dist_nm;
|
||||
assert!((covered - 400.0).abs() < 1e-6, "covered = {covered}");
|
||||
assert!(plan.cruise.dist_nm > 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_exceeds_trip_and_reserves_are_positive() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(400.0), 360, Some(120.0), None);
|
||||
assert!(plan.trip_fuel_kg > 0.0);
|
||||
assert!(plan.contingency_kg > 0.0);
|
||||
assert!(plan.final_reserve_kg > 0.0);
|
||||
assert!(plan.alternate_kg > 0.0);
|
||||
assert!(plan.block_fuel_kg > plan.trip_fuel_kg);
|
||||
// Cumulative fuel on the last leg equals trip fuel.
|
||||
let last = plan.legs.last().unwrap();
|
||||
assert!((last.cum_fuel_kg - plan.trip_fuel_kg).abs() < 1e-6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_flight_has_no_cruise() {
|
||||
let plan = compute_fuel_plan(&a320(), &route(20.0), 360, None, None);
|
||||
assert!(
|
||||
plan.cruise.dist_nm < 1e-6,
|
||||
"cruise = {}",
|
||||
plan.cruise.dist_nm
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openap_profile_makes_cruise_burn_altitude_dependent() {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
assert!(
|
||||
p.openap.is_some(),
|
||||
"bundled A320 should carry OpenAP coeffs"
|
||||
);
|
||||
|
||||
let (_c_lo, cruise_lo, _d_lo) = phase_fuel_flows(&p, 240, 24_000.0, 64_000.0);
|
||||
let (_c_hi, cruise_hi, _d_hi) = phase_fuel_flows(&p, 360, 36_000.0, 64_000.0);
|
||||
// Same weight, higher FL → less burn; and in a realistic A320 band.
|
||||
assert!(
|
||||
cruise_hi < cruise_lo,
|
||||
"hi {cruise_hi:.0} < lo {cruise_lo:.0}"
|
||||
);
|
||||
assert!(
|
||||
(1800.0..=2800.0).contains(&cruise_hi),
|
||||
"cruise ff {cruise_hi:.0} kg/h"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn openap_and_fixed_profiles_both_produce_positive_trip_fuel() {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
let plan = compute_fuel_plan(&p, &route(400.0), 360, None, None);
|
||||
assert!(plan.trip_fuel_kg > 0.0);
|
||||
assert!(plan.cruise.fuel_kg > 0.0);
|
||||
}
|
||||
|
||||
fn bundled_a320() -> AircraftProfile {
|
||||
use std::path::Path;
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
AircraftProfile::load(&dir, "A320").unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mass_breakdown_is_consistent() {
|
||||
let p = bundled_a320();
|
||||
let plan = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(16_000.0));
|
||||
let m = plan.masses.expect("OpenAP profile yields a mass breakdown");
|
||||
let params = p.openap.as_ref().unwrap();
|
||||
// ZFW = OEW + payload; TOW = ZFW + block − taxi; LDW = TOW − trip.
|
||||
assert!((m.zfw_kg - (params.oew_kg + 16_000.0)).abs() < 1e-6);
|
||||
assert!((m.takeoff_kg - (m.zfw_kg + plan.block_fuel_kg - plan.taxi_kg)).abs() < 1e-6);
|
||||
assert!((m.landing_kg - (m.takeoff_kg - plan.trip_fuel_kg)).abs() < 1e-6);
|
||||
// Take-off weight must stay within MTOW for a sane load.
|
||||
assert!(
|
||||
m.takeoff_kg <= params.mtow_kg,
|
||||
"TOW {:.0} > MTOW",
|
||||
m.takeoff_kg
|
||||
);
|
||||
assert!(!m.over_mtow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_payload_burns_more_trip_fuel() {
|
||||
let p = bundled_a320();
|
||||
let light = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(8_000.0));
|
||||
let heavy = compute_fuel_plan(&p, &route(1500.0), 360, None, Some(20_000.0));
|
||||
assert!(
|
||||
heavy.trip_fuel_kg > light.trip_fuel_kg,
|
||||
"heavy {:.0} should exceed light {:.0}",
|
||||
heavy.trip_fuel_kg,
|
||||
light.trip_fuel_kg
|
||||
);
|
||||
assert!(heavy.masses.unwrap().takeoff_kg > light.masses.unwrap().takeoff_kg);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn block_fuel_iteration_converges() {
|
||||
// The reported block must be self-consistent with the mass it implies:
|
||||
// re-running at the converged take-off mass reproduces the same block.
|
||||
let p = bundled_a320();
|
||||
let plan = compute_fuel_plan(&p, &route(2000.0), 360, None, Some(15_000.0));
|
||||
let m = plan.masses.unwrap();
|
||||
let avg = m.takeoff_kg - plan.trip_fuel_kg / 2.0;
|
||||
let (_c, cruise_ff, _d) = phase_fuel_flows(&p, 360, 36_000.0, avg);
|
||||
// Cruise burn recomputed at the average mass is a sane A320 figure.
|
||||
assert!(
|
||||
(1800.0..=3000.0).contains(&cruise_ff),
|
||||
"cruise ff {cruise_ff:.0}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
//! Physics-based aircraft performance, in the spirit of OpenAP (TU Delft,
|
||||
//! Junzi Sun) — an open, first-principles alternative to EUROCONTROL BADA.
|
||||
//!
|
||||
//! Fuel flow is derived from the actual aerodynamic drag at the current mass,
|
||||
//! altitude and speed (via an ISA atmosphere and a parabolic drag polar), then
|
||||
//! turned into fuel burn through a thrust-specific fuel consumption (TSFC).
|
||||
//! Unlike a fixed `fuel_flow_kgph`, this makes burn depend on **weight** and
|
||||
//! **flight level** — the two things that dominate real trip fuel.
|
||||
//!
|
||||
//! Coefficients (`cd0`, `k`, wing area, engine thrust/TSFC/idle flow) come from
|
||||
//! OpenAP's open dataset. The cruise TSFC is calibrated to a validated installed
|
||||
//! cruise fuel flow (OpenAP's ideal `cruise_sfc` is uninstalled and runs a bit
|
||||
//! optimistic); BADA datasets can be dropped in later behind the same interface.
|
||||
|
||||
/// Standard gravity (m/s²).
|
||||
const G0: f64 = 9.80665;
|
||||
/// Specific gas constant for dry air (J/(kg·K)).
|
||||
const R_AIR: f64 = 287.05287;
|
||||
/// Ratio of specific heats for air.
|
||||
const GAMMA: f64 = 1.4;
|
||||
/// ISA sea-level temperature (K) and pressure (Pa).
|
||||
const T0: f64 = 288.15;
|
||||
const P0: f64 = 101_325.0;
|
||||
/// Tropospheric lapse rate (K/m) and tropopause height (m).
|
||||
const LAPSE: f64 = 0.0065;
|
||||
const H_TROP: f64 = 11_000.0;
|
||||
const T_TROP: f64 = 216.65;
|
||||
/// Metres per foot; metres/second per knot.
|
||||
const M_PER_FT: f64 = 0.3048;
|
||||
const MS_PER_KT: f64 = 0.514_444;
|
||||
/// Seconds per hour, for kg/s ↔ kg/h.
|
||||
const S_PER_H: f64 = 3600.0;
|
||||
|
||||
/// ISA atmospheric state at a geopotential altitude.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Atmosphere {
|
||||
/// Static temperature (K).
|
||||
pub temperature_k: f64,
|
||||
/// Static pressure (Pa).
|
||||
pub pressure_pa: f64,
|
||||
/// Air density (kg/m³).
|
||||
pub density: f64,
|
||||
/// Speed of sound (m/s).
|
||||
pub sound_speed: f64,
|
||||
}
|
||||
|
||||
impl Atmosphere {
|
||||
/// ISA state at `alt_ft` (International Standard Atmosphere, troposphere +
|
||||
/// lower stratosphere; valid to ~20 km, i.e. well above any airliner).
|
||||
pub fn isa(alt_ft: f64) -> Self {
|
||||
let h = alt_ft * M_PER_FT;
|
||||
let (t, p) = if h <= H_TROP {
|
||||
let t = T0 - LAPSE * h;
|
||||
// Barometric formula, troposphere: p = p0 (T/T0)^(g/(L·R)).
|
||||
let p = P0 * (t / T0).powf(G0 / (LAPSE * R_AIR));
|
||||
(t, p)
|
||||
} else {
|
||||
let p_trop = P0 * (T_TROP / T0).powf(G0 / (LAPSE * R_AIR));
|
||||
// Isothermal layer: p = p_trop · exp(-g (h-11000)/(R·T)).
|
||||
let p = p_trop * (-G0 * (h - H_TROP) / (R_AIR * T_TROP)).exp();
|
||||
(T_TROP, p)
|
||||
};
|
||||
let density = p / (R_AIR * t);
|
||||
let sound_speed = (GAMMA * R_AIR * t).sqrt();
|
||||
Self {
|
||||
temperature_k: t,
|
||||
pressure_pa: p,
|
||||
density,
|
||||
sound_speed,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A parabolic drag polar plus the engine data needed to turn drag into fuel.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub struct PerfModel {
|
||||
/// Reference wing area (m²).
|
||||
pub wing_area_m2: f64,
|
||||
/// Zero-lift drag coefficient (clean configuration).
|
||||
pub cd0: f64,
|
||||
/// Lift-induced drag factor `k` in `CD = CD0 + k·CL²`.
|
||||
pub induced_k: f64,
|
||||
/// Number of engines.
|
||||
pub n_engines: u32,
|
||||
/// Maximum (sea-level static) thrust per engine (N).
|
||||
pub max_thrust_n: f64,
|
||||
/// Cruise thrust-specific fuel consumption at the reference altitude,
|
||||
/// in grams of fuel per newton of thrust per second — g/(N·s).
|
||||
pub tsfc_cruise_g_per_ns: f64,
|
||||
/// Idle fuel flow per engine (kg/s); the descent floor.
|
||||
pub ff_idle_kgs: f64,
|
||||
/// Altitude (ft) at which `tsfc_cruise_g_per_ns` is quoted.
|
||||
pub ref_cruise_alt_ft: f64,
|
||||
}
|
||||
|
||||
impl PerfModel {
|
||||
/// True airspeed (m/s) for a given Mach number at `alt_ft`.
|
||||
pub fn tas_ms(&self, mach: f64, alt_ft: f64) -> f64 {
|
||||
mach * Atmosphere::isa(alt_ft).sound_speed
|
||||
}
|
||||
|
||||
/// Total aerodynamic drag (N) in steady level flight at `mass_kg`, true
|
||||
/// airspeed `tas_ms`, altitude `alt_ft`.
|
||||
pub fn drag_n(&self, mass_kg: f64, tas_ms: f64, alt_ft: f64) -> f64 {
|
||||
let atmos = Atmosphere::isa(alt_ft);
|
||||
let q = 0.5 * atmos.density * tas_ms * tas_ms; // dynamic pressure (Pa)
|
||||
let cl = (mass_kg * G0) / (q * self.wing_area_m2);
|
||||
let cd = self.cd0 + self.induced_k * cl * cl;
|
||||
q * self.wing_area_m2 * cd
|
||||
}
|
||||
|
||||
/// Effective TSFC at `alt_ft`. Turbofan TSFC scales roughly with √θ (θ =
|
||||
/// local/reference temperature ratio): colder air aloft burns less per
|
||||
/// newton, which is why jets climb to cruise.
|
||||
fn tsfc_g_per_ns(&self, alt_ft: f64) -> f64 {
|
||||
let t = Atmosphere::isa(alt_ft).temperature_k;
|
||||
let t_ref = Atmosphere::isa(self.ref_cruise_alt_ft).temperature_k;
|
||||
self.tsfc_cruise_g_per_ns * (t / t_ref).sqrt()
|
||||
}
|
||||
|
||||
/// Total fuel flow (kg/s) at the given state. `path_angle_rad` is the climb
|
||||
/// (+) or descent (−) flight-path angle; the weight component along the path
|
||||
/// adds to (climb) or subtracts from (descent) the thrust required. Never
|
||||
/// falls below combined engine idle flow.
|
||||
pub fn fuel_flow_kgs(
|
||||
&self,
|
||||
mass_kg: f64,
|
||||
tas_ms: f64,
|
||||
alt_ft: f64,
|
||||
path_angle_rad: f64,
|
||||
) -> f64 {
|
||||
let drag = self.drag_n(mass_kg, tas_ms, alt_ft);
|
||||
let thrust_req = drag + mass_kg * G0 * path_angle_rad.sin();
|
||||
let ff_from_thrust = self.tsfc_g_per_ns(alt_ft) * thrust_req.max(0.0) / 1000.0;
|
||||
let idle_floor = self.n_engines as f64 * self.ff_idle_kgs;
|
||||
ff_from_thrust.max(idle_floor)
|
||||
}
|
||||
|
||||
/// Level-flight fuel flow in kg/h (convenience for cruise).
|
||||
pub fn fuel_flow_kgph(&self, mass_kg: f64, tas_ms: f64, alt_ft: f64) -> f64 {
|
||||
self.fuel_flow_kgs(mass_kg, tas_ms, alt_ft, 0.0) * S_PER_H
|
||||
}
|
||||
|
||||
/// Cruise fuel flow (kg/h) at `mass_kg` and `cruise_fl` (×100 ft), flying at
|
||||
/// `mach`.
|
||||
pub fn cruise_fuel_flow_kgph(&self, mass_kg: f64, cruise_fl: i32, mach: f64) -> f64 {
|
||||
let alt_ft = cruise_fl as f64 * 100.0;
|
||||
let tas = self.tas_ms(mach, alt_ft);
|
||||
self.fuel_flow_kgph(mass_kg, tas, alt_ft)
|
||||
}
|
||||
}
|
||||
|
||||
/// Convert a true airspeed in knots to m/s.
|
||||
pub fn kt_to_ms(kt: f64) -> f64 {
|
||||
kt * MS_PER_KT
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn a320() -> PerfModel {
|
||||
PerfModel {
|
||||
wing_area_m2: 124.0,
|
||||
cd0: 0.018,
|
||||
induced_k: 0.039,
|
||||
n_engines: 2,
|
||||
max_thrust_n: 117_900.0,
|
||||
tsfc_cruise_g_per_ns: 0.0170,
|
||||
ff_idle_kgs: 0.107,
|
||||
ref_cruise_alt_ft: 35_000.0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn isa_matches_known_values() {
|
||||
let sl = Atmosphere::isa(0.0);
|
||||
assert!((sl.temperature_k - 288.15).abs() < 0.01);
|
||||
assert!((sl.pressure_pa - 101_325.0).abs() < 1.0);
|
||||
assert!((sl.density - 1.225).abs() < 0.001);
|
||||
assert!((sl.sound_speed - 340.3).abs() < 0.5);
|
||||
|
||||
// FL350: T ≈ 218.8 K, p ≈ 23.8 kPa, ρ ≈ 0.380 kg/m³.
|
||||
let cruise = Atmosphere::isa(35_000.0);
|
||||
assert!(
|
||||
(cruise.temperature_k - 218.8).abs() < 0.5,
|
||||
"{}",
|
||||
cruise.temperature_k
|
||||
);
|
||||
assert!(
|
||||
(cruise.pressure_pa - 23_842.0).abs() < 200.0,
|
||||
"{}",
|
||||
cruise.pressure_pa
|
||||
);
|
||||
assert!((cruise.density - 0.380).abs() < 0.005, "{}", cruise.density);
|
||||
|
||||
// Tropopause is continuous across the layer boundary.
|
||||
let below = Atmosphere::isa(36_089.0 - 1.0);
|
||||
let above = Atmosphere::isa(36_089.0 + 1.0);
|
||||
assert!((below.pressure_pa - above.pressure_pa).abs() < 5.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a320_cruise_fuel_flow_is_realistic() {
|
||||
// ~64 t A320 at FL350, M0.78 burns roughly 2.0–2.5 t/h (both engines).
|
||||
let ff = a320().cruise_fuel_flow_kgph(64_000.0, 350, 0.78);
|
||||
assert!((2000.0..=2600.0).contains(&ff), "cruise ff = {ff:.0} kg/h");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heavier_aircraft_burns_more() {
|
||||
let m = a320();
|
||||
let light = m.cruise_fuel_flow_kgph(58_000.0, 350, 0.78);
|
||||
let heavy = m.cruise_fuel_flow_kgph(72_000.0, 350, 0.78);
|
||||
assert!(
|
||||
heavy > light,
|
||||
"heavy {heavy:.0} should exceed light {light:.0}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn optimum_altitude_beats_low_altitude_for_same_mass() {
|
||||
// For a given weight, cruising higher (thinner air) is more efficient
|
||||
// until induced drag takes over — FL350 should beat FL200.
|
||||
let m = a320();
|
||||
let low = m.cruise_fuel_flow_kgph(64_000.0, 200, 0.78);
|
||||
let high = m.cruise_fuel_flow_kgph(64_000.0, 350, 0.78);
|
||||
assert!(high < low, "high {high:.0} should beat low {low:.0}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn descent_is_floored_at_idle() {
|
||||
let m = a320();
|
||||
let tas = m.tas_ms(0.6, 20_000.0);
|
||||
// A steep idle descent must not go below combined idle flow.
|
||||
let ff = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, (-6.0_f64).to_radians());
|
||||
let idle = m.n_engines as f64 * m.ff_idle_kgs;
|
||||
assert!((ff - idle).abs() < 1e-9, "descent ff {ff} vs idle {idle}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn climb_burns_more_than_cruise() {
|
||||
let m = a320();
|
||||
let tas = m.tas_ms(0.60, 20_000.0);
|
||||
let climb = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, 5.0_f64.to_radians());
|
||||
let level = m.fuel_flow_kgs(64_000.0, tas, 20_000.0, 0.0);
|
||||
assert!(climb > level, "climb {climb} should exceed level {level}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Aircraft performance profile, loaded from `data/aircraft/<icao>.json`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::perf::openap::PerfModel;
|
||||
|
||||
/// Per-phase speeds and fuel flow plus regulatory reserves for one aircraft type.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct AircraftProfile {
|
||||
pub icao: String,
|
||||
pub name: String,
|
||||
pub default_cruise_fl: i32,
|
||||
pub phases: Phases,
|
||||
pub reserves: Reserves,
|
||||
/// Optional physics-based (OpenAP) coefficients. When present, fuel flow is
|
||||
/// computed from drag at the actual mass/altitude instead of the fixed
|
||||
/// per-phase `fuel_flow_kgph` figures.
|
||||
#[serde(default)]
|
||||
pub openap: Option<OpenApParams>,
|
||||
}
|
||||
|
||||
/// OpenAP-derived aerodynamic and engine coefficients plus mass envelope.
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct OpenApParams {
|
||||
pub wing_area_m2: f64,
|
||||
pub cd0: f64,
|
||||
pub induced_k: f64,
|
||||
pub n_engines: u32,
|
||||
pub engine: String,
|
||||
pub max_thrust_n: f64,
|
||||
pub tsfc_cruise_g_per_ns: f64,
|
||||
pub ff_idle_kgs: f64,
|
||||
pub ref_cruise_alt_ft: f64,
|
||||
pub oew_kg: f64,
|
||||
pub mtow_kg: f64,
|
||||
pub mlw_kg: f64,
|
||||
pub max_fuel_kg: f64,
|
||||
pub cruise_mach: f64,
|
||||
}
|
||||
|
||||
impl OpenApParams {
|
||||
/// Build the runtime physics model from these coefficients.
|
||||
pub fn model(&self) -> PerfModel {
|
||||
PerfModel {
|
||||
wing_area_m2: self.wing_area_m2,
|
||||
cd0: self.cd0,
|
||||
induced_k: self.induced_k,
|
||||
n_engines: self.n_engines,
|
||||
max_thrust_n: self.max_thrust_n,
|
||||
tsfc_cruise_g_per_ns: self.tsfc_cruise_g_per_ns,
|
||||
ff_idle_kgs: self.ff_idle_kgs,
|
||||
ref_cruise_alt_ft: self.ref_cruise_alt_ft,
|
||||
}
|
||||
}
|
||||
|
||||
/// Default payload (kg) when the caller gives none: 70 % of a max-payload
|
||||
/// proxy (MLW − OEW). A stand-in until real max-zero-fuel-weight data is
|
||||
/// added; yields a realistic ~medium load factor.
|
||||
pub fn default_payload_kg(&self) -> f64 {
|
||||
0.70 * (self.mlw_kg - self.oew_kg)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Phases {
|
||||
pub climb: ClimbPhase,
|
||||
pub cruise: CruisePhase,
|
||||
pub descent: DescentPhase,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ClimbPhase {
|
||||
pub ias_kt: f64,
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
pub roc_fpm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CruisePhase {
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct DescentPhase {
|
||||
pub ias_kt: f64,
|
||||
pub mach: f64,
|
||||
pub tas_kt: f64,
|
||||
pub fuel_flow_kgph: f64,
|
||||
pub rod_fpm: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct Reserves {
|
||||
pub final_reserve_min: f64,
|
||||
pub contingency_pct: f64,
|
||||
pub taxi_kg: f64,
|
||||
}
|
||||
|
||||
impl AircraftProfile {
|
||||
/// Deserialize a profile from a JSON string.
|
||||
pub fn from_json_str(json: &str) -> Result<Self> {
|
||||
Ok(serde_json::from_str(json)?)
|
||||
}
|
||||
|
||||
/// Load `<dir>/<icao lowercased>.json`.
|
||||
pub fn load(dir: &Path, icao: &str) -> Result<Self> {
|
||||
let path = dir.join(format!("{}.json", icao.to_lowercase()));
|
||||
let json = std::fs::read_to_string(&path).map_err(|e| {
|
||||
if e.kind() == std::io::ErrorKind::NotFound {
|
||||
CoreError::NotFound(format!("aircraft profile '{icao}' ({})", path.display()))
|
||||
} else {
|
||||
CoreError::Io(e)
|
||||
}
|
||||
})?;
|
||||
Self::from_json_str(&json)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn loads_bundled_a320_profile() {
|
||||
// data/aircraft lives at the workspace root, two levels up from this crate.
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let p = AircraftProfile::load(&dir, "A320").unwrap();
|
||||
assert_eq!(p.icao, "A320");
|
||||
assert_eq!(p.default_cruise_fl, 360);
|
||||
assert!(p.phases.cruise.tas_kt > 400.0);
|
||||
assert_eq!(p.reserves.final_reserve_min, 30.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn missing_profile_is_not_found() {
|
||||
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../data/aircraft");
|
||||
let err = AircraftProfile::load(&dir, "ZZZZ").unwrap_err();
|
||||
assert!(err.to_string().contains("ZZZZ"), "{err}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
//! Import PFPX `.route` files as seeds for our **own** route DB.
|
||||
//!
|
||||
//! PFPX stores one XML `.route` file per city-pair variant. We extract only the
|
||||
//! filed route string (item-15 `<ATC>`), keyed by the ADEP/ADES in the filename,
|
||||
//! and store it in our `routes` table as an **unvalidated seed** (`source="pfpx"`).
|
||||
//! These seeds are often years old, so they are re-validated / repaired against
|
||||
//! the live IFPUV oracle (and OUR RAD) before use. Nothing PFPX-specific (its RAD
|
||||
//! or navdata) is imported — only the route string, which we then make current.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
/// A filed route extracted from a PFPX `.route` file.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PfpxRoute {
|
||||
pub adep: String,
|
||||
pub ades: String,
|
||||
/// Item-15 (airways + DCT), from `<ATC>`.
|
||||
pub route: String,
|
||||
pub cycle: String,
|
||||
pub dist_nm: f64,
|
||||
}
|
||||
|
||||
/// Value of the first `<name>…</name>` element.
|
||||
fn tag<'a>(s: &'a str, name: &str) -> Option<&'a str> {
|
||||
let open = format!("<{name}>");
|
||||
let close = format!("</{name}>");
|
||||
let i = s.find(&open)? + open.len();
|
||||
let rest = &s[i..];
|
||||
let j = rest.find(&close)?;
|
||||
Some(rest[..j].trim())
|
||||
}
|
||||
|
||||
/// Parse a `.route` XML body for the given ADEP/ADES.
|
||||
pub fn parse_route_str(adep: &str, ades: &str, text: &str) -> Option<PfpxRoute> {
|
||||
let route = tag(text, "ATC")?.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if route.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let cycle = tag(text, "Cycle").unwrap_or("").to_string();
|
||||
let dist_nm = tag(text, "TotalDistance").and_then(|s| s.parse().ok()).unwrap_or(0.0);
|
||||
Some(PfpxRoute {
|
||||
adep: adep.to_uppercase(),
|
||||
ades: ades.to_uppercase(),
|
||||
route,
|
||||
cycle,
|
||||
dist_nm,
|
||||
})
|
||||
}
|
||||
|
||||
/// Parse one `.route` file. ADEP/ADES come from the 8-char filename prefix
|
||||
/// (`LFPGBIKF01.route` → LFPG, BIKF). `None` if it has no `<ATC>` route.
|
||||
pub fn parse_route_file(path: &Path) -> Option<PfpxRoute> {
|
||||
let stem = path.file_stem()?.to_str()?;
|
||||
if stem.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let (adep, ades) = (&stem[0..4], &stem[4..8]);
|
||||
if !adep.chars().chain(ades.chars()).all(|c| c.is_ascii_alphanumeric()) {
|
||||
return None;
|
||||
}
|
||||
let text = fs::read_to_string(path).ok()?;
|
||||
parse_route_str(adep, ades, &text)
|
||||
}
|
||||
|
||||
/// Import every `.route` file in `dir` as an unvalidated seed into the `routes`
|
||||
/// table (existing dep/dest/route rows are left untouched). Returns
|
||||
/// `(parsed, inserted)`.
|
||||
pub fn import_dir(conn: &mut Connection, dir: &Path) -> Result<(usize, usize)> {
|
||||
let mut parsed = 0usize;
|
||||
let mut inserted = 0usize;
|
||||
let tx = conn.transaction()?;
|
||||
for entry in fs::read_dir(dir)? {
|
||||
let path = entry?.path();
|
||||
if path.extension().and_then(|e| e.to_str()) != Some("route") {
|
||||
continue;
|
||||
}
|
||||
let Some(r) = parse_route_file(&path) else { continue };
|
||||
parsed += 1;
|
||||
let source = if r.cycle.is_empty() { "pfpx".to_string() } else { format!("pfpx {}", r.cycle) };
|
||||
let n = tx.execute(
|
||||
"INSERT INTO routes \
|
||||
(dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at) \
|
||||
VALUES (?1,?2,0,?3,?4,0,0,'', ?5, datetime('now')) \
|
||||
ON CONFLICT(dep,dest,cruise_fl,route_string) DO NOTHING",
|
||||
params![r.adep, r.ades, r.route, r.dist_nm, source],
|
||||
)?;
|
||||
inserted += n;
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok((parsed, inserted))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_atc_route() {
|
||||
let xml = "<PFPXROUTE><GENERAL>\
|
||||
<ATC>OPALE UT421 BIG UT420 TNT DCT KEF</ATC>\
|
||||
<TotalDistance>1236.700000</TotalDistance>\
|
||||
<Cycle>AS1302</Cycle></GENERAL></PFPXROUTE>";
|
||||
let r = parse_route_str("lfpg", "bikf", xml).unwrap();
|
||||
assert_eq!(r.adep, "LFPG");
|
||||
assert_eq!(r.ades, "BIKF");
|
||||
assert_eq!(r.route, "OPALE UT421 BIG UT420 TNT DCT KEF");
|
||||
assert_eq!(r.cycle, "AS1302");
|
||||
assert!((r.dist_nm - 1236.7).abs() < 0.1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn none_without_atc() {
|
||||
assert!(parse_route_str("LFPG", "EGLL", "<PFPXROUTE></PFPXROUTE>").is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
//! Eurocontrol RAD (Route Availability Document) model + application.
|
||||
//!
|
||||
//! The *parsing* of the RAD Excel workbook lives in the `flightplanner-rad`
|
||||
//! crate (it needs `calamine`); this module only holds the plain data model and
|
||||
//! the logic to apply restrictions during routing/validation, so `core` stays
|
||||
//! dependency-light. Callers load a [`RadData`] once and pass it in.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A named area (Annex 1): an ID mapping to a set of aerodrome ICAOs.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Area {
|
||||
pub id: String,
|
||||
pub airports: Vec<String>,
|
||||
pub region: String,
|
||||
}
|
||||
|
||||
/// Coarse classification of a DCT restriction's utilization text.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub enum DctKind {
|
||||
/// The direct is not available (for some/all traffic).
|
||||
Forbidden,
|
||||
/// Available only for specific traffic (ARR/DEP a place, via a point, …).
|
||||
ConditionalOnly,
|
||||
/// Mandatory routing.
|
||||
Compulsory,
|
||||
Other,
|
||||
}
|
||||
|
||||
/// A DCT (direct) restriction (Annex 3B DCT).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DctRestriction {
|
||||
pub id: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub lower_fl: Option<i32>,
|
||||
pub upper_fl: Option<i32>,
|
||||
/// `Y/N` column: is this direct available at all.
|
||||
pub available: bool,
|
||||
/// Free-text condition ("NOT AVBL FOR TFC …", "ONLY AVBL …", "COMPULSORY …").
|
||||
pub utilization: String,
|
||||
/// `Even` / `Odd` / empty — permitted cruising-level parity.
|
||||
pub direction: String,
|
||||
}
|
||||
|
||||
impl DctRestriction {
|
||||
pub fn kind(&self) -> DctKind {
|
||||
let u = self.utilization.to_uppercase();
|
||||
if u.contains("COMPULSORY") {
|
||||
DctKind::Compulsory
|
||||
} else if u.starts_with("NOT AVBL") || !self.available {
|
||||
DctKind::Forbidden
|
||||
} else if u.starts_with("ONLY AVBL") {
|
||||
DctKind::ConditionalOnly
|
||||
} else {
|
||||
DctKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// Does this restriction's flight-level band contain `fl`?
|
||||
/// (`lower` missing ⇒ 0, `upper` missing ⇒ unlimited.)
|
||||
pub fn covers_fl(&self, fl: i32) -> bool {
|
||||
fl >= self.lower_fl.unwrap_or(0) && fl <= self.upper_fl.unwrap_or(i32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// A directed "allowed FRA direct" edge, extracted from the `A DCT B` point
|
||||
/// sequences published in the RAD Annex 2 VIA-clauses. Together these form the
|
||||
/// FRA connectivity graph that IFPS accepts in Free Route Airspace.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub struct FraEdge {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
}
|
||||
|
||||
/// A published Free-Route-Airspace significant point (official EUROCONTROL FRA
|
||||
/// points list). Roles: en-route `E`ntry/e`X`it/`I`ntermediate (`EX` = both);
|
||||
/// arr/dep `A`/`D`. `level_lo`/`level_hi` are the point's usable FL band; `flos`
|
||||
/// is its cruising-level orientation (ODD/EVEN/ALL…).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct FraPoint {
|
||||
pub name: String,
|
||||
pub lat: f64,
|
||||
pub lon: f64,
|
||||
/// FRA area(s) the point belongs to (e.g. "SECSI", "SEE").
|
||||
pub areas: Vec<String>,
|
||||
/// En-route relevance: `E`, `X`, `I`, `EX`, or empty.
|
||||
pub enroute: String,
|
||||
/// Arr/dep relevance: `A`, `D`, `AD`, or empty.
|
||||
pub arrdep: String,
|
||||
pub arr_airports: Vec<String>,
|
||||
pub dep_airports: Vec<String>,
|
||||
pub flos: String,
|
||||
pub level_lo: Option<i32>,
|
||||
pub level_hi: Option<i32>,
|
||||
/// FIR/ACC location indicators the point belongs to.
|
||||
pub loc_ind: Vec<String>,
|
||||
}
|
||||
|
||||
impl FraPoint {
|
||||
/// Usable as an en-route waypoint (not withdrawn / not purely arr-dep).
|
||||
pub fn is_enroute(&self) -> bool {
|
||||
matches!(self.enroute.as_str(), "E" | "X" | "I" | "EX")
|
||||
}
|
||||
/// Is `fl` within the point's published level band?
|
||||
pub fn covers_fl(&self, fl: i32) -> bool {
|
||||
fl >= self.level_lo.unwrap_or(0) && fl <= self.level_hi.unwrap_or(i32::MAX)
|
||||
}
|
||||
}
|
||||
|
||||
/// A city-pair flight-level cap (Annex 2A): traffic from `from` to `to` may not
|
||||
/// file above `cap_fl`. `from`/`to` may name Annex-1 groups (expanded via areas).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct LevelCap {
|
||||
pub id: String,
|
||||
pub from: Vec<String>,
|
||||
pub to: Vec<String>,
|
||||
/// The routing condition ("VIA …" / "EXC VIA …"); kept verbatim, ignored in v1.
|
||||
pub condition: String,
|
||||
/// Ceiling flight level (lowest FL mentioned in the capping cell).
|
||||
pub cap_fl: Option<i32>,
|
||||
}
|
||||
|
||||
/// The parsed RAD (the parts we currently model).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub struct RadData {
|
||||
pub areas: Vec<Area>,
|
||||
pub dct: Vec<DctRestriction>,
|
||||
/// FRA connectivity edges (Annex 2 VIA-clauses); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub fra_edges: Vec<FraEdge>,
|
||||
/// City-pair flight-level caps (Annex 2A); empty if not parsed.
|
||||
#[serde(default)]
|
||||
pub level_caps: Vec<LevelCap>,
|
||||
/// Official EUROCONTROL FRA significant points (separate file); empty if not loaded.
|
||||
#[serde(default)]
|
||||
pub fra_points: Vec<FraPoint>,
|
||||
}
|
||||
|
||||
impl RadData {
|
||||
/// A **forbidden** DCT restriction matching `from`→`to` at `fl`, if any —
|
||||
/// i.e. flying this direct would violate the RAD.
|
||||
pub fn forbidden_dct(&self, from: &str, to: &str, fl: i32) -> Option<&DctRestriction> {
|
||||
self.dct.iter().find(|d| {
|
||||
d.kind() == DctKind::Forbidden
|
||||
&& d.from.eq_ignore_ascii_case(from)
|
||||
&& d.to.eq_ignore_ascii_case(to)
|
||||
&& d.covers_fl(fl)
|
||||
})
|
||||
}
|
||||
|
||||
/// Undirected adjacency map of the FRA connectivity graph (point → neighbours).
|
||||
pub fn fra_adjacency(&self) -> std::collections::HashMap<String, Vec<String>> {
|
||||
let mut m: std::collections::HashMap<String, Vec<String>> = std::collections::HashMap::new();
|
||||
for e in &self.fra_edges {
|
||||
m.entry(e.from.clone()).or_default().push(e.to.clone());
|
||||
m.entry(e.to.clone()).or_default().push(e.from.clone());
|
||||
}
|
||||
m
|
||||
}
|
||||
|
||||
/// The lowest RAD flight-level cap (Annex 2A) applying to `from`→`to`, or
|
||||
/// `None` if uncapped. Group names in the From/To sets are expanded to their
|
||||
/// Annex-1 airports. v1 applies matches unconditionally (ignores conditions).
|
||||
pub fn max_cruise_fl(&self, from: &str, to: &str) -> Option<i32> {
|
||||
let icao_in = |icao: &str, set: &[String]| -> bool {
|
||||
set.iter().any(|s| {
|
||||
s.eq_ignore_ascii_case(icao)
|
||||
|| self.areas.iter().any(|a| {
|
||||
a.id.eq_ignore_ascii_case(s)
|
||||
&& a.airports.iter().any(|ap| ap.eq_ignore_ascii_case(icao))
|
||||
})
|
||||
})
|
||||
};
|
||||
self.level_caps
|
||||
.iter()
|
||||
.filter(|c| icao_in(from, &c.from) && icao_in(to, &c.to))
|
||||
.filter_map(|c| c.cap_fl)
|
||||
.min()
|
||||
}
|
||||
|
||||
/// Count of restrictions by kind — for summaries/status.
|
||||
pub fn dct_counts(&self) -> (usize, usize, usize) {
|
||||
let mut forbidden = 0;
|
||||
let mut only = 0;
|
||||
let mut comp = 0;
|
||||
for d in &self.dct {
|
||||
match d.kind() {
|
||||
DctKind::Forbidden => forbidden += 1,
|
||||
DctKind::ConditionalOnly => only += 1,
|
||||
DctKind::Compulsory => comp += 1,
|
||||
DctKind::Other => {}
|
||||
}
|
||||
}
|
||||
(forbidden, only, comp)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn restr(from: &str, to: &str, lo: i32, hi: i32, util: &str) -> DctRestriction {
|
||||
DctRestriction {
|
||||
id: "X".into(),
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
lower_fl: Some(lo),
|
||||
upper_fl: Some(hi),
|
||||
available: true,
|
||||
utilization: util.into(),
|
||||
direction: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn detects_forbidden_dct_in_band() {
|
||||
let rad = RadData {
|
||||
areas: vec![],
|
||||
dct: vec![restr("ABC", "DEF", 100, 300, "NOT AVBL FOR TFC ARR EDDF")],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![],
|
||||
fra_points: vec![],
|
||||
};
|
||||
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", 350).is_none()); // above band
|
||||
assert!(rad.forbidden_dct("DEF", "ABC", 200).is_none()); // directional
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kind_classification() {
|
||||
assert_eq!(restr("A", "B", 0, 999, "NOT AVBL FOR TFC X").kind(), DctKind::Forbidden);
|
||||
assert_eq!(restr("A", "B", 0, 999, "ONLY AVBL FOR TFC ARR X").kind(), DctKind::ConditionalOnly);
|
||||
assert_eq!(restr("A", "B", 0, 999, "ONLY AVBL AND COMPULSORY FOR X").kind(), DctKind::Compulsory);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn max_cruise_fl_matches_and_expands_groups() {
|
||||
let rad = RadData {
|
||||
areas: vec![Area {
|
||||
id: "GENEVA_AREA".into(),
|
||||
airports: vec!["LSGG".into(), "LSGE".into()],
|
||||
region: "LS".into(),
|
||||
}],
|
||||
dct: vec![],
|
||||
fra_edges: vec![],
|
||||
level_caps: vec![
|
||||
LevelCap { id: "R1".into(), from: vec!["LFPG".into()], to: vec!["GENEVA_AREA".into()], condition: String::new(), cap_fl: Some(345) },
|
||||
LevelCap { id: "R2".into(), from: vec!["LFPG".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(295) },
|
||||
LevelCap { id: "R3".into(), from: vec!["EGLL".into()], to: vec!["LSGG".into()], condition: String::new(), cap_fl: Some(200) },
|
||||
],
|
||||
fra_points: vec![],
|
||||
};
|
||||
// LFPG→LSGG matches R1 (via group) and R2 → min cap 295.
|
||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGG"), Some(295));
|
||||
// LFPG→LSGE only matches R1 (group) → 345.
|
||||
assert_eq!(rad.max_cruise_fl("LFPG", "LSGE"), Some(345));
|
||||
// No matching pair.
|
||||
assert_eq!(rad.max_cruise_fl("EDDF", "LEMD"), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
//! Our own database of flight plans — routes we've generated and IFPS-pre-checked,
|
||||
//! cached per city pair in the `routes` table (see `db::schema`).
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
const SEP: &str = " ||| ";
|
||||
|
||||
/// A stored (generated + pre-checked) flight plan.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct CachedRoute {
|
||||
pub dep: String,
|
||||
pub dest: String,
|
||||
pub cruise_fl: i32,
|
||||
pub route_string: String,
|
||||
pub dist_nm: f64,
|
||||
pub via_airways: bool,
|
||||
pub ifps_ok: bool,
|
||||
pub ifps_errors: Vec<String>,
|
||||
pub source: String,
|
||||
pub generated_at: String,
|
||||
}
|
||||
|
||||
/// Insert or refresh a route (unique on dep/dest/FL/route_string).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn record(
|
||||
conn: &Connection,
|
||||
dep: &str,
|
||||
dest: &str,
|
||||
cruise_fl: i32,
|
||||
route_string: &str,
|
||||
dist_nm: f64,
|
||||
via_airways: bool,
|
||||
ifps_ok: bool,
|
||||
ifps_errors: &[String],
|
||||
source: &str,
|
||||
) -> Result<()> {
|
||||
conn.execute(
|
||||
"INSERT INTO routes \
|
||||
(dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at) \
|
||||
VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9, datetime('now')) \
|
||||
ON CONFLICT(dep,dest,cruise_fl,route_string) DO UPDATE SET \
|
||||
dist_nm=excluded.dist_nm, via_airways=excluded.via_airways, ifps_ok=excluded.ifps_ok, \
|
||||
ifps_errors=excluded.ifps_errors, source=excluded.source, generated_at=excluded.generated_at",
|
||||
params![
|
||||
dep.to_uppercase(),
|
||||
dest.to_uppercase(),
|
||||
cruise_fl,
|
||||
route_string,
|
||||
dist_nm,
|
||||
via_airways as i32,
|
||||
ifps_ok as i32,
|
||||
ifps_errors.join(SEP),
|
||||
source,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair, most recent first.
|
||||
pub fn recent(conn: &Connection, dep: &str, dest: &str, limit: usize) -> Result<Vec<CachedRoute>> {
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT dep,dest,cruise_fl,route_string,dist_nm,via_airways,ifps_ok,ifps_errors,source,generated_at \
|
||||
FROM routes WHERE dep=?1 AND dest=?2 ORDER BY generated_at DESC LIMIT ?3",
|
||||
)?;
|
||||
let rows = stmt.query_map(
|
||||
params![dep.to_uppercase(), dest.to_uppercase(), limit as i64],
|
||||
|r| {
|
||||
let errs: String = r.get(7)?;
|
||||
Ok(CachedRoute {
|
||||
dep: r.get(0)?,
|
||||
dest: r.get(1)?,
|
||||
cruise_fl: r.get(2)?,
|
||||
route_string: r.get(3)?,
|
||||
dist_nm: r.get(4)?,
|
||||
via_airways: r.get::<_, i32>(5)? != 0,
|
||||
ifps_ok: r.get::<_, i32>(6)? != 0,
|
||||
ifps_errors: if errs.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
errs.split(SEP).map(str::to_string).collect()
|
||||
},
|
||||
source: r.get(8)?,
|
||||
generated_at: r.get(9)?,
|
||||
})
|
||||
},
|
||||
)?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
out.push(row?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Total number of stored routes.
|
||||
pub fn count(conn: &Connection) -> Result<i64> {
|
||||
Ok(conn.query_row("SELECT COUNT(*) FROM routes", [], |r| r.get(0))?)
|
||||
}
|
||||
@@ -0,0 +1,607 @@
|
||||
//! Oracle-in-the-loop route discovery.
|
||||
//!
|
||||
//! Offline synthesis can't guarantee IFPS validity (the RAD is thousands of
|
||||
//! conditional rules). Instead we generate a candidate, submit it to an
|
||||
//! authoritative [`IfpsValidator`] (the public Eurocontrol IFPUV), read its
|
||||
//! precise feedback, mechanically repair the route, and re-validate — until it
|
||||
//! is ACCEPTED. The loop is **monotonic**: a repair is kept only if it strictly
|
||||
//! reduces the error count, so the result is never worse than the first draft.
|
||||
//!
|
||||
//! v1 repairs the mechanical, high-impact error classes:
|
||||
//! * `ROUTE165` (DCT too long in a TMA/area) → reroute that segment via airways
|
||||
//! * `PROF204/205` flight-level caps → move the cruise level into the window
|
||||
//! * `ROUTE130` unknown designator → drop the token
|
||||
//! Mandatory-routing / FRA-border errors (`PROF205 mandatory`, `ROUTE52`) are
|
||||
//! reported, not auto-fixed, in v1.
|
||||
|
||||
use rusqlite::Connection;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::error::Result;
|
||||
|
||||
use super::{airway_path, plan_preferred, Leg, Route};
|
||||
|
||||
/// One IFPS message (code + text).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IfpsErr {
|
||||
pub code: String,
|
||||
pub msg: String,
|
||||
}
|
||||
|
||||
/// Verdict from an authoritative IFPS validator.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct IfpsVerdict {
|
||||
pub accepted: bool,
|
||||
pub errors: Vec<IfpsErr>,
|
||||
}
|
||||
|
||||
/// Something that can validate an ICAO route against IFPS. Implemented in the app
|
||||
/// layer by spawning the IFPUV scraper; mocked in tests.
|
||||
pub trait IfpsValidator {
|
||||
/// Validate item-15 `route` from `adep` to `ades` at flight level `fl`.
|
||||
fn validate(&self, adep: &str, ades: &str, route: &str, fl: i32) -> Result<IfpsVerdict>;
|
||||
}
|
||||
|
||||
/// Outcome of a discovery run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DiscoverResult {
|
||||
pub accepted: bool,
|
||||
pub route_string: String,
|
||||
/// Item-15 (enroute) portion that was validated.
|
||||
pub item15: String,
|
||||
pub fl: i32,
|
||||
pub total_nm: f64,
|
||||
pub errors: Vec<IfpsErr>,
|
||||
pub iterations: usize,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// ICAO item-15 (enroute) string for a route: start at the SID exit fix, then
|
||||
/// `airway to` for each enroute leg, ending at the STAR entry fix. The leading
|
||||
/// SID and trailing STAR (first/last leg) are omitted — IFPS derives them.
|
||||
pub fn route_item15(route: &Route) -> String {
|
||||
let legs = &route.legs;
|
||||
match legs.len() {
|
||||
0 => String::new(),
|
||||
1 => "DCT".to_owned(),
|
||||
n => {
|
||||
let mut s = legs[0].to.clone();
|
||||
for leg in &legs[1..n - 1] {
|
||||
s.push(' ');
|
||||
s.push_str(&leg.airway);
|
||||
s.push(' ');
|
||||
s.push_str(&leg.to);
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn fl3(fl: i32) -> String {
|
||||
format!("F{:03}", fl)
|
||||
}
|
||||
|
||||
// ── error parsing ───────────────────────────────────────────────────────────
|
||||
|
||||
/// The `A..B` fix pair from a `ROUTE165` "DCT SEGMENT A..B (n NM) IS TOO LONG".
|
||||
fn parse_too_long(msg: &str) -> Option<(String, String)> {
|
||||
let up = msg.to_uppercase();
|
||||
let seg = up.split("SEGMENT ").nth(1)?;
|
||||
let pair = seg.split(" (").next()?.trim(); // "A..B"
|
||||
let (a, b) = pair.split_once("..")?;
|
||||
let (a, b) = (a.trim(), b.trim());
|
||||
// Only simple single-token idents (skip the verbose ROUTE52 form with spaces).
|
||||
if a.is_empty() || b.is_empty() || a.contains(' ') || b.contains(' ') {
|
||||
return None;
|
||||
}
|
||||
Some((a.to_owned(), b.to_owned()))
|
||||
}
|
||||
|
||||
/// The `(A, B)` fix pair from a `PROF195` "A AWY B DOES NOT EXIST IN FL RANGE …"
|
||||
/// (an airway that isn't valid at the current flight level).
|
||||
fn parse_fl_gap(msg: &str) -> Option<(String, String)> {
|
||||
let up = msg.to_uppercase();
|
||||
let head = up.split(" DOES NOT EXIST").next()?; // "DIK T856 ADUSU"
|
||||
let toks: Vec<&str> = head.split_whitespace().collect();
|
||||
if toks.len() < 3 {
|
||||
return None;
|
||||
}
|
||||
let (a, b) = (toks[0], toks[toks.len() - 1]);
|
||||
if a.is_empty() || b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some((a.to_owned(), b.to_owned()))
|
||||
}
|
||||
|
||||
/// A forbidden flight-level band expressed by a PROF204/205 message.
|
||||
#[derive(Default, Clone, Copy)]
|
||||
struct Band {
|
||||
ceiling: Option<i32>, // forbidden at/above this FL
|
||||
floor: Option<i32>, // forbidden at/below this FL
|
||||
}
|
||||
|
||||
fn parse_band(msg: &str) -> Option<Band> {
|
||||
let up = msg.to_uppercase();
|
||||
let mut band = Band::default();
|
||||
// `F<lo>..F<hi>`
|
||||
if let Some(i) = up.find("..F") {
|
||||
let before = &up[..i];
|
||||
if let Some(fpos) = before.rfind('F') {
|
||||
let lo: String = before[fpos + 1..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
let hi: String = up[i + 3..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let (Ok(lo), Ok(hi)) = (lo.parse::<i32>(), hi.parse::<i32>()) {
|
||||
if lo == 0 {
|
||||
band.floor = Some(hi); // forbidden below hi → fly above
|
||||
} else {
|
||||
// `Fa..F999` (ceiling) OR a narrow hole `Fa..Fb`: in both
|
||||
// cases flying below `a` avoids it → treat as a ceiling at a.
|
||||
band.ceiling = Some(lo);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// `NOT ABV FL<x>` (also covers `RFL NOT ABV FL335/FL355` → the lower cap)
|
||||
if let Some(i) = up.find("NOT ABV FL") {
|
||||
let x: String = up[i + "NOT ABV FL".len()..].chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
if let Ok(x) = x.parse::<i32>() {
|
||||
band.ceiling = Some(band.ceiling.map_or(x, |c| c.min(x)));
|
||||
}
|
||||
}
|
||||
(band.ceiling.is_some() || band.floor.is_some()).then_some(band)
|
||||
}
|
||||
|
||||
/// The `X` from `ROUTE130 UNKNOWN DESIGNATOR X`.
|
||||
fn parse_unknown(msg: &str) -> Option<String> {
|
||||
let up = msg.to_uppercase();
|
||||
let rest = up.split("UNKNOWN DESIGNATOR ").nth(1)?;
|
||||
rest.split_whitespace().next().map(str::to_owned).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
// ── repairs ─────────────────────────────────────────────────────────────────
|
||||
|
||||
fn total_nm(legs: &[Leg]) -> f64 {
|
||||
legs.iter().map(|l| l.dist_nm).sum()
|
||||
}
|
||||
|
||||
/// Try to repair `route`/`fl` from `errors`. Returns the new route/fl and a note,
|
||||
/// or `None` when no repair applies. One class of repair per call. `from`/`to`
|
||||
/// and the connector fixes let the FL repair re-plan the route at the new level.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn apply_repairs(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_fixes: &[String],
|
||||
dest_fixes: &[String],
|
||||
route: &Route,
|
||||
fl: i32,
|
||||
errors: &[IfpsErr],
|
||||
) -> Result<Option<(Route, i32, String)>> {
|
||||
// 1) Re-route a broken segment through the FL-filtered airway graph:
|
||||
// ROUTE165 (DCT too long in a TMA) and PROF195 (airway not valid at this
|
||||
// FL — e.g. we lowered FL for a RAD cap and broke a high-level airway).
|
||||
let mut segs: Vec<(String, String)> = errors
|
||||
.iter()
|
||||
.filter_map(|e| match e.code.as_str() {
|
||||
"ROUTE165" => parse_too_long(&e.msg),
|
||||
"PROF195" => parse_fl_gap(&e.msg),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
segs.sort();
|
||||
segs.dedup();
|
||||
if !segs.is_empty() {
|
||||
let mut legs: Vec<Leg> = Vec::with_capacity(route.legs.len());
|
||||
let mut spliced = Vec::new();
|
||||
for leg in &route.legs {
|
||||
// Any leg matching a broken segment (DCT or airway) is re-routed.
|
||||
let hit = segs.iter().any(|(a, b)| a == &leg.from && b == &leg.to);
|
||||
if hit {
|
||||
if let Some(path) = airway_path(conn, &leg.from, &leg.to, Some(fl))? {
|
||||
spliced.push(format!("{}..{}", leg.from, leg.to));
|
||||
legs.extend(path);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
legs.push(leg.clone());
|
||||
}
|
||||
if !spliced.is_empty() {
|
||||
let nm = total_nm(&legs);
|
||||
return Ok(Some((
|
||||
Route { legs, total_nm: nm, via_airways: true },
|
||||
fl,
|
||||
format!("airway-splice {}", spliced.join(", ")),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
// 2) FL caps → move into the allowed window.
|
||||
let mut ceiling = i32::MAX;
|
||||
let mut floor = 0;
|
||||
let mut saw = false;
|
||||
for e in errors {
|
||||
if e.code != "PROF204" && e.code != "PROF205" {
|
||||
continue;
|
||||
}
|
||||
if let Some(b) = parse_band(&e.msg) {
|
||||
saw = true;
|
||||
if let Some(c) = b.ceiling {
|
||||
ceiling = ceiling.min(c);
|
||||
}
|
||||
if let Some(f) = b.floor {
|
||||
floor = floor.max(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
// Move the cruise level into the allowed window `(floor, ceiling)`. Handles
|
||||
// both a ceiling (fly lower) and a floor (fly higher — e.g. after we over-
|
||||
// lowered). Empty window (floor ≥ ceiling) ⇒ genuine conflict, no fix.
|
||||
if saw {
|
||||
let ceil = if ceiling == i32::MAX { 600 } else { ceiling };
|
||||
let hi_bound = ((ceil - 5) / 10) * 10; // highest 10s FL below the ceiling
|
||||
let lo_bound = (floor / 10 + 1) * 10; // lowest 10s FL above the floor
|
||||
let outside = fl >= ceil || fl <= floor;
|
||||
if outside && lo_bound <= hi_bound {
|
||||
// prefer a standard cruise level inside the window
|
||||
let target = 360.clamp(lo_bound, hi_bound);
|
||||
if target != fl && target >= 60 {
|
||||
// Re-plan at the new level so airways are valid there (a route
|
||||
// planned at F360 may use airways that don't exist at F190).
|
||||
let replanned = super::plan_route_best(conn, from, to, Some(target), dep_fixes, dest_fixes)
|
||||
.ok()
|
||||
.filter(|r| r.legs.len() >= 2);
|
||||
let (route, how) = match replanned {
|
||||
Some(r) => (r, "re-planned"),
|
||||
None => (route.clone(), "same route"),
|
||||
};
|
||||
return Ok(Some((route, target, format!("RAD level window → {} ({how})", fl3(target)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) unknown designators → drop the token.
|
||||
let drop: Vec<String> = errors
|
||||
.iter()
|
||||
.filter(|e| e.code == "ROUTE130")
|
||||
.filter_map(|e| parse_unknown(&e.msg))
|
||||
.collect();
|
||||
if !drop.is_empty() {
|
||||
let legs: Vec<Leg> = route
|
||||
.legs
|
||||
.iter()
|
||||
.filter(|l| !drop.iter().any(|d| d == &l.to || d == &l.airway))
|
||||
.cloned()
|
||||
.collect();
|
||||
if legs.len() != route.legs.len() && legs.len() >= 1 {
|
||||
let nm = total_nm(&legs);
|
||||
return Ok(Some((
|
||||
Route { legs, total_nm: nm, via_airways: route.via_airways },
|
||||
fl,
|
||||
format!("drop {}", drop.join(", ")),
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
// ── the loop ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Maximum validate/repair iterations (each is one live IFPUV round-trip).
|
||||
const MAX_ITERS: usize = 8;
|
||||
|
||||
/// Max attempts with different SID/STAR gateways (each is a full inner run).
|
||||
const MAX_GATEWAY_ATTEMPTS: usize = 3;
|
||||
|
||||
/// Discover an IFPS-valid route from `from` to `to`. Runs the candidate/repair
|
||||
/// loop; if it stalls on a SID/STAR **gateway limit** (`ROUTE135`/`ROUTE134`), it
|
||||
/// excludes that gateway fix and retries via another, up to
|
||||
/// [`MAX_GATEWAY_ATTEMPTS`]. Returns the best (fewest-error) result seen;
|
||||
/// `accepted` means IFPS no-error.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn find_valid_route(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
start_fl: i32,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> Result<DiscoverResult> {
|
||||
let mut excl_dep: HashSet<String> = HashSet::new();
|
||||
let mut excl_dest: HashSet<String> = HashSet::new();
|
||||
let mut full_log: Vec<String> = Vec::new();
|
||||
let mut best: Option<DiscoverResult> = None;
|
||||
|
||||
for attempt in 1..=MAX_GATEWAY_ATTEMPTS {
|
||||
let dsid: Vec<(String, String)> =
|
||||
dep_sid.iter().filter(|(_, f)| !excl_dep.contains(&f.to_uppercase())).cloned().collect();
|
||||
let dstar: Vec<(String, String)> =
|
||||
dest_star.iter().filter(|(_, f)| !excl_dest.contains(&f.to_uppercase())).cloned().collect();
|
||||
if attempt > 1 {
|
||||
full_log.push(format!("— gateway attempt {attempt} —"));
|
||||
}
|
||||
|
||||
let res = attempt_once(conn, from, to, &dsid, &dstar, start_fl, rad, validator)?;
|
||||
full_log.extend(res.log.iter().cloned());
|
||||
if res.accepted {
|
||||
let mut r = res;
|
||||
r.log = full_log;
|
||||
return Ok(r);
|
||||
}
|
||||
let swap = res.errors.iter().find_map(gateway_fix);
|
||||
if best.as_ref().map(|b| res.errors.len() < b.errors.len()).unwrap_or(true) {
|
||||
best = Some(res);
|
||||
}
|
||||
match swap {
|
||||
Some((is_dep, fix)) if attempt < MAX_GATEWAY_ATTEMPTS => {
|
||||
full_log.push(format!("gateway limit on {fix} → excluding and retrying"));
|
||||
if is_dep {
|
||||
excl_dep.insert(fix);
|
||||
} else {
|
||||
excl_dest.insert(fix);
|
||||
}
|
||||
}
|
||||
_ => break,
|
||||
}
|
||||
}
|
||||
let mut r = best.expect("at least one attempt ran");
|
||||
r.log = full_log;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
/// One discovery attempt with a fixed set of allowed gateways: generate the seed
|
||||
/// / FRA / airway candidates, start the repair loop from whichever the oracle
|
||||
/// rates best.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn attempt_once(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
start_fl: i32,
|
||||
rad: Option<&crate::rad::RadData>,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> 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();
|
||||
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.
|
||||
if let Some(r) = rad.filter(|r| !r.fra_points.is_empty()).and_then(|r| {
|
||||
super::fra_points::plan_fra_points(conn, from, to, start_fl, &r.fra_points, Some(r))
|
||||
.ok()
|
||||
.flatten()
|
||||
}) {
|
||||
if r.legs.len() >= 2 {
|
||||
candidates.push(("fra-points", r));
|
||||
}
|
||||
}
|
||||
// Real filed seeds next (e.g. imported PFPX routes) — a real route, even if
|
||||
// stale, is a far better starting point for the oracle than pure synthesis.
|
||||
if let Ok(stored) = crate::routes::recent(conn, from, to, 6) {
|
||||
if let Some(seed) = stored.iter().find(|s| s.source.starts_with("pfpx")) {
|
||||
if let Ok(Some(r)) = super::parse_route_string(conn, from, to, &seed.route_string) {
|
||||
if r.legs.len() >= 2 {
|
||||
candidates.push(("seed", r));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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) {
|
||||
candidates.push(("airway", r));
|
||||
}
|
||||
|
||||
let mut pre_log: Vec<String> = Vec::new();
|
||||
let mut chosen: Option<(Route, IfpsVerdict)> = None;
|
||||
for (tag, cand) in candidates {
|
||||
let v = validator.validate(from, to, &route_item15(&cand), start_fl)?;
|
||||
pre_log.push(format!("candidate {tag}: {} err{}", v.errors.len(), if v.accepted { " (ACCEPTED)" } else { "" }));
|
||||
let better = chosen.as_ref().map(|(_, cv)| v.errors.len() < cv.errors.len()).unwrap_or(true);
|
||||
let accepted = v.accepted;
|
||||
if better {
|
||||
chosen = Some((cand, v));
|
||||
}
|
||||
if accepted {
|
||||
break;
|
||||
}
|
||||
}
|
||||
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)?;
|
||||
pre_log.append(&mut res.log);
|
||||
res.log = pre_log;
|
||||
Ok(res)
|
||||
}
|
||||
|
||||
/// A SID/STAR gateway fix to exclude, parsed from a `ROUTE135` (SID) / `ROUTE134`
|
||||
/// (STAR) "…LIMIT IS EXCEEDED … CONNECTING TO <fix>." Returns `(is_dep_sid, fix)`.
|
||||
fn gateway_fix(e: &IfpsErr) -> Option<(bool, String)> {
|
||||
let is_dep = match e.code.as_str() {
|
||||
"ROUTE135" => true, // SID limit
|
||||
"ROUTE134" => false, // STAR limit
|
||||
_ => return None,
|
||||
};
|
||||
let up = e.msg.to_uppercase();
|
||||
let fix = up
|
||||
.split("CONNECTING TO ")
|
||||
.nth(1)?
|
||||
.split_whitespace()
|
||||
.next()?
|
||||
.trim_end_matches('.')
|
||||
.to_string();
|
||||
(!fix.is_empty()).then_some((is_dep, fix))
|
||||
}
|
||||
|
||||
/// The validate/repair loop over a chosen `initial` candidate and its already
|
||||
/// obtained `initial_verdict`. Separated so it can be unit-tested with a mock
|
||||
/// validator and injected verdicts.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn run_loop(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_fixes: &[String],
|
||||
dest_fixes: &[String],
|
||||
initial: Route,
|
||||
initial_verdict: IfpsVerdict,
|
||||
start_fl: i32,
|
||||
validator: &dyn IfpsValidator,
|
||||
) -> Result<DiscoverResult> {
|
||||
let mut cur = initial;
|
||||
let mut fl = start_fl;
|
||||
let mut verdict = initial_verdict;
|
||||
let mut log: Vec<String> = Vec::new();
|
||||
let mut best: Option<(Route, i32, Vec<IfpsErr>)> = None;
|
||||
let mut iterations = 0;
|
||||
|
||||
for i in 1..=MAX_ITERS {
|
||||
iterations = i;
|
||||
if verdict.accepted {
|
||||
log.push(format!("iter {i}: {} → ACCEPTED", fl3(fl)));
|
||||
best = Some((cur, fl, Vec::new()));
|
||||
break;
|
||||
}
|
||||
if let Some((_, _, berr)) = &best {
|
||||
if verdict.errors.len() >= berr.len() {
|
||||
log.push(format!(
|
||||
"iter {i}: {} → {} err (not better than {}) — reverting",
|
||||
fl3(fl),
|
||||
verdict.errors.len(),
|
||||
berr.len()
|
||||
));
|
||||
break;
|
||||
}
|
||||
}
|
||||
let codes: Vec<&str> = verdict.errors.iter().map(|e| e.code.as_str()).collect();
|
||||
best = Some((cur.clone(), fl, verdict.errors.clone()));
|
||||
match apply_repairs(conn, from, to, dep_fixes, dest_fixes, &cur, fl, &verdict.errors)? {
|
||||
Some((r, nfl, note)) => {
|
||||
log.push(format!("iter {i}: {} → {} err [{}] | fix: {note}", fl3(fl), verdict.errors.len(), codes.join(",")));
|
||||
cur = r;
|
||||
fl = nfl;
|
||||
verdict = validator.validate(from, to, &route_item15(&cur), fl)?;
|
||||
}
|
||||
None => {
|
||||
log.push(format!("iter {i}: {} → {} err [{}] | no auto-fix", fl3(fl), verdict.errors.len(), codes.join(",")));
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (route, vfl, errors) = best.expect("loop runs at least once");
|
||||
Ok(DiscoverResult {
|
||||
accepted: errors.is_empty(),
|
||||
route_string: route.route_string(),
|
||||
item15: route_item15(&route),
|
||||
fl: vfl,
|
||||
total_nm: route.total_nm,
|
||||
errors,
|
||||
iterations,
|
||||
log,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
|
||||
fn leg(from: &str, awy: &str, to: &str, nm: f64) -> Leg {
|
||||
Leg { from: from.into(), airway: awy.into(), to: to.into(), dist_nm: nm }
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn item15_skips_sid_star_and_keeps_airways() {
|
||||
let route = Route {
|
||||
legs: vec![
|
||||
leg("LFPG", "OPAL6A", "OPALE", 20.0),
|
||||
leg("OPALE", "DCT", "KESAX", 15.0),
|
||||
leg("KESAX", "UN491", "DIMAL", 30.0),
|
||||
leg("DIMAL", "ALES1H", "EGLL", 40.0),
|
||||
],
|
||||
total_nm: 105.0,
|
||||
via_airways: false,
|
||||
};
|
||||
assert_eq!(route_item15(&route), "OPALE DCT KESAX UN491 DIMAL");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_error_messages() {
|
||||
assert_eq!(
|
||||
parse_too_long("THE DCT SEGMENT KOGAS..VEVAR (66 NM) IS TOO LONG FOR LFMMDCTX. MAXIMUM IS 0 NM"),
|
||||
Some(("KOGAS".into(), "VEVAR".into()))
|
||||
);
|
||||
assert_eq!(parse_unknown("UNKNOWN DESIGNATOR AGOP6A").as_deref(), Some("AGOP6A"));
|
||||
assert_eq!(parse_band("VIA EG LF:F245..F999 IS ON FORBIDDEN ROUTE").unwrap().ceiling, Some(245));
|
||||
assert_eq!(parse_band("RFL NOT ABV FL335/FL355").unwrap().ceiling, Some(335));
|
||||
// narrow forbidden band F355..F365 → fly below 355 (ceiling), not a floor
|
||||
let hole = parse_band("VIA BOMBI GIGET:F355..F365 IS ON FORBIDDEN ROUTE").unwrap();
|
||||
assert_eq!((hole.ceiling, hole.floor), (Some(355), None));
|
||||
assert_eq!(
|
||||
parse_fl_gap("DIK T856 ADUSU DOES NOT EXIST IN FL RANGE F000..F245"),
|
||||
Some(("DIK".into(), "ADUSU".into()))
|
||||
);
|
||||
}
|
||||
|
||||
/// A mock validator that replays a scripted sequence of verdicts, so we can
|
||||
/// test the loop's monotonicity + repair ordering without any network.
|
||||
struct Mock {
|
||||
script: RefCell<Vec<IfpsVerdict>>,
|
||||
}
|
||||
impl IfpsValidator for Mock {
|
||||
fn validate(&self, _a: &str, _b: &str, _r: &str, _fl: i32) -> Result<IfpsVerdict> {
|
||||
let mut s = self.script.borrow_mut();
|
||||
Ok(if s.is_empty() { IfpsVerdict { accepted: true, errors: vec![] } } else { s.remove(0) })
|
||||
}
|
||||
}
|
||||
|
||||
fn err(code: &str, msg: &str) -> IfpsErr {
|
||||
IfpsErr { code: code.into(), msg: msg.into() }
|
||||
}
|
||||
|
||||
fn route3() -> Route {
|
||||
// A short enroute route so item-15 has a fix; no airways to splice.
|
||||
Route {
|
||||
legs: vec![leg("LFPG", "SID1A", "AAAAA", 20.0), leg("AAAAA", "DCT", "BBBBB", 40.0), leg("BBBBB", "STAR1", "LFMN", 30.0)],
|
||||
total_nm: 90.0,
|
||||
via_airways: false,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_reverts_when_a_fix_makes_it_worse() {
|
||||
let conn = Connection::open_in_memory().unwrap();
|
||||
// iter1: one FL-cap error (fixable) → lower FL; iter2: 3 errors (worse)
|
||||
// → revert and report the F360 / 1-error state.
|
||||
let initial = IfpsVerdict { accepted: false, errors: vec![err("PROF204", "EG LF:F245..F999 IS ON FORBIDDEN ROUTE")] };
|
||||
let mock = Mock {
|
||||
script: RefCell::new(vec![IfpsVerdict {
|
||||
accepted: false,
|
||||
errors: vec![err("X1", "a"), err("X2", "b"), err("X3", "c")],
|
||||
}]),
|
||||
};
|
||||
let r = run_loop(&conn, "LFPG", "LFMN", &[], &[], route3(), initial, 360, &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");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loop_accepts_after_level_fix() {
|
||||
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();
|
||||
assert!(r.accepted, "log: {:?}", r.log);
|
||||
assert_eq!(r.fl, 240, "lowered below the 245 cap");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
//! Free Route Airspace (FRA) routing.
|
||||
//!
|
||||
//! Modern European enroute airspace is *free route*: flight plans are filed as a
|
||||
//! chain of **DCT** (directs) between published points, not along airways. This
|
||||
//! router builds such a chain from the SID exit fix to the STAR entry fix,
|
||||
//! anchoring on real waypoints roughly along the great circle, and skipping
|
||||
//! directs the RAD marks as forbidden.
|
||||
//!
|
||||
//! It is a heuristic (it can't know the exact FRA horizontal entry/exit points),
|
||||
//! so it produces FRA-*style* routes — closer to reality than airway routing —
|
||||
//! but, like any offline engine, it can't *guarantee* IFPS acceptance.
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// Spacing between DCT anchor points along the route.
|
||||
const ANCHOR_STEP_NM: f64 = 170.0;
|
||||
/// Max cross-track distance to accept a waypoint as an anchor.
|
||||
const ANCHOR_MAX_XTK_NM: f64 = 55.0;
|
||||
|
||||
/// Plan a FRA route from `from_icao` to `to_icao`. `dep_exits` are the SID exit
|
||||
/// fixes, `star_entries` the STAR entry fixes; the enroute portion is a DCT chain
|
||||
/// between them. `rad`, when given, removes RAD-forbidden directs.
|
||||
pub fn plan_fra(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
dep_sids: &[(String, String)],
|
||||
star_entries: &[(String, String)],
|
||||
cruise_fl: i32,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Route> {
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
|
||||
// Pick the SID exit / STAR entry that lies most *on the way* (minimises the
|
||||
// dep→fix→dest detour), not just the first that resolves — otherwise a SID
|
||||
// heading the wrong way (e.g. LFPG AGOP6A→RBT, south) gets chosen for a
|
||||
// northbound flight.
|
||||
let start = resolve_fix(conn, dep_sids, dep, dep, dst); // (sid_name, fix, pos)
|
||||
let end = resolve_fix(conn, star_entries, dst, dep, dst); // (star_name, fix, pos)
|
||||
let start_pos = start.as_ref().map(|(_, _, p)| *p).unwrap_or(dep);
|
||||
let end_pos = end.as_ref().map(|(_, _, p)| *p).unwrap_or(dst);
|
||||
|
||||
let wps = load_box(conn, dep, dst, 2.5)?;
|
||||
|
||||
// Ordered points: DEP → [SID exit] → anchors → [STAR entry] → DEST.
|
||||
let mut points: Vec<(String, LatLon)> = vec![(from_icao.to_uppercase(), dep)];
|
||||
if let Some((_, fix, pos)) = &start {
|
||||
points.push((fix.clone(), *pos));
|
||||
}
|
||||
|
||||
let span = start_pos.distance_nm(&end_pos);
|
||||
let n = (span / ANCHOR_STEP_NM).floor() as usize;
|
||||
for i in 1..=n {
|
||||
let frac = i as f64 / (n + 1) as f64;
|
||||
let target = interpolate(start_pos, end_pos, frac);
|
||||
if let Some((id, pos)) = nearest_wp(&wps, target, ANCHOR_MAX_XTK_NM) {
|
||||
let prev = &points.last().unwrap().0;
|
||||
if prev == &id {
|
||||
continue;
|
||||
}
|
||||
if let Some(rad) = rad {
|
||||
if rad.forbidden_dct(prev, &id, cruise_fl).is_some() {
|
||||
continue; // RAD forbids this direct — skip the anchor
|
||||
}
|
||||
}
|
||||
points.push((id, pos));
|
||||
}
|
||||
}
|
||||
|
||||
if let Some((_, fix, pos)) = &end {
|
||||
if points.last().map(|(id, _)| id != fix).unwrap_or(true) {
|
||||
points.push((fix.clone(), *pos));
|
||||
}
|
||||
}
|
||||
points.push((to_icao.to_uppercase(), dst));
|
||||
|
||||
// First leg = SID (dep → its exit fix), last leg = STAR (entry fix → dest),
|
||||
// middle legs are DCT.
|
||||
let sid = start.as_ref().map(|(n, _, _)| n.clone());
|
||||
let star = end.as_ref().map(|(n, _, _)| n.clone());
|
||||
let last = points.len().saturating_sub(2);
|
||||
let mut legs = Vec::with_capacity(last + 1);
|
||||
let mut total_nm = 0.0;
|
||||
for (i, w) in points.windows(2).enumerate() {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
let airway = if i == 0 {
|
||||
sid.clone().unwrap_or_else(|| "DCT".to_owned())
|
||||
} else if i == last {
|
||||
star.clone().unwrap_or_else(|| "DCT".to_owned())
|
||||
} else {
|
||||
"DCT".to_owned()
|
||||
};
|
||||
legs.push(Leg {
|
||||
from: w[0].0.clone(),
|
||||
to: w[1].0.clone(),
|
||||
airway,
|
||||
dist_nm: d,
|
||||
});
|
||||
}
|
||||
Ok(Route {
|
||||
legs,
|
||||
total_nm,
|
||||
via_airways: false,
|
||||
})
|
||||
}
|
||||
|
||||
/// The (procedure, fix) best aligned with the flight: among connectors whose fix
|
||||
/// resolves to a position (disambiguated nearest `near`), the one minimising the
|
||||
/// `dep → fix → dst` detour. Returns `(procedure_name, fix_ident, position)`.
|
||||
fn resolve_fix(
|
||||
conn: &Connection,
|
||||
procs: &[(String, String)],
|
||||
near: LatLon,
|
||||
dep: LatLon,
|
||||
dst: LatLon,
|
||||
) -> Option<(String, String, LatLon)> {
|
||||
procs
|
||||
.iter()
|
||||
.filter_map(|(name, fix)| {
|
||||
point_pos(conn, fix, near).map(|p| (name.clone(), fix.to_uppercase(), p))
|
||||
})
|
||||
.min_by(|a, b| {
|
||||
let da = dep.distance_nm(&a.2) + a.2.distance_nm(&dst);
|
||||
let db = dep.distance_nm(&b.2) + b.2.distance_nm(&dst);
|
||||
da.total_cmp(&db)
|
||||
})
|
||||
}
|
||||
|
||||
/// Position of `ident` (waypoint or navaid), nearest to `near` when ambiguous.
|
||||
fn point_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
if let Ok(mut stmt) = conn.prepare(sql) {
|
||||
if let Ok(rows) =
|
||||
stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))
|
||||
{
|
||||
cands.extend(rows.flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
cands
|
||||
.into_iter()
|
||||
.min_by(|a, b| a.distance_nm(&near).total_cmp(&b.distance_nm(&near)))
|
||||
}
|
||||
|
||||
/// Load waypoints + navaids inside the dep/dest bounding box (+`margin` degrees).
|
||||
fn load_box(conn: &Connection, a: LatLon, b: LatLon, margin: f64) -> Result<Vec<(String, LatLon)>> {
|
||||
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 out = Vec::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
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 {
|
||||
out.push(row?);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Nearest waypoint to `target` within `max_xtk_nm`.
|
||||
fn nearest_wp(wps: &[(String, LatLon)], target: LatLon, max_xtk_nm: f64) -> Option<(String, LatLon)> {
|
||||
wps.iter()
|
||||
.map(|(id, p)| (p.distance_nm(&target), id, p))
|
||||
.filter(|(d, _, _)| *d <= max_xtk_nm)
|
||||
.min_by(|a, b| a.0.total_cmp(&b.0))
|
||||
.map(|(_, id, p)| (id.clone(), *p))
|
||||
}
|
||||
|
||||
/// Spherical (great-circle) interpolation between two positions at fraction `f`.
|
||||
fn interpolate(a: LatLon, b: LatLon, f: f64) -> LatLon {
|
||||
let (lat1, lon1) = (a.lat.to_radians(), a.lon.to_radians());
|
||||
let (lat2, lon2) = (b.lat.to_radians(), b.lon.to_radians());
|
||||
let dlat = (lat2 - lat1) / 2.0;
|
||||
let dlon = (lon2 - lon1) / 2.0;
|
||||
let hav = dlat.sin().powi(2) + lat1.cos() * lat2.cos() * dlon.sin().powi(2);
|
||||
let d = 2.0 * hav.sqrt().asin();
|
||||
if d.abs() < 1e-9 {
|
||||
return a;
|
||||
}
|
||||
let ca = ((1.0 - f) * d).sin() / d.sin();
|
||||
let cb = (f * d).sin() / d.sin();
|
||||
let x = ca * lat1.cos() * lon1.cos() + cb * lat2.cos() * lon2.cos();
|
||||
let y = ca * lat1.cos() * lon1.sin() + cb * lat2.cos() * lon2.sin();
|
||||
let z = ca * lat1.sin() + cb * lat2.sin();
|
||||
let lat = z.atan2((x * x + y * y).sqrt());
|
||||
let lon = y.atan2(x);
|
||||
LatLon::new(lat.to_degrees(), lon.to_degrees())
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
//! FRA routing over the **published** connectivity graph.
|
||||
//!
|
||||
//! [`crate::rad`] extracts the `A DCT B` point sequences from the RAD Annex 2
|
||||
//! VIA-clauses into a graph of allowed Free-Route directs. This router finds the
|
||||
//! shortest great-circle path through that graph from a SID exit fix to a STAR
|
||||
//! entry fix — so the enroute portion uses *real* published FRA points and
|
||||
//! directs (what IFPS accepts), instead of invented anchors.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::collections::{BinaryHeap, HashMap};
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// Min-heap state for Dijkstra (ordered by ascending cost).
|
||||
struct State {
|
||||
cost: f64,
|
||||
node: String,
|
||||
}
|
||||
impl PartialEq for State {
|
||||
fn eq(&self, o: &Self) -> bool {
|
||||
self.cost == o.cost
|
||||
}
|
||||
}
|
||||
impl Eq for State {}
|
||||
impl PartialOrd for State {
|
||||
fn partial_cmp(&self, o: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(o))
|
||||
}
|
||||
}
|
||||
impl Ord for State {
|
||||
fn cmp(&self, o: &Self) -> Ordering {
|
||||
// reversed: smaller cost = higher priority
|
||||
o.cost.total_cmp(&self.cost)
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan a route through the FRA graph `adj` (point → neighbours). Chooses the SID
|
||||
/// exit / STAR entry and the intermediate directs that minimise total distance.
|
||||
/// Returns `None` when no SID exit and STAR entry are connected in the graph.
|
||||
pub fn plan_fra_graph(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
dep_sids: &[(String, String)],
|
||||
star_entries: &[(String, String)],
|
||||
adj: &HashMap<String, Vec<String>>,
|
||||
) -> Result<Option<Route>> {
|
||||
if adj.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
let mid = LatLon::new((dep.lat + dst.lat) / 2.0, (dep.lon + dst.lon) / 2.0);
|
||||
|
||||
// Load every navdata point inside the dep→dest corridor (2 queries), keeping
|
||||
// the one nearest the corridor mid per ident. FRA nodes outside the corridor
|
||||
// are dropped — they can't be on a sensible path anyway. This bounds both the
|
||||
// query count and the graph size (fast enough for interactive planning).
|
||||
let pos = load_corridor(conn, dep, dst, mid, 3.0)?;
|
||||
|
||||
// SID exit / STAR entry candidates that are present (and placeable) in the graph.
|
||||
let name_of = |list: &[(String, String)]| -> HashMap<String, String> {
|
||||
let mut m = HashMap::new();
|
||||
for (name, fix) in list {
|
||||
m.entry(fix.to_uppercase()).or_insert_with(|| name.clone());
|
||||
}
|
||||
m
|
||||
};
|
||||
let sid_name = name_of(dep_sids);
|
||||
let star_name = name_of(star_entries);
|
||||
let starts: Vec<String> = sid_name.keys().filter(|f| pos.contains_key(*f)).cloned().collect();
|
||||
let goals: std::collections::HashSet<String> =
|
||||
star_name.keys().filter(|f| pos.contains_key(*f)).cloned().collect();
|
||||
if starts.is_empty() || goals.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Dijkstra from all starts (seeded by the DEP→exit leg distance) to any goal;
|
||||
// goal cost includes the goal→DEST leg so we pick the best overall gateway.
|
||||
let mut dist: HashMap<String, f64> = HashMap::new();
|
||||
let mut prev: HashMap<String, String> = HashMap::new();
|
||||
let mut heap = BinaryHeap::new();
|
||||
for s in &starts {
|
||||
let d0 = dep.distance_nm(&pos[s]);
|
||||
if dist.get(s).map(|&d| d0 < d).unwrap_or(true) {
|
||||
dist.insert(s.clone(), d0);
|
||||
heap.push(State { cost: d0, node: s.clone() });
|
||||
}
|
||||
}
|
||||
|
||||
let mut best_goal: Option<(String, f64)> = None;
|
||||
while let Some(State { cost, node }) = heap.pop() {
|
||||
if cost > *dist.get(&node).unwrap_or(&f64::INFINITY) {
|
||||
continue;
|
||||
}
|
||||
if goals.contains(&node) {
|
||||
let total = cost + pos[&node].distance_nm(&dst);
|
||||
if best_goal.as_ref().map(|(_, c)| total < *c).unwrap_or(true) {
|
||||
best_goal = Some((node.clone(), total));
|
||||
}
|
||||
}
|
||||
let pu = pos[&node];
|
||||
for v in adj.get(&node).into_iter().flatten() {
|
||||
let Some(pv) = pos.get(v) else { continue };
|
||||
let nd = cost + pu.distance_nm(pv);
|
||||
if nd < *dist.get(v).unwrap_or(&f64::INFINITY) {
|
||||
dist.insert(v.clone(), nd);
|
||||
prev.insert(v.clone(), node.clone());
|
||||
heap.push(State { cost: nd, node: v.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some((goal, _)) = best_goal else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Reconstruct the point path start → … → goal.
|
||||
let mut path = vec![goal.clone()];
|
||||
let mut cur = goal.clone();
|
||||
while let Some(p) = prev.get(&cur) {
|
||||
path.push(p.clone());
|
||||
cur = p.clone();
|
||||
}
|
||||
path.reverse();
|
||||
|
||||
// Assemble the route: DEP →(SID) path[0] → DCT … → path[last] →(STAR) DEST.
|
||||
let mut pts: Vec<(String, LatLon)> = vec![(from_icao.to_uppercase(), dep)];
|
||||
for id in &path {
|
||||
pts.push((id.clone(), pos[id]));
|
||||
}
|
||||
pts.push((to_icao.to_uppercase(), dst));
|
||||
|
||||
let sid = sid_name.get(&path[0]).cloned().unwrap_or_else(|| "DCT".into());
|
||||
let star = star_name.get(&goal).cloned().unwrap_or_else(|| "DCT".into());
|
||||
let last = pts.len().saturating_sub(2);
|
||||
let mut legs = Vec::with_capacity(last + 1);
|
||||
let mut total_nm = 0.0;
|
||||
for (i, w) in pts.windows(2).enumerate() {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
let airway = if i == 0 {
|
||||
sid.clone()
|
||||
} else if i == last {
|
||||
star.clone()
|
||||
} else {
|
||||
"DCT".to_owned()
|
||||
};
|
||||
legs.push(Leg { from: w[0].0.clone(), to: w[1].0.clone(), airway, dist_nm: d });
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: false }))
|
||||
}
|
||||
|
||||
/// All navdata points (waypoints + navaids) inside the dep/dest bounding box
|
||||
/// (+`margin` degrees), as ident → position, keeping the one nearest `near` when
|
||||
/// an ident occurs more than once.
|
||||
fn load_corridor(
|
||||
conn: &Connection,
|
||||
a: LatLon,
|
||||
b: LatLon,
|
||||
near: LatLon,
|
||||
margin: f64,
|
||||
) -> Result<HashMap<String, LatLon>> {
|
||||
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 out: HashMap<String, LatLon> = HashMap::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
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;
|
||||
out.entry(ident)
|
||||
.and_modify(|cur| {
|
||||
if p.distance_nm(&near) < cur.distance_nm(&near) {
|
||||
*cur = p;
|
||||
}
|
||||
})
|
||||
.or_insert(p);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
//! Routing over the **official EUROCONTROL FRA points** with a connectivity graph.
|
||||
//!
|
||||
//! The FRA points list gives the point catalog + roles (E/X/I, A/D) + level
|
||||
//! bands, but not which points connect. In Free Route Airspace you may DCT
|
||||
//! between two points **only within the same FRA area** (a point in several areas
|
||||
//! bridges them); crossing longer legs or wrong-area pairs is what IFPS rejects
|
||||
//! (`ROUTE52`, `ROUTE165`). So this router builds a graph whose edges join points
|
||||
//! that **share an FRA area** (within a DCT-length cap, both level-valid, not
|
||||
//! RAD-forbidden) and A*-searches from a real **departure** point (`D`/`E`) to a
|
||||
//! real **arrival** point (`A`/`X`). The IFPUV oracle + repair loop handle the
|
||||
//! residual (TMA airway splices, mandatory routings).
|
||||
|
||||
use petgraph::algo::astar;
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::{FraPoint, RadData};
|
||||
|
||||
use super::{airport_pos, Leg, Route};
|
||||
|
||||
/// How far from the airport a departure/arrival FRA point may sit.
|
||||
const TERMINAL_RADIUS_NM: f64 = 500.0;
|
||||
/// Corridor bounding-box margin (degrees) around the dep→dest line.
|
||||
const CORRIDOR_MARGIN: f64 = 3.0;
|
||||
/// Max length of a single FRA DCT edge (nm). Nearby FRA points (incl. across an
|
||||
/// area boundary — the border crossing) connect; longer TMA legs are caught by
|
||||
/// the oracle (`ROUTE165`) and repaired with airways.
|
||||
const MAX_FRA_DCT_NM: f64 = 150.0;
|
||||
/// Per-hop penalty (nm) added to each edge so A* prefers fewer, longer DCTs
|
||||
/// (real routes use ~8 points, not one per nearby FRA point).
|
||||
const HOP_PENALTY_NM: f64 = 25.0;
|
||||
|
||||
fn pos(p: &FraPoint) -> LatLon {
|
||||
LatLon::new(p.lat, p.lon)
|
||||
}
|
||||
|
||||
/// Plan a Free-Route path through the official FRA points from `from` to `to` at
|
||||
/// `cruise_fl`, routing only along same-area DCT edges. `None` if no connected
|
||||
/// path between a departure and an arrival FRA point exists.
|
||||
pub fn plan_fra_points(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
cruise_fl: i32,
|
||||
points: &[FraPoint],
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Option<Route>> {
|
||||
if points.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let dep = airport_pos(conn, from)?;
|
||||
let dst = airport_pos(conn, to)?;
|
||||
let fl = cruise_fl;
|
||||
let d0 = dep.distance_nm(&dst);
|
||||
|
||||
// Corridor: level-valid FRA points inside the dep→dest bounding box.
|
||||
let (min_lat, max_lat) = (dep.lat.min(dst.lat) - CORRIDOR_MARGIN, dep.lat.max(dst.lat) + CORRIDOR_MARGIN);
|
||||
let (min_lon, max_lon) = (dep.lon.min(dst.lon) - CORRIDOR_MARGIN, dep.lon.max(dst.lon) + CORRIDOR_MARGIN);
|
||||
let corridor: Vec<&FraPoint> = points
|
||||
.iter()
|
||||
.filter(|p| {
|
||||
p.covers_fl(fl)
|
||||
&& p.lat >= min_lat && p.lat <= max_lat
|
||||
&& p.lon >= min_lon && p.lon <= max_lon
|
||||
})
|
||||
.collect();
|
||||
if corridor.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Departure / arrival points (indices into `corridor`).
|
||||
let dep_i = corridor
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| (p.arrdep.contains('D') || p.enroute == "E" || p.enroute == "EX")
|
||||
&& dep.distance_nm(&pos(p)) <= TERMINAL_RADIUS_NM
|
||||
&& pos(p).distance_nm(&dst) < d0)
|
||||
.min_by(|(_, a), (_, b)| dep.distance_nm(&pos(a)).total_cmp(&dep.distance_nm(&pos(b))))
|
||||
.map(|(i, _)| i);
|
||||
let arr_i = corridor
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, p)| (p.arrdep.contains('A') || p.enroute == "X" || p.enroute == "EX")
|
||||
&& dst.distance_nm(&pos(p)) <= TERMINAL_RADIUS_NM
|
||||
&& pos(p).distance_nm(&dep) < d0)
|
||||
.min_by(|(_, a), (_, b)| dst.distance_nm(&pos(a)).total_cmp(&dst.distance_nm(&pos(b))))
|
||||
.map(|(i, _)| i);
|
||||
let (Some(dep_i), Some(arr_i)) = (dep_i, arr_i) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if dep_i == arr_i {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Graph: each corridor FRA point → its K nearest within the DCT cap (not
|
||||
// RAD-forbidden at this FL). Node weight = corridor index.
|
||||
let mut g: DiGraph<usize, f64> = DiGraph::new();
|
||||
let node: Vec<NodeIndex> = (0..corridor.len()).map(|i| g.add_node(i)).collect();
|
||||
// (RAD-forbidden directs are left to the IFPUV oracle — checking every one of
|
||||
// the thousands of DCT restrictions inside this O(N²) loop is too slow.)
|
||||
let _ = rad;
|
||||
for i in 0..corridor.len() {
|
||||
let pi = pos(corridor[i]);
|
||||
for j in 0..corridor.len() {
|
||||
if i == j {
|
||||
continue;
|
||||
}
|
||||
let d = pi.distance_nm(&pos(corridor[j]));
|
||||
if d > MAX_FRA_DCT_NM {
|
||||
continue;
|
||||
}
|
||||
// Per-hop penalty ⇒ A* prefers fewer, longer DCTs.
|
||||
g.add_edge(node[i], node[j], d + HOP_PENALTY_NM);
|
||||
}
|
||||
}
|
||||
|
||||
let target = pos(corridor[arr_i]);
|
||||
let result = astar(
|
||||
&g,
|
||||
node[dep_i],
|
||||
|n| n == node[arr_i],
|
||||
|e| *e.weight(),
|
||||
|n| pos(corridor[g[n]]).distance_nm(&target),
|
||||
);
|
||||
let Some((_, path)) = result else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
// Route: DEP → dep_pt → … → arr_pt → DEST (all DCT; IFPS derives SID/STAR).
|
||||
let mut pts: Vec<(String, LatLon)> = vec![(from.to_uppercase(), dep)];
|
||||
for n in &path {
|
||||
let p = corridor[g[*n]];
|
||||
pts.push((p.name.clone(), pos(p)));
|
||||
}
|
||||
pts.push((to.to_uppercase(), dst));
|
||||
|
||||
let mut legs = Vec::with_capacity(pts.len() - 1);
|
||||
let mut total_nm = 0.0;
|
||||
for w in pts.windows(2) {
|
||||
let d = w[0].1.distance_nm(&w[1].1);
|
||||
total_nm += d;
|
||||
legs.push(Leg { from: w[0].0.clone(), to: w[1].0.clone(), airway: "DCT".to_owned(), dist_nm: d });
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: false }))
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
//! In-memory airway graph built from SQLite, used by A* routing.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use petgraph::graph::{DiGraph, NodeIndex};
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::model::LatLon;
|
||||
|
||||
/// A routable point (airway endpoint) in the graph.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NodeData {
|
||||
pub ident: String,
|
||||
pub region: String,
|
||||
pub pos: LatLon,
|
||||
}
|
||||
|
||||
/// An airway (or DCT connector) edge between two points.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EdgeData {
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
pub base_fl: i32,
|
||||
pub top_fl: i32,
|
||||
}
|
||||
|
||||
/// Whether a flight level lies within an airway segment's `base…top` band
|
||||
/// (`top <= 0` means unlimited/unknown).
|
||||
pub fn fl_in_band(fl: i32, base_fl: i32, top_fl: i32) -> bool {
|
||||
fl >= base_fl && (top_fl <= 0 || fl <= top_fl)
|
||||
}
|
||||
|
||||
/// The directed airway network.
|
||||
pub struct RouteGraph {
|
||||
pub g: DiGraph<NodeData, EdgeData>,
|
||||
}
|
||||
|
||||
impl RouteGraph {
|
||||
/// Build the graph from `waypoints`/`navaids` (positions) and
|
||||
/// `airway_segments` (edges). Segments whose endpoints have no known
|
||||
/// position are skipped. When `cruise_fl` is `Some`, only airways valid at
|
||||
/// that flight level (within their `base…top` band) are included.
|
||||
pub fn build(conn: &Connection, cruise_fl: Option<i32>) -> Result<Self> {
|
||||
let positions = load_positions(conn)?;
|
||||
let mut g = DiGraph::new();
|
||||
let mut index: HashMap<(String, String), NodeIndex> = HashMap::new();
|
||||
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT from_ident, from_region, to_ident, to_region, direction, airway_name, base_fl, top_fl \
|
||||
FROM airway_segments",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, String>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, String>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, i32>(6)?,
|
||||
r.get::<_, i32>(7)?,
|
||||
))
|
||||
})?;
|
||||
|
||||
for row in rows {
|
||||
let (fi, fr, ti, tr, dir, awy, base_fl, top_fl) = row?;
|
||||
if let Some(fl) = cruise_fl {
|
||||
if !fl_in_band(fl, base_fl, top_fl) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let (fp, tp) = match (
|
||||
positions.get(&(fi.clone(), fr.clone())),
|
||||
positions.get(&(ti.clone(), tr.clone())),
|
||||
) {
|
||||
(Some(fp), Some(tp)) => (*fp, *tp),
|
||||
_ => continue,
|
||||
};
|
||||
let a = node_or_insert(&mut g, &mut index, fi, fr, fp);
|
||||
let b = node_or_insert(&mut g, &mut index, ti, tr, tp);
|
||||
let dist_nm = fp.distance_nm(&tp);
|
||||
let forward = matches!(dir.chars().next(), Some('N') | Some('F'));
|
||||
let backward = matches!(dir.chars().next(), Some('N') | Some('B'));
|
||||
if forward {
|
||||
g.add_edge(
|
||||
a,
|
||||
b,
|
||||
EdgeData {
|
||||
airway: awy.clone(),
|
||||
dist_nm,
|
||||
base_fl,
|
||||
top_fl,
|
||||
},
|
||||
);
|
||||
}
|
||||
if backward {
|
||||
g.add_edge(
|
||||
b,
|
||||
a,
|
||||
EdgeData {
|
||||
airway: awy,
|
||||
dist_nm,
|
||||
base_fl,
|
||||
top_fl,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(Self { g })
|
||||
}
|
||||
|
||||
/// Number of graph nodes (airway endpoints).
|
||||
pub fn node_count(&self) -> usize {
|
||||
self.g.node_count()
|
||||
}
|
||||
|
||||
/// Any graph node with `ident` (first match). Used when a rough position
|
||||
/// hint isn't available (e.g. splicing an airway sub-path between two fixes).
|
||||
pub fn find_ident(&self, ident: &str) -> Option<NodeIndex> {
|
||||
self.g
|
||||
.node_indices()
|
||||
.find(|&ix| self.g[ix].ident.eq_ignore_ascii_case(ident))
|
||||
}
|
||||
|
||||
/// The graph node with `ident` (nearest to `near` when it repeats across
|
||||
/// regions). Used to connect an airport to a named SID/STAR fix.
|
||||
pub fn node_by_ident(&self, ident: &str, near: LatLon) -> Option<NodeIndex> {
|
||||
self.g
|
||||
.node_indices()
|
||||
.filter(|&ix| self.g[ix].ident.eq_ignore_ascii_case(ident))
|
||||
.min_by(|&a, &b| {
|
||||
self.g[a]
|
||||
.pos
|
||||
.distance_nm(&near)
|
||||
.total_cmp(&self.g[b].pos.distance_nm(&near))
|
||||
})
|
||||
}
|
||||
|
||||
/// The `k` graph nodes closest to `from`, within `max_nm`, nearest first.
|
||||
pub fn nearest_nodes(&self, from: LatLon, k: usize, max_nm: f64) -> Vec<NodeIndex> {
|
||||
let mut candidates: Vec<(f64, NodeIndex)> = self
|
||||
.g
|
||||
.node_indices()
|
||||
.filter_map(|ix| {
|
||||
let d = self.g[ix].pos.distance_nm(&from);
|
||||
(d <= max_nm).then_some((d, ix))
|
||||
})
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
candidates.truncate(k);
|
||||
candidates.into_iter().map(|(_, ix)| ix).collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn node_or_insert(
|
||||
g: &mut DiGraph<NodeData, EdgeData>,
|
||||
index: &mut HashMap<(String, String), NodeIndex>,
|
||||
ident: String,
|
||||
region: String,
|
||||
pos: LatLon,
|
||||
) -> NodeIndex {
|
||||
if let Some(ix) = index.get(&(ident.clone(), region.clone())) {
|
||||
return *ix;
|
||||
}
|
||||
let ix = g.add_node(NodeData {
|
||||
ident: ident.clone(),
|
||||
region: region.clone(),
|
||||
pos,
|
||||
});
|
||||
index.insert((ident, region), ix);
|
||||
ix
|
||||
}
|
||||
|
||||
fn load_positions(conn: &Connection) -> Result<HashMap<(String, String), LatLon>> {
|
||||
let mut map: HashMap<(String, String), LatLon> = HashMap::new();
|
||||
for table in ["waypoints", "navaids"] {
|
||||
let mut stmt = conn.prepare(&format!("SELECT ident, region, lat, lon FROM {table}"))?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
(r.get::<_, String>(0)?, r.get::<_, String>(1)?),
|
||||
LatLon::new(r.get(2)?, r.get(3)?),
|
||||
))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (key, pos) = row?;
|
||||
map.entry(key).or_insert(pos);
|
||||
}
|
||||
}
|
||||
Ok(map)
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
//! 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. Paris–Nice `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:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,462 @@
|
||||
//! Airway routing: build a graph from the airway network and run A* between two
|
||||
//! airports, connecting each airport to nearby airway points with DCT legs, and
|
||||
//! falling back to a single direct great-circle leg when no path is found.
|
||||
|
||||
pub mod discover;
|
||||
pub mod fra;
|
||||
pub mod fra_graph;
|
||||
pub mod fra_points;
|
||||
pub mod graph;
|
||||
pub mod hybrid;
|
||||
|
||||
use petgraph::algo::astar;
|
||||
use petgraph::graph::NodeIndex;
|
||||
use rusqlite::{params, Connection};
|
||||
|
||||
use crate::error::{CoreError, Result};
|
||||
use crate::model::LatLon;
|
||||
use crate::rad::RadData;
|
||||
use graph::{EdgeData, NodeData, RouteGraph};
|
||||
|
||||
/// Radius within which an airport is connected to airway points (DCT), and how
|
||||
/// many such entry/exit points to consider.
|
||||
const CONNECT_MAX_NM: f64 = 100.0;
|
||||
const CONNECT_K: usize = 30;
|
||||
|
||||
/// One leg of a computed route.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct Leg {
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
/// Airway name, or `"DCT"` for a direct leg.
|
||||
pub airway: String,
|
||||
pub dist_nm: f64,
|
||||
}
|
||||
|
||||
/// A computed route from departure to destination.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Route {
|
||||
pub legs: Vec<Leg>,
|
||||
pub total_nm: f64,
|
||||
/// `true` if routed through the airway graph, `false` for the direct fallback.
|
||||
pub via_airways: bool,
|
||||
}
|
||||
|
||||
impl Route {
|
||||
/// Flight-plan style string: `LFPG DCT ABEAM T100 BEACN … EGLL`.
|
||||
pub fn route_string(&self) -> String {
|
||||
let mut s = self
|
||||
.legs
|
||||
.first()
|
||||
.map(|l| l.from.clone())
|
||||
.unwrap_or_default();
|
||||
for leg in &self.legs {
|
||||
s.push_str(&format!(" {} {}", leg.airway, leg.to));
|
||||
}
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan a route between two airport ICAO codes. When `cruise_fl` is `Some`, only
|
||||
/// airways valid at that flight level are used.
|
||||
pub fn plan_route(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> Result<Route> {
|
||||
plan_route_conn(conn, from_icao, to_icao, cruise_fl, &[], &[])
|
||||
}
|
||||
|
||||
/// Like [`plan_route`], but the departure/destination connect to the network via
|
||||
/// the given SID **exit** fixes / STAR **entry** fixes when any resolve to graph
|
||||
/// nodes; otherwise it falls back to the nearest airway points (DCT).
|
||||
pub fn plan_route_conn(
|
||||
conn: &Connection,
|
||||
from_icao: &str,
|
||||
to_icao: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
let dep = airport_pos(conn, from_icao)?;
|
||||
let dst = airport_pos(conn, to_icao)?;
|
||||
|
||||
let mut rg = RouteGraph::build(conn, cruise_fl)?;
|
||||
let entries = resolve_conn(&rg, dep_conn, dep)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dep, CONNECT_K, CONNECT_MAX_NM));
|
||||
let exits = resolve_conn(&rg, dest_conn, dst)
|
||||
.unwrap_or_else(|| rg.nearest_nodes(dst, CONNECT_K, CONNECT_MAX_NM));
|
||||
|
||||
if entries.is_empty() || exits.is_empty() {
|
||||
return Ok(direct_route(from_icao, to_icao, dep, dst));
|
||||
}
|
||||
|
||||
// Add departure/destination as temporary nodes wired to nearby airway points.
|
||||
let dep_ix = rg.g.add_node(NodeData {
|
||||
ident: from_icao.to_owned(),
|
||||
region: String::new(),
|
||||
pos: dep,
|
||||
});
|
||||
for e in entries {
|
||||
let dist_nm = dep.distance_nm(&rg.g[e].pos);
|
||||
rg.g.add_edge(
|
||||
dep_ix,
|
||||
e,
|
||||
EdgeData {
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
base_fl: 0,
|
||||
top_fl: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
let dst_ix = rg.g.add_node(NodeData {
|
||||
ident: to_icao.to_owned(),
|
||||
region: String::new(),
|
||||
pos: dst,
|
||||
});
|
||||
for e in exits {
|
||||
let dist_nm = rg.g[e].pos.distance_nm(&dst);
|
||||
rg.g.add_edge(
|
||||
e,
|
||||
dst_ix,
|
||||
EdgeData {
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
base_fl: 0,
|
||||
top_fl: 0,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
let result = astar(
|
||||
&rg.g,
|
||||
dep_ix,
|
||||
|n| n == dst_ix,
|
||||
|e| e.weight().dist_nm,
|
||||
|n| rg.g[n].pos.distance_nm(&dst),
|
||||
);
|
||||
|
||||
match result {
|
||||
Some((total_nm, path)) => {
|
||||
let mut legs = Vec::with_capacity(path.len().saturating_sub(1));
|
||||
for pair in path.windows(2) {
|
||||
let (a, b) = (pair[0], pair[1]);
|
||||
let airway =
|
||||
rg.g.find_edge(a, b)
|
||||
.and_then(|e| rg.g.edge_weight(e))
|
||||
.map(|ed| ed.airway.clone())
|
||||
.unwrap_or_else(|| "DCT".to_owned());
|
||||
legs.push(Leg {
|
||||
from: rg.g[a].ident.clone(),
|
||||
to: rg.g[b].ident.clone(),
|
||||
airway,
|
||||
dist_nm: rg.g[a].pos.distance_nm(&rg.g[b].pos),
|
||||
});
|
||||
}
|
||||
Ok(Route {
|
||||
legs,
|
||||
total_nm,
|
||||
via_airways: true,
|
||||
})
|
||||
}
|
||||
None => Ok(direct_route(from_icao, to_icao, dep, dst)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve connector fix idents (SID exits / STAR entries) to graph nodes.
|
||||
/// `None` when the list is empty or none resolve, so the caller falls back to
|
||||
/// nearest-airway-point connection.
|
||||
fn resolve_conn(rg: &RouteGraph, idents: &[String], near: LatLon) -> Option<Vec<NodeIndex>> {
|
||||
if idents.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let nodes: Vec<NodeIndex> = idents
|
||||
.iter()
|
||||
.filter_map(|id| rg.node_by_ident(id, near))
|
||||
.collect();
|
||||
(!nodes.is_empty()).then_some(nodes)
|
||||
}
|
||||
|
||||
/// Filtering airways to the cruise FL yields an IFPS-coherent route, but on some
|
||||
/// pairs the upper network is fragmented and forces an absurd detour; the full
|
||||
/// network gives the short route but may cite airways invalid at the FL. So we
|
||||
/// compute both and keep the FL-valid one unless it detours by >60 %.
|
||||
const FL_ROUTE_MAX_RATIO: f64 = 1.6;
|
||||
|
||||
/// Best airway route: FL-filtered when it doesn't detour too much, else full.
|
||||
pub fn plan_route_best(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
dep_conn: &[String],
|
||||
dest_conn: &[String],
|
||||
) -> Result<Route> {
|
||||
let full = plan_route_conn(conn, from, to, None, dep_conn, dest_conn)?;
|
||||
match cruise_fl {
|
||||
Some(fl) => {
|
||||
let fl_route = plan_route_conn(conn, from, to, Some(fl), dep_conn, dest_conn)?;
|
||||
if fl_route.via_airways && fl_route.total_nm <= FL_ROUTE_MAX_RATIO * full.total_nm.max(1.0) {
|
||||
Ok(fl_route)
|
||||
} else {
|
||||
Ok(full)
|
||||
}
|
||||
}
|
||||
None => Ok(full),
|
||||
}
|
||||
}
|
||||
|
||||
/// The route to file for modern European airspace, in preference order:
|
||||
/// 1. FRA **graph** (published Annex-2 directs), 2. FRA heuristic (great-circle
|
||||
/// anchors), 3. airway routing. `dep_sid`/`dest_star` are (procedure, fix) pairs.
|
||||
pub fn plan_preferred(
|
||||
conn: &Connection,
|
||||
from: &str,
|
||||
to: &str,
|
||||
dep_sid: &[(String, String)],
|
||||
dest_star: &[(String, String)],
|
||||
cruise_fl: Option<i32>,
|
||||
rad: Option<&RadData>,
|
||||
) -> Result<Route> {
|
||||
// Official FRA-points corridor first (real dep/arr/intermediate points).
|
||||
if let Some(r) = rad.filter(|r| !r.fra_points.is_empty()).and_then(|r| {
|
||||
fra_points::plan_fra_points(conn, from, to, cruise_fl.unwrap_or(350), &r.fra_points, Some(r))
|
||||
.ok()
|
||||
.flatten()
|
||||
}) {
|
||||
if r.legs.len() >= 2 {
|
||||
return Ok(r);
|
||||
}
|
||||
}
|
||||
let fra_adj = rad.map(|r| r.fra_adjacency()).unwrap_or_default();
|
||||
if let Some(r) = fra_graph::plan_fra_graph(conn, from, to, dep_sid, dest_star, &fra_adj)
|
||||
.ok()
|
||||
.flatten()
|
||||
.filter(|r| r.legs.len() >= 2)
|
||||
{
|
||||
return Ok(r);
|
||||
}
|
||||
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();
|
||||
match fra::plan_fra(conn, from, to, dep_sid, dest_star, cruise_fl.unwrap_or(350), rad) {
|
||||
Ok(r) if r.legs.len() >= 2 => Ok(r),
|
||||
_ => plan_route_best(conn, from, to, cruise_fl, &dep_fixes, &dest_fixes),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `t` looks like an airway designator (`UT421`, `UN570`, `L613`, `Y8`)
|
||||
/// rather than a fix: 1–3 leading letters, ≥1 digit, optional trailing letters.
|
||||
fn is_airway(t: &str) -> bool {
|
||||
let b = t.as_bytes();
|
||||
let mut i = 0;
|
||||
while i < b.len() && b[i].is_ascii_uppercase() {
|
||||
i += 1;
|
||||
}
|
||||
if i == 0 || i > 3 {
|
||||
return false;
|
||||
}
|
||||
let after_letters = i;
|
||||
while i < b.len() && b[i].is_ascii_digit() {
|
||||
i += 1;
|
||||
}
|
||||
if i == after_letters {
|
||||
return false; // needs at least one digit
|
||||
}
|
||||
while i < b.len() && b[i].is_ascii_uppercase() {
|
||||
i += 1;
|
||||
}
|
||||
i == b.len()
|
||||
}
|
||||
|
||||
/// Position of `ident` (waypoint or navaid), nearest `near` when ambiguous.
|
||||
fn ident_pos(conn: &Connection, ident: &str, near: LatLon) -> Option<LatLon> {
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
if let Ok(mut stmt) = conn.prepare(sql) {
|
||||
if let Ok(rows) = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?))) {
|
||||
cands.extend(rows.flatten());
|
||||
}
|
||||
}
|
||||
}
|
||||
cands.into_iter().min_by(|a, b| a.distance_nm(&near).total_cmp(&b.distance_nm(&near)))
|
||||
}
|
||||
|
||||
/// Parse a filed item-15 string (`OPALE UT421 BIG DCT KEF`) into a [`Route`]
|
||||
/// DEP → … → DEST, resolving fix positions from the DB. Airway tokens attach to
|
||||
/// the following leg; unresolvable tokens (e.g. lat/lon shorthand) are skipped.
|
||||
/// `None` when fewer than one usable enroute fix resolves. Used to turn a stored
|
||||
/// (e.g. imported PFPX) route seed into a repairable candidate.
|
||||
pub fn parse_route_string(conn: &Connection, from: &str, to: &str, item15: &str) -> Result<Option<Route>> {
|
||||
let dep = airport_pos(conn, from)?;
|
||||
let dst = airport_pos(conn, to)?;
|
||||
let mid = LatLon::new((dep.lat + dst.lat) / 2.0, (dep.lon + dst.lon) / 2.0);
|
||||
let mut points: Vec<(String, LatLon)> = vec![(from.to_uppercase(), dep)];
|
||||
let mut awys: Vec<String> = Vec::new();
|
||||
let mut pending = "DCT".to_string();
|
||||
for tok in item15.split_whitespace() {
|
||||
let t = tok.to_uppercase();
|
||||
if t == "DCT" || is_airway(&t) {
|
||||
pending = t;
|
||||
continue;
|
||||
}
|
||||
if let Some(p) = ident_pos(conn, &t, mid) {
|
||||
awys.push(std::mem::replace(&mut pending, "DCT".to_owned()));
|
||||
points.push((t, p));
|
||||
}
|
||||
}
|
||||
awys.push(pending); // DEP-last-fix … DEST hop
|
||||
points.push((to.to_uppercase(), dst));
|
||||
if points.len() < 3 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut legs = Vec::with_capacity(points.len() - 1);
|
||||
let mut total_nm = 0.0;
|
||||
for i in 0..points.len() - 1 {
|
||||
let d = points[i].1.distance_nm(&points[i + 1].1);
|
||||
total_nm += d;
|
||||
legs.push(Leg {
|
||||
from: points[i].0.clone(),
|
||||
to: points[i + 1].0.clone(),
|
||||
airway: awys[i].clone(),
|
||||
dist_nm: d,
|
||||
});
|
||||
}
|
||||
Ok(Some(Route { legs, total_nm, via_airways: true }))
|
||||
}
|
||||
|
||||
/// Route the sub-segment between two enroute fixes through the airway network,
|
||||
/// returning the ordered legs (with airway names) — used to repair a DCT that
|
||||
/// IFPS rejects as "too long" (DCT not allowed in that TMA/area). `None` when
|
||||
/// either fix isn't an airway node or no path exists at `cruise_fl`.
|
||||
pub fn airway_path(
|
||||
conn: &Connection,
|
||||
from_ident: &str,
|
||||
to_ident: &str,
|
||||
cruise_fl: Option<i32>,
|
||||
) -> Result<Option<Vec<Leg>>> {
|
||||
let rg = RouteGraph::build(conn, cruise_fl)?;
|
||||
let (Some(a), Some(b)) = (rg.find_ident(from_ident), rg.find_ident(to_ident)) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let target = rg.g[b].pos;
|
||||
let result = astar(
|
||||
&rg.g,
|
||||
a,
|
||||
|n| n == b,
|
||||
|e| e.weight().dist_nm,
|
||||
|n| rg.g[n].pos.distance_nm(&target),
|
||||
);
|
||||
let Some((_, path)) = result else {
|
||||
return Ok(None);
|
||||
};
|
||||
if path.len() < 2 {
|
||||
return Ok(None);
|
||||
}
|
||||
let mut legs = Vec::with_capacity(path.len() - 1);
|
||||
for pair in path.windows(2) {
|
||||
let (x, y) = (pair[0], pair[1]);
|
||||
let airway = rg
|
||||
.g
|
||||
.find_edge(x, y)
|
||||
.and_then(|e| rg.g.edge_weight(e))
|
||||
.map(|ed| ed.airway.clone())
|
||||
.unwrap_or_else(|| "DCT".to_owned());
|
||||
legs.push(Leg {
|
||||
from: rg.g[x].ident.clone(),
|
||||
to: rg.g[y].ident.clone(),
|
||||
airway,
|
||||
dist_nm: rg.g[x].pos.distance_nm(&rg.g[y].pos),
|
||||
});
|
||||
}
|
||||
Ok(Some(legs))
|
||||
}
|
||||
|
||||
fn direct_route(from: &str, to: &str, dep: LatLon, dst: LatLon) -> Route {
|
||||
let dist_nm = dep.distance_nm(&dst);
|
||||
Route {
|
||||
legs: vec![Leg {
|
||||
from: from.to_owned(),
|
||||
to: to.to_owned(),
|
||||
airway: "DCT".to_owned(),
|
||||
dist_nm,
|
||||
}],
|
||||
total_nm: dist_nm,
|
||||
via_airways: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn airport_pos(conn: &Connection, icao: &str) -> Result<LatLon> {
|
||||
conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![icao],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
)
|
||||
.map_err(|e| match e {
|
||||
rusqlite::Error::QueryReturnedNoRows => {
|
||||
CoreError::NotFound(format!("airport {icao} (import navdata first?)"))
|
||||
}
|
||||
other => other.into(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Resolve the ordered waypoints of a computed `route` to geographic positions
|
||||
/// (for drawing on a map). Endpoints match airports first, then en-route
|
||||
/// fixes/navaids; when an ident repeats across regions the candidate nearest the
|
||||
/// previous point is chosen. Idents that can't be resolved are skipped.
|
||||
pub fn resolve_geometry(conn: &Connection, route: &Route) -> Result<Vec<(String, LatLon)>> {
|
||||
let mut idents: Vec<&str> = Vec::new();
|
||||
if let Some(first) = route.legs.first() {
|
||||
idents.push(first.from.as_str());
|
||||
}
|
||||
for l in &route.legs {
|
||||
idents.push(l.to.as_str());
|
||||
}
|
||||
|
||||
let mut out = Vec::with_capacity(idents.len());
|
||||
let mut prev: Option<LatLon> = None;
|
||||
for id in idents {
|
||||
if let Some(pos) = lookup_pos(conn, id, prev)? {
|
||||
out.push((id.to_owned(), pos));
|
||||
prev = Some(pos);
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn lookup_pos(conn: &Connection, ident: &str, near: Option<LatLon>) -> Result<Option<LatLon>> {
|
||||
// Airports resolve uniquely by ICAO.
|
||||
if let Ok(pos) = conn.query_row(
|
||||
"SELECT lat, lon FROM airports WHERE icao = ?1",
|
||||
params![ident],
|
||||
|r| Ok(LatLon::new(r.get(0)?, r.get(1)?)),
|
||||
) {
|
||||
return Ok(Some(pos));
|
||||
}
|
||||
|
||||
// En-route fixes and navaids may repeat across regions; collect candidates.
|
||||
let mut cands: Vec<LatLon> = Vec::new();
|
||||
for sql in [
|
||||
"SELECT lat, lon FROM waypoints WHERE ident = ?1",
|
||||
"SELECT lat, lon FROM navaids WHERE ident = ?1",
|
||||
] {
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let rows = stmt.query_map(params![ident], |r| Ok(LatLon::new(r.get(0)?, r.get(1)?)))?;
|
||||
for row in rows {
|
||||
cands.push(row?);
|
||||
}
|
||||
}
|
||||
let pick = match near {
|
||||
Some(p) => cands.into_iter().min_by(|a, b| {
|
||||
a.distance_nm(&p)
|
||||
.partial_cmp(&b.distance_nm(&p))
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
}),
|
||||
None => cands.into_iter().next(),
|
||||
};
|
||||
Ok(pick)
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
RWY:RW09L, , ,00079, , ,3, ;N51285200,W000273600,0000;
|
||||
RWY:RW27R, , ,00078, , ,3, ;N51284900,W000260900,0000;
|
||||
@@ -0,0 +1,2 @@
|
||||
RWY:RW08L, , ,00338, ,GLE ,3, ;N48594447,E002330988,0000;
|
||||
RWY:RW26R, , ,00318, ,GAU ,3, ;N48595395,E002360724,1725;
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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:?}");
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
[package]
|
||||
name = "flightplanner-rad"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
name = "flightplanner_rad"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "rad-tool"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
flightplanner-core = { workspace = true }
|
||||
calamine = "0.26"
|
||||
serde = { workspace = true }
|
||||
anyhow = { workspace = true }
|
||||
@@ -0,0 +1,355 @@
|
||||
//! Eurocontrol RAD (Route Availability Document) parsing.
|
||||
//!
|
||||
//! The RAD is a public per-AIRAC Excel workbook (see the `rad-data-source`
|
||||
//! reference). This crate reads it with `calamine` and turns the annexes into a
|
||||
//! restriction model the routing/validation engine can apply.
|
||||
|
||||
use anyhow::Result;
|
||||
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};
|
||||
|
||||
/// Parse the official EUROCONTROL "FRA Points" list (a separate `.xlsx` — see the
|
||||
/// `fra-points-official` note). Sheet `"FRA Points"`, one row per point.
|
||||
pub fn parse_fra_points(path: &str) -> Result<Vec<FraPoint>> {
|
||||
let rows = rows(path, "FRA Points")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let name = cell(r, 2);
|
||||
let lat = parse_lat(&cell(r, 3));
|
||||
let lon = parse_lon(&cell(r, 4));
|
||||
let (name, lat, lon) = match (name.is_empty(), lat, lon) {
|
||||
(false, Some(la), Some(lo)) => (name, la, lo),
|
||||
_ => return None,
|
||||
};
|
||||
let (level_lo, level_hi) = parse_levels(&cell(r, 11));
|
||||
Some(FraPoint {
|
||||
name,
|
||||
lat,
|
||||
lon,
|
||||
areas: split_amp(&cell(r, 5)),
|
||||
enroute: cell(r, 6).trim_matches('-').trim().to_string(),
|
||||
arrdep: cell(r, 7).trim_matches('-').trim().to_string(),
|
||||
arr_airports: split_ws(&cell(r, 8)),
|
||||
dep_airports: split_ws(&cell(r, 9)),
|
||||
flos: cell(r, 10).trim_matches('-').trim().to_string(),
|
||||
level_lo,
|
||||
level_hi,
|
||||
loc_ind: split_ws(&cell(r, 13)),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Latitude `XDDMMSS` (X = N/S) → signed decimal degrees.
|
||||
fn parse_lat(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let b = s.as_bytes();
|
||||
if b.len() < 7 {
|
||||
return None;
|
||||
}
|
||||
let sign = match b[0] {
|
||||
b'N' => 1.0,
|
||||
b'S' => -1.0,
|
||||
_ => return None,
|
||||
};
|
||||
let d: f64 = s[1..3].parse().ok()?;
|
||||
let m: f64 = s[3..5].parse().ok()?;
|
||||
let sec: f64 = s[5..7].parse().ok()?;
|
||||
Some(sign * (d + m / 60.0 + sec / 3600.0))
|
||||
}
|
||||
|
||||
/// Longitude `XDDDMMSS` (X = E/W) → signed decimal degrees.
|
||||
fn parse_lon(s: &str) -> Option<f64> {
|
||||
let s = s.trim();
|
||||
let b = s.as_bytes();
|
||||
if b.len() < 8 {
|
||||
return None;
|
||||
}
|
||||
let sign = match b[0] {
|
||||
b'E' => 1.0,
|
||||
b'W' => -1.0,
|
||||
_ => return None,
|
||||
};
|
||||
let d: f64 = s[1..4].parse().ok()?;
|
||||
let m: f64 = s[4..6].parse().ok()?;
|
||||
let sec: f64 = s[6..8].parse().ok()?;
|
||||
Some(sign * (d + m / 60.0 + sec / 3600.0))
|
||||
}
|
||||
|
||||
/// "FL195 / FL660" → (Some(195), Some(660)); "GND / FL245" → (Some(0), Some(245)).
|
||||
fn parse_levels(s: &str) -> (Option<i32>, Option<i32>) {
|
||||
let up = s.to_uppercase();
|
||||
let mut fls: Vec<i32> = Vec::new();
|
||||
let mut rest = up.as_str();
|
||||
while let Some(p) = rest.find("FL") {
|
||||
rest = &rest[p + 2..];
|
||||
let num: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
rest = &rest[num.len()..];
|
||||
if let Ok(n) = num.parse::<i32>() {
|
||||
fls.push(n);
|
||||
}
|
||||
}
|
||||
let lo = fls.first().copied().or_else(|| up.contains("GND").then_some(0));
|
||||
let hi = fls.get(1).copied().or_else(|| if fls.len() == 1 { None } else { fls.first().copied() });
|
||||
(lo, hi)
|
||||
}
|
||||
|
||||
fn split_amp(s: &str) -> Vec<String> {
|
||||
s.split('&').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()
|
||||
}
|
||||
|
||||
fn split_ws(s: &str) -> Vec<String> {
|
||||
s.split([' ', ',', '\n']).map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect()
|
||||
}
|
||||
|
||||
/// Parse the annexes we currently model from the workbook at `path`.
|
||||
pub fn parse(path: &str) -> Result<RadData> {
|
||||
Ok(RadData {
|
||||
areas: parse_areas(path)?,
|
||||
dct: parse_dct(path)?,
|
||||
fra_edges: parse_fra_edges(path)?,
|
||||
level_caps: parse_level_caps(path)?,
|
||||
fra_points: Vec::new(), // loaded separately via parse_fra_points
|
||||
})
|
||||
}
|
||||
|
||||
/// Annex 2A — city-pair flight-level caps.
|
||||
pub fn parse_level_caps(path: &str) -> Result<Vec<LevelCap>> {
|
||||
let rows = rows(path, "Annex 2A")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let from = parse_idents(&cell(r, 4));
|
||||
let to = parse_idents(&cell(r, 6));
|
||||
let cap_fl = min_fl(&cell(r, 8));
|
||||
if from.is_empty() || to.is_empty() || cap_fl.is_none() {
|
||||
return None;
|
||||
}
|
||||
Some(LevelCap {
|
||||
id: cell(r, 3),
|
||||
from,
|
||||
to,
|
||||
condition: cell(r, 7),
|
||||
cap_fl,
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Lowest flight level mentioned in a capping cell like `FL345` or `FL355FL375`.
|
||||
fn min_fl(s: &str) -> Option<i32> {
|
||||
let up = s.to_uppercase();
|
||||
let mut out: Option<i32> = None;
|
||||
let mut rest = up.as_str();
|
||||
while let Some(pos) = rest.find("FL") {
|
||||
rest = &rest[pos + 2..];
|
||||
let num: String = rest.chars().take_while(char::is_ascii_digit).collect();
|
||||
rest = &rest[num.len()..];
|
||||
if let Ok(fl) = num.parse::<i32>() {
|
||||
out = Some(out.map_or(fl, |m| m.min(fl)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Annex 1 — area definitions.
|
||||
pub fn parse_areas(path: &str) -> Result<Vec<Area>> {
|
||||
let rows = rows(path, "Annex 1")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let id = cell(r, 3);
|
||||
let def = cell(r, 4);
|
||||
if id.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(Area {
|
||||
id,
|
||||
airports: parse_idents(&def),
|
||||
region: cell(r, 6),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Annex 3B — DCT restrictions.
|
||||
pub fn parse_dct(path: &str) -> Result<Vec<DctRestriction>> {
|
||||
let rows = rows(path, "Annex 3B DCT")?;
|
||||
Ok(rows
|
||||
.iter()
|
||||
.skip(1) // header
|
||||
.filter_map(|r| {
|
||||
let from = cell(r, 4);
|
||||
let to = cell(r, 5);
|
||||
if from.is_empty() || to.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let avail = cell(r, 8).to_uppercase();
|
||||
Some(DctRestriction {
|
||||
id: cell(r, 3),
|
||||
from,
|
||||
to,
|
||||
lower_fl: parse_fl(&cell(r, 6)),
|
||||
upper_fl: parse_fl(&cell(r, 7)),
|
||||
available: avail.starts_with('Y'),
|
||||
utilization: cell(r, 9),
|
||||
direction: cell(r, 13),
|
||||
})
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Scan Annex 2A/2B/2C for `A DCT B` fix pairs and return the de-duplicated set
|
||||
/// of allowed FRA direct edges. This is our FRA connectivity catalog: routing
|
||||
/// through these points/edges is what IFPS accepts in Free Route Airspace.
|
||||
pub fn parse_fra_edges(path: &str) -> Result<Vec<FraEdge>> {
|
||||
use std::collections::HashSet;
|
||||
let mut seen: HashSet<FraEdge> = HashSet::new();
|
||||
let mut out = Vec::new();
|
||||
// Annex 2A/2B/2C hold the enroute FRA routings; Annex 3A DEP/ARR hold the
|
||||
// compulsory departure/arrival routings (e.g. LF7352: LFPG deps via
|
||||
// `OPALE DCT KESAX DCT DIMAL DCT ALESO`) — both are needed for the graph.
|
||||
for sheet in ["Annex 2A", "Annex 2B", "Annex 2C", "Annex 3A DEP", "Annex 3A ARR"] {
|
||||
let Ok(rows) = rows(path, sheet) else { continue };
|
||||
for row in rows {
|
||||
// Normalise separators so `DCT` always stands alone as a token.
|
||||
let text = row.join(" ").replace(['(', ')', ',', '\n'], " ");
|
||||
let toks: Vec<&str> = text.split_whitespace().collect();
|
||||
for w in toks.windows(3) {
|
||||
if w[1] == "DCT" && is_fix(w[0]) && is_fix(w[2]) {
|
||||
let e = FraEdge { from: w[0].to_string(), to: w[2].to_string() };
|
||||
if seen.insert(e.clone()) {
|
||||
out.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A plausible navaid/waypoint ident: 2–6 chars, letters+digits, ≥1 letter, not
|
||||
/// a RAD keyword. Excludes airway designators would be nice but they rarely sit
|
||||
/// on both sides of a literal `DCT`, so the DCT-pair test already filters them.
|
||||
fn is_fix(s: &str) -> bool {
|
||||
let s = s.trim();
|
||||
let len = s.len();
|
||||
if !(2..=6).contains(&len) {
|
||||
return false;
|
||||
}
|
||||
if !s.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
|
||||
return false;
|
||||
}
|
||||
if !s.chars().any(|c| c.is_ascii_uppercase()) {
|
||||
return false;
|
||||
}
|
||||
!matches!(
|
||||
s,
|
||||
"DCT" | "VIA" | "AND" | "THEN" | "ARR" | "DEP" | "EXC" | "RFL" | "BLW"
|
||||
| "ABV" | "BTN" | "IAW" | "LOA" | "TFC" | "AVBL" | "NOT" | "ONLY"
|
||||
| "H24" | "UFN" | "FL" | "AT" | "IN" | "OR" | "TO" | "VItoken"
|
||||
)
|
||||
}
|
||||
|
||||
// ── low-level helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/// List the sheet (annex) names in the workbook.
|
||||
pub fn sheets(path: &str) -> Result<Vec<String>> {
|
||||
let wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb.sheet_names().to_vec())
|
||||
}
|
||||
|
||||
/// Dimensions (rows, cols) of a sheet.
|
||||
pub fn dims(path: &str, sheet: &str) -> Result<(usize, usize)> {
|
||||
let mut wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb.worksheet_range(sheet)?.get_size())
|
||||
}
|
||||
|
||||
/// All rows of `sheet` as trimmed strings.
|
||||
pub fn rows(path: &str, sheet: &str) -> Result<Vec<Vec<String>>> {
|
||||
let mut wb: Xlsx<_> = open_workbook(path)?;
|
||||
Ok(wb
|
||||
.worksheet_range(sheet)?
|
||||
.rows()
|
||||
.map(|r| r.iter().map(cell_str).collect())
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// First `n` rows of `sheet` (for exploration).
|
||||
pub fn dump(path: &str, sheet: &str, n: usize) -> Result<Vec<Vec<String>>> {
|
||||
Ok(rows(path, sheet)?.into_iter().take(n).collect())
|
||||
}
|
||||
|
||||
fn cell(row: &[String], i: usize) -> String {
|
||||
row.get(i).cloned().unwrap_or_default().trim().to_string()
|
||||
}
|
||||
|
||||
fn cell_str(c: &Data) -> String {
|
||||
match c {
|
||||
Data::Empty => String::new(),
|
||||
Data::String(s) => s.trim().to_string(),
|
||||
Data::Float(f) => f.to_string(),
|
||||
Data::Int(i) => i.to_string(),
|
||||
Data::Bool(b) => b.to_string(),
|
||||
other => other.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// First flight level found in a cell like `FL245`, `MEAFL025`, `FL195FL315`.
|
||||
fn parse_fl(s: &str) -> Option<i32> {
|
||||
let up = s.to_uppercase();
|
||||
let pos = up.find("FL")?;
|
||||
let num: String = up[pos + 2..].chars().take_while(char::is_ascii_digit).collect();
|
||||
num.parse().ok()
|
||||
}
|
||||
|
||||
/// Split an Annex-1 definition like `(EGBB, EGBE, EGNX)` into idents.
|
||||
fn parse_idents(def: &str) -> Vec<String> {
|
||||
def.trim()
|
||||
.trim_matches(|c| c == '(' || c == ')')
|
||||
.split([',', '\n'])
|
||||
.map(|s| s.trim().trim_matches(|c| c == '(' || c == ')').to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// Parses the real RAD workbook when it's present (CWD = crate dir in tests).
|
||||
/// Skips (passes) otherwise so the test stays portable.
|
||||
#[test]
|
||||
fn parses_real_rad_when_present() {
|
||||
let f = "../../rad/RAD_current.xlsx";
|
||||
if !std::path::Path::new(f).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = parse(f).unwrap();
|
||||
assert!(rad.areas.len() > 50, "areas = {}", rad.areas.len());
|
||||
assert!(rad.dct.len() > 1000, "dct = {}", rad.dct.len());
|
||||
assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Forbidden));
|
||||
assert!(rad.dct.iter().any(|d| d.kind() == DctKind::Compulsory));
|
||||
// FL bands parse to plausible values.
|
||||
assert!(rad
|
||||
.dct
|
||||
.iter()
|
||||
.filter_map(|d| d.upper_fl)
|
||||
.all(|fl| (0..=700).contains(&fl)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_fl_variants() {
|
||||
assert_eq!(parse_fl("FL245"), Some(245));
|
||||
assert_eq!(parse_fl("MEAFL025"), Some(25));
|
||||
assert_eq!(parse_fl("FL195FL315"), Some(195));
|
||||
assert_eq!(parse_fl(""), None);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
//! `rad-tool` — explore/parse the Eurocontrol RAD workbook.
|
||||
//!
|
||||
//! Usage:
|
||||
//! rad-tool sheets
|
||||
//! rad-tool dump "<sheet>" [rows]
|
||||
//! RAD file path from $RAD_FILE, default `rad/RAD_current.xlsx`.
|
||||
|
||||
use anyhow::Result;
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let path = std::env::var("RAD_FILE").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
|
||||
|
||||
match args.get(1).map(String::as_str) {
|
||||
Some("sheets") => {
|
||||
for s in flightplanner_rad::sheets(&path)? {
|
||||
let (r, c) = flightplanner_rad::dims(&path, &s).unwrap_or((0, 0));
|
||||
println!("{s} ({r} rows x {c} cols)");
|
||||
}
|
||||
}
|
||||
Some("dump") => {
|
||||
let sheet = args.get(2).cloned().unwrap_or_default();
|
||||
let n: usize = args.get(3).and_then(|s| s.parse().ok()).unwrap_or(15);
|
||||
for (i, row) in flightplanner_rad::dump(&path, &sheet, n)?.into_iter().enumerate() {
|
||||
println!("[{i:>3}] {}", row.join(" | "));
|
||||
}
|
||||
}
|
||||
Some("find") => {
|
||||
// find "<sheet>" <substr> [max] — print rows containing <substr>.
|
||||
let sheet = args.get(2).cloned().unwrap_or_default();
|
||||
let needle = args.get(3).cloned().unwrap_or_default().to_uppercase();
|
||||
let max: usize = args.get(4).and_then(|s| s.parse().ok()).unwrap_or(20);
|
||||
let mut hits = 0;
|
||||
for (i, row) in flightplanner_rad::rows(&path, &sheet)?.into_iter().enumerate() {
|
||||
let joined = row.join(" | ");
|
||||
if joined.to_uppercase().contains(&needle) {
|
||||
println!("[{i:>4}] {joined}");
|
||||
hits += 1;
|
||||
if hits >= max {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
println!("-- {hits} row(s) matched '{needle}' in {sheet}");
|
||||
}
|
||||
Some("summary") => {
|
||||
use flightplanner_rad::DctKind;
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Parsed RAD: {}", path);
|
||||
println!(" Areas (Annex 1) : {}", rad.areas.len());
|
||||
println!(" DCT restrictions (3B) : {}", rad.dct.len());
|
||||
let forbidden = rad.dct.iter().filter(|d| d.kind() == DctKind::Forbidden).count();
|
||||
let only = rad.dct.iter().filter(|d| d.kind() == DctKind::ConditionalOnly).count();
|
||||
let comp = rad.dct.iter().filter(|d| d.kind() == DctKind::Compulsory).count();
|
||||
println!(" forbidden={forbidden} conditional-only={only} compulsory={comp}");
|
||||
println!(" sample areas:");
|
||||
for a in rad.areas.iter().take(3) {
|
||||
println!(" {} [{}] = {:?}", a.id, a.region, a.airports);
|
||||
}
|
||||
println!(" sample DCT restrictions:");
|
||||
for d in rad.dct.iter().take(4) {
|
||||
println!(
|
||||
" {} {}->{} FL{:?}-{:?} avail={} [{:?}] {}",
|
||||
d.id, d.from, d.to, d.lower_fl, d.upper_fl, d.available, d.kind(),
|
||||
d.utilization.replace('\n', " ").chars().take(60).collect::<String>()
|
||||
);
|
||||
}
|
||||
}
|
||||
Some("fra") => {
|
||||
// fra [point] — extract allowed FRA DCT edges; if a point is given,
|
||||
// list its neighbours.
|
||||
let edges = flightplanner_rad::parse_fra_edges(&path)?;
|
||||
use std::collections::BTreeSet;
|
||||
let points: BTreeSet<&str> =
|
||||
edges.iter().flat_map(|e| [e.from.as_str(), e.to.as_str()]).collect();
|
||||
println!("FRA edges: {} | distinct points: {}", edges.len(), points.len());
|
||||
match args.get(2) {
|
||||
Some(p) => {
|
||||
let p = p.to_uppercase();
|
||||
let nbrs: BTreeSet<&str> = edges
|
||||
.iter()
|
||||
.filter_map(|e| {
|
||||
if e.from == p {
|
||||
Some(e.to.as_str())
|
||||
} else if e.to == p {
|
||||
Some(e.from.as_str())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
println!("{p} present: {} neighbours ({}): {:?}", points.contains(p.as_str()), nbrs.len(), nbrs);
|
||||
}
|
||||
None => {
|
||||
for e in edges.iter().take(25) {
|
||||
println!(" {} -> {}", e.from, e.to);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("caps") => {
|
||||
// caps [FROM TO] — total level caps, or the cap for a city pair.
|
||||
let rad = flightplanner_rad::parse(&path)?;
|
||||
println!("Level caps (Annex 2A): {}", rad.level_caps.len());
|
||||
match (args.get(2), args.get(3)) {
|
||||
(Some(f), Some(t)) => {
|
||||
let f = f.to_uppercase();
|
||||
let t = t.to_uppercase();
|
||||
match rad.max_cruise_fl(&f, &t) {
|
||||
Some(cap) => println!("{f}->{t}: max cruise FL{cap:03}"),
|
||||
None => println!("{f}->{t}: no cap"),
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
for c in rad.level_caps.iter().take(6) {
|
||||
println!(" {} {:?}->{:?} FL{:?} [{}]", c.id, c.from, c.to, c.cap_fl, c.condition.replace('\n', " ").chars().take(30).collect::<String>());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("frapts") => {
|
||||
// frapts [file] [POINT] — parse the official FRA points list.
|
||||
let file = args.get(2).cloned().unwrap_or_else(|| "rad/fra-points.xlsx".into());
|
||||
let pts = flightplanner_rad::parse_fra_points(&file)?;
|
||||
println!("FRA points: {}", pts.len());
|
||||
let roles = |r: &str| pts.iter().filter(|p| p.enroute == r).count();
|
||||
println!(" roles: E={} X={} EX={} I={}", roles("E"), roles("X"), roles("EX"), roles("I"));
|
||||
match args.get(3) {
|
||||
Some(name) => {
|
||||
let name = name.to_uppercase();
|
||||
for p in pts.iter().filter(|p| p.name.eq_ignore_ascii_case(&name)) {
|
||||
println!(
|
||||
" {} @{:.4},{:.4} area={:?} enroute={} arrdep={} FL{:?}-{:?} flos={} loc={:?}",
|
||||
p.name, p.lat, p.lon, p.areas, p.enroute, p.arrdep, p.level_lo, p.level_hi, p.flos, p.loc_ind
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {
|
||||
for p in pts.iter().take(4) {
|
||||
println!(" {} @{:.3},{:.3} {:?} {} FL{:?}-{:?}", p.name, p.lat, p.lon, p.areas, p.enroute, p.level_lo, p.level_hi);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => println!("usage: rad-tool sheets | dump | find | summary | fra [point] | caps [FROM TO] | frapts [file] [POINT]"),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
[package]
|
||||
name = "flightplanner-server"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
|
||||
[[bin]]
|
||||
name = "flightplanner-server"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
flightplanner-core = { workspace = true }
|
||||
flightplanner-rad = { path = "../rad" }
|
||||
axum = "0.7"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net"] }
|
||||
tower-http = { version = "0.5", features = ["cors"] }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
@@ -0,0 +1,134 @@
|
||||
//! Custom HTTP backend for Flight Planner — the "server" mode of the app.
|
||||
//!
|
||||
//! Exposes the same `core::api::plan` engine over HTTP so several clients can
|
||||
//! share one navdata DB / route database. The GUI can point at this instead of
|
||||
//! computing locally (chosen by the end user in Settings).
|
||||
//!
|
||||
//! Config via env: `FP_DB` (navdata SQLite), `FP_AIRCRAFT_DIR`, `FP_BIND`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::{
|
||||
extract::{Query, State},
|
||||
http::StatusCode,
|
||||
routing::{get, post},
|
||||
Json, Router,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use flightplanner_core::api::{self, CachedRouteDto, PlanRequest, PlanResult};
|
||||
use flightplanner_core::rad::RadData;
|
||||
|
||||
/// Server-side data locations (the client never dictates server file paths).
|
||||
#[derive(Clone)]
|
||||
struct Cfg {
|
||||
db: String,
|
||||
aircraft_dir: String,
|
||||
cifp: String,
|
||||
rad: Option<Arc<RadData>>,
|
||||
}
|
||||
|
||||
async fn health() -> &'static str {
|
||||
"ok"
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct RoutesQuery {
|
||||
dep: String,
|
||||
dest: String,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// Our own database of stored flight plans for a city pair.
|
||||
async fn routes(
|
||||
State(cfg): State<Cfg>,
|
||||
Query(q): Query<RoutesQuery>,
|
||||
) -> Result<Json<Vec<CachedRouteDto>>, (StatusCode, String)> {
|
||||
let db = cfg.db.clone();
|
||||
let res = tokio::task::spawn_blocking(move || {
|
||||
api::recent_routes(&db, &q.dep, &q.dest, q.limit.unwrap_or(10))
|
||||
})
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
res.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
async fn stats(State(cfg): State<Cfg>) -> Result<Json<serde_json::Value>, (StatusCode, String)> {
|
||||
let db = cfg.db.clone();
|
||||
let n = tokio::task::spawn_blocking(move || api::route_db_count(&db))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))?;
|
||||
Ok(Json(serde_json::json!({ "routes": n })))
|
||||
}
|
||||
|
||||
async fn plan(
|
||||
State(cfg): State<Cfg>,
|
||||
Json(mut req): Json<PlanRequest>,
|
||||
) -> Result<Json<PlanResult>, (StatusCode, String)> {
|
||||
// Force the server's own data paths, ignoring whatever the client sent.
|
||||
req.db_path = cfg.db.clone();
|
||||
req.aircraft_dir = cfg.aircraft_dir.clone();
|
||||
req.cifp_dir = Some(cfg.cifp.clone());
|
||||
let rad = cfg.rad.clone();
|
||||
// `api::plan` is blocking (SQLite + compute) — keep it off the async pool.
|
||||
let res = tokio::task::spawn_blocking(move || api::plan(&req, rad.as_deref()))
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
res.map(Json)
|
||||
.map_err(|e| (StatusCode::BAD_REQUEST, e.to_string()))
|
||||
}
|
||||
|
||||
/// Load the RAD workbook once (env `FP_RAD`, default `rad/RAD_current.xlsx`).
|
||||
fn load_rad() -> Option<RadData> {
|
||||
let path = std::env::var("FP_RAD").unwrap_or_else(|_| "rad/RAD_current.xlsx".into());
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
eprintln!("RAD: file not found ({path}) — RAD checks disabled");
|
||||
return None;
|
||||
}
|
||||
match flightplanner_rad::parse(&path) {
|
||||
Ok(rad) => {
|
||||
let (f, o, c) = rad.dct_counts();
|
||||
println!(
|
||||
"RAD loaded: {} areas, {} DCT ({f} forbidden / {o} conditional / {c} compulsory)",
|
||||
rad.areas.len(),
|
||||
rad.dct.len()
|
||||
);
|
||||
Some(rad)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("RAD: parse failed ({e}) — RAD checks disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let rad = load_rad();
|
||||
let cfg = Cfg {
|
||||
db: std::env::var("FP_DB").unwrap_or_else(|_| "real.db".into()),
|
||||
aircraft_dir: std::env::var("FP_AIRCRAFT_DIR").unwrap_or_else(|_| "data/aircraft".into()),
|
||||
cifp: std::env::var("FP_CIFP").unwrap_or_else(|_| "navdata/CIFP".into()),
|
||||
rad: rad.map(Arc::new),
|
||||
};
|
||||
let bind = std::env::var("FP_BIND").unwrap_or_else(|_| "0.0.0.0:8787".into());
|
||||
|
||||
let app = Router::new()
|
||||
.route("/health", get(health))
|
||||
.route("/plan", post(plan))
|
||||
.route("/routes", get(routes))
|
||||
.route("/stats", get(stats))
|
||||
.layer(CorsLayer::permissive())
|
||||
.with_state(cfg.clone());
|
||||
|
||||
let addr: SocketAddr = bind.parse().expect("invalid FP_BIND");
|
||||
println!("Flight Planner server → http://{addr}");
|
||||
println!(" navdata DB : {}", cfg.db);
|
||||
println!(" aircraft dir : {}", cfg.aircraft_dir);
|
||||
let listener = tokio::net::TcpListener::bind(addr).await.expect("bind failed");
|
||||
axum::serve(listener, app).await.expect("server crashed");
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"icao": "A320",
|
||||
"name": "Airbus A320-200 (CFM56-5B)",
|
||||
"default_cruise_fl": 360,
|
||||
"phases": {
|
||||
"climb": { "ias_kt": 290, "mach": 0.78, "tas_kt": 380, "fuel_flow_kgph": 2600, "roc_fpm": 2000 },
|
||||
"cruise": { "mach": 0.78, "tas_kt": 447, "fuel_flow_kgph": 2400 },
|
||||
"descent": { "ias_kt": 290, "mach": 0.78, "tas_kt": 320, "fuel_flow_kgph": 1200, "rod_fpm": 1800 }
|
||||
},
|
||||
"reserves": {
|
||||
"final_reserve_min": 30,
|
||||
"contingency_pct": 5.0,
|
||||
"taxi_kg": 200
|
||||
},
|
||||
"openap": {
|
||||
"wing_area_m2": 124,
|
||||
"cd0": 0.018,
|
||||
"induced_k": 0.039,
|
||||
"n_engines": 2,
|
||||
"engine": "CFM56-5B4",
|
||||
"max_thrust_n": 117900,
|
||||
"tsfc_cruise_g_per_ns": 0.0170,
|
||||
"ff_idle_kgs": 0.107,
|
||||
"ref_cruise_alt_ft": 35000,
|
||||
"oew_kg": 42600,
|
||||
"mtow_kg": 78000,
|
||||
"mlw_kg": 66000,
|
||||
"max_fuel_kg": 24210,
|
||||
"cruise_mach": 0.78
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"icao": "B738",
|
||||
"name": "Boeing 737-800 (CFM56-7B)",
|
||||
"default_cruise_fl": 370,
|
||||
"phases": {
|
||||
"climb": { "ias_kt": 280, "mach": 0.78, "tas_kt": 370, "fuel_flow_kgph": 2800, "roc_fpm": 2200 },
|
||||
"cruise": { "mach": 0.785, "tas_kt": 450, "fuel_flow_kgph": 2500 },
|
||||
"descent": { "ias_kt": 280, "mach": 0.78, "tas_kt": 320, "fuel_flow_kgph": 1100, "rod_fpm": 1800 }
|
||||
},
|
||||
"reserves": {
|
||||
"final_reserve_min": 30,
|
||||
"contingency_pct": 5.0,
|
||||
"taxi_kg": 250
|
||||
},
|
||||
"openap": {
|
||||
"wing_area_m2": 124.6,
|
||||
"cd0": 0.019,
|
||||
"induced_k": 0.042,
|
||||
"n_engines": 2,
|
||||
"engine": "CFM56-7B26",
|
||||
"max_thrust_n": 116990,
|
||||
"tsfc_cruise_g_per_ns": 0.0172,
|
||||
"ff_idle_kgs": 0.113,
|
||||
"ref_cruise_alt_ft": 35000,
|
||||
"oew_kg": 41400,
|
||||
"mtow_kg": 79000,
|
||||
"mlw_kg": 66300,
|
||||
"max_fuel_kg": 26000,
|
||||
"cruise_mach": 0.789
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"recommendations": ["tauri-apps.tauri-vscode", "rust-lang.rust-analyzer"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
# Tauri + React + Typescript
|
||||
|
||||
This template should help get you started developing with Tauri, React and Typescript in Vite.
|
||||
|
||||
## Recommended IDE Setup
|
||||
|
||||
- [VS Code](https://code.visualstudio.com/) + [Tauri](https://marketplace.visualstudio.com/items?itemName=tauri-apps.tauri-vscode) + [rust-analyzer](https://marketplace.visualstudio.com/items?itemName=rust-lang.rust-analyzer)
|
||||
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tauri + React + Typescript</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "gui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fluentui/react-components": "^9.74.6",
|
||||
"@fluentui/react-icons": "^2.0.337",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"7.css": "^0.21.1",
|
||||
"maplibre-gl": "^6.4.1",
|
||||
"react": "^19.1.0",
|
||||
"react-dom": "^19.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2",
|
||||
"@types/react": "^19.1.8",
|
||||
"@types/react-dom": "^19.1.6",
|
||||
"@vitejs/plugin-react": "^4.6.0",
|
||||
"typescript": "~5.8.3",
|
||||
"vite": "^7.0.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<svg width="206" height="231" viewBox="0 0 206 231" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M143.143 84C143.143 96.1503 133.293 106 121.143 106C108.992 106 99.1426 96.1503 99.1426 84C99.1426 71.8497 108.992 62 121.143 62C133.293 62 143.143 71.8497 143.143 84Z" fill="#FFC131"/>
|
||||
<ellipse cx="84.1426" cy="147" rx="22" ry="22" transform="rotate(180 84.1426 147)" fill="#24C8DB"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M166.738 154.548C157.86 160.286 148.023 164.269 137.757 166.341C139.858 160.282 141 153.774 141 147C141 144.543 140.85 142.121 140.558 139.743C144.975 138.204 149.215 136.139 153.183 133.575C162.73 127.404 170.292 118.608 174.961 108.244C179.63 97.8797 181.207 86.3876 179.502 75.1487C177.798 63.9098 172.884 53.4021 165.352 44.8883C157.82 36.3744 147.99 30.2165 137.042 27.1546C126.095 24.0926 114.496 24.2568 103.64 27.6274C92.7839 30.998 83.1319 37.4317 75.8437 46.1553C74.9102 47.2727 74.0206 48.4216 73.176 49.5993C61.9292 50.8488 51.0363 54.0318 40.9629 58.9556C44.2417 48.4586 49.5653 38.6591 56.679 30.1442C67.0505 17.7298 80.7861 8.57426 96.2354 3.77762C111.685 -1.01901 128.19 -1.25267 143.769 3.10474C159.348 7.46215 173.337 16.2252 184.056 28.3411C194.775 40.457 201.767 55.4101 204.193 71.404C206.619 87.3978 204.374 103.752 197.73 118.501C191.086 133.25 180.324 145.767 166.738 154.548ZM41.9631 74.275L62.5557 76.8042C63.0459 72.813 63.9401 68.9018 65.2138 65.1274C57.0465 67.0016 49.2088 70.087 41.9631 74.275Z" fill="#FFC131"/>
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M38.4045 76.4519C47.3493 70.6709 57.2677 66.6712 67.6171 64.6132C65.2774 70.9669 64 77.8343 64 85.0001C64 87.1434 64.1143 89.26 64.3371 91.3442C60.0093 92.8732 55.8533 94.9092 51.9599 97.4256C42.4128 103.596 34.8505 112.392 30.1816 122.756C25.5126 133.12 23.9357 144.612 25.6403 155.851C27.3449 167.09 32.2584 177.598 39.7906 186.112C47.3227 194.626 57.153 200.784 68.1003 203.846C79.0476 206.907 90.6462 206.743 101.502 203.373C112.359 200.002 122.011 193.568 129.299 184.845C130.237 183.722 131.131 182.567 131.979 181.383C143.235 180.114 154.132 176.91 164.205 171.962C160.929 182.49 155.596 192.319 148.464 200.856C138.092 213.27 124.357 222.426 108.907 227.222C93.458 232.019 76.9524 232.253 61.3736 227.895C45.7948 223.538 31.8055 214.775 21.0867 202.659C10.3679 190.543 3.37557 175.59 0.949823 159.596C-1.47592 143.602 0.768139 127.248 7.41237 112.499C14.0566 97.7497 24.8183 85.2327 38.4045 76.4519ZM163.062 156.711L163.062 156.711C162.954 156.773 162.846 156.835 162.738 156.897C162.846 156.835 162.954 156.773 163.062 156.711Z" fill="#24C8DB"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.5 KiB |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,7 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
|
||||
# Generated by Tauri
|
||||
# will have schema files for capabilities auto-completion
|
||||
/gen/schemas
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "flightplanner-gui"
|
||||
version = "0.1.0"
|
||||
description = "Flight Planner desktop GUI (Tauri)"
|
||||
authors = ["you"]
|
||||
edition = "2021"
|
||||
|
||||
[lib]
|
||||
# The `_lib` suffix keeps the lib name distinct from the bin on Windows.
|
||||
# See https://github.com/rust-lang/cargo/issues/8519
|
||||
name = "gui_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
flightplanner-core = { workspace = true }
|
||||
flightplanner-rad = { path = "../../crates/rad" }
|
||||
@@ -0,0 +1,3 @@
|
||||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Capability for the main window",
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-unmaximize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging"
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 974 B |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 903 B |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 2.0 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -0,0 +1,388 @@
|
||||
//! Tauri backend for the Flight Planner GUI.
|
||||
//!
|
||||
//! The `plan` command just delegates to the shared engine in `core::api`, so the
|
||||
//! local (in-process) path runs the exact same code as the custom HTTP server.
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::Arc;
|
||||
|
||||
use flightplanner_core::api::{self, CachedRouteDto, PlanRequest, PlanResult};
|
||||
use flightplanner_core::error::CoreError;
|
||||
use flightplanner_core::rad::RadData;
|
||||
use flightplanner_core::routing::discover::{DiscoverResult, IfpsErr, IfpsValidator, IfpsVerdict};
|
||||
use tauri::{Emitter, State};
|
||||
|
||||
/// The RAD loaded once at startup (None if the file isn't present).
|
||||
struct RadState(Option<Arc<RadData>>);
|
||||
|
||||
/// Run route + optional fuel plan locally. Errors are stringified for the UI.
|
||||
#[tauri::command]
|
||||
fn plan(req: PlanRequest, rad: State<RadState>) -> Result<PlanResult, String> {
|
||||
api::plan(&req, rad.0.as_deref()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Authoritative IFPS validator backed by the IFPUV scraper (`validate.mjs`).
|
||||
/// Emits a `discover-progress` event before each ~25 s round-trip so the UI can
|
||||
/// show what's being checked.
|
||||
struct NodeIfps {
|
||||
dir: String,
|
||||
app: Option<tauri::AppHandle>,
|
||||
step: AtomicUsize,
|
||||
}
|
||||
|
||||
impl IfpsValidator for NodeIfps {
|
||||
fn validate(&self, adep: &str, ades: &str, route: &str, fl: i32) -> Result<IfpsVerdict, CoreError> {
|
||||
let step = self.step.fetch_add(1, Ordering::Relaxed) + 1;
|
||||
if let Some(app) = &self.app {
|
||||
let _ = app.emit(
|
||||
"discover-progress",
|
||||
serde_json::json!({
|
||||
"step": step,
|
||||
"adep": adep, "ades": ades, "route": route, "fl": fl,
|
||||
}),
|
||||
);
|
||||
}
|
||||
let payload = serde_json::json!({
|
||||
"adep": adep, "ades": ades, "route": route, "level": format!("F{:03}", fl),
|
||||
});
|
||||
let out = std::process::Command::new("node")
|
||||
.arg("validate.mjs")
|
||||
.arg(payload.to_string())
|
||||
.current_dir(&self.dir)
|
||||
.output()?; // io::Error → CoreError::Io
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.ok_or_else(|| {
|
||||
CoreError::Other(format!(
|
||||
"IFPUV validator produced no result. stderr: {}",
|
||||
String::from_utf8_lossy(&out.stderr).trim()
|
||||
))
|
||||
})?;
|
||||
#[derive(serde::Deserialize)]
|
||||
struct V {
|
||||
accepted: Option<bool>,
|
||||
#[serde(default)]
|
||||
errors: Vec<IfpsErr>,
|
||||
}
|
||||
let v: V = serde_json::from_str(line)?; // serde error → CoreError::Json
|
||||
Ok(IfpsVerdict { accepted: v.accepted.unwrap_or(false), errors: v.errors })
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate an IFPS-valid route via the oracle loop (live IFPUV, ~1–3 min the
|
||||
/// first time), store it, and return the discovery result. Runs off-thread.
|
||||
#[tauri::command]
|
||||
async fn generate_validated(
|
||||
app: tauri::AppHandle,
|
||||
req: PlanRequest,
|
||||
rad: State<'_, RadState>,
|
||||
) -> Result<DiscoverResult, String> {
|
||||
let rad = rad.0.clone();
|
||||
let dir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\tools\ifpuv".to_string());
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let validator = NodeIfps { dir, app: Some(app), step: AtomicUsize::new(0) };
|
||||
api::discover_route(&req, rad.as_deref(), &validator).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Stored routes for a city pair from our own route database (local file).
|
||||
#[tauri::command]
|
||||
fn recent_routes(db_path: String, dep: String, dest: String) -> Result<Vec<CachedRouteDto>, String> {
|
||||
api::recent_routes(&db_path, &dep, &dest, 10).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Total number of stored routes.
|
||||
#[tauri::command]
|
||||
fn route_db_count(db_path: String) -> Result<i64, String> {
|
||||
api::route_db_count(&db_path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Run a Playwright IFPUV script (`script`) with the JSON `payload` and return
|
||||
/// its single JSON result line. Round-trips the public Eurocontrol validator.
|
||||
fn run_ifpuv(script: &str, payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
let dir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\tools\ifpuv".to_string());
|
||||
let out = std::process::Command::new("node")
|
||||
.arg(script)
|
||||
.arg(payload.to_string())
|
||||
.current_dir(&dir)
|
||||
.output()
|
||||
.map_err(|e| format!("cannot launch Node.js validator: {e}"))?;
|
||||
let stdout = String::from_utf8_lossy(&out.stdout);
|
||||
let line = stdout
|
||||
.lines()
|
||||
.rev()
|
||||
.find(|l| l.trim_start().starts_with('{'))
|
||||
.ok_or_else(|| {
|
||||
format!("validator produced no result. stderr: {}", String::from_utf8_lossy(&out.stderr).trim())
|
||||
})?;
|
||||
serde_json::from_str::<serde_json::Value>(line).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Authoritative IFPS validation: builds an ICAO FPL from `payload` and submits
|
||||
/// it to the public Eurocontrol IFPUV. Returns `{ accepted, errors:[{code,msg}], raw, fpl }`.
|
||||
/// Network round-trip, ~20–30 s (guest GWT app load).
|
||||
#[tauri::command]
|
||||
async fn ifps_validate(payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || run_ifpuv("validate.mjs", payload))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Oracle-driven IFPS auto-correction: validates and mechanically fixes the
|
||||
/// tractable errors (RAD level caps, unknown designators) against the IFPUV,
|
||||
/// looping until accepted. Returns `{ accepted, level, route, iterations, log, errors }`.
|
||||
#[tauri::command]
|
||||
async fn ifps_autofix(payload: serde_json::Value) -> Result<serde_json::Value, String> {
|
||||
tauri::async_runtime::spawn_blocking(move || run_ifpuv("autofix.mjs", payload))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
/// Load the RAD workbook once (env `FP_RAD`, else the project default).
|
||||
fn load_rad() -> Option<RadData> {
|
||||
let path = std::env::var("FP_RAD")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\rad\RAD_current.xlsx".to_string());
|
||||
if !std::path::Path::new(&path).exists() {
|
||||
eprintln!("RAD: file not found ({path}) — RAD checks disabled");
|
||||
return None;
|
||||
}
|
||||
match flightplanner_rad::parse(&path) {
|
||||
Ok(mut rad) => {
|
||||
let (f, o, c) = rad.dct_counts();
|
||||
// Official EUROCONTROL FRA points (separate file) for the FRA-points router.
|
||||
let fra_path = std::env::var("FP_FRA_POINTS")
|
||||
.unwrap_or_else(|_| r"C:\Users\Alexandre\flightplanner\rad\fra-points.xlsx".to_string());
|
||||
rad.fra_points = flightplanner_rad::parse_fra_points(&fra_path).unwrap_or_default();
|
||||
eprintln!(
|
||||
"RAD loaded: {} areas, {} DCT ({f} forbidden / {o} conditional / {c} compulsory), {} level caps, {} FRA points",
|
||||
rad.areas.len(),
|
||||
rad.dct.len(),
|
||||
rad.level_caps.len(),
|
||||
rad.fra_points.len(),
|
||||
);
|
||||
Some(rad)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("RAD: parse failed ({e}) — RAD checks disabled");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.manage(RadState(load_rad().map(Arc::new)))
|
||||
.invoke_handler(tauri::generate_handler![plan, recent_routes, route_db_count, ifps_validate, ifps_autofix, generate_validated])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End-to-end smoke test of the shared `plan` engine against the real DB.
|
||||
/// Skips (passes) when the DB isn't present so the test stays portable.
|
||||
#[test]
|
||||
fn plan_command_end_to_end() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
// Load the RAD so the FRA graph router is exercised (as in the real app).
|
||||
let rad = load_rad();
|
||||
let res = api::plan(
|
||||
&PlanRequest {
|
||||
from: "LFPG".into(),
|
||||
to: "EGLL".into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some("C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP".into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
},
|
||||
rad.as_ref(),
|
||||
)
|
||||
.expect("plan should succeed");
|
||||
|
||||
eprintln!("ROUTE: {}", res.route_string);
|
||||
assert!(!res.legs.is_empty(), "route should have legs");
|
||||
assert!(res.route_string.starts_with("LFPG"), "{}", res.route_string);
|
||||
assert!(res.ofp.contains("OPERATIONAL FLIGHT PLAN"), "OFP present");
|
||||
let fuel = res.fuel.expect("A320 ⇒ fuel plan");
|
||||
assert!(fuel.block_fuel_kg > fuel.trip_fuel_kg, "block > trip");
|
||||
let mass = fuel.mass.expect("OpenAP ⇒ mass breakdown");
|
||||
assert!(mass.takeoff_kg > mass.landing_kg, "TOW > LDW");
|
||||
let last = res.legs.last().unwrap();
|
||||
assert!((last.cum_dist_nm - res.total_nm).abs() < 1.0, "cum dist ends at total");
|
||||
}
|
||||
|
||||
/// Plan several European pairs at once (RAD loaded once) and print each
|
||||
/// route + the enroute item-15 (fixes joined by DCT) ready for the IFPUV
|
||||
/// oracle. Run: `cargo test -p flightplanner-gui plan_pairs -- --nocapture`.
|
||||
#[test]
|
||||
fn plan_pairs() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let pairs_env = std::env::var("FP_TEST_PAIRS")
|
||||
.unwrap_or_else(|_| "LFPG-EGLL,EGLL-LFPG,LFPG-EDDF,EHAM-LSZH,LFPG-LEMD,EDDM-LEBL".into());
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
for (from, to) in &pairs {
|
||||
let (from, to) = (from.as_str(), to.as_str());
|
||||
let res = api::plan(
|
||||
&PlanRequest {
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
},
|
||||
rad.as_ref(),
|
||||
);
|
||||
match res {
|
||||
Ok(r) => {
|
||||
// item 15 = enroute fix chain (each leg's `to` except the destination).
|
||||
let n = r.legs.len();
|
||||
let item15: Vec<String> =
|
||||
r.legs.iter().take(n.saturating_sub(1)).map(|l| l.to.clone()).collect();
|
||||
eprintln!("PAIR {from}->{to}\n ROUTE : {}\n ITEM15: {}", r.route_string, item15.join(" DCT "));
|
||||
}
|
||||
Err(e) => eprintln!("PAIR {from}->{to} ERROR: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Batch coverage: run discovery over several European pairs and report the
|
||||
/// no-error rate + which error codes block the failures. Ignored (very slow,
|
||||
/// many live IFPUV round-trips). Pairs via env `FP_BATCH_PAIRS` (comma-sep
|
||||
/// `LFPG-EGLL,...`). Run: `cargo test -p flightplanner-gui batch_coverage -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "live IFPUV batch, many minutes"]
|
||||
fn batch_coverage() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let ifdir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| "C:\\Users\\Alexandre\\flightplanner\\tools\\ifpuv".to_string());
|
||||
let validator = NodeIfps { dir: ifdir, app: None, step: std::sync::atomic::AtomicUsize::new(0) };
|
||||
let pairs_env = std::env::var("FP_BATCH_PAIRS").unwrap_or_else(|_| {
|
||||
"LFPG-EGLL,LFPG-LFMN,LFPG-EDDF,EHAM-LSZH,LEMD-LEBL,EDDM-LEBL".into()
|
||||
});
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
|
||||
let mut ok = 0usize;
|
||||
let mut hist: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
|
||||
for (from, to) in &pairs {
|
||||
let req = PlanRequest {
|
||||
from: from.clone(),
|
||||
to: to.clone(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
};
|
||||
match api::discover_route(&req, rad.as_ref(), &validator) {
|
||||
Ok(r) => {
|
||||
let codes: Vec<&str> = r.errors.iter().map(|e| e.code.as_str()).collect();
|
||||
eprintln!(
|
||||
"BATCH {from}->{to}: {} F{:03} iters={} errs={} [{}]",
|
||||
if r.accepted { "OK " } else { "FAIL" },
|
||||
r.fl,
|
||||
r.iterations,
|
||||
r.errors.len(),
|
||||
codes.join(",")
|
||||
);
|
||||
if r.accepted {
|
||||
ok += 1;
|
||||
}
|
||||
for e in &r.errors {
|
||||
*hist.entry(e.code.clone()).or_default() += 1;
|
||||
}
|
||||
}
|
||||
Err(e) => eprintln!("BATCH {from}->{to}: ERROR {e}"),
|
||||
}
|
||||
}
|
||||
let mut codes: Vec<(String, usize)> = hist.into_iter().collect();
|
||||
codes.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
eprintln!("BATCH COVERAGE: {ok}/{} no-error ({:.0}%)", pairs.len(), 100.0 * ok as f64 / pairs.len().max(1) as f64);
|
||||
eprintln!("BATCH blocking codes: {codes:?}");
|
||||
}
|
||||
|
||||
/// Live end-to-end: run the oracle discovery loop against the real IFPUV.
|
||||
/// Ignored by default (network + several ~25 s round-trips). Run with:
|
||||
/// `cargo test -p flightplanner-gui discover_e2e -- --ignored --nocapture`.
|
||||
#[test]
|
||||
#[ignore = "live IFPUV round-trips (minutes) + network"]
|
||||
fn discover_e2e() {
|
||||
let db = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
let dir = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
let cifp = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
if !std::path::Path::new(db).exists() {
|
||||
return;
|
||||
}
|
||||
let rad = load_rad();
|
||||
let ifdir = std::env::var("FP_IFPUV_DIR")
|
||||
.unwrap_or_else(|_| "C:\\Users\\Alexandre\\flightplanner\\tools\\ifpuv".to_string());
|
||||
let validator = NodeIfps { dir: ifdir, app: None, step: std::sync::atomic::AtomicUsize::new(0) };
|
||||
let pairs_env = std::env::var("FP_TEST_PAIRS").unwrap_or_else(|_| "LFPG-EGLL,LFPG-LFMN".into());
|
||||
let pairs: Vec<(String, String)> = pairs_env
|
||||
.split(',')
|
||||
.filter_map(|p| p.split_once('-').map(|(a, b)| (a.trim().to_uppercase(), b.trim().to_uppercase())))
|
||||
.collect();
|
||||
for (from, to) in &pairs {
|
||||
let (from, to) = (from.as_str(), to.as_str());
|
||||
let req = PlanRequest {
|
||||
from: from.into(),
|
||||
to: to.into(),
|
||||
db_path: db.into(),
|
||||
aircraft: Some("A320".into()),
|
||||
aircraft_dir: dir.into(),
|
||||
cifp_dir: Some(cifp.into()),
|
||||
cruise_fl: Some(360),
|
||||
alternate: None,
|
||||
payload_kg: Some(16_000.0),
|
||||
};
|
||||
match api::discover_route(&req, rad.as_ref(), &validator) {
|
||||
Ok(r) => eprintln!(
|
||||
"== {from}->{to} accepted={} fl={} nm={:.0}\n route : {}\n item15: {}\n log:\n {}",
|
||||
r.accepted, r.fl, r.total_nm, r.route_string, r.item15, r.log.join("\n ")
|
||||
),
|
||||
Err(e) => eprintln!("== {from}->{to} ERROR: {e}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Prevents additional console window on Windows in release, DO NOT REMOVE!!
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
gui_lib::run()
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Flight Planner",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.flightplanner.app",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"title": "Flight Planner",
|
||||
"width": 1180,
|
||||
"height": 820,
|
||||
"minWidth": 900,
|
||||
"minHeight": 640,
|
||||
"decorations": false
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": null
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/* ── Modern dark dispatch dashboard — one deliberate design system ─────────── */
|
||||
:root {
|
||||
--bg: #0b0e14;
|
||||
--panel: #11151d;
|
||||
--panel-2: #161b25;
|
||||
--elev: #1b2230;
|
||||
--border: #232b39;
|
||||
--border-soft: #1a212c;
|
||||
--text: #e6edf3;
|
||||
--muted: #8a97a8;
|
||||
--faint: #5b6675;
|
||||
--accent: #4c8dff;
|
||||
--accent-2: #2f6bd6;
|
||||
--accent-weak: #14233d;
|
||||
--good: #40c463;
|
||||
--warn: #ff5c5c;
|
||||
--amber: #f0a92b;
|
||||
--radius: 8px;
|
||||
--radius-sm: 6px;
|
||||
--mono: "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
|
||||
font-family: "Segoe UI Variable", "Segoe UI", Inter, system-ui, sans-serif;
|
||||
font-size: 13px;
|
||||
color: var(--text);
|
||||
color-scheme: dark;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body, #root { margin: 0; height: 100%; }
|
||||
body { background: var(--bg); overflow: hidden; }
|
||||
::-webkit-scrollbar { width: 10px; height: 10px; }
|
||||
::-webkit-scrollbar-thumb { background: #263041; border-radius: 6px; border: 2px solid var(--bg); }
|
||||
::-webkit-scrollbar-thumb:hover { background: #33405600; background: #334056; }
|
||||
|
||||
.app { height: 100vh; width: 100vw; display: flex; flex-direction: column; }
|
||||
|
||||
/* Title bar (borderless window) */
|
||||
.titlebar {
|
||||
height: 40px; flex: 0 0 40px; display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 0 8px 0 14px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
user-select: none;
|
||||
}
|
||||
.tb-left { display: flex; align-items: center; gap: 10px; font-weight: 600; }
|
||||
.logo { color: var(--accent); font-size: 15px; }
|
||||
.tb-flight { color: var(--muted); font-weight: 500; font-family: var(--mono); font-size: 12px; }
|
||||
.win-controls { display: flex; gap: 2px; }
|
||||
.win-controls button {
|
||||
width: 34px; height: 28px; border: none; background: transparent; color: var(--muted);
|
||||
border-radius: var(--radius-sm); cursor: pointer; font-size: 13px; display: grid; place-items: center;
|
||||
}
|
||||
.win-controls button:hover { background: var(--elev); color: var(--text); }
|
||||
.win-controls button.close:hover { background: #b3202a; color: #fff; }
|
||||
|
||||
/* Body */
|
||||
.body { flex: 1; display: grid; grid-template-columns: 320px 1fr; min-height: 0; }
|
||||
.sidebar { background: var(--panel); border-right: 1px solid var(--border); overflow-y: auto; display: flex; flex-direction: column; }
|
||||
.main { display: flex; flex-direction: column; min-height: 0; }
|
||||
|
||||
/* Sidebar header (primary inputs + action) */
|
||||
.side-head { padding: 14px; border-bottom: 1px solid var(--border-soft); display: flex; flex-direction: column; gap: 10px; }
|
||||
.flightid { display: flex; align-items: center; gap: 8px; }
|
||||
.flightid input { text-align: center; font-family: var(--mono); font-size: 15px; letter-spacing: 1px; font-weight: 600; }
|
||||
.flightid .arrow { color: var(--faint); }
|
||||
.row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
||||
.row3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; }
|
||||
|
||||
/* Sections */
|
||||
.sections { padding: 6px 14px 16px; display: flex; flex-direction: column; }
|
||||
.section { padding: 12px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.section:last-child { border-bottom: none; }
|
||||
.section-title {
|
||||
font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted);
|
||||
margin: 0 0 10px; display: flex; align-items: center; gap: 7px;
|
||||
}
|
||||
.section-title::before { content: ""; width: 3px; height: 12px; background: var(--accent); border-radius: 2px; }
|
||||
|
||||
/* Fields */
|
||||
.field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.field + .field, .row2 + .field, .field + .row2, .row2 + .row2, .row3 + .field, .field + .row3 { margin-top: 8px; }
|
||||
.field label { font-size: 11px; color: var(--muted); }
|
||||
input, select {
|
||||
width: 100%; height: 30px; padding: 0 9px; background: var(--panel-2); color: var(--text);
|
||||
border: 1px solid var(--border); border-radius: var(--radius-sm); outline: none; font-family: inherit; font-size: 13px;
|
||||
transition: border-color .12s, box-shadow .12s;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-weak); }
|
||||
input::placeholder { color: var(--faint); }
|
||||
select { appearance: none; background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'><path d='M1 1l4 4 4-4' stroke='%238a97a8' stroke-width='1.5' fill='none' stroke-linecap='round'/></svg>"); background-repeat: no-repeat; background-position: right 9px center; padding-right: 26px; }
|
||||
|
||||
/* Buttons */
|
||||
.btn { height: 32px; padding: 0 14px; border-radius: var(--radius-sm); border: 1px solid var(--border); background: var(--elev); color: var(--text); cursor: pointer; font: inherit; font-weight: 500; display: inline-flex; align-items: center; gap: 7px; }
|
||||
.btn:hover { border-color: #33405a; background: #1f2735; }
|
||||
.btn:disabled { opacity: .45; cursor: default; }
|
||||
.btn-primary { border: none; background: linear-gradient(180deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600; width: 100%; height: 36px; justify-content: center; }
|
||||
.btn-primary:hover { filter: brightness(1.07); }
|
||||
.btn-primary:disabled { filter: none; opacity: .6; }
|
||||
.err { color: var(--warn); font-size: 12px; }
|
||||
|
||||
/* KPI strip */
|
||||
.kpis { display: flex; gap: 10px; padding: 12px 14px; border-bottom: 1px solid var(--border); background: var(--panel); overflow-x: auto; }
|
||||
.kpi { min-width: 96px; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 8px 12px; }
|
||||
.kpi .k { font-size: 10px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); }
|
||||
.kpi .v { font-size: 18px; font-weight: 700; margin-top: 2px; font-family: var(--mono); }
|
||||
.kpi .s { font-size: 11px; color: var(--faint); }
|
||||
.kpi.warn .v { color: var(--warn); }
|
||||
|
||||
/* Map */
|
||||
.map { flex: 1.5; min-height: 220px; position: relative; }
|
||||
.maproot { width: 100%; height: 100%; }
|
||||
.map .hint { position: absolute; top: 12px; left: 12px; z-index: 1; background: rgba(11,14,20,.75); border: 1px solid var(--border); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--muted); font-size: 12px; pointer-events: none; }
|
||||
.maplibregl-ctrl-group { background: var(--panel) !important; border: 1px solid var(--border) !important; box-shadow: none !important; }
|
||||
.maplibregl-ctrl-group button + button { border-top: 1px solid var(--border) !important; }
|
||||
.maplibregl-ctrl-group button span { filter: invert(1) brightness(1.4); }
|
||||
|
||||
/* Dock */
|
||||
.dock { flex: 1; min-height: 0; display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.tabs { display: flex; gap: 2px; padding: 6px 10px 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { padding: 8px 14px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12.5px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.on { color: var(--text); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.dock-body { flex: 1; overflow: auto; padding: 14px; }
|
||||
.empty { color: var(--muted); text-align: center; padding: 40px 20px; }
|
||||
|
||||
/* Route string */
|
||||
.route-str { font-family: var(--mono); font-size: 12.5px; line-height: 1.7; color: #cfe0ff; background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); padding: 10px 12px; word-break: break-word; margin-bottom: 12px; }
|
||||
.route-meta { color: var(--muted); font-size: 12px; margin-top: 6px; }
|
||||
|
||||
/* Tables */
|
||||
.tbl { width: 100%; border-collapse: collapse; font-size: 12.5px; }
|
||||
.tbl thead th { position: sticky; top: 0; background: var(--panel-2); text-align: left; padding: 8px 12px; color: var(--muted); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .5px; border-bottom: 1px solid var(--border); }
|
||||
.tbl td { padding: 7px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.tbl tbody tr:hover { background: var(--panel-2); }
|
||||
.tbl .mono { font-family: var(--mono); }
|
||||
.tbl .num { text-align: right; font-family: var(--mono); }
|
||||
.tbl .via { color: var(--accent); font-weight: 600; }
|
||||
.card { background: var(--panel-2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.cardtitle { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--muted); padding: 10px 12px; border-bottom: 1px solid var(--border); }
|
||||
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; align-items: start; }
|
||||
.kvrow { display: flex; justify-content: space-between; padding: 6px 12px; border-bottom: 1px solid var(--border-soft); }
|
||||
.kvrow:last-child { border-bottom: none; }
|
||||
.kvrow.total { border-top: 1px solid var(--border); font-weight: 700; }
|
||||
.kvrow .val { font-family: var(--mono); }
|
||||
.ofp-pre { font-family: var(--mono); font-size: 11.5px; white-space: pre; overflow-x: auto; margin: 0; color: #cdd9e5; }
|
||||
.badge-warn { color: var(--warn); font-size: 11px; margin-left: 6px; }
|
||||
|
||||
/* Status bar */
|
||||
.status { height: 26px; flex: 0 0 26px; display: flex; align-items: center; gap: 18px; padding: 0 14px; background: var(--panel); border-top: 1px solid var(--border); color: var(--muted); font-size: 11.5px; }
|
||||
.status .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--good); display: inline-block; margin-right: 6px; }
|
||||
.status .sp { flex: 1; }
|
||||
|
||||
/* ── Ribbon (PFPX-style disposition, modern skin) ─────────────────────────── */
|
||||
.ribbon-tabs { display: flex; gap: 2px; padding: 4px 10px 0; background: var(--panel); }
|
||||
.ribbon-tab { padding: 6px 16px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12.5px; border-radius: 6px 6px 0 0; }
|
||||
.ribbon-tab:hover { color: var(--text); background: var(--panel-2); }
|
||||
.ribbon-tab.on { color: var(--text); background: var(--elev); font-weight: 600; }
|
||||
.ribbon { display: flex; align-items: stretch; gap: 0; padding: 8px 10px; background: var(--elev); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); overflow-x: auto; }
|
||||
.rgroup { display: flex; flex-direction: column; align-items: center; padding: 0 10px; gap: 4px; }
|
||||
.rgroup-btns { display: flex; flex-wrap: wrap; gap: 3px; justify-content: center; max-width: 190px; }
|
||||
.rgroup-cap { font-size: 10px; color: var(--faint); }
|
||||
.rbtn { display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 8px; background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px; color: var(--text); font: inherit; font-size: 11.5px; cursor: pointer; white-space: nowrap; }
|
||||
.rbtn:hover { border-color: #33405a; background: #1f2735; }
|
||||
.rbtn:disabled { opacity: .4; cursor: default; }
|
||||
.rbtn.accent { border-color: var(--accent-2); background: var(--accent-weak); color: #dce9ff; }
|
||||
.rbtn .g { color: var(--accent); font-size: 12px; }
|
||||
.rbtn.on { border-color: var(--accent); color: #dce9ff; background: var(--accent-weak); }
|
||||
.rsep { width: 1px; background: var(--border); margin: 2px 6px; }
|
||||
|
||||
/* Menubar (tiny top toolbar) */
|
||||
.menubar { height: 30px; flex: 0 0 30px; display: flex; align-items: center; gap: 4px; padding: 0 10px; background: var(--panel); border-bottom: 1px solid var(--border-soft); }
|
||||
.iconbtn { width: 26px; height: 22px; border: 1px solid transparent; background: transparent; color: var(--muted); border-radius: 5px; cursor: pointer; display: grid; place-items: center; font-size: 13px; }
|
||||
.iconbtn:hover { background: var(--elev); color: var(--text); border-color: var(--border); }
|
||||
|
||||
/* Flight info bar */
|
||||
.flightbar { display: flex; align-items: center; gap: 10px; padding: 8px 14px; background: var(--panel); border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.flightbar .tag { font-family: var(--mono); font-weight: 700; }
|
||||
.flightbar .apt { color: var(--muted); }
|
||||
.flightbar .apt b { color: var(--text); font-family: var(--mono); }
|
||||
.flightbar input { width: 74px; text-align: center; font-family: var(--mono); font-weight: 600; }
|
||||
.flightbar .sp { flex: 1; }
|
||||
|
||||
/* Alternates mini table */
|
||||
.alt-table { width: 100%; border-collapse: collapse; font-size: 12px; margin-top: 6px; }
|
||||
.alt-table th { text-align: left; color: var(--muted); font-weight: 600; font-size: 10.5px; text-transform: uppercase; padding: 3px 6px; border-bottom: 1px solid var(--border); }
|
||||
.alt-table td { padding: 2px 4px; }
|
||||
.alt-table input, .alt-table select { height: 26px; font-size: 12px; }
|
||||
|
||||
/* Map area (right) */
|
||||
.maparea { flex: 1; display: flex; flex-direction: column; min-height: 0; }
|
||||
.map-toolbar { display: flex; align-items: center; gap: 3px; padding: 5px 8px; background: var(--panel); border-bottom: 1px solid var(--border); }
|
||||
.map-tabs { display: flex; gap: 2px; padding: 4px 8px; background: var(--panel); border-top: 1px solid var(--border); }
|
||||
.map-tab { padding: 5px 12px; border: none; background: transparent; color: var(--muted); cursor: pointer; font: inherit; font-size: 12px; border-radius: 5px; }
|
||||
.map-tab:hover { color: var(--text); }
|
||||
.map-tab.on { color: #dce9ff; background: var(--accent-weak); }
|
||||
|
||||
/* Bottom output dock (full width) */
|
||||
.output { flex: 0 0 var(--dockh, 210px); display: flex; flex-direction: column; background: var(--panel); border-top: 1px solid var(--border); min-height: 0; }
|
||||
|
||||
/* Settings modal */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(2,4,8,.6); display: grid; place-items: center; z-index: 50; }
|
||||
.modal { width: 760px; max-width: 92vw; height: 520px; max-height: 88vh; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 24px 60px rgba(0,0,0,.5); display: flex; flex-direction: column; overflow: hidden; }
|
||||
.modal-head { display: flex; align-items: center; justify-content: space-between; padding: 12px 16px; border-bottom: 1px solid var(--border); font-weight: 600; }
|
||||
.modal-main { flex: 1; display: grid; grid-template-columns: 190px 1fr; min-height: 0; }
|
||||
.modal-cats { border-right: 1px solid var(--border); padding: 8px; overflow-y: auto; display: flex; flex-direction: column; gap: 2px; }
|
||||
.modal-cat { text-align: left; padding: 8px 12px; border: none; background: transparent; color: var(--muted); border-radius: 6px; cursor: pointer; font: inherit; }
|
||||
.modal-cat:hover { background: var(--panel-2); color: var(--text); }
|
||||
.modal-cat.on { background: var(--accent-weak); color: #dce9ff; }
|
||||
.modal-body { padding: 16px 20px; overflow-y: auto; }
|
||||
.modal-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }
|
||||
.set-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.set-row .lbl { font-size: 13px; }
|
||||
.set-row .desc { font-size: 11px; color: var(--muted); margin-top: 2px; }
|
||||
.set-row .ctl { flex: 0 0 auto; }
|
||||
.set-row select, .set-row input[type="text"], .set-row input[type="number"] { width: 180px; }
|
||||
.switch { width: 40px; height: 22px; border-radius: 12px; background: var(--border); border: none; cursor: pointer; position: relative; transition: background .15s; }
|
||||
.switch.on { background: var(--accent); }
|
||||
.switch::after { content: ""; position: absolute; top: 2px; left: 2px; width: 18px; height: 18px; border-radius: 50%; background: #fff; transition: transform .15s; }
|
||||
.switch.on::after { transform: translateX(18px); }
|
||||
.section-sub { font-size: 11px; text-transform: uppercase; letter-spacing: .6px; color: var(--faint); margin: 14px 0 4px; }
|
||||
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
@@ -0,0 +1,756 @@
|
||||
import { useState, useRef, useEffect, type ReactNode } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import * as maplibregl from "maplibre-gl";
|
||||
|
||||
const appWindow = getCurrentWindow();
|
||||
|
||||
// ── Result DTOs ────────────────────────────────────────────────────────────
|
||||
interface Phase { dist_nm: number; time_min: number; fuel_kg: number }
|
||||
interface Mass { payload_kg: number; zfw_kg: number; takeoff_kg: number; landing_kg: number; over_mtow: boolean; over_mlw: boolean }
|
||||
interface Fuel {
|
||||
aircraft: string; cruise_fl: number; climb: Phase; cruise: Phase; descent: Phase;
|
||||
trip_fuel_kg: number; trip_time_min: number; taxi_kg: number; contingency_kg: number;
|
||||
alternate_kg: number; final_reserve_kg: number; block_fuel_kg: number; mass: Mass | null;
|
||||
}
|
||||
interface Leg { from: string; via: string; to: string; dist_nm: number; cum_dist_nm: number; time_min: number | null; cum_time_min: number | null; fuel_kg: number | null; cum_fuel_kg: number | null }
|
||||
interface Point { ident: string; lat: number; lon: number }
|
||||
interface ProcTrack { name: string; kind: string; points: Point[] }
|
||||
interface Ifps { accepted: boolean; errors: string[]; warnings: string[] }
|
||||
interface IfpsErr { code: string; msg: string }
|
||||
interface IfpsCheck { accepted: boolean | null; errors: IfpsErr[]; raw?: string; fpl?: string; error?: string | null }
|
||||
interface IfpsFix { accepted: boolean; level: string | null; route: string | null; iterations: number; log: string[]; errors: IfpsErr[]; error?: string | null }
|
||||
interface Discover { accepted: boolean; route_string: string; item15: string; fl: number; total_nm: number; errors: IfpsErr[]; iterations: number; log: string[]; error?: string | null }
|
||||
interface CachedRoute { dep: string; dest: string; cruise_fl: number; route_string: string; dist_nm: number; ifps_ok: boolean; source: string; generated_at: string }
|
||||
interface PlanResult { route_string: string; via_airways: boolean; total_nm: number; cruise_fl: number | null; legs: Leg[]; fuel: Fuel | null; geometry: Point[]; procedures: ProcTrack[]; ofp: string; ifps: Ifps }
|
||||
|
||||
const DEFAULT_DB = "C:\\Users\\Alexandre\\flightplanner\\real.db";
|
||||
const DEFAULT_AIRCRAFT_DIR = "C:\\Users\\Alexandre\\flightplanner\\data\\aircraft";
|
||||
const DEFAULT_CIFP = "C:\\Users\\Alexandre\\flightplanner\\navdata\\CIFP";
|
||||
const kg = (n: number) => Math.round(n).toLocaleString("en-US");
|
||||
const nm = (n: number) => `${Math.round(n)}`;
|
||||
const hm = (min: number) => `${Math.floor(Math.round(min) / 60)}:${String(Math.round(min) % 60).padStart(2, "0")}`;
|
||||
const flText = (fl: number | null) => (fl != null ? `FL${String(fl).padStart(3, "0")}` : "—");
|
||||
|
||||
// ── Dark offline world map with a glowing route ────────────────────────────
|
||||
const C = { sea: "#0b0e14", land: "#161d29", border: "#263143", route: "#4c8dff", label: "#cdd9e5", labelBg: "rgba(11,14,20,.82)" };
|
||||
function RouteMap({ points, procedures }: { points: Point[]; procedures: ProcTrack[] }) {
|
||||
const container = useRef<HTMLDivElement | null>(null);
|
||||
const map = useRef<maplibregl.Map | null>(null);
|
||||
const markers = useRef<maplibregl.Marker[]>([]);
|
||||
const [ready, setReady] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!container.current || map.current) return;
|
||||
const style: maplibregl.StyleSpecification = { version: 8, sources: {}, layers: [{ id: "bg", type: "background", paint: { "background-color": C.sea } }] };
|
||||
const m = new maplibregl.Map({ container: container.current, style, center: [5, 46], zoom: 3, attributionControl: false });
|
||||
m.addControl(new maplibregl.NavigationControl({ showCompass: false }), "top-right");
|
||||
m.on("load", async () => {
|
||||
try {
|
||||
const gj = await (await fetch("/world.geojson")).json();
|
||||
m.addSource("world", { type: "geojson", data: gj });
|
||||
m.addLayer({ id: "land", type: "fill", source: "world", paint: { "fill-color": C.land } });
|
||||
m.addLayer({ id: "border", type: "line", source: "world", paint: { "line-color": C.border, "line-width": 0.7 } });
|
||||
} catch { /* offline */ }
|
||||
setReady(true);
|
||||
});
|
||||
map.current = m;
|
||||
return () => { m.remove(); map.current = null; };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const m = map.current;
|
||||
if (!m || !ready) return;
|
||||
const coords = points.map((p) => [p.lon, p.lat] as [number, number]);
|
||||
const line = { type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: coords } };
|
||||
const pts = { type: "FeatureCollection" as const, features: points.map((p) => ({ type: "Feature" as const, properties: {}, geometry: { type: "Point" as const, coordinates: [p.lon, p.lat] as [number, number] } })) };
|
||||
const src = m.getSource("route") as maplibregl.GeoJSONSource | undefined;
|
||||
if (src) { src.setData(line); (m.getSource("wpts") as maplibregl.GeoJSONSource).setData(pts); }
|
||||
else {
|
||||
m.addSource("route", { type: "geojson", data: line });
|
||||
m.addLayer({ id: "route-glow", type: "line", source: "route", paint: { "line-color": C.route, "line-width": 9, "line-blur": 9, "line-opacity": 0.35 }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addLayer({ id: "route-line", type: "line", source: "route", paint: { "line-color": C.route, "line-width": 2.4 }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addSource("wpts", { type: "geojson", data: pts });
|
||||
m.addLayer({ id: "wpts-dot", type: "circle", source: "wpts", paint: { "circle-radius": 3.4, "circle-color": C.sea, "circle-stroke-color": C.route, "circle-stroke-width": 1.6 } });
|
||||
}
|
||||
markers.current.forEach((mk) => mk.remove());
|
||||
markers.current = points.map((p) => {
|
||||
const el = document.createElement("div");
|
||||
el.textContent = p.ident;
|
||||
el.style.cssText = `font:600 10px/1 'Segoe UI',sans-serif;color:${C.label};background:${C.labelBg};padding:1px 4px;border-radius:3px;white-space:nowrap;transform:translateY(-9px);`;
|
||||
return new maplibregl.Marker({ element: el, anchor: "bottom" }).setLngLat([p.lon, p.lat]).addTo(m);
|
||||
});
|
||||
// SID/STAR procedure tracks (amber, dashed).
|
||||
const procFc = { type: "FeatureCollection" as const, features: procedures.map((pr) => ({ type: "Feature" as const, properties: {}, geometry: { type: "LineString" as const, coordinates: pr.points.map((p) => [p.lon, p.lat] as [number, number]) } })) };
|
||||
const procDots = { type: "FeatureCollection" as const, features: procedures.flatMap((pr) => pr.points.map((p) => ({ type: "Feature" as const, properties: {}, geometry: { type: "Point" as const, coordinates: [p.lon, p.lat] as [number, number] } }))) };
|
||||
const procSrc = m.getSource("proc") as maplibregl.GeoJSONSource | undefined;
|
||||
if (procSrc) { procSrc.setData(procFc); (m.getSource("proc-pts") as maplibregl.GeoJSONSource).setData(procDots); }
|
||||
else {
|
||||
m.addSource("proc", { type: "geojson", data: procFc });
|
||||
m.addLayer({ id: "proc-line", type: "line", source: "proc", paint: { "line-color": "#f0a92b", "line-width": 2, "line-dasharray": [2, 1.6] }, layout: { "line-cap": "round", "line-join": "round" } });
|
||||
m.addSource("proc-pts", { type: "geojson", data: procDots });
|
||||
m.addLayer({ id: "proc-dot", type: "circle", source: "proc-pts", paint: { "circle-radius": 2.2, "circle-color": "#f0a92b" } });
|
||||
}
|
||||
if (coords.length > 0) { const b = new maplibregl.LngLatBounds(coords[0], coords[0]); coords.forEach((co) => b.extend(co)); procedures.forEach((pr) => pr.points.forEach((p) => b.extend([p.lon, p.lat]))); m.fitBounds(b, { padding: 70, maxZoom: 7, duration: 700 }); }
|
||||
}, [points, procedures, ready]);
|
||||
return <div className="maproot" ref={container} />;
|
||||
}
|
||||
|
||||
// ── Small building blocks ──────────────────────────────────────────────────
|
||||
function Field(props: { label: string; children: ReactNode }) { return <div className="field"><label>{props.label}</label>{props.children}</div>; }
|
||||
function RB(props: { g: string; label: string; onClick?: () => void; disabled?: boolean; on?: boolean; accent?: boolean }) {
|
||||
return <button className={`rbtn${props.on ? " on" : ""}${props.accent ? " accent" : ""}`} onClick={props.onClick} disabled={props.disabled}><span className="g">{props.g}</span>{props.label}</button>;
|
||||
}
|
||||
function Grp(props: { cap: string; children: ReactNode }) { return <div className="rgroup"><div className="rgroup-btns">{props.children}</div><div className="rgroup-cap">{props.cap}</div></div>; }
|
||||
function Sw(props: { on: boolean; onClick: () => void }) { return <button className={`switch${props.on ? " on" : ""}`} onClick={props.onClick} />; }
|
||||
|
||||
// ── Settings modal (many cosmetic options for a real preview) ──────────────
|
||||
const SET_CATS = ["Mode", "Backend", "General", "Units", "Weather", "Navigation data", "Simulator export", "Fuel policy", "Map", "About"];
|
||||
function Settings(props: { onClose: () => void; backend: string; setBackend: (v: string) => void; url: string; setUrl: (v: string) => void; mode: string; setMode: (v: string) => void }) {
|
||||
const { onClose, backend, setBackend, url, setUrl, mode, setMode } = props;
|
||||
const [cat, setCat] = useState("Mode");
|
||||
const [testStatus, setTestStatus] = useState("");
|
||||
async function testConn() {
|
||||
setTestStatus("checking…");
|
||||
try {
|
||||
const r = await fetch(url.replace(/\/$/, "") + "/health");
|
||||
setTestStatus(r.ok ? "✓ online" : `✗ error ${r.status}`);
|
||||
} catch { setTestStatus("✗ unreachable"); }
|
||||
}
|
||||
const [sw, setSw] = useState<Record<string, boolean>>({
|
||||
autosave: true, checkUpdates: true, confirmExit: false, sounds: false,
|
||||
dlWeather: true, windUplink: true, turbulence: false, notams: true,
|
||||
autoAirac: true, validateIfps: true, prefRoutes: true,
|
||||
etops: false, tankering: false, redispatch: false,
|
||||
airways: true, fixes: false, firs: true, tracks: true, terrain: false,
|
||||
});
|
||||
const t = (k: string) => setSw((s) => ({ ...s, [k]: !s[k] }));
|
||||
const Row = (p: { lbl: string; desc?: string; children: ReactNode }) => (
|
||||
<div className="set-row"><div><div className="lbl">{p.lbl}</div>{p.desc ? <div className="desc">{p.desc}</div> : null}</div><div className="ctl">{p.children}</div></div>
|
||||
);
|
||||
return (
|
||||
<div className="overlay" onMouseDown={onClose}>
|
||||
<div className="modal" onMouseDown={(e) => e.stopPropagation()}>
|
||||
<div className="modal-head"><span>⚙ Settings</span><button className="iconbtn" onClick={onClose}>✕</button></div>
|
||||
<div className="modal-main">
|
||||
<div className="modal-cats">{SET_CATS.map((c) => <button key={c} className={`modal-cat${cat === c ? " on" : ""}`} onClick={() => setCat(c)}>{c}</button>)}</div>
|
||||
<div className="modal-body">
|
||||
{cat === "Mode" && (<>
|
||||
<div className="section-sub">Operating mode</div>
|
||||
<Row lbl="Mode" desc="Free Flight = one-off flights (SimBrief-style). Career = run your airline: fleet, registrations, roster.">
|
||||
<select value={mode} onChange={(e) => setMode(e.target.value)}>
|
||||
<option value="freeflight">Free Flight</option>
|
||||
<option value="career">Career</option>
|
||||
</select>
|
||||
</Row>
|
||||
{mode === "career" ? (
|
||||
<div className="desc" style={{ marginTop: 10 }}>
|
||||
<b>Career</b> — manage a virtual airline: operator/ICAO callsign, a fleet of aircraft with individual registrations, and scheduled flights. Company data can live on the shared server (multi-tenant, for VAs). <i>Fleet & company management is being built.</i>
|
||||
</div>
|
||||
) : (
|
||||
<div className="desc" style={{ marginTop: 10 }}>
|
||||
<b>Free Flight</b> — plan any city pair on demand, generate the OFP, no persistent company. Switch to Career to manage a fleet and airline.
|
||||
</div>
|
||||
)}
|
||||
</>)}
|
||||
{cat === "Backend" && (<>
|
||||
<div className="section-sub">Compute backend</div>
|
||||
<Row lbl="Mode" desc="Local runs everything offline on this machine. Custom server calls a shared backend over HTTP.">
|
||||
<select value={backend} onChange={(e) => setBackend(e.target.value)}>
|
||||
<option value="local">Local (offline)</option>
|
||||
<option value="server">Custom server</option>
|
||||
</select>
|
||||
</Row>
|
||||
<Row lbl="Server URL"><input type="text" value={url} onChange={(e) => setUrl(e.target.value)} disabled={backend !== "server"} /></Row>
|
||||
<Row lbl="Connection">
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
<button className="btn" style={{ height: 28 }} disabled={backend !== "server"} onClick={testConn}>Test</button>
|
||||
<span style={{ fontSize: 12, color: "var(--muted)" }}>{testStatus}</span>
|
||||
</div>
|
||||
</Row>
|
||||
<div className="desc" style={{ marginTop: 10 }}>Server mode calls <b>POST /plan</b> on the URL above. Run it with <span className="mono">flightplanner-server</span> (env <span className="mono">FP_DB</span>, <span className="mono">FP_BIND</span>). The engine (routing/perf) is identical to local.</div>
|
||||
</>)}
|
||||
{cat === "General" && (<>
|
||||
<Row lbl="Auto-save flights" desc="Keep a working copy of the current flight"><Sw on={sw.autosave} onClick={() => t("autosave")} /></Row>
|
||||
<Row lbl="Check for updates on start"><Sw on={sw.checkUpdates} onClick={() => t("checkUpdates")} /></Row>
|
||||
<Row lbl="Confirm before exit"><Sw on={sw.confirmExit} onClick={() => t("confirmExit")} /></Row>
|
||||
<Row lbl="Interface sounds"><Sw on={sw.sounds} onClick={() => t("sounds")} /></Row>
|
||||
<div className="section-sub">Startup</div>
|
||||
<Row lbl="Default view"><select defaultValue="Flight"><option>Schedule</option><option>Flight</option><option>Traffic</option></select></Row>
|
||||
<Row lbl="Operator / airline"><input type="text" defaultValue="AIR FRANCE" /></Row>
|
||||
</>)}
|
||||
{cat === "Units" && (<>
|
||||
<Row lbl="Weight"><select defaultValue="kg"><option>kg</option><option>lb</option></select></Row>
|
||||
<Row lbl="Distance"><select defaultValue="NM"><option>NM</option><option>km</option></select></Row>
|
||||
<Row lbl="Altitude"><select defaultValue="ft"><option>ft</option><option>m</option></select></Row>
|
||||
<Row lbl="Speed"><select defaultValue="kt"><option>kt</option><option>km/h</option><option>Mach</option></select></Row>
|
||||
<Row lbl="Temperature"><select defaultValue="°C"><option>°C</option><option>°F</option></select></Row>
|
||||
<Row lbl="Coordinates"><select defaultValue="DMS"><option>DMS</option><option>Decimal</option></select></Row>
|
||||
</>)}
|
||||
{cat === "Weather" && (<>
|
||||
<Row lbl="Download winds & temps (GRIB)" desc="GFS global forecast, offline once cached"><Sw on={sw.dlWeather} onClick={() => t("dlWeather")} /></Row>
|
||||
<Row lbl="Wind uplink to simulator"><Sw on={sw.windUplink} onClick={() => t("windUplink")} /></Row>
|
||||
<Row lbl="Turbulence overlay"><Sw on={sw.turbulence} onClick={() => t("turbulence")} /></Row>
|
||||
<Row lbl="METAR / TAF & NOTAMs"><Sw on={sw.notams} onClick={() => t("notams")} /></Row>
|
||||
<Row lbl="Forecast model"><select defaultValue="GFS 0.25°"><option>GFS 0.25°</option><option>GFS 0.5°</option><option>ECMWF</option></select></Row>
|
||||
<Row lbl="Refresh interval (min)"><input type="number" defaultValue={30} /></Row>
|
||||
</>)}
|
||||
{cat === "Navigation data" && (<>
|
||||
<Row lbl="Auto-update AIRAC cycle"><Sw on={sw.autoAirac} onClick={() => t("autoAirac")} /></Row>
|
||||
<Row lbl="IFPS validation (Eurocontrol)"><Sw on={sw.validateIfps} onClick={() => t("validateIfps")} /></Row>
|
||||
<Row lbl="Use preferred / RAD routes"><Sw on={sw.prefRoutes} onClick={() => t("prefRoutes")} /></Row>
|
||||
<Row lbl="Active cycle"><input type="text" defaultValue="AIRAC 2608" /></Row>
|
||||
<Row lbl="Provider"><select defaultValue="Navigraph"><option>Navigraph</option><option>Custom</option></select></Row>
|
||||
</>)}
|
||||
{cat === "Simulator export" && (<>
|
||||
<Row lbl="MSFS / Prepar3D (.pln)"><Sw on={true} onClick={() => {}} /></Row>
|
||||
<Row lbl="X-Plane (.fms)"><Sw on={true} onClick={() => {}} /></Row>
|
||||
<Row lbl="PMDG (.rte)"><Sw on={false} onClick={() => {}} /></Row>
|
||||
<Row lbl="Export folder"><input type="text" defaultValue="C:\\Users\\Alexandre\\Documents\\Flights" /></Row>
|
||||
</>)}
|
||||
{cat === "Fuel policy" && (<>
|
||||
<Row lbl="Policy"><select defaultValue="ICAO"><option>ICAO</option><option>Company</option><option>Minimum</option></select></Row>
|
||||
<Row lbl="Contingency (%)"><input type="number" defaultValue={5} /></Row>
|
||||
<Row lbl="Final reserve (min)"><input type="number" defaultValue={30} /></Row>
|
||||
<Row lbl="Taxi fuel (kg)"><input type="number" defaultValue={200} /></Row>
|
||||
<Row lbl="Tankering optimisation"><Sw on={sw.tankering} onClick={() => t("tankering")} /></Row>
|
||||
<Row lbl="ETOPS planning"><Sw on={sw.etops} onClick={() => t("etops")} /></Row>
|
||||
<Row lbl="Redispatch"><Sw on={sw.redispatch} onClick={() => t("redispatch")} /></Row>
|
||||
</>)}
|
||||
{cat === "Map" && (<>
|
||||
<Row lbl="Airways"><Sw on={sw.airways} onClick={() => t("airways")} /></Row>
|
||||
<Row lbl="Fixes & navaids"><Sw on={sw.fixes} onClick={() => t("fixes")} /></Row>
|
||||
<Row lbl="FIR boundaries"><Sw on={sw.firs} onClick={() => t("firs")} /></Row>
|
||||
<Row lbl="Oceanic tracks (NAT/PACOTS)"><Sw on={sw.tracks} onClick={() => t("tracks")} /></Row>
|
||||
<Row lbl="Terrain shading"><Sw on={sw.terrain} onClick={() => t("terrain")} /></Row>
|
||||
<Row lbl="Projection"><select defaultValue="Mercator"><option>Mercator</option><option>Globe</option></select></Row>
|
||||
</>)}
|
||||
{cat === "About" && (<>
|
||||
<div className="lbl">Flight Planner</div>
|
||||
<div className="desc">Offline flight planning · Rust core + OpenAP performance · Navigraph AIRAC 2608</div>
|
||||
<div className="section-sub">Version</div><div className="desc">0.1.0 (dev)</div>
|
||||
<div className="section-sub">Engine</div><div className="desc">Routing A* · WGS84 geodesic · OpenAP fuel model</div>
|
||||
</>)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-foot"><button className="btn" onClick={onClose}>Cancel</button><button className="btn-primary" style={{ width: "auto", padding: "0 18px", height: 32 }} onClick={onClose}>Save</button></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
// real (wired) inputs
|
||||
const [from, setFrom] = useState("LFPG");
|
||||
const [to, setTo] = useState("EGLL");
|
||||
const [type, setType] = useState("A320");
|
||||
const [cruiseFl, setCruiseFl] = useState("");
|
||||
const [alternate, setAlternate] = useState("EGKK");
|
||||
const [adults, setAdults] = useState("150");
|
||||
const [children, setChildren] = useState("8");
|
||||
const [infants, setInfants] = useState("2");
|
||||
const [cargo, setCargo] = useState("2000");
|
||||
const [dbPath] = useState(DEFAULT_DB);
|
||||
const [aircraftDir] = useState(DEFAULT_AIRCRAFT_DIR);
|
||||
|
||||
const [result, setResult] = useState<PlanResult | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [ifpsCheck, setIfpsCheck] = useState<IfpsCheck | null>(null);
|
||||
const [ifpsLoading, setIfpsLoading] = useState(false);
|
||||
const [ifpsFix, setIfpsFix] = useState<IfpsFix | null>(null);
|
||||
const [fixLoading, setFixLoading] = useState(false);
|
||||
const [discover, setDiscover] = useState<Discover | null>(null);
|
||||
const [discoverLoading, setDiscoverLoading] = useState(false);
|
||||
const [dbRoutes, setDbRoutes] = useState<CachedRoute[]>([]);
|
||||
const [progress, setProgress] = useState<{ step: number; route: string; fl: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const un = listen<{ step: number; route: string; fl: number }>("discover-progress", (e) => setProgress(e.payload));
|
||||
return () => { un.then((f) => f()); };
|
||||
}, []);
|
||||
const [ribbonTab, setRibbonTab] = useState("Flight");
|
||||
const [mapTab, setMapTab] = useState("map");
|
||||
const [outTab, setOutTab] = useState("navlog");
|
||||
const [settings, setSettings] = useState(false);
|
||||
const [payloadPreset, setPayloadPreset] = useState("min");
|
||||
const [clock, setClock] = useState("");
|
||||
const [backend, setBackend] = useState(() => localStorage.getItem("fp_backend") || "local");
|
||||
const [mode, setMode] = useState(() => localStorage.getItem("fp_mode") || "freeflight");
|
||||
const [serverUrl, setServerUrl] = useState(() => localStorage.getItem("fp_server_url") || "http://127.0.0.1:8787");
|
||||
const [routeCount, setRouteCount] = useState<number | null>(null);
|
||||
useEffect(() => { localStorage.setItem("fp_backend", backend); }, [backend]);
|
||||
useEffect(() => { localStorage.setItem("fp_mode", mode); }, [mode]);
|
||||
useEffect(() => { localStorage.setItem("fp_server_url", serverUrl); }, [serverUrl]);
|
||||
|
||||
async function refreshCount() {
|
||||
try {
|
||||
if (backend === "server") {
|
||||
const s = await fetch(serverUrl.replace(/\/$/, "") + "/stats").then((r) => r.json());
|
||||
setRouteCount(s.routes);
|
||||
} else {
|
||||
setRouteCount(await invoke<number>("route_db_count", { dbPath }));
|
||||
}
|
||||
} catch { setRouteCount(null); }
|
||||
}
|
||||
|
||||
// Stored routes for the current city pair (our own IFPS route DB).
|
||||
async function refreshDbRoutes() {
|
||||
const dep = from.trim().toUpperCase();
|
||||
const dest = to.trim().toUpperCase();
|
||||
if (!dep || !dest) { setDbRoutes([]); return; }
|
||||
try {
|
||||
if (backend === "server") {
|
||||
const url = serverUrl.replace(/\/$/, "") + `/routes?dep=${dep}&dest=${dest}`;
|
||||
setDbRoutes(await fetch(url).then((r) => r.json()));
|
||||
} else {
|
||||
setDbRoutes(await invoke<CachedRoute[]>("recent_routes", { dbPath, dep, dest }));
|
||||
}
|
||||
} catch { setDbRoutes([]); }
|
||||
}
|
||||
useEffect(() => { refreshCount(); }, [backend]);
|
||||
|
||||
useEffect(() => {
|
||||
const tick = () => setClock(new Date().toUTCString().slice(17, 22));
|
||||
tick(); const id = setInterval(tick, 30000); return () => clearInterval(id);
|
||||
}, []);
|
||||
|
||||
async function compute() {
|
||||
setError("");
|
||||
if (!from.trim() || !to.trim()) { setError("Departure and destination are required."); return; }
|
||||
setLoading(true);
|
||||
try {
|
||||
const pax = Number(adults || 0) * 100 + Number(children || 0) * 50 + Number(infants || 0) * 15;
|
||||
const pl = pax + Number(cargo || 0);
|
||||
const req = { from: from.trim(), to: to.trim(), db_path: dbPath, aircraft: type || null, aircraft_dir: aircraftDir, cifp_dir: DEFAULT_CIFP, cruise_fl: cruiseFl.trim() === "" ? null : Number(cruiseFl), alternate: alternate.trim() || null, payload_kg: pl > 0 ? pl : null };
|
||||
if (backend === "server") {
|
||||
const res = await fetch(serverUrl.replace(/\/$/, "") + "/plan", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(req) });
|
||||
if (!res.ok) throw new Error(await res.text());
|
||||
setResult(await res.json());
|
||||
} else {
|
||||
setResult(await invoke<PlanResult>("plan", { req }));
|
||||
}
|
||||
setOutTab("navlog");
|
||||
setIfpsCheck(null);
|
||||
setIfpsFix(null);
|
||||
setDiscover(null);
|
||||
refreshCount(); refreshDbRoutes();
|
||||
} catch (e) { setError(typeof e === "string" ? e : String(e)); setResult(null); }
|
||||
finally { setLoading(false); }
|
||||
}
|
||||
function reset() { setResult(null); setError(""); setIfpsCheck(null); setIfpsFix(null); setDiscover(null); setDbRoutes([]); }
|
||||
|
||||
// Build the IFPUV payload from the current plan (item 15 = route without ADEP/ADES).
|
||||
function ifpsPayload() {
|
||||
if (!result) return null;
|
||||
const toks = result.route_string.trim().split(/\s+/);
|
||||
const mid = toks.slice(1, -1);
|
||||
// Drop leading SID / trailing STAR *designators* (e.g. OPAL6A, OCK1Z) — IFPS
|
||||
// item 15 lists fixes, and derives the procedure from the runway + first/last point.
|
||||
const isProc = (t: string) => /^[A-Z]{2,5}\d[A-Z]$/.test(t);
|
||||
while (mid.length && isProc(mid[0])) mid.shift();
|
||||
while (mid.length && isProc(mid[mid.length - 1])) mid.pop();
|
||||
const route = mid.join(" ") || "DCT";
|
||||
const fl = result.cruise_fl ?? (cruiseFl.trim() ? Number(cruiseFl) : 350);
|
||||
return {
|
||||
adep: from.trim().toUpperCase(),
|
||||
ades: to.trim().toUpperCase(),
|
||||
route,
|
||||
actype: (type || "A320").toUpperCase(),
|
||||
level: `F${String(fl).padStart(3, "0")}`,
|
||||
};
|
||||
}
|
||||
|
||||
// Authoritative IFPS validation via the Eurocontrol IFPUV (Playwright scraper).
|
||||
async function validateIfps() {
|
||||
const payload = ifpsPayload();
|
||||
if (!payload) return;
|
||||
setIfpsLoading(true);
|
||||
try {
|
||||
setIfpsCheck(await invoke<IfpsCheck>("ifps_validate", { payload }));
|
||||
} catch (e) { setIfpsCheck({ accepted: null, errors: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setIfpsLoading(false); }
|
||||
}
|
||||
|
||||
// Oracle-driven auto-correction (RAD level caps, unknown designators).
|
||||
async function autofixIfps() {
|
||||
const payload = ifpsPayload();
|
||||
if (!payload) return;
|
||||
setFixLoading(true);
|
||||
setIfpsFix(null);
|
||||
try {
|
||||
setIfpsFix(await invoke<IfpsFix>("ifps_autofix", { payload }));
|
||||
} catch (e) { setIfpsFix({ accepted: false, level: null, route: null, iterations: 0, log: [], errors: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setFixLoading(false); }
|
||||
}
|
||||
|
||||
// Oracle-in-the-loop: generate a candidate, validate + repair against the live
|
||||
// IFPUV until IFPS-accepted, and store it in the route DB (slow first time).
|
||||
async function generateValidated() {
|
||||
if (!from.trim() || !to.trim()) { setError("Departure and destination are required."); return; }
|
||||
setDiscoverLoading(true);
|
||||
setDiscover(null);
|
||||
setProgress(null);
|
||||
try {
|
||||
const pax = Number(adults || 0) * 100 + Number(children || 0) * 50 + Number(infants || 0) * 15;
|
||||
const pl = pax + Number(cargo || 0);
|
||||
const req = { from: from.trim(), to: to.trim(), db_path: dbPath, aircraft: type || null, aircraft_dir: aircraftDir, cifp_dir: DEFAULT_CIFP, cruise_fl: cruiseFl.trim() === "" ? null : Number(cruiseFl), alternate: alternate.trim() || null, payload_kg: pl > 0 ? pl : null };
|
||||
setDiscover(await invoke<Discover>("generate_validated", { req }));
|
||||
refreshCount(); refreshDbRoutes();
|
||||
} catch (e) { setDiscover({ accepted: false, route_string: "", item15: "", fl: 0, total_nm: 0, errors: [], iterations: 0, log: [], error: typeof e === "string" ? e : String(e) }); }
|
||||
finally { setDiscoverLoading(false); }
|
||||
}
|
||||
|
||||
const f = result?.fuel ?? null;
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{settings ? <Settings onClose={() => setSettings(false)} backend={backend} setBackend={setBackend} url={serverUrl} setUrl={setServerUrl} mode={mode} setMode={setMode} /> : null}
|
||||
|
||||
{/* Title bar */}
|
||||
<div className="titlebar" data-tauri-drag-region>
|
||||
<div className="tb-left"><span className="logo">✈</span> Flight Planner<span className="tb-flight">{result ? `— AFR1234 · ${from.toUpperCase()}–${to.toUpperCase()}` : ""}</span></div>
|
||||
<div className="win-controls">
|
||||
<button onClick={() => appWindow.minimize()} title="Minimize">─</button>
|
||||
<button onClick={() => appWindow.toggleMaximize()} title="Maximize">▢</button>
|
||||
<button className="close" onClick={() => appWindow.close()} title="Close">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tiny toolbar */}
|
||||
<div className="menubar">
|
||||
<button className="iconbtn" title="New">+</button>
|
||||
<button className="iconbtn" title="Open">📂</button>
|
||||
<button className="iconbtn" title="Save">💾</button>
|
||||
<div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 4px" }} />
|
||||
<button className="iconbtn" title="Print">🖨</button>
|
||||
<button className="iconbtn" title="Export">⇱</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<button className="iconbtn" title="Settings" onClick={() => setSettings(true)}>⚙</button>
|
||||
</div>
|
||||
|
||||
{/* Ribbon tabs */}
|
||||
<div className="ribbon-tabs">
|
||||
{["Schedule", "Flight", "Traffic", "Browser"].map((tb) => (
|
||||
<button key={tb} className={`ribbon-tab${ribbonTab === tb ? " on" : ""}`} onClick={() => setRibbonTab(tb)}>{tb}</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ribbon */}
|
||||
<div className="ribbon">
|
||||
<Grp cap="Flight">
|
||||
<RB g="▸" label="Compute" onClick={compute} accent />
|
||||
<RB g="+" label="New" />
|
||||
<RB g="↺" label="Reset" onClick={reset} />
|
||||
<RB g="✈" label="In-flight" />
|
||||
</Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Performance"><RB g="↗" label="Take-off" /><RB g="↘" label="Landing" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Payload">
|
||||
<RB g="○" label="None" on={payloadPreset === "none"} onClick={() => setPayloadPreset("none")} />
|
||||
<RB g="◔" label="Min" on={payloadPreset === "min"} onClick={() => setPayloadPreset("min")} />
|
||||
<RB g="●" label="Max" on={payloadPreset === "max"} onClick={() => setPayloadPreset("max")} />
|
||||
<RB g="⚄" label="Random" onClick={() => setPayloadPreset("rnd")} />
|
||||
</Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Fuel"><RB g="⭳" label="Load" /><RB g="▤" label="Build" /><RB g="✓" label="Validate" /><RB g="⇱" label="Export" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Route"><RB g="✎" label="Edit" /><RB g="⌕" label="Find" /><RB g="✕" label="Clear" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="Alternates"><RB g="↗" label="Take-off" /><RB g="→" label="Enroute" /></Grp>
|
||||
<div className="rsep" />
|
||||
<Grp cap="ETOPS"><RB g="❄" label="Icing" /><RB g="⊘" label="Disable" /></Grp>
|
||||
</div>
|
||||
|
||||
{/* Flight info bar */}
|
||||
<div className="flightbar">
|
||||
<span className="tag">AFR1234</span>
|
||||
<span className="apt">· F-GKXA ·</span>
|
||||
<input value={from} onChange={(e) => setFrom(e.target.value.toUpperCase())} />
|
||||
<span className="apt"><b>{from.toUpperCase()}</b></span>
|
||||
<span className="arrow">→</span>
|
||||
<input value={to} onChange={(e) => setTo(e.target.value.toUpperCase())} />
|
||||
<span className="apt"><b>{to.toUpperCase()}</b></span>
|
||||
<button className="btn-primary" style={{ width: "auto", height: 30, padding: "0 16px", marginLeft: 8 }} onClick={compute} disabled={loading}>{loading ? "Computing…" : "Compute Flight"}</button>
|
||||
{error ? <span className="err" style={{ marginLeft: 8 }}>⚠ {error}</span> : null}
|
||||
<span className="sp" />
|
||||
{result ? (
|
||||
discover?.accepted || ifpsCheck?.accepted || ifpsFix?.accepted
|
||||
? <span style={{ color: "var(--good)", fontWeight: 600 }} title="Validated by the real Eurocontrol IFPUV">IFPS ✓ Eurocontrol</span>
|
||||
: (ifpsCheck && !ifpsCheck.accepted && !ifpsCheck.error)
|
||||
? <span style={{ color: "var(--warn)", fontWeight: 600 }} title="Rejected by Eurocontrol IFPUV">IFPS ✗ Eurocontrol</span>
|
||||
: <span style={{ color: "var(--muted)", fontWeight: 600 }} title="Offline draft — use Validate / Generate for the authoritative Eurocontrol check">IFPS draft</span>
|
||||
) : null}
|
||||
{result ? <span className="apt mono">{flText(result.cruise_fl)} · {result.legs.length} legs · {nm(result.total_nm)} nm{f ? ` · ${hm(f.trip_time_min)} · ${kg(f.block_fuel_kg)} kg` : ""}</span> : null}
|
||||
</div>
|
||||
|
||||
{/* Body: left setup + right map */}
|
||||
<div className="body" style={{ gridTemplateColumns: "344px 1fr" }}>
|
||||
<aside className="sidebar">
|
||||
<div className="sections">
|
||||
<div className="section">
|
||||
<div className="section-title">Flight</div>
|
||||
<div className="row2"><Field label="Airline"><input defaultValue="AFR" /></Field><Field label="Flight number"><input defaultValue="1234" /></Field></div>
|
||||
<div className="row2"><Field label="Commercial flt nr"><input defaultValue="AF1234" /></Field><Field label="Flight type"><select defaultValue="Scheduled"><option>Scheduled</option><option>Charter</option><option>Cargo</option></select></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Aircraft</div>
|
||||
<div className="row2"><Field label="Registration"><input defaultValue="F-GKXA" /></Field><Field label="Type"><select value={type} onChange={(e) => setType(e.target.value)}><option value="">Route only</option><option value="A320">A320-200</option><option value="B738">B737-800</option></select></Field></div>
|
||||
<div className="row3"><Field label="Climb"><input defaultValue="250" /></Field><Field label="Cruise"><input defaultValue="0.78" /></Field><Field label="Cost idx"><input defaultValue="30" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Optimization</div>
|
||||
<div className="row2"><Field label="Profile"><select defaultValue="Minimum fuel"><option>Minimum fuel</option><option>Minimum cost</option><option>Minimum time</option></select></Field><Field label="Cruise FL"><input value={cruiseFl} onChange={(e) => setCruiseFl(e.target.value)} placeholder="auto" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Payload</div>
|
||||
<div className="row3"><Field label="Adults"><input value={adults} onChange={(e) => setAdults(e.target.value)} /></Field><Field label="Children"><input value={children} onChange={(e) => setChildren(e.target.value)} /></Field><Field label="Infants"><input value={infants} onChange={(e) => setInfants(e.target.value)} /></Field></div>
|
||||
<div className="row2"><Field label="Cargo (kg)"><input value={cargo} onChange={(e) => setCargo(e.target.value)} /></Field><Field label="Pax capacity"><input defaultValue="180" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Fuel</div>
|
||||
<div className="row2"><Field label="Policy"><select defaultValue="ICAO"><option>ICAO</option><option>Company</option><option>Minimum</option></select></Field><Field label="Extra (kg)"><input defaultValue="0" /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Route</div>
|
||||
<div className="row2"><Field label="Route name"><input defaultValue="LFPGEGLL01" /></Field><Field label="Distance"><input readOnly value={result ? `${nm(result.total_nm)} nm` : "—"} /></Field></div>
|
||||
</div>
|
||||
<div className="section">
|
||||
<div className="section-title">Alternates</div>
|
||||
<div className="row3"><Field label="Required"><select defaultValue="One"><option>None</option><option>One</option><option>Two</option></select></Field><Field label="Priority"><select defaultValue="Fuel"><option>Fuel</option><option>Distance</option></select></Field><Field label="Auto"><input defaultValue="(Auto)" readOnly /></Field></div>
|
||||
<table className="alt-table">
|
||||
<thead><tr><th>Alternate</th><th>Rwy</th><th>Route</th></tr></thead>
|
||||
<tbody>
|
||||
<tr><td><input value={alternate} onChange={(e) => setAlternate(e.target.value.toUpperCase())} /></td><td><input defaultValue="26L" style={{ width: 54 }} /></td><td><input defaultValue="WOD" /></td></tr>
|
||||
<tr><td><input defaultValue="EGLC" /></td><td><input defaultValue="27" style={{ width: 54 }} /></td><td><input defaultValue="LYD" /></td></tr>
|
||||
<tr><td><input defaultValue="EGSS" /></td><td><input defaultValue="22" style={{ width: 54 }} /></td><td><input defaultValue="DET" /></td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* Right: map area */}
|
||||
<div className="maparea">
|
||||
<div className="map-toolbar">
|
||||
<button className="iconbtn" title="Zoom in">+</button>
|
||||
<button className="iconbtn" title="Zoom out">-</button>
|
||||
<button className="iconbtn" title="Fit route">⤢</button>
|
||||
<div style={{ width: 1, height: 16, background: "var(--border)", margin: "0 4px" }} />
|
||||
<button className="iconbtn" title="Airways">A</button>
|
||||
<button className="iconbtn" title="Fixes">◇</button>
|
||||
<button className="iconbtn" title="FIRs">▦</button>
|
||||
<button className="iconbtn" title="Weather">☁</button>
|
||||
<button className="iconbtn" title="Measure">📏</button>
|
||||
<div style={{ flex: 1 }} />
|
||||
<span style={{ fontSize: 11, color: "var(--muted)" }}>{result ? `${result.geometry.length} waypoints` : "no route"}</span>
|
||||
</div>
|
||||
<div style={{ flex: 1, position: "relative", minHeight: 0 }}>
|
||||
<RouteMap points={result?.geometry ?? []} procedures={result?.procedures ?? []} />
|
||||
{!result && mapTab === "map" ? <div className="hint">Compute a flight to plot the route.</div> : null}
|
||||
{mapTab !== "map" ? (
|
||||
<div style={{ position: "absolute", inset: 0, background: "var(--bg)", display: "grid", placeItems: "center", color: "var(--muted)", textAlign: "center", padding: 20 }}>
|
||||
{mapTab === "wx" ? "Weather & NOTAMs — winds aloft (GFS GRIB), METAR/TAF, SIGWX. Coming next." :
|
||||
mapTab === "tracks" ? "Track Messages — NAT / PACOTS oceanic tracks. Coming next." :
|
||||
"Scratchpad — free notes for this flight."}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="map-tabs">
|
||||
{[["map", "World Map"], ["wx", "Weather & NOTAMs"], ["tracks", "Track Messages"], ["scratch", "Scratchpad"]].map(([id, lb]) => (
|
||||
<button key={id} className={`map-tab${mapTab === id ? " on" : ""}`} onClick={() => setMapTab(id)}>{lb}</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bottom output dock */}
|
||||
<div className="output">
|
||||
<div className="tabs">
|
||||
{[["navlog", "Nav Log"], ["fuel", "Fuel & Load"], ["ofp", "OFP"], ["ifps", "IFPS"]].map(([id, lb]) => (
|
||||
<button key={id} className={`tab${outTab === id ? " on" : ""}`} onClick={() => setOutTab(id)}>{lb}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="dock-body">
|
||||
{!result ? (
|
||||
<div className="empty">No active flight — set a route and press <b>Compute Flight</b>.</div>
|
||||
) : outTab === "navlog" ? (
|
||||
<div className="card">
|
||||
<table className="tbl">
|
||||
<thead><tr><th>From</th><th>Via</th><th>To</th><th className="num">Dist</th><th className="num">Cum</th>{f ? (<><th className="num">ETE</th><th className="num">Time</th><th className="num">Fuel</th><th className="num">Used</th></>) : null}</tr></thead>
|
||||
<tbody>
|
||||
{result.legs.map((l, i) => (
|
||||
<tr key={i}><td className="mono">{l.from}</td><td className="mono via">{l.via}</td><td className="mono">{l.to}</td><td className="num">{Math.round(l.dist_nm)}</td><td className="num">{Math.round(l.cum_dist_nm)}</td>
|
||||
{f ? (<><td className="num">{l.time_min != null ? hm(l.time_min) : "—"}</td><td className="num">{l.cum_time_min != null ? hm(l.cum_time_min) : "—"}</td><td className="num">{l.fuel_kg != null ? Math.round(l.fuel_kg) : "—"}</td><td className="num">{l.cum_fuel_kg != null ? kg(l.cum_fuel_kg) : "—"}</td></>) : null}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : outTab === "fuel" ? (
|
||||
!f ? <div className="empty">Select an aircraft to compute fuel and weights.</div> : (
|
||||
<div className="grid3">
|
||||
<div className="card"><div className="cardtitle">Phases</div><table className="tbl"><thead><tr><th>Phase</th><th className="num">Dist</th><th className="num">Time</th><th className="num">Fuel</th></tr></thead><tbody>
|
||||
{([["Climb", f.climb], ["Cruise", f.cruise], ["Descent", f.descent]] as [string, Phase][]).map(([n2, p]) => (<tr key={n2}><td>{n2}</td><td className="num">{Math.round(p.dist_nm)}</td><td className="num">{hm(p.time_min)}</td><td className="num">{kg(p.fuel_kg)}</td></tr>))}
|
||||
</tbody></table></div>
|
||||
<div className="card"><div className="cardtitle">Fuel</div>
|
||||
<div className="kvrow"><span>Taxi</span><span className="val">{kg(f.taxi_kg)}</span></div>
|
||||
<div className="kvrow"><span>Trip</span><span className="val">{kg(f.trip_fuel_kg)}</span></div>
|
||||
<div className="kvrow"><span>Contingency</span><span className="val">{kg(f.contingency_kg)}</span></div>
|
||||
<div className="kvrow"><span>Alternate</span><span className="val">{kg(f.alternate_kg)}</span></div>
|
||||
<div className="kvrow"><span>Final reserve</span><span className="val">{kg(f.final_reserve_kg)}</span></div>
|
||||
<div className="kvrow total"><span>Block</span><span className="val">{kg(f.block_fuel_kg)}</span></div>
|
||||
</div>
|
||||
{f.mass ? (<div className="card"><div className="cardtitle">Weights</div>
|
||||
<div className="kvrow"><span>Payload</span><span className="val">{kg(f.mass.payload_kg)}</span></div>
|
||||
<div className="kvrow"><span>Zero-fuel</span><span className="val">{kg(f.mass.zfw_kg)}</span></div>
|
||||
<div className="kvrow"><span>Take-off{f.mass.over_mtow ? <span className="badge-warn">> MTOW</span> : null}</span><span className="val">{kg(f.mass.takeoff_kg)}</span></div>
|
||||
<div className="kvrow"><span>Landing{f.mass.over_mlw ? <span className="badge-warn">> MLW</span> : null}</span><span className="val">{kg(f.mass.landing_kg)}</span></div>
|
||||
</div>) : null}
|
||||
</div>
|
||||
)
|
||||
) : outTab === "ofp" ? (
|
||||
<div className="card" style={{ padding: 12 }}><pre className="ofp-pre">{result.ofp}</pre></div>
|
||||
) : (
|
||||
<div className="card" style={{ padding: 14 }}>
|
||||
{/* Our own route DB for this pair (IFPS-validated routes are reused). */}
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--muted)", marginBottom: 6 }}>
|
||||
Route DB — {from.toUpperCase()}→{to.toUpperCase()} ({dbRoutes.length})
|
||||
</div>
|
||||
{dbRoutes.length === 0 ? (
|
||||
<div style={{ color: "var(--muted)", fontSize: 12 }}>No stored routes yet — Generate & Validate to add one.</div>
|
||||
) : (
|
||||
dbRoutes.slice(0, 6).map((r, i) => (
|
||||
<div key={i} style={{ fontSize: 12, padding: "3px 0", borderBottom: "1px solid var(--border-soft)", display: "flex", gap: 8, alignItems: "baseline" }}>
|
||||
<span style={{ fontWeight: 700, color: r.ifps_ok ? "var(--good)" : "var(--muted)", minWidth: 66 }}>
|
||||
{r.ifps_ok ? "IFPS ✓" : "unvalidated"}
|
||||
</span>
|
||||
<span className="tag" style={{ minWidth: 54 }}>{r.source.split(" ")[0]}</span>
|
||||
<span className="mono" style={{ minWidth: 44 }}>FL{String(r.cruise_fl).padStart(3, "0")}</span>
|
||||
<span style={{ flex: 1, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }} title={r.route_string}>{r.route_string}</span>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
{/* Authoritative Eurocontrol IFPS validation (live IFPUV). */}
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 10, marginBottom: 8 }}>
|
||||
<button className="btn" onClick={validateIfps} disabled={ifpsLoading || fixLoading}>
|
||||
{ifpsLoading ? "Validating with Eurocontrol…" : "Validate with Eurocontrol IFPS"}
|
||||
</button>
|
||||
<button className="btn" onClick={autofixIfps} disabled={ifpsLoading || fixLoading}>
|
||||
{fixLoading ? "Auto-correcting…" : "Auto-fix (Eurocontrol)"}
|
||||
</button>
|
||||
<button className="btn-primary" style={{ width: "auto", height: 30, padding: "0 14px" }} onClick={generateValidated} disabled={discoverLoading}>
|
||||
{discoverLoading ? "Generating…" : "Generate & Validate (IFPS)"}
|
||||
</button>
|
||||
<span style={{ fontSize: 11, color: "var(--muted)" }}>Live IFPUV round-trip (~25 s)</span>
|
||||
</div>
|
||||
{discoverLoading ? (
|
||||
<div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
|
||||
<span className="spin" style={{ display: "inline-block", width: 12, height: 12, border: "2px solid var(--border)", borderTopColor: "var(--accent)", borderRadius: "50%", animation: "spin 0.8s linear infinite" }} />
|
||||
<span>Discovering an IFPS-valid route (real Eurocontrol check, ~25 s per step; cached after)…</span>
|
||||
</div>
|
||||
{progress ? (
|
||||
<div style={{ marginTop: 6, fontFamily: "monospace", fontSize: 11.5, color: "var(--text)" }}>
|
||||
Step {progress.step} · checking @ FL{String(progress.fl).padStart(3, "0")}<br />
|
||||
<span style={{ color: "var(--muted)" }}>{progress.route}</span>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{discover ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{discover.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Generator error: {discover.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: discover.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{discover.accepted
|
||||
? `✓ IFPS-valid route found — FL${String(discover.fl).padStart(3, "0")} · ${Math.round(discover.total_nm)} nm · saved to route DB`
|
||||
: `⚠ Best route found is not IFPS-accepted yet (${discover.iterations} iteration${discover.iterations > 1 ? "s" : ""})`}
|
||||
</div>
|
||||
{discover.route_string ? <div style={{ fontSize: 12.5, marginBottom: 6 }}><b>{discover.route_string}</b></div> : null}
|
||||
{discover.log.map((l, i) => <div key={i} style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "monospace", padding: "1px 0" }}>{l}</div>)}
|
||||
{!discover.accepted && discover.errors.length ? (
|
||||
<div style={{ marginTop: 6 }}>{discover.errors.map((e, i) => <div key={i} style={{ fontSize: 12, padding: "2px 0" }}><b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}</div>)}</div>
|
||||
) : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
{ifpsLoading ? <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>Submitting FPL to the public Eurocontrol validator…</div> : null}
|
||||
{fixLoading ? <div style={{ fontSize: 12, color: "var(--muted)", marginBottom: 12 }}>Iterating with the Eurocontrol validator (level caps, designators)…</div> : null}
|
||||
{ifpsFix ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{ifpsFix.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Auto-fix error: {ifpsFix.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: ifpsFix.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{ifpsFix.accepted
|
||||
? `✓ Auto-corrected — IFPS ACCEPTED at ${ifpsFix.level} (${ifpsFix.iterations} iteration${ifpsFix.iterations > 1 ? "s" : ""})`
|
||||
: `⚠ Could not fully auto-correct (${ifpsFix.iterations} iteration${ifpsFix.iterations > 1 ? "s" : ""})`}
|
||||
</div>
|
||||
{ifpsFix.accepted && ifpsFix.route ? (
|
||||
<div style={{ fontSize: 12.5, marginBottom: 6 }}>Route: <b>{from.toUpperCase()} {ifpsFix.route} {to.toUpperCase()}</b> · <b>{ifpsFix.level}</b></div>
|
||||
) : null}
|
||||
{ifpsFix.log.map((l, i) => <div key={i} style={{ fontSize: 11.5, color: "var(--muted)", fontFamily: "monospace", padding: "1px 0" }}>{l}</div>)}
|
||||
{!ifpsFix.accepted && ifpsFix.errors.length ? (
|
||||
<div style={{ marginTop: 6 }}>{ifpsFix.errors.map((e, i) => <div key={i} style={{ fontSize: 12, padding: "2px 0" }}><b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}</div>)}</div>
|
||||
) : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
{ifpsCheck ? (
|
||||
<div style={{ marginBottom: 14, paddingBottom: 12, borderBottom: "1px solid var(--border-soft)" }}>
|
||||
{ifpsCheck.error ? (
|
||||
<div style={{ color: "var(--warn)", fontSize: 12.5 }}>Validator error: {ifpsCheck.error}</div>
|
||||
) : (<>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: ifpsCheck.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{ifpsCheck.accepted ? "✓ IFPS ACCEPTED — Eurocontrol (authoritative)" : `✗ IFPS REJECTED — Eurocontrol — ${ifpsCheck.errors.length} error(s)`}
|
||||
</div>
|
||||
{ifpsCheck.errors.map((e, i) => (
|
||||
<div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)" }}>
|
||||
<b style={{ color: "var(--warn)" }}>{e.code}</b> {e.msg}
|
||||
</div>
|
||||
))}
|
||||
{ifpsCheck.accepted ? <div style={{ color: "var(--good)", fontSize: 12.5 }}>NO ERRORS — the flight plan is IFPS-valid.</div> : null}
|
||||
{ifpsCheck.fpl ? <pre className="ofp-pre" style={{ marginTop: 8, fontSize: 10.5, maxHeight: 120 }}>{ifpsCheck.fpl}</pre> : null}
|
||||
</>)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--muted)", marginBottom: 6 }}>Offline pre-check</div>
|
||||
<div style={{ fontWeight: 700, marginBottom: 6, color: result.ifps.accepted ? "var(--good)" : "var(--warn)" }}>
|
||||
{result.ifps.accepted ? "IFPS ✓ Accepted (offline pre-check)" : `IFPS ✗ Rejected — ${result.ifps.errors.length} error(s)`}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, color: "var(--muted)", marginBottom: 12 }}>Best-effort offline structural pre-check — not an authoritative Eurocontrol IFPS validation.</div>
|
||||
{result.ifps.errors.length > 0 ? (<>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--warn)", marginBottom: 4 }}>Errors</div>
|
||||
{result.ifps.errors.map((e, i) => <div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)" }}>✗ {e}</div>)}
|
||||
</>) : null}
|
||||
{result.ifps.warnings.length > 0 ? (<>
|
||||
<div style={{ fontSize: 11, textTransform: "uppercase", letterSpacing: ".5px", color: "var(--amber)", margin: "12px 0 4px" }}>Warnings</div>
|
||||
{result.ifps.warnings.map((w, i) => <div key={i} style={{ fontSize: 12.5, padding: "3px 0", borderBottom: "1px solid var(--border-soft)", color: "var(--muted)" }}>! {w}</div>)}
|
||||
</>) : null}
|
||||
{result.ifps.errors.length === 0 && result.ifps.warnings.length === 0 ? <div style={{ color: "var(--muted)" }}>No structural issues found.</div> : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="status">
|
||||
<span><span className="dot" />Ready</span>
|
||||
<span>Nav: AIRAC 2608</span>
|
||||
<span>WX: <b style={{ color: "var(--good)" }}>Online</b></span>
|
||||
<span>Tracks: <b style={{ color: "var(--good)" }}>Online</b></span>
|
||||
<span>{result ? `${from.toUpperCase()}–${to.toUpperCase()} · ${nm(result.total_nm)} nm` : "No flight"}</span>
|
||||
<span className="sp" />
|
||||
<span>Route DB: <b>{routeCount ?? "—"}</b></span>
|
||||
<span>Mode: <b style={{ color: mode === "career" ? "var(--amber)" : "var(--good)" }}>{mode === "career" ? "Career" : "Free Flight"}</b></span>
|
||||
<span>Backend: <b style={{ color: backend === "server" ? "var(--amber)" : "var(--good)" }}>{backend === "server" ? "Server" : "Local"}</b></span>
|
||||
<span>Perf: OpenAP</span>
|
||||
<span>{clock} UTC</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,11 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import "maplibre-gl/dist/maplibre-gl.css";
|
||||
import App from "./App";
|
||||
import "./App.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"allowSyntheticDefaultImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
// @ts-expect-error process is a nodejs global
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(async () => ({
|
||||
plugins: [react()],
|
||||
|
||||
// Keep MapLibre out of Vite's dep pre-bundling: its web worker breaks when the
|
||||
// optimizer re-bundles mid-session ("maplibre-gl-worker.mjs does not exist"),
|
||||
// which silently drops all GeoJSON layers (land + route). Excluding it makes
|
||||
// the worker resolve reliably in `tauri dev`.
|
||||
optimizeDeps: {
|
||||
exclude: ["maplibre-gl"],
|
||||
},
|
||||
|
||||
// Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build`
|
||||
//
|
||||
// 1. prevent Vite from obscuring rust errors
|
||||
clearScreen: false,
|
||||
// 2. tauri expects a fixed port, fail if that port is not available
|
||||
server: {
|
||||
port: 1420,
|
||||
strictPort: true,
|
||||
host: host || false,
|
||||
hmr: host
|
||||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
// 3. tell Vite to ignore watching `src-tauri`
|
||||
ignored: ["**/src-tauri/**"],
|
||||
},
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "stable"
|
||||
components = ["rustfmt", "clippy"]
|
||||
@@ -0,0 +1,161 @@
|
||||
// Oracle-driven IFPS auto-correction against the public Eurocontrol IFPUV.
|
||||
//
|
||||
// Given a route, repeatedly validate and mechanically fix the tractable error
|
||||
// classes until the plan is IFPS-ACCEPTED (or no further fix applies):
|
||||
// * ROUTE130 UNKNOWN DESIGNATOR X -> drop token X from item 15
|
||||
// * PROF204/205 forbidden FL band -> move the cruise level into the
|
||||
// allowed window (RAD level capping)
|
||||
// One browser session is reused across iterations (fast loop).
|
||||
//
|
||||
// Usage:
|
||||
// node autofix.mjs '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO","level":"F360"}'
|
||||
// Prints one JSON line: { accepted, level, route, iterations, log, errors }.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const URL = "https://www.public.nm.eurocontrol.int/PUBPORTAL/gateway/spec/";
|
||||
const ROUTE_SEL =
|
||||
'[id="FREE_TEXT_EDITOR.FLIGHT_DATA_AREA.GENERAL_DATA_ENTRY.INTRODUCE_FLIGHT_PLAN_FIELD"]';
|
||||
const VALIDATE_SEL = '[id="FREE_TEXT_EDITOR.FLIGHT_DATA_AREA.VALIDATE_ACTION_LABEL"]';
|
||||
const MAX_ITERS = 8;
|
||||
|
||||
function tomorrowDOF() {
|
||||
const d = new Date(Date.now() + 24 * 3600 * 1000);
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${String(d.getUTCFullYear()).slice(2)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}`;
|
||||
}
|
||||
|
||||
function buildFpl(o, route, flNum) {
|
||||
const type = o.actype || "A320";
|
||||
const level = `F${String(flNum).padStart(3, "0")}`;
|
||||
return (
|
||||
`(FPL-TEST01-IS\n` +
|
||||
`-${type}/${o.wtc || "M"}-${o.equip || "SDE1E2E3FGHIRWY/LB1"}\n` +
|
||||
`-${o.adep}${o.eobt || "1200"}\n` +
|
||||
`-${o.speed || "N0450"}${level} ${route.trim()}\n` +
|
||||
`-${o.ades}${o.eet || "0040"}\n` +
|
||||
`-PBN/${o.pbn || "A1B1C1D1O1S2"} REG/${o.reg || "FGKXA"} DOF/${o.dof || tomorrowDOF()})`
|
||||
);
|
||||
}
|
||||
|
||||
const HOUSEKEEPING = /^(EFPM(51|234|168|166)|PROF191)$/;
|
||||
function parseErrors(body) {
|
||||
const start = body.indexOf("Validation Results");
|
||||
const region = start >= 0 ? body.slice(start) : body;
|
||||
const re = /\b([A-Z]{2,6}\d{1,4}):\s*([^\n\t]+)/g;
|
||||
const errors = [];
|
||||
let m;
|
||||
while ((m = re.exec(region))) {
|
||||
if (!HOUSEKEEPING.test(m[1])) errors.push({ code: m[1], msg: m[2].trim() });
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
// Extract the forbidden FL band(s) from a PROF204/205 message. `Fa..Fb`:
|
||||
// a==0 -> forbidden below b (a floor); b>=600 -> forbidden above a (a ceiling).
|
||||
function bandOf(msg) {
|
||||
const m = msg.match(/F(\d{1,3})\.\.F(\d{3})/);
|
||||
if (!m) return null;
|
||||
const lo = +m[1], hi = +m[2];
|
||||
if (lo === 0) return { floor: hi }; // forbidden 0..hi => must fly above hi
|
||||
return { ceiling: lo }; // Fa..F999 ceiling, or narrow hole Fa..Fb → fly below a
|
||||
}
|
||||
|
||||
// Decide the next fix from the current error set. Returns { route?, fl?, note } or null.
|
||||
function planFix(errors, route, fl) {
|
||||
// 1) unknown designators -> strip the offending token
|
||||
const unknown = errors
|
||||
.filter((e) => e.code === "ROUTE130" && /UNKNOWN DESIGNATOR/i.test(e.msg))
|
||||
.map((e) => e.msg.match(/UNKNOWN DESIGNATOR\s+(\S+)/i)?.[1])
|
||||
.filter(Boolean);
|
||||
if (unknown.length) {
|
||||
const drop = new Set(unknown);
|
||||
const toks = route.split(/\s+/).filter((t) => !drop.has(t));
|
||||
// collapse dangling DCT left by removal
|
||||
const cleaned = toks.filter((t, i) => !(t === "DCT" && (i === 0 || i === toks.length - 1 || toks[i - 1] === "DCT")));
|
||||
return { route: cleaned.join(" "), note: `drop unknown designator(s): ${[...drop].join(", ")}` };
|
||||
}
|
||||
// 2) FL band restrictions -> move into the allowed window
|
||||
let ceiling = Infinity, floor = 0, sawBand = false;
|
||||
for (const e of errors) {
|
||||
if (e.code !== "PROF204" && e.code !== "PROF205") continue;
|
||||
const b = bandOf(e.msg);
|
||||
if (!b) continue;
|
||||
sawBand = true;
|
||||
if (b.ceiling != null) ceiling = Math.min(ceiling, b.ceiling);
|
||||
if (b.floor != null) floor = Math.max(floor, b.floor);
|
||||
}
|
||||
// Lower the level whenever the current FL violates a RAD ceiling — even if
|
||||
// other (non-level) errors remain; clearing the band errors is strict progress
|
||||
// and the loop stops afterwards if nothing else is fixable.
|
||||
if (sawBand && ceiling !== Infinity && fl >= ceiling) {
|
||||
const target = Math.floor((ceiling - 5) / 10) * 10; // highest 10s FL below the cap
|
||||
if (target > floor && target !== fl && target >= 60) {
|
||||
return { fl: target, note: `RAD level cap ${ceiling} -> retry at F${String(target).padStart(3, "0")}` };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// ── run ────────────────────────────────────────────────────────────────────
|
||||
const o = JSON.parse(process.argv[2] || '{"adep":"LFPG","ades":"EGLL","route":"OPALE DCT KESAX DCT DIMAL DCT ALESO","level":"F360"}');
|
||||
let route = o.route.trim();
|
||||
let fl = parseInt(String(o.level || "F350").replace(/\D/g, ""), 10) || 350;
|
||||
|
||||
const b = await chromium.launch({ headless: true });
|
||||
const ctx = await b.newContext({ viewport: { width: 1400, height: 950 } });
|
||||
const page = await ctx.newPage();
|
||||
const out = { accepted: false, level: null, route: null, iterations: 0, log: [], errors: [], error: null };
|
||||
try {
|
||||
await page.goto(URL, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
await page.waitForTimeout(8000);
|
||||
const popupP = ctx.waitForEvent("page", { timeout: 15000 }).catch(() => null);
|
||||
await page.getByText("Free Text Editor", { exact: false }).first().click({ timeout: 15000 });
|
||||
const ed = (await popupP) || page;
|
||||
await ed.waitForSelector(ROUTE_SEL, { timeout: 20000 });
|
||||
|
||||
const submit = async (fpl) => {
|
||||
await ed.locator(ROUTE_SEL).fill(fpl);
|
||||
await ed.locator(VALIDATE_SEL).click({ timeout: 15000 });
|
||||
await ed.waitForTimeout(6000);
|
||||
return parseErrors(await ed.locator("body").innerText());
|
||||
};
|
||||
|
||||
// Monotonic auto-correction: only *keep* a change if it strictly reduces the
|
||||
// error count. A fix that makes things worse (e.g. lowering FL into a low-level
|
||||
// TMA full of DCT restrictions) is reverted, and we report the best state seen.
|
||||
// The result is never worse than the input.
|
||||
const fl3 = (n) => `F${String(n).padStart(3, "0")}`;
|
||||
let best = null; // { route, fl, errors }
|
||||
let curRoute = route;
|
||||
let curFl = fl;
|
||||
for (let i = 1; i <= MAX_ITERS; i++) {
|
||||
out.iterations = i;
|
||||
const errors = await submit(buildFpl(o, curRoute, curFl));
|
||||
if (errors.length === 0) {
|
||||
best = { route: curRoute, fl: curFl, errors: [] };
|
||||
out.log.push(`iter ${i}: ${fl3(curFl)} "${curRoute}" -> ACCEPTED`);
|
||||
break;
|
||||
}
|
||||
if (best !== null && errors.length >= best.errors.length) {
|
||||
// The last applied fix didn't help — revert to the best and stop.
|
||||
out.log.push(`iter ${i}: ${fl3(curFl)} -> ${errors.length} err (not better than ${best.errors.length}) — reverting`);
|
||||
break;
|
||||
}
|
||||
best = { route: curRoute, fl: curFl, errors };
|
||||
const fix = planFix(errors, curRoute, curFl);
|
||||
out.log.push(`iter ${i}: ${fl3(curFl)} -> ${errors.length} err [${errors.map((e) => e.code).join(",")}]${fix ? " | fix: " + fix.note : " | no auto-fix"}`);
|
||||
if (!fix) break;
|
||||
if (fix.route != null) curRoute = fix.route;
|
||||
if (fix.fl != null) curFl = fix.fl;
|
||||
}
|
||||
best = best || { route: curRoute, fl: curFl, errors: out.errors };
|
||||
out.accepted = best.errors.length === 0;
|
||||
out.level = fl3(best.fl);
|
||||
out.route = best.route;
|
||||
out.errors = best.errors;
|
||||
} catch (e) {
|
||||
out.error = e.message;
|
||||
} finally {
|
||||
await b.close();
|
||||
}
|
||||
console.log(JSON.stringify(out));
|
||||
|
After Width: | Height: | Size: 31 KiB |