Skip to content

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

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, .parapente and .delta presets, 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 on Flight.detectedSportProfile. Not the average cruise speed proposed under Auto-Detection Possibility, and not a separate FlightSportEnricher: the detection runs in the enricher’s init, 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 Eagle and Scenes packages being dissolved (2026-09-07): Sport and the preference live in Packages/Preferences, the screen in Packages/SettingsScreens/Sources/SettingsScreens/Settings/Sports/, the marker views in Modules/App/Sources/Map/Rendering/Views/.

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.


File: Packages/Eagle/Sources/Eagle/Models/Sport.swift

Three cases: .glider, .parapente, .delta. Stored in UserDefaults with key VultureAnnotationStyle, synced to iCloud.

  1. Changes the aircraft icon on the map (3 annotation views subscribe via Combine)
  2. Reports analytics events when changed
  3. Nothing else — no impact on flight analysis
  • 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)”
ParameterCurrentGliderHang GliderParagliderWhy
Turn radius max200m (line 62)150-250m200-350m80-150mParagliders circle much tighter; hang gliders wider
Thermal clustering500m (line 68)500m500m300mParaglider thermals drift less due to lower speed
Min turn count10 (line 145)1086Paragliders complete tighter circles faster
Min thermal duration20s (line 122)20s20s15sFaster turn completion in paragliders
Min leg duration60s (line 261)60s45s30sShorter transitions between thermals

Files: FlightTakeoffEnricher.swift, FlightLandingEnricher.swift, Enricher.swift

ParameterCurrentGliderHang GliderParagliderWhy
Min flying speed30 km/h (Enricher.swift:14)45 km/h25 km/h18 km/hStall speeds differ dramatically
Takeoff duration15s (line 66)15s10s8sFoot-launched sports accelerate differently
Landing duration30s (LandingEnricher:51)30s20s15sParagliders decelerate faster on landing
Winch climb rate8 m/s (line 73)8 m/sn/an/aOnly relevant for gliders
Release detectionyesyesnonoHang gliders and paragliders are self-launched or towed differently
Engine level (ENL)700 (line 41)700n/an/aOnly relevant for motorgliders

Distance Optimization (FlightDistanceEnricher.swift)

Section titled “Distance Optimization (FlightDistanceEnricher.swift)”
ParameterCurrentGliderHang GliderParaglider
Calculation step51 fixes (line 28)515125 (slower, denser track)
Triangle closure~79m (line 103)79m79m50m (shorter distances)
ParameterCurrentGliderHang GliderParaglider
Min thermal for wind calc90s (line 161)90s90s60s (more wind-affected, shorter thermals still useful)

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
  1. Add sport field to CoreFlight (Core Data entity in Eagle package) — persisted per flight
  2. Default to the current global setting when importing a new flight
  3. Allow override per flight in the flight detail UI
  4. Pass sport to enrichers as a parameter instead of using hardcoded values

The sport could be auto-detected from IGC flight data:

SignalGliderHang GliderParaglider
Typical cruise speed80-250 km/h30-80 km/h25-55 km/h
Typical thermal turn radius150-250m200-350m80-150m
IGC glider type headerASK-21, LS8, etc.Wills Wing, etc.Ozone, Gin, etc.
Takeoff methodWinch/tow/self-launchFoot/towFoot/tow
ENL data presentOftenRarelyRarely
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.


Currently the sport selection is a simple 3-icon grid with no context. Improvements:

  1. Add description per sport explaining what changes:
    • “Optimizes thermal detection, takeoff/landing thresholds, and map icons for [sport]”
  2. Show which parameters are affected (optional, for advanced users)
  3. 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

Currently all flights show the same icon. With per-flight sport storage:

  1. Show the sport icon next to each flight in the list
  2. Color-code or badge flights by sport type
  3. 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.


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 — add SportProfile parameter
  • Packages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift — use profile thresholds
  • Packages/Flights/Sources/Flights/Enricher/FlightTakeoffEnricher.swift — use profile thresholds
  • Packages/Flights/Sources/Flights/Enricher/FlightLandingEnricher.swift — use profile thresholds
  • Packages/Flights/Sources/Flights/Enricher/FlightEnricher.swift — pass sport to pipeline

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

Add a FlightSportEnricher that runs after speed calculation and before phases. Uses cruise speed heuristic to suggest sport, with user override.

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, like isEnrichingEnabled, 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 — and reanalyzeFlights() decodes every flight on the map again off the main queue: a logbook flight from its record, any other from the IGC text Flight.content keeps (Flight.reanalyzed()). Each gets a new FlightViewModel (FlightViewModel.reanalyzed()), because a view model’s shapes are lazily built from its let 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), and willReanalyzeFlights has the controller park the current viewport in pendingViewport, 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 one Flight. 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:

  1. Preferences — detectsSportAutomatically, default true, defaults key VultureDetectSportAutomatically, mirrored to the iCloud key-value store the way sport is: written in its update, merged in the initial cloud/local reconciliation, and picked up in the external-change handler. A publisher beside sport’s. Tests on an isolated store: the default, the write-through, the external change.
  2. Flights — FlightDecoder gains a sportProfile: SportProfile? property (or a defaulted init parameter, nil), handed to FlightEnricher; nil detects as today, a value is used as given and is what lands on Flight.detectedSportProfile. Defaulted, so no public signature breaks. Tests: a forced profile is kept on a flight that would detect as something else; nil still detects.
  3. App call sites — the decoders that analyse a flight for the pilot pass nil when the toggle is on and Preferences.shared.sportValue.sportProfile when it is off: FlightViewModel, DecodableFlight, FlightDocument, FlightItemProvider, and SplitViewModel.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 on Preferences (parsingSportProfile) so the five sites read the same thing; grep FlightDecoder and createFlight(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.
  4. Sport settings screen — a second Section under the tiles, holding a SettingsToggleRow with the explanation as the section’s footer (the group-per-setting pattern). SportsSettingsProviding gains the value, its update, and a merged did-change publisher; MockSportsSettings a send…() 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.
  5. 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.


ComponentPath
Sport enumPackages/Eagle/Sources/Eagle/Models/Sport.swift
Sport iconsModules/App/Sources/Components/Extensions/Sport+Image.swift
Sport icon assetsModules/App/Resources/Assets.xcassets/Icons/{Glider,Parapente,Delta}.imageset/
Settings storagePackages/Eagle/Sources/Eagle/Modules/Settings/Settings.swift (lines 89-90, 467-475)
Sport selection UIModules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionViewController.swift
Sport selection VMModules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionViewModel.swift
Sport selection cellModules/App/Sources/Modules/Settings/Controllers/Sport/SportSelectionCollectionViewCell.swift
Enricher basePackages/Flights/Sources/Flights/Enricher/Enricher.swift (minimumSpeed at line 14)
Enricher pipelinePackages/Flights/Sources/Flights/Enricher/FlightEnricher.swift
Thermal detectionPackages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift
Takeoff detectionPackages/Flights/Sources/Flights/Enricher/FlightTakeoffEnricher.swift
Landing detectionPackages/Flights/Sources/Flights/Enricher/FlightLandingEnricher.swift
Distance optimizationPackages/Flights/Sources/Flights/Enricher/FlightDistanceEnricher.swift
Speed calculationPackages/Flights/Sources/Flights/Enricher/FlightSpeedEnricher.swift
Thermal modelPackages/Flights/Sources/Flights/Models/Thermal.swift
Thermal geometryPackages/Flights/Sources/Flights/Extensions/CLLocationCoordinate2D+Thermal.swift
In-flight annotationModules/App/Sources/Components/Map/Views/InFlightAnnotationView.swift
Flight annotationModules/App/Sources/Components/Map/Views/GliderAnnotationView.swift
Location annotationModules/App/Sources/Components/Map/Views/LocationAnnotationView.swift