Sport-Specific Flight Analysis Investigation
Moved from vulture-ios/Docs/sport-specific-analysis.md
Status: Phases 1 and 3 shipped (in a different shape than planned here, see Where this stands), and so did Phase 5 — a setting for the auto-detection — on 2026-09-21; Phases 2 and 4 are open Date: 2026-08-12, status and Phase 5 added 2026-09-21
Where this stands (2026-09-21)
Section titled “Where this stands (2026-09-21)”The investigation below is kept as written; this is what the code does now.
- Thresholds per sport exist.
SportProfile(Packages/Flights/Sources/Flights/Enricher/SportProfile.swift) carries the takeoff, landing, thermal, wind and leg thresholds, with.glider,.parapenteand.deltapresets, and the times, takeoff and phases enrichers take it as a parameter. - The sport is detected unless the pilot switched that off (Phase 5).
FlightEnricher.detectSportProfile(from:)classifies a parsed flight from its 95th-percentile groundspeed — above 100 km/h a glider, 80–100 km/h a delta, below that a parapente — and stores the result onFlight.detectedSportProfile. Not the average cruise speed proposed under Auto-Detection Possibility, and not a separateFlightSportEnricher: the detection runs in the enricher’sinit, before anything else. - The Settings sport reaches a parsed flight only with the detection off.
On the flight map the flight’s profile replaces the marker too
(
FlightMapViewDataSource+Delegate→GliderAnnotationView.sportProfileOverride), so with the detection on the sport picked in Settings styles the in-flight and location markers, and nothing else. - Nothing is stored per flight (Phase 2), and the settings screen has no explanation of what the sport changes beyond its footer (Phase 4).
- The paths in this document predate the
EagleandScenespackages being dissolved (2026-09-07):Sportand the preference live inPackages/Preferences, the screen inPackages/SettingsScreens/Sources/SettingsScreens/Settings/Sports/, the marker views inModules/App/Sources/Map/Rendering/Views/.
Context
Section titled “Context”Vulture supports three sports — gliding, hang gliding, and paragliding — but the sport selection currently only changes the aircraft icon on the map. All flight analysis (thermal detection, takeoff/landing, distance optimization) uses identical hardcoded thresholds regardless of sport. This means paraglider flights are analyzed with sailplane assumptions, leading to missed thermals, incorrect takeoff detection, and inaccurate phase analysis.
Current Implementation
Section titled “Current Implementation”Sport Enum
Section titled “Sport Enum”File: Packages/Eagle/Sources/Eagle/Models/Sport.swift
Three cases: .glider, .parapente, .delta. Stored in UserDefaults with key VultureAnnotationStyle, synced to iCloud.
What the sport setting does today
Section titled “What the sport setting does today”- Changes the aircraft icon on the map (3 annotation views subscribe via Combine)
- Reports analytics events when changed
- Nothing else — no impact on flight analysis
What it does NOT do
Section titled “What it does NOT do”- No sport-specific thermal detection thresholds
- No sport-specific takeoff/landing speed
- No sport-specific distance optimization
- No per-flight sport storage (global setting only)
- No auto-detection from flight data
- No sport context in the settings UI
Analysis Thresholds That Should Differ Per Sport
Section titled “Analysis Thresholds That Should Differ Per Sport”Thermal Detection (FlightPhasesEnricher.swift)
Section titled “Thermal Detection (FlightPhasesEnricher.swift)”| Parameter | Current | Glider | Hang Glider | Paraglider | Why |
|---|---|---|---|---|---|
| Turn radius max | 200m (line 62) | 150-250m | 200-350m | 80-150m | Paragliders circle much tighter; hang gliders wider |
| Thermal clustering | 500m (line 68) | 500m | 500m | 300m | Paraglider thermals drift less due to lower speed |
| Min turn count | 10 (line 145) | 10 | 8 | 6 | Paragliders complete tighter circles faster |
| Min thermal duration | 20s (line 122) | 20s | 20s | 15s | Faster turn completion in paragliders |
| Min leg duration | 60s (line 261) | 60s | 45s | 30s | Shorter transitions between thermals |
Takeoff & Landing Detection
Section titled “Takeoff & Landing Detection”Files: FlightTakeoffEnricher.swift, FlightLandingEnricher.swift, Enricher.swift
| Parameter | Current | Glider | Hang Glider | Paraglider | Why |
|---|---|---|---|---|---|
| Min flying speed | 30 km/h (Enricher.swift:14) | 45 km/h | 25 km/h | 18 km/h | Stall speeds differ dramatically |
| Takeoff duration | 15s (line 66) | 15s | 10s | 8s | Foot-launched sports accelerate differently |
| Landing duration | 30s (LandingEnricher:51) | 30s | 20s | 15s | Paragliders decelerate faster on landing |
| Winch climb rate | 8 m/s (line 73) | 8 m/s | n/a | n/a | Only relevant for gliders |
| Release detection | yes | yes | no | no | Hang gliders and paragliders are self-launched or towed differently |
| Engine level (ENL) | 700 (line 41) | 700 | n/a | n/a | Only relevant for motorgliders |
Distance Optimization (FlightDistanceEnricher.swift)
Section titled “Distance Optimization (FlightDistanceEnricher.swift)”| Parameter | Current | Glider | Hang Glider | Paraglider |
|---|---|---|---|---|
| Calculation step | 51 fixes (line 28) | 51 | 51 | 25 (slower, denser track) |
| Triangle closure | ~79m (line 103) | 79m | 79m | 50m (shorter distances) |
Wind Estimation
Section titled “Wind Estimation”| Parameter | Current | Glider | Hang Glider | Paraglider |
|---|---|---|---|---|
| Min thermal for wind calc | 90s (line 161) | 90s | 90s | 60s (more wind-affected, shorter thermals still useful) |
Architecture: Per-Flight Sport Storage
Section titled “Architecture: Per-Flight Sport Storage”Problem
Section titled “Problem”The sport is a global setting, not stored per flight. This means:
- A pilot who flies both glider and paraglider has to manually switch before analyzing
- All historical flights are re-analyzed with the current sport setting
- There’s no way to have correct analysis for a mixed logbook
Proposed Solution
Section titled “Proposed Solution”- Add
sportfield toCoreFlight(Core Data entity in Eagle package) — persisted per flight - Default to the current global setting when importing a new flight
- Allow override per flight in the flight detail UI
- Pass sport to enrichers as a parameter instead of using hardcoded values
Auto-Detection Possibility
Section titled “Auto-Detection Possibility”The sport could be auto-detected from IGC flight data:
| Signal | Glider | Hang Glider | Paraglider |
|---|---|---|---|
| Typical cruise speed | 80-250 km/h | 30-80 km/h | 25-55 km/h |
| Typical thermal turn radius | 150-250m | 200-350m | 80-150m |
| IGC glider type header | ASK-21, LS8, etc. | Wills Wing, etc. | Ozone, Gin, etc. |
| Takeoff method | Winch/tow/self-launch | Foot/tow | Foot/tow |
| ENL data present | Often | Rarely | Rarely |
| Max groundspeed | >200 km/h | <120 km/h | <70 km/h |
A simple heuristic based on average cruise speed would catch most cases:
- Avg cruise speed > 70 km/h → glider
- Avg cruise speed 40-70 km/h → hang glider
- Avg cruise speed < 40 km/h → paraglider
This could run after the speed enricher and before the phases enricher in the pipeline.
UI Changes
Section titled “UI Changes”Settings Screen
Section titled “Settings Screen”Currently the sport selection is a simple 3-icon grid with no context. Improvements:
- Add description per sport explaining what changes:
- “Optimizes thermal detection, takeoff/landing thresholds, and map icons for [sport]”
- Show which parameters are affected (optional, for advanced users)
- Add a “per-flight” note once per-flight storage is implemented:
- “This sets the default for new flights. You can change the sport per flight in the flight detail.”
File: Modules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionViewController.swift
Flights List
Section titled “Flights List”Currently all flights show the same icon. With per-flight sport storage:
- Show the sport icon next to each flight in the list
- Color-code or badge flights by sport type
- Filter by sport in the flights list
Already implemented — annotation views subscribe to Settings.sport and show the correct icon. With per-flight storage, the annotation views should read from the flight’s sport property instead of the global setting.
Implementation Approach
Section titled “Implementation Approach”Phase 1: Sport-Specific Thresholds (No Data Model Changes)
Section titled “Phase 1: Sport-Specific Thresholds (No Data Model Changes)”Create a SportProfile struct that maps sport to analysis thresholds. Pass it through the enricher pipeline. Use the global setting for now.
Files to modify:
Packages/Flights/Sources/Flights/Enricher/Enricher.swift— addSportProfileparameterPackages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift— use profile thresholdsPackages/Flights/Sources/Flights/Enricher/FlightTakeoffEnricher.swift— use profile thresholdsPackages/Flights/Sources/Flights/Enricher/FlightLandingEnricher.swift— use profile thresholdsPackages/Flights/Sources/Flights/Enricher/FlightEnricher.swift— pass sport to pipeline
Phase 2: Per-Flight Sport Storage
Section titled “Phase 2: Per-Flight Sport Storage”Add sport to CoreFlight Core Data entity. Default to global setting on import. Allow override in flight detail.
Files to modify:
Packages/Eagle/Sources/Eagle/Modules/Database/Model/CoreFlight— add sport field- Core Data model migration
- Flight detail UI — sport picker
Phase 3: Auto-Detection
Section titled “Phase 3: Auto-Detection”Add a FlightSportEnricher that runs after speed calculation and before phases. Uses cruise speed heuristic to suggest sport, with user override.
Phase 4: Settings UI Enhancement
Section titled “Phase 4: Settings UI Enhancement”Add descriptions and context to the sport selection screen.
Phase 5: A setting for the auto-detection (built 2026-09-21)
Section titled “Phase 5: A setting for the auto-detection (built 2026-09-21)”Built as planned below, with two differences. The decoder takes the sport as a property,
FlightDecoder.sportProfile, likeisEnrichingEnabled, not as an init parameter. And seven sites follow the setting, not five: the two logger imports (BluetoothSendDataViewModel,FlarmSendDataViewModel) and the recorder (LocationRecorder.saveRecords) write a flight into the store too. The sport footer lost its second sentence — “Flight analysis automatically detects the sport” — which the toggle’s own footer now says, and only while it is true. The open question below was settled by leaving the stored figures of flights already imported alone.Flights on an open map follow a change at once (asked for the same day).
FlightMapViewModel.setupSportBindings()listens to the detection’s switch, and to the sport while the detection is off — both also arrive over iCloud — andreanalyzeFlights()decodes every flight on the map again off the main queue: a logbook flight from its record, any other from the IGC textFlight.contentkeeps (Flight.reanalyzed()). Each gets a newFlightViewModel(FlightViewModel.reanalyzed()), because a view model’s shapes are lazily built from itslet flight; the new one keeps the colour, rank, download source and start-alignment offset. The batch then replaces the old one through the ordinary remove-all and add-batch publishers, so every observer — map, panels, graph, inspector — takes it as it takes any batch. What keeps it from feeling like a reload: the selected index and the glider’s time are put back, the selected task stays (the new flights were given it while decoding), andwillReanalyzeFlightshas the controller park the current viewport inpendingViewport, the slot a handed-over map uses, so presenting the panel does not re-centre. Nothing is re-analysed in place:FlightEnricher.improveFlightData()shifts fix times and pressure altitudes and is not safe to run twice on oneFlight. A switch to “off” is skipped when every flight already reads as the pilot’s sport, and a change arriving mid-run is picked up when the run ends.
The request. Make the detection of the sport during flight parsing configurable: a toggle on the Sport settings screen, on by default, synced over iCloud. Off means every flight is analysed — and drawn — as the sport selected on that screen, which is what a pilot wants whose slow glider flight is taken for a delta, or whose fast delta flight for a glider.
The constraint that shapes it. Preferences depends on Flights
(Sport.sportProfile returns a SportProfile), so Flights cannot read the
preference. The choice travels into the decoder as a parameter, and the app
resolves it where it builds a decoder.
Steps, one commit each:
- Preferences —
detectsSportAutomatically, defaulttrue, defaults keyVultureDetectSportAutomatically, mirrored to the iCloud key-value store the waysportis: written in itsupdate, merged in the initial cloud/local reconciliation, and picked up in the external-change handler. A publisher besidesport’s. Tests on an isolated store: the default, the write-through, the external change. - Flights —
FlightDecodergains asportProfile: SportProfile?property (or a defaulted init parameter,nil), handed toFlightEnricher;nildetects as today, a value is used as given and is what lands onFlight.detectedSportProfile. Defaulted, so no public signature breaks. Tests: a forced profile is kept on a flight that would detect as something else;nilstill detects. - App call sites — the decoders that analyse a flight for the pilot pass
nilwhen the toggle is on andPreferences.shared.sportValue.sportProfilewhen it is off:FlightViewModel,DecodableFlight,FlightDocument,FlightItemProvider, andSplitViewModel.importFlight(at:)— the import is the one that writes a flight’s takeoff time and duration into the store, so it must agree with what opening the flight will show. One helper onPreferences(parsingSportProfile) so the five sites read the same thing; grepFlightDecoderandcreateFlight(in:again before starting, the list is as of 2026-09-21. The decoders that only draw a track keep detecting and need no change: Quick Look, Thumbnail, the App Clip,TrackPreviewGenerator,GalleryArtworkGenerator, the SkyLines upload. - Sport settings screen — a second
Sectionunder the tiles, holding aSettingsToggleRowwith the explanation as the section’s footer (the group-per-setting pattern).SportsSettingsProvidinggains the value, its update, and a merged did-change publisher;MockSportsSettingsasend…()helper; the view model tests the initial state, the write-through and the refresh after an external change. Strings in the DesignSystem catalog, all 8 locales, with a resolution test. - Changelog under
Added, and a release-note line — pilots will notice.
Proposed English copy. Title: Detect Sport Automatically. Footer: Vulture works out from a flight’s speed whether it was flown in a glider, a hang glider or a paraglider, and analyses it accordingly. Turn this off to analyse every flight as the sport selected above.
Open question — what the logbook already holds. Opening a flight parses
and analyses it again every time — FlightCache holds terrain elevations
only, not the analysis — so the map, the graph and the detail follow the
toggle from the next opening on, with nothing to invalidate. What does not
follow is what was written into the store at import: CoreFlight.takeoffAt
and duration (CoreFlight+CoreData.swift) come from the takeoff and
landing the import-time profile found, and the list rows, the statistics and
the widgets read those. A flight imported as a detected delta keeps that
takeoff time in its row after the toggle goes off, while its map shows the
glider’s. Either accept that and say in the footer that the logbook’s times
are those of the import, or re-derive the stored figures when a flight is
opened and they differ (check first whether opening a flight already writes
back to its CoreFlight). Undecided; the difference is seconds in the
ordinary case, so accepting it was the recommendation.
How it sits with Phase 2. A per-flight sport would override both: the order becomes the flight’s own sport, else the detection when it is on, else the Settings sport.
Key Files Reference
Section titled “Key Files Reference”| Component | Path |
|---|---|
| Sport enum | Packages/Eagle/Sources/Eagle/Models/Sport.swift |
| Sport icons | Modules/App/Sources/Components/Extensions/Sport+Image.swift |
| Sport icon assets | Modules/App/Resources/Assets.xcassets/Icons/{Glider,Parapente,Delta}.imageset/ |
| Settings storage | Packages/Eagle/Sources/Eagle/Modules/Settings/Settings.swift (lines 89-90, 467-475) |
| Sport selection UI | Modules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionViewController.swift |
| Sport selection VM | Modules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionViewModel.swift |
| Sport selection cell | Modules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionCollectionViewCell.swift |
| Enricher base | Packages/Flights/Sources/Flights/Enricher/Enricher.swift (minimumSpeed at line 14) |
| Enricher pipeline | Packages/Flights/Sources/Flights/Enricher/FlightEnricher.swift |
| Thermal detection | Packages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift |
| Takeoff detection | Packages/Flights/Sources/Flights/Enricher/FlightTakeoffEnricher.swift |
| Landing detection | Packages/Flights/Sources/Flights/Enricher/FlightLandingEnricher.swift |
| Distance optimization | Packages/Flights/Sources/Flights/Enricher/FlightDistanceEnricher.swift |
| Speed calculation | Packages/Flights/Sources/Flights/Enricher/FlightSpeedEnricher.swift |
| Thermal model | Packages/Flights/Sources/Flights/Models/Thermal.swift |
| Thermal geometry | Packages/Flights/Sources/Flights/Extensions/CLLocationCoordinate2D+Thermal.swift |
| In-flight annotation | Modules/App/Sources/Components/Map/Views/InFlightAnnotationView.swift |
| Flight annotation | Modules/App/Sources/Components/Map/Views/GliderAnnotationView.swift |
| Location annotation | Modules/App/Sources/Components/Map/Views/LocationAnnotationView.swift |
