Skip to content

In flight and tools

Recording, the in-flight screen, the Live Activity, the Watch app, nearest airports and the weather screens exist only on iOS. Android has none of them in v1 (see the feature pages under docs/features/recording/, apple-platforms/ and weather/). The one shared tool is the measure tool, which Android ports.

All distances and bearings on this page use the spherical helpers of Packages/Map (haversine with R = 6371 km, initial great-circle bearing in (0, 360]), specified in Flight analysis §7.1. Unit conversion and the UnitFormatter rules are in Flight analysis §9.2–9.3. CLLocationAltitude is a type alias for Int in Vulture (Packages/Waypoints/Sources/Waypoints/Aliases.swift), so every altitude below is whole metres.

What every in-flight value is computed from.

Inputs: Core Location (activityType = .airborne, kCLLocationAccuracyBest, background updates on) and, on iPhone, the barometer (CMAltimeter relative-altitude updates).

Algorithm. A 1.0 s repeating Timer on the main run loop (.common mode) builds one fix per tick from CLLocationManager.location, the latest location Core Location holds. One extra fix is built immediately when location becomes available. The location delegate itself does not produce fixes.

FieldFormulaUnit
coordinatelocation.coordinatedegrees
gpsAltitudeInt(location.altitude) (truncated toward zero)m MSL
pressureInt(altitudeData.pressure × 10); CMAltimeter reports kPa, so this is whole hPa (truncated)hPa
timestampDate() at the timer tick, not location.timestamp
speedlocation.speedm/s
bearingInt(previous.coordinate.bearing(to: current.coordinate))degrees
vario(alt − prevAlt) / (t − prevT), using the two CLLocations’ own timestampsm/s

Edge cases:

  • bearing is nil and vario is 0 on the first fix.
  • When Core Location has not delivered a new location since the last tick, the same CLLocation is read again. The fix then repeats the coordinate under a new timestamp, and vario is 0/0 = NaN.
  • location.speed is −1 when Core Location has no valid speed, and that value passes through unchanged.
  • The doc comment on LocationFix.pressure says “pressure altitude, in meters”. The value is actually hPa.
  • On watchOS the barometer is not started, so pressure is always nil there.

Source: vulture-ios/Packages/FlightRecording/Sources/FlightRecording/Location/Location.swift (updateLocation(_:), NSNumber.correctedPressure), .../Location/LocationFix.swift.

Every fix goes to LocationRecorder.record. The recorder appends the fix to an in-memory buffer and inserts a FixRecord into its own Core Data store, so the recording survives a crash. The recording interval is therefore the 1 s timer above. Mark event sets a flag, and the flag is stored on the next record as isPilotEvent.

FixRecord keeps the time, coordinate, GPS altitude, speed, bearing, vario, heart rate and SpO2. It does not keep pressure. On save, the records (sorted by recordedAt) are written as IGC and decoded straight back through FlightDecoder with the pilot’s parsing sport profile. The IGC line format, including “pressure altitude = GPS altitude”, is specified in Flight analysis §9.5.

Source: vulture-ios/Packages/FlightRecording/Sources/FlightRecording/Location/LocationRecorder.swift, .../Location/Model/FixRecord.swift, .../Location/Extensions/FixRecord+IGC.swift.

The in-flight view model (InFlightMapViewModel, iPhone and iPad) handles each fix in this order:

  1. If a release is known, append the coordinate to rawCoordinates and run the distance step.
  2. Run the thermal step (always, also before take-off).
  3. Append the fix to fixes.
  4. While the take-off or the release is unknown, run the live take-off detector (FlightTakeoffEnricher) again over all of fixes. This detector is specified in Flight analysis §7.7. Once both are found, the detector stops running.
  5. Trim fixes to the newest 3700, about an hour at 1 Hz.
  6. Throttle by wall clock: when at least 1.0 s has passed since the last refresh, run the wind step, rebuild every cell and update the Live Activity.

When the screen opens, the view model seeds fixes with the recorder’s buffer and runs the detector once. When a release is found there, rawCoordinates becomes the coordinates from the release index onwards.

landingLocation is declared but never set, so no landing is detected in flight.

ConstantValue
Fix interval1.0 s
Fix buffer, iPhone3700 fixes
Fix buffer, Watch120 fixes
Cell refresh throttle≥ 1.0 s wall clock
Optimised distance recomputeevery 10th coordinate since the release

Source: vulture-ios/Modules/App/Sources/Map/InFlight/InFlightMapViewModel.swift (init, handle, reloadSnapshot), vulture-ios/Packages/Flights/Sources/Flights/Enricher/FlightTakeoffEnricher.swift.

The cells come in three sections: Flying, Target (headed Task when a task is loaded) and Performance. A Debug section is added in debug builds. Every value is computed from the newest fix fix. A cell without a value shows a placeholder.

ref = the newest fix in `fixes` with fix.timestamp − ref.timestamp ≥ 25 s
vario = (fix.gpsAltitude − ref.gpsAltitude) / 25.0 // m/s

The divisor is always 25, even when the real gap between the two fixes is longer (after a gap in the fixes). The cell stays empty until 25 s of fixes exist. The value is formatted with the vertical-speed unit and no unit label (rawVerticalSpeedString).

One cell, switched by Show flight level (preferences.showFlightLevel, toggled from the cell):

MSL = fix.gpsAltitude // m, formatted in the altitude unit
FL = Int((1013.2 − pressure_hPa) × 30 / 100) // shown as "FL {n}"

The flight level uses the rule of thumb of 30 ft per hPa below 1013.2 hPa, truncated toward zero. It is computed from the raw barometer pressure, with no QNH or temperature correction. FL is nil without a barometer, so it is always nil on the Watch.

AGL = fix.gpsAltitude − terrainElevation(fix.coordinate) // m

The terrain comes from the SRTM3 tile of the fix: the same endpoint and tile lookup as Flight analysis §8.1 ([Int16].elevation(for:), 0 outside the tile, voids not filtered). Tiles are downloaded on demand, one at a time per tile name, and cached in memory for the session. AGL is nil while the tile is downloading. A failed download is retried on the next fix.

The cell shows AGL converted to the pilot’s altitude unit, without the unit (the cell’s label carries it), as the MSL cell does. It showed raw metres before Calculation fixes §4 (2026-10-20). The Live Activity converts it with the unit.

fix.speed (m/s from Core Location), formatted in the speed unit. It is not smoothed. Core Location’s −1 for “no valid speed” is formatted as is.

The live counterpart of thermal detection (Flight analysis §5.2–5.3), using a cluster of circling fixes:

  1. Fit a circle through the previous two fixes and the new one (Flight analysis §5.2). Stop unless radius < thermalTurnRadiusLimit.
  2. If haversine(thermalFixes.last.coordinate, circle.center) < thermalClusteringDistance, append the fix to thermalFixes. Otherwise start a new cluster [fix].
  3. When the cluster has more than one fix and spans duration = t_last − t_first > 20 s: averageVario = (last.gpsAltitude − first.gpsAltitude) / duration (m/s).
Sport profilethermalTurnRadiusLimitthermalClusteringDistance
Glider (default)200 m500 m
Paraglider150 m300 m
Hang glider300 m500 m

The profile is preferences.sportValue.sportProfile, the pilot’s chosen sport, not a detected one. The value is never cleared when circling stops, so the cell keeps showing the last thermal’s average until a new cluster passes 20 s. Unlike the post-flight analysis, there is no minimum turn count and no rejection step.

Computed from the drift of the current thermal cluster, at most once per refresh:

duration = t_last − t_first // over thermalFixes; requires > 90 s and ≥ 2 fixes
speed = haversine(first, last) / duration // m/s
direction = Int(bearing(from: last, to: first)) // degrees, where the wind comes from

The cell shows the speed in the wind unit and the direction as "{n}°". The arrow is rotated by (direction − 180)°. The value is never cleared, so the last estimate stays until a newer cluster passes 90 s.

fix.bearing: the bearing from the previous CLLocation to the current one, truncated to an integer, shown as "{n}°". It is not smoothed.

Target, distance and bearing to it (Target)

Section titled “Target, distance and bearing to it (Target)”

The target is the stored go-to location (Defaults[.locationToName], .locationToLatitude, .locationToLongitude) when a name and both coordinates are set. Otherwise it is the live take-off fix. With no take-off yet there is no target.

distance = haversine(fix.coordinate, target) // m, formatted with 1 decimal, no unit label
bearing = Int(bearing(fix.coordinate → target)) // "{n}°"

Without a target the distance shows 0 (formatted) and the bearing is empty. The line drawn on the map to the target uses the same target rule.

requiredLD = floor(distance_to_target / fix.gpsAltitude) // formatted "%0.f"

The divisor is the GPS altitude above sea level. The ratio ignores the terrain, the target’s elevation and any safety height. With an altitude of 0 the division gives inf, printed as “inf”. The cell is empty without a target.

ref = the newest fix in `fixes` with fix.timestamp − ref.timestamp ≥ 60 s
d = haversine(ref.coordinate, fix.coordinate) // straight line, m
ld = floor(d / (fix.gpsAltitude − ref.gpsAltitude) × −1)
ResultShown
NaN (no movement and no height change)empty
< 0 (climbing, or a height change of 0 with movement, which gives −∞)∞
otherwise"%0.f" of ld

fix.timestamp − takeoff.timestamp in whole seconds, from the live take-off detector. It is formatted HH:mm:ss (timeValue, which wraps at 24 h). It is empty before take-off.

These run on every 10th coordinate since the release (rawCoordinates.count % 10 == 0), on a background queue.

CellFormula
Optimised distanceDistanceEnricher(rawCoordinates).optimalDistance(): the free-distance optimisation of Flight analysis §6, over the coordinates since the release. Shown with 1 decimal. The optimised path is also drawn on the map.
Closed distanceThe same optimisation over rawCoordinates + [rawCoordinates[0]], so the path must end back at the release point. 1 decimal.
Average speed((distance_m / 1000) / (flightTime_s / 3600)) / 3.6: km/h turned back into m/s for the speed formatter
Speed over the last hourOnly when flightTime > 3600 s. Uses the coordinates from the newest kept fix that is at least 3600 s old up to now (RecentTrack.coordinates, counted back from the shared newest end, because fixes and rawCoordinates start at different points). Its optimal distance is taken as km per hour: (distance_m / 1000) / 3.6 m/s. Until then, or while the value is 0, the cell shows the average speed.

Edge case: the iPhone does not check for flightTime > 0, so a zero flight time gives an infinite speed (the Watch checks). The values keep their last result between recomputes.

Source: vulture-ios/Modules/App/Sources/Map/InFlight/InFlightMapViewModel.swift (varioString, flightLevel, altitudeAGL, elevation(for:), processThermal, processWind, goToCoordinate, goToDistanceString, goToBearingString, glideRatioString, currentGlideRatioString, duration, processDistance), .../InFlight/Cell/InFlightWindCollectionViewCell.swift, .../InFlight/DataSource/InFlightMapViewDataSource+TrackToWaypoint.swift, vulture-ios/Packages/FlightRecording/Sources/FlightRecording/Location/RecentTrack.swift, vulture-ios/Packages/Flights/Sources/Flights/Extensions/CLLocationCoordinate2D+Thermal.swift, vulture-ios/Packages/Flights/Sources/Flights/Enricher/SportProfile.swift.

The Live Activity carries four strings, all computed by the iPhone in-flight view model. It is started when the screen opens and updated on every cell refresh. An update is only sent when the content differs from the last one shown.

FieldValue
shortTimeStringFlight time as shortTimeValue: "3h05m", "3h", "05m". Under one minute it gives "0h".
distanceStringOptimised distance, 0 decimals, with unit
averageSpeedStringAverage speed (above), with unit
aglStringAGL converted to the altitude unit, with unit

At start, shortTimeString is the flight-time cell’s current text (HH:mm:ss, usually still nil) rather than shortTimeValue. The first update replaces it.

Source: vulture-ios/Packages/LiveActivity/Sources/LiveActivity/LiveActivityManager.swift, .../InFlightActivityAttributes.swift, vulture-ios/Modules/MetarWidget/Sources/Features/LiveLiveActivity.swift, InFlightMapViewModel.reloadPerformanceData.

The Watch computes its own values with a copy of the iPhone code. It uses the same Location fix source and the same FlightTakeoffEnricher, DistanceEnricher and thermal clustering. The differences:

ValueWatch
Fix buffer120 fixes, not 3700
AltitudeGPS MSL in the altitude unit. No flight level (no barometer), no AGL, no ground speed.
Average varioSame 25 s formula
Average thermalComputed but not shown
WindSame drift formula. The speed is formatted with the speed unit and its label (Double.speedString), not the wind unit. The arrow is rotated by direction, not direction − 180.
DistanceOptimised distance only, 1 decimal
Average speedSame formula, guarded by flightTime > 0. Computed but not shown.
TargetAlways the take-off fix: the stored go-to target is not read. Distance, bearing, required L/D and current L/D use the iPhone formulas.
Flight timeSame, HH:mm:ss
Heart rate and SpO2From HealthKit samples: heart rate Int(count/min), SpO2 Int(percent × 100). Attached to each recorded fix.

The thermal and wind steps run the iPhone algorithm with the same sport profile thresholds.

Source: vulture-ios/Modules/Watch/Sources/Features/InFlight/InFlightViewModel.swift, InFlightViewModel+Thermals.swift, InFlightViewModel+Target.swift, InFlightViewModel+Formatting.swift, .../InFlight/Views/InFlightStatisticsView.swift, .../InFlight/Views/InFlightTargetView.swift.

There are no proactive airspace warnings. No code checks the live position against airspace on each fix, and no distance or vertical margin triggers an alert. The In-flight airspace warnings feature is the airspace sheet. When the pilot long-presses the in-flight map, the sheet lists the airspaces drawn at that point. Each listed airspace is then judged against the glider’s live position, LocationAltitude(agl, fl, msl) from the cells above:

  1. If the glider’s coordinate is not inside the airspace polygon (even-odd test, Flight analysis §7.1), the result is outside.
  2. If floor < altitude and ceiling > altitude, the result is inside.
  3. If floor > altitude, the result is below floor by |floor.differenceInMeters(from: altitude)|.
  4. Otherwise the result is above ceiling by |ceiling.differenceInMeters(from: altitude)|.

Each limit is compared in its own reference:

Limit< / > comparesdifferenceInMeters
GNDalways below the glider (< true, > false)agl
AGL hInt(h in m) against aglagl − Int(h in m)
AMSL hInt(h in m) against mslmsl − Int(h in m)
FL nn against the live FLInt(FL×100 / 3.2808399) − Int(n×100 / 3.2808399)
UNLalways above the glider (> true, < false)0

A comparison whose reference is nil (no AGL tile yet, no barometer for FL) is false, and the difference is 0. The sheet uses the airspaces the map draws, so the hidden-airspace and openAIP rules of Map, barogram and airspace apply. Infringement detection after the flight is a different algorithm (that page, §5.7).

Source: vulture-ios/Modules/App/Sources/Map/Airspace/AirspaceViewModel.swift (position(of:at:), height(of:above:)), vulture-ios/Packages/AirspaceScreens/Sources/AirspaceScreens/Extensions/Airspace.Altitude+Comparison.swift, vulture-ios/Modules/App/Sources/Map/InFlight/InFlightMapViewController.swift (the altitude binding).

A two-finger long press on the flight, task or in-flight map draws a line between the fingers with a label. The label shows the distance only. The bearing is computed only to turn the label along the line.

  1. Each finger’s screen point is moved up by 30 pt (30 dp on Android) so the finger does not cover it, then converted to a map coordinate.
  2. distance = haversine(p0, p1), formatted in the distance unit with 2 decimals and the unit label.
  3. The label sits at the great-circle midpoint p0.center(to: p1).
  4. rotation = bearing(p0 → p1). If it is above 180, subtract 180. Then subtract 90. The text is turned by that many degrees, so it reads along the line and is never upside down.

Platform differences: Android is a port with the same formulas (:core:geo haversine and bearing, the same midpoint and rotation). It also fades out the end-ring strokes (B35) and draws the text on a pill (B37). Android re-formats a shown label at once when the distance unit changes. iOS only formats while the fingers move.

Source: vulture-ios/Modules/App/Sources/Map/MapViewDataSource+Measure.swift, vulture-ios/Modules/App/Sources/Map/Extensions/CLLocationCoordinate2D+Helpers.swift (measureMeta, center(to:)); vulture-android/domain/airspace/src/main/kotlin/be/fousa/vulture/domain/airspace/measure/MeasureGeometry.kt, vulture-android/core/map/src/main/kotlin/be/fousa/vulture/core/map/measure/MeasureTool.kt, .../measure/MeasureFeatures.kt, .../measure/MeasureDistanceFormat.kt, vulture-android/core/geo/src/main/kotlin/be/fousa/vulture/core/geo/SphericalGeometry.kt.

The airport database is the bundled airports.json. Nearest airports (from the in-flight menu) sorts the whole database by haversine(glider, airport) on a background task, keeps the first 50, and shows each distance in the distance unit with 0 decimals. The search field filters those 50 by name or ICAO code, without case. No bearing is computed. Picking Go To stores the airport’s name and coordinate as the in-flight target, used by the target cells.

AirportManager.airport(for: coordinate) returns the nearest airport only when it is within 3000 m. The airport detail shows the database elevation in the altitude unit.

Platform differences: Android has no nearest airports (only an ICAO lookup for the Charron scoreboard).

Source: vulture-ios/Packages/Airports/Sources/Airports/AirportManager.swift, vulture-ios/Packages/AirportScreens/Sources/AirportScreens/Hosting/Airport/AirportManager+NearestAirportsProviding.swift, .../Airport/NearestAirportsViewModel.swift, .../Airport/AirportViewModel.swift.

The home base is a stored name and coordinate. The maps centre on it and draw it as a waypoint. No distance or bearing to the home base is computed anywhere.

Source: vulture-ios/Packages/Persistence/Sources/Persistence/HomebaseManager.swift, vulture-ios/Modules/App/Sources/Map/MapViewDataSource.swift (homeWaypointShape).

All weather comes decoded from AVWX (avwx.rest). Flight rules (VFR, MVFR, IFR, LIFR) are not computed in the app. They are AVWX’s flight_rules field, shown as received. The units are the station’s, as AVWX reports them. Wind is never converted to the pilot’s wind unit, and the label is AVWX’s units.wind_speed.

ValueRule
StationThe METAR at /api/metar/{lat},{lon}, so AVWX picks the nearest station
AltimeterInt(value) (truncated) when the unit is hPa, else "%0.2f" (inHg). A missing value with hPa gives 1013.
Wind"{dir}° / {speed}". VRB becomes the localised “variable”. The speed is String(Int(repr)) when numeric, else the raw repr.
VisibilityWhen the unit is m: ≥ 9999 m gives 10, otherwise m / 1000, printed as a whole number when integral, else "%.1f". The unit becomes km. CAVOK has no unit.
Temperature and dew pointAVWX’s repr, with unit °{unit}
Cloud layersCloudData(raw:) on each repr: the first 3 characters give the coverage, the rest × 100 the base in feet

CloudData quirks:

  • The coverage table maps SKC/CLR to Clear, FEW, SCT, BKN to Broken, and OVC. BKN030 is Broken at 3,000 ft. (It read BKC before Calculation fixes §5, and dropped broken layers.)
  • SKC, CLR and VV have no numeric height and are dropped too.
ValueRule
Launch windowTomorrow 10:00–14:00 local. When no TAF covers it: now to min(now + 4 h, 21:00), only when that is at least 1 h long.
Period shownOf the base periods (FROM, BECMG) with start ≤ t < end, the one that starts last, at the window’s midpoint. TEMPO, PROB and INTER are ignored.
CeilingThe lowest layer of type BKN, OVC or VV that has an altitude. Shown as the repr (BKN040) or altitude × 100 ft.
Day stripTomorrow at 06, 09, 12, 15, 18 and 21 h, or the hours still ahead today. Each slot gets the period in force at that time. Its cloud is the lowest layer of any type.
VisibilitySame metres-to-km rule as the METAR. Without a value the repr is shown (e.g. CAVOK).
Station fallbackThe TAF at the coordinate first. If it does not cover tomorrow’s window, the 4 nearest reporting stations (/api/station/near, n = 6) are tried in order. If none covers tomorrow, the first fetched TAF that covers the rest of today is used.

Platform differences: Android has no weather screens.

Source: vulture-ios/Packages/AviationWeather/Sources/AviationWeather/Models/WeatherData.swift, .../Models/CloudData.swift, .../Models/Taf/TafSummary.swift, .../Models/Taf/TafPeriod.swift, .../Models/Taf/TafResult.swift (period(at:)), .../Requests/, vulture-ios/Modules/MetarWidget/Sources/Providers/WeatherManager.swift.