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.
The live fix
Section titled “The live fix”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.
| Field | Formula | Unit |
|---|---|---|
coordinate | location.coordinate | degrees |
gpsAltitude | Int(location.altitude) (truncated toward zero) | m MSL |
pressure | Int(altitudeData.pressure × 10); CMAltimeter reports kPa, so this is whole hPa (truncated) | hPa |
timestamp | Date() at the timer tick, not location.timestamp | |
speed | location.speed | m/s |
bearing | Int(previous.coordinate.bearing(to: current.coordinate)) | degrees |
vario | (alt − prevAlt) / (t − prevT), using the two CLLocations’ own timestamps | m/s |
Edge cases:
bearingisnilandvariois 0 on the first fix.- When Core Location has not delivered a new location since the last tick, the same
CLLocationis read again. The fix then repeats the coordinate under a newtimestamp, andvariois0/0 = NaN. location.speedis −1 when Core Location has no valid speed, and that value passes through unchanged.- The doc comment on
LocationFix.pressuresays “pressure altitude, in meters”. The value is actually hPa. - On watchOS the barometer is not started, so
pressureis alwaysnilthere.
Source: vulture-ios/Packages/FlightRecording/Sources/FlightRecording/Location/Location.swift
(updateLocation(_:), NSNumber.correctedPressure), .../Location/LocationFix.swift.
Recording and IGC writing
Section titled “Recording and IGC writing”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.
In-flight state and update cadence
Section titled “In-flight state and update cadence”The in-flight view model (InFlightMapViewModel, iPhone and iPad) handles each fix in this order:
- If a release is known, append the coordinate to
rawCoordinatesand run the distance step. - Run the thermal step (always, also before take-off).
- Append the fix to
fixes. - While the take-off or the release is unknown, run the live take-off detector (
FlightTakeoffEnricher) again over all offixes. This detector is specified in Flight analysis §7.7. Once both are found, the detector stops running. - Trim
fixesto the newest 3700, about an hour at 1 Hz. - 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.
| Constant | Value |
|---|---|
| Fix interval | 1.0 s |
| Fix buffer, iPhone | 3700 fixes |
| Fix buffer, Watch | 120 fixes |
| Cell refresh throttle | ≥ 1.0 s wall clock |
| Optimised distance recompute | every 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.
In-flight cells
Section titled “In-flight cells”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.
Average vario (Flying)
Section titled “Average vario (Flying)”ref = the newest fix in `fixes` with fix.timestamp − ref.timestamp ≥ 25 svario = (fix.gpsAltitude − ref.gpsAltitude) / 25.0 // m/sThe 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).
MSL or flight level (Flying)
Section titled “MSL or flight level (Flying)”One cell, switched by Show flight level (preferences.showFlightLevel, toggled from the cell):
MSL = fix.gpsAltitude // m, formatted in the altitude unitFL = 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 (Flying)
Section titled “AGL (Flying)”AGL = fix.gpsAltitude − terrainElevation(fix.coordinate) // mThe 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.
Ground speed (Flying)
Section titled “Ground speed (Flying)”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.
Average thermal
Section titled “Average thermal”The live counterpart of thermal detection (Flight analysis §5.2–5.3), using a cluster of circling fixes:
- Fit a circle through the previous two fixes and the new one (Flight analysis §5.2). Stop unless
radius < thermalTurnRadiusLimit. - If
haversine(thermalFixes.last.coordinate, circle.center) < thermalClusteringDistance, append the fix tothermalFixes. Otherwise start a new cluster[fix]. - 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 profile | thermalTurnRadiusLimit | thermalClusteringDistance |
|---|---|---|
| Glider (default) | 200 m | 500 m |
| Paraglider | 150 m | 300 m |
| Hang glider | 300 m | 500 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 fixesspeed = haversine(first, last) / duration // m/sdirection = Int(bearing(from: last, to: first)) // degrees, where the wind comes fromThe 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.
Current track (Target)
Section titled “Current track (Target)”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 labelbearing = 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.
Required L/D (Target)
Section titled “Required L/D (Target)”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.
Current L/D (Target)
Section titled “Current L/D (Target)”ref = the newest fix in `fixes` with fix.timestamp − ref.timestamp ≥ 60 sd = haversine(ref.coordinate, fix.coordinate) // straight line, mld = floor(d / (fix.gpsAltitude − ref.gpsAltitude) × −1)| Result | Shown |
|---|---|
| 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 |
Flight time (Performance)
Section titled “Flight time (Performance)”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.
Optimised distance and speeds
Section titled “Optimised distance and speeds”These run on every 10th coordinate since the release (rawCoordinates.count % 10 == 0), on a background queue.
| Cell | Formula |
|---|---|
| Optimised distance | DistanceEnricher(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 distance | The 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 hour | Only 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.
Live Activity
Section titled “Live Activity”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.
| Field | Value |
|---|---|
shortTimeString | Flight time as shortTimeValue: "3h05m", "3h", "05m". Under one minute it gives "0h". |
distanceString | Optimised distance, 0 decimals, with unit |
averageSpeedString | Average speed (above), with unit |
aglString | AGL 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.
Apple Watch
Section titled “Apple Watch”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:
| Value | Watch |
|---|---|
| Fix buffer | 120 fixes, not 3700 |
| Altitude | GPS MSL in the altitude unit. No flight level (no barometer), no AGL, no ground speed. |
| Average vario | Same 25 s formula |
| Average thermal | Computed but not shown |
| Wind | Same 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. |
| Distance | Optimised distance only, 1 decimal |
| Average speed | Same formula, guarded by flightTime > 0. Computed but not shown. |
| Target | Always 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 time | Same, HH:mm:ss |
| Heart rate and SpO2 | From 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.
Airspace in flight
Section titled “Airspace in flight”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:
- If the glider’s coordinate is not inside the airspace polygon (even-odd test, Flight analysis §7.1), the result is outside.
- If
floor < altitudeandceiling > altitude, the result is inside. - If
floor > altitude, the result is below floor by|floor.differenceInMeters(from: altitude)|. - Otherwise the result is above ceiling by
|ceiling.differenceInMeters(from: altitude)|.
Each limit is compared in its own reference:
| Limit | < / > compares | differenceInMeters |
|---|---|---|
GND | always below the glider (< true, > false) | agl |
AGL h | Int(h in m) against agl | agl − Int(h in m) |
AMSL h | Int(h in m) against msl | msl − Int(h in m) |
FL n | n against the live FL | Int(FL×100 / 3.2808399) − Int(n×100 / 3.2808399) |
UNL | always 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).
Measure tool
Section titled “Measure tool”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.
- 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.
distance = haversine(p0, p1), formatted in the distance unit with 2 decimals and the unit label.- The label sits at the great-circle midpoint
p0.center(to: p1). 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.
Nearest airports
Section titled “Nearest airports”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.
Home base
Section titled “Home base”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).
METAR and TAF values
Section titled “METAR and TAF values”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.
METAR (WeatherData)
Section titled “METAR (WeatherData)”| Value | Rule |
|---|---|
| Station | The METAR at /api/metar/{lat},{lon}, so AVWX picks the nearest station |
| Altimeter | Int(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. |
| Visibility | When 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 point | AVWX’s repr, with unit °{unit} |
| Cloud layers | CloudData(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/CLRto Clear,FEW,SCT,BKNto Broken, andOVC.BKN030is Broken at 3,000 ft. (It readBKCbefore Calculation fixes §5, and dropped broken layers.) SKC,CLRandVVhave no numeric height and are dropped too.
TAF (TafSummary, the Tomorrow widgets)
Section titled “TAF (TafSummary, the Tomorrow widgets)”| Value | Rule |
|---|---|
| Launch window | Tomorrow 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 shown | Of 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. |
| Ceiling | The lowest layer of type BKN, OVC or VV that has an altitude. Shown as the repr (BKN040) or altitude × 100 ft. |
| Day strip | Tomorrow 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. |
| Visibility | Same metres-to-km rule as the METAR. Without a value the repr is shown (e.g. CAVOK). |
| Station fallback | The 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.
