Skip to content

Thermals

This page starts where the analysis pipeline stops. Circling detection, the thermal’s entry and exit, its turn direction, its bounding circle, its average climb and the wind are specified in Flight analysis §5.2–5.4, and the Thermal model in §2.3. Everything here is computed from a thermal that already passed those filters (flight.thermals).

Units are SI throughout: metres, seconds, m/s. Formatting into the pilot’s units is Flight analysis §9. Android ports all of it; a calculation says so where Android differs.

ValueMeaning
thermal.fixesThe fixes that passed the circle-fit test, in order. Not every fix between entry and exit: in wind, the downwind stretch of a circle often fails the fit and is missing.
thermal.entryAltitude, exitAltitudePressure altitude of the first and last circling fix (whole metres).
thermal.entryTime, exitTimerawTime of the first and last circling fix (s).
thermal.vario(exit − entry altitude) / (exit − entry time), Flight analysis §5.4.
thermal.turnleft, right or mixed, Flight analysis §2.3 and §5.4.
Flown fixesEvery fix of the flight from thermal.fixes.first to thermal.fixes.last, both included. Found by object identity in flight.fixes; if either is not found, thermal.fixes is used instead. The sheet’s three charts use these, not thermal.fixes.
fix.varioThe per-fix climb rate, already smoothed (Flight analysis §3.6).
fix.timeFix time shifted to the take-off time zone (s). Only differences are used here.

Source: vulture-ios/Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalViewModel.swift (flownFixes(of:in:)); vulture-android/domain/thermal/src/main/kotlin/be/fousa/vulture/domain/thermal/ThermalDetailModel.kt (flownFixes). Same on Android.

The thermal sheet’s peek shows a 4 × 2 grid. Each value comes from the Thermal model.

Label (en)ValueFormulaFormat
Average variothermal.varioFlight analysis §5.4vertical-speed unit
Left / Right / Mixed turnthermal.turnFlight analysis §2.3icon and word; hidden when nil
DiameterInt(radius × 2)see belowaltitude unit (PARITY, decisions §9 D9)
Turn rateInt(turnRate)see below; really the bank angle"%i°"
Average airspeedaverageAirspeedsee belowspeed unit
Height gained / lostaltitudeGained / altitudeLostsee below"<gained> / <lost>", altitude unit
DurationexitTime − entryTimeHH:mm:ss
Driftdriftsee belowdistance unit
altitudeGained = exitAltitude − entryAltitude // m, whole metres

Net gain from the first to the last circling fix. It is not the climb from the lowest to the highest point. A thermal with altitudeGained ≤ 0 is an attempt (isAttempt).

altitudeLost = Σ (alt[i+1] − alt[i]) for i in 0 ..< fixes.count − 1, only where the difference < 0

The sum of every drop between consecutive circling fixes, so it is zero or negative and is shown with its minus sign. It measures how uneven the climb was. With fewer than 2 fixes it is 0. Because it runs over thermal.fixes, a drop across a gap of skipped fixes counts as one drop.

radius = mean(turnRadiusValues) // m; 0 when empty
turnRate = deg(atan(avgSpeed² / (9.81 · radius))) // degrees; 0 when no speeds or radius ≤ 0
diameter = Int(radius · 2) // truncated

turnRadiusValues are the three-point circle-fit radii of the circling fixes, and speedValues their TAS (or ground speed when TAS ≤ 0), both collected in Flight analysis §5.3. The “turn rate” is the bank angle of a coordinated turn at that speed and radius (quirk B52, kept as PARITY). The bounding radius totalRadius is a different number: it is only used for the map circle.

averageAirspeed = Σ fix.trueAirspeed / fixes.count // m/s

Only true airspeed is summed, so a file without a TAS extension shows 0 here. With no fixes the division gives NaN.

drift = haversine(fixes.first, fixes.last) // m, R = 6 371 km; 0 when fixes.count ≤ 2

The straight distance between the first and last circling fix. This is not the Centring view’s drift, which is fitted (see Centring).

Source: vulture-ios/Packages/Flights/Sources/Flights/Models/Thermal.swift; vulture-ios/Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalViewModel.swift; vulture-android/domain/flight/src/main/kotlin/be/fousa/vulture/domain/flight/model/Thermal.kt; vulture-android/domain/thermal/src/main/kotlin/be/fousa/vulture/domain/thermal/ThermalDetailModel.kt (grid). Same on Android.

The sheet pages through the thermals the map shows: all of flight.thermals when both “Show thermals” and “Show thermal attempts” are on, only the climbing ones (altitudeGained > 0) or only the attempts when one is on, none when both are off. The title’s “n of N” is the 1-based position in that list. Previous and next are the neighbours in it, in flight order. There is no ranking of thermals by strength.

Source: ThermalViewModel (currentIndex, totalCount); vulture-ios/Modules/App/Sources/Map/Flight/Thermal/ThermalController.swift (thermals(for:)); Android ThermalDetailModel.pagingThermals. Same on Android.

Two plots share one time axis: Altitude (top) and Climb rate (a strip under it). Both are built from one profile of the flown fixes.

One sample per flown fix, none dropped:

offset = fix.time − first.time // s since entry
altitude = Double(fix.pressureAltitude) // m
rate = fix.vario // m/s
DerivedFormula
minimumAltitude, maximumAltitudemin and max of altitude (0 when empty)
altitudeSpanmax(maximumAltitude − minimumAltitude, 1), so it is never zero
bestRatemax of rate (0 when empty)
durationoffset of the last sample

The chart applies no extra smoothing. fix.vario itself is already the smoothed per-fix vario of Flight analysis §3.6.

The chart reduces the samples to columns that fit the width, and both plots use the same columns.

columnCount = max(Int(plotWidth / 8), 1) // one column per 8 pt
columnWidth = max(plotWidth / columnCount − 2, 1) // 2 pt gap
columns = min(columnCount, samples.count)
for c in 0 ..< columns:
lower = c · n / columns // integer division, n = samples.count
upper = max(lower + 1, (c + 1) · n / columns)
slice = samples[lower ..< min(upper, n)]
fraction = (c + 0.5) / columns // horizontal centre, 0…1
altitude = slice.last.altitude // height at the end of the column
minimum, maximum, mean = min, max, mean of slice.rate
  • Altitude plot: one rounded bar per column, from the bottom up to altitude, filled with the colour of mean at 80 % opacity. The vertical scale runs from minimumAltitude to minimumAltitude + altitudeSpan. Labels show the maximum, the middle and the minimum. Time labels are placed at every whole minute from 1:00.
  • Climb rate strip: for each column, a pale bar (35 % opacity) from 0 to maximum and a solid bar (80 %) from 0 to mean, both coloured by mean. The gap between them shows a short surge that the mean hides. A zero line is drawn. The strip’s range always includes 0 and the best rate:
rateRange = (min(min(rate), 0) − 0.2) … (max(bestRate, 1) + 0.3) // m/s
stripHeight = min(max(chartHeight · 0.26, 26), 140) // pt
  • Scrub: a finger at fraction f selects sample round(f · (n − 1)), clamped to 0…n−1. The readout shows that sample’s rate, altitude and m:ss offset. The strip’s marker sits on the mean of column min(Int(f · columns), columns − 1). Scrubbing moves the map’s glider to that fix.

The sheet colours rate on a fixed scale, unlike the map’s relative one:

rate not finite → Thermal Bad
rate < 0 → Thermal Sink
else → position = clamp(rate / 4.0 · 2, 0, 2)
blend stops [Bad, Average, Good] between floor(position) and the next, by the fraction
ConstantValue
highestRate4.0 m/s (the ramp is fully Good from here)
StopsThermal Bad, Thermal Average, Thermal Good (RGB linear blend)
Below zeroThermal Sink

So 0 m/s is Bad, 2 m/s is Average and 4 m/s or more is Good.

A caption under the Climb chart says how the climb ended, e.g. “Last 0:30 at 1.2 m/s · still climbing when you left”.

trailingWindow = round15(clamp(duration / 4, 30, 90)) // s; round15(q) = ((q + 7) / 15) · 15, integers
trailingRate = mean(rate of samples with offset ≥ max(duration − trailingWindow, 0)) // 0 if none
averageRate = mean(rate of all samples) // plain per-fix mean, not thermal.vario
departure = averageRate > 0 && trailingRate < 0.7 · averageRate ? late : timely
ConstantValue
Windowa quarter of the climb, at least 30 s, at most 90 s, rounded to 15 s
Late thresholdtrailing rate below 70 % of the average

late reads “faded before you left”; timely reads “still climbing when you left”. If the average is 0 or less, the result is always timely. This is the only “weak end of climb” judgement on the sheet. It is not the same as an abandoned thermal: the Debrief calls the attempts (altitudeGained ≤ 0) abandoned, see Rating and Debrief.

Source: vulture-ios/Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalProfile.swift, ThermalProfileLayout.swift, ThermalProfileChart+Drawing.swift, ClimbRamp.swift, ThermalDetailView.swift (departureCaption); vulture-android/domain/thermal/src/main/kotlin/be/fousa/vulture/domain/thermal/ThermalProfile.kt, ThermalProfileLayout.kt, ClimbRamp.kt, vulture-android/feature/thermal/src/main/kotlin/be/fousa/vulture/feature/thermal/ClimbProfileChart.kt. Same on Android. Android rounds the scrub index half away from zero, as Swift’s rounded() does.

The Centring view shows the circles from above. With “Wind correction” on (the default), the drift is taken out so the circles stack. It needs at least 3 flown fixes; with fewer the plan is empty, and every value is 0.

Each fix is placed in metres east and north of the first fix, on a flat earth:

north = (lat − lat0) · 111 320
east = (lon − lon0) · 111 320 · cos(lat0)

The drift velocity is the least-squares slope of position against time, over every fix:

t̄ = mean(time), ē = mean(east), n̄ = mean(north)
S = Σ (tᵢ − t̄)²
wind.east = Σ (tᵢ − t̄)(eᵢ − ē) / S // m/s
wind.north = Σ (tᵢ − t̄)(nᵢ − n̄) / S // m/s; (0, 0) when S = 0 or fewer than 2 fixes

A complete circle adds nothing to a linear fit, so the slope is the drift. The code deliberately does not use the first-to-last displacement, because that mostly depends on where in the circle the pilot entered and left.

corrected[i] = offset[i] − wind · (time[i] − time[0])
points = corrected − mean(corrected) // drift removed, about their own middle
driftedPoints = offset − mean(offset) // as flown, about their own middle

Each point keeps its fix’s pressure altitude and vario.

radius = mean(√(e² + n²)) over points // m, the circle actually flown
core = Σ (pᵢ · rateᵢ²) / Σ rateᵢ² over points with rate > 0 // (0, 0) when none climbed
coreDistance = |core| // m
coreBearing = round(atan2(core.east, core.north) in degrees, normalised to 0…360) mod 360 // 0 when core = (0, 0)
centering = radius > 0 ? max(0, 1 − coreDistance / radius) : 0

Squaring the rate pulls the core toward the strongest lift. Sink adds no weight. A score of 1 means the core was at the middle of the circles. A score of 0 means it was on the rim or beyond. “Round” is not measured: the circles are not fitted individually, and the radius is the mean distance of all points from their common middle.

span = max(last.time − first.time, 1) // s
driftDistance = |wind| · span // m
driftBearing = bearing of wind, as coreBearing // the direction the glider drifted towards
StateCaption (en)
Wind correction offDrift <driftDistance>
On, coreDistance ≤ 1 mCentred on the core · Drift <d>
On, otherwiseCentred <score %> · Core <Int(coreDistance)> at <coreBearing> · Drift <d>

The score is a whole percent (.percent, 0 fraction digits). The core offset is truncated to whole metres and formatted in the altitude unit, like the diameter. The flown circle (radius radius) is tinted with the absolute climb colour of averageRate = mean(rate) over the points. The core marker and dashed line are only drawn when coreDistance > 1 m.

Toggling the correction animates each point linearly from driftedPoints to points over 0.7 s. The frame size is interpolated in the same way from max(|driftedPoints|) to max(|points|), each at least 1 m. Pinch zoom runs from 1× up to max(6, extent / max(radius, 1) · 1.2).

Source: vulture-ios/Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalPlan.swift, ThermalPlanChart.swift, ThermalPlanChart+Zoom.swift; vulture-android/domain/thermal/src/main/kotlin/be/fousa/vulture/domain/thermal/ThermalPlan.kt, ThermalPlanZoom.kt, ThermalDetailModel.kt (centering), vulture-android/feature/thermal/src/main/kotlin/be/fousa/vulture/feature/thermal/CenteringPlanChart.kt. Same on Android. Android rounds the bearing half away from zero, like Swift, and formats the percent with the device locale’s NumberFormat.

The 3D view draws driftedPoints (step 3 above, drift not removed) with their altitude. The lean of the column is therefore the real drift as flown. Nothing is computed for the lean itself.

across = extent(driftedPoints) · 2 // m
scale = min((width − axisWidth − 16) / (across · 1.1), 1.4) // pt per m
rise = max(height − 30 − 26, 20) / (maxAlt − minAlt) // pt per m, altitude range at least 1 m
x = centreX + (east · cos a − north · sin a) · scale
y = baseY − (altitude − minAlt) · rise
depth = east · sin a + north · cos a // painter's sort, far segments first

The azimuth a starts at 45° and changes by 0.6° per point dragged (15° per VoiceOver/TalkBack step). Each segment is coloured by the absolute climb colour of the mean of its two end rates. The caption reads <maxAlt − minAlt> gained, topping out at <maxAlt>. This gain is the altitude range of the flown fixes, so it can differ from the grid’s altitudeGained, which is exit minus entry.

Source: vulture-ios/Packages/FlightScreens/Sources/FlightScreens/ThermalDetail/ThermalHelixChart.swift; vulture-android/domain/thermal/src/main/kotlin/be/fousa/vulture/domain/thermal/ThermalHelix.kt, vulture-android/feature/thermal/src/main/kotlin/be/fousa/vulture/feature/thermal/HelixChart.kt. Same on Android.

The flight detail’s Thermals page aggregates over flight.thermals (attempts included unless stated). Every value is nil (shown empty) when the flight has no thermal list, and most are also nil when the list is empty.

Label (en)FormulaEdge cases
Number of thermalsthermals.count
Total durationΣ duration (s), HH:mm:ss0 for an empty list
Average varioΣ altitudeGained / Σ duration, time-weightednil if empty or Σ duration = 0
During flightΣ duration / flight.duration · 100, "%0.2f %"flight.duration is take-off to landing (rawTime), not release; nil without take-off or landing
Best variomax(thermal.vario)
Distance between thermalsΣ distanceToNextThermal / thermals.countthe last thermal has no next and adds 0, but still counts in the divisor; NaN for an empty list
Height gained / lostΣ altitudeGained / Σ altitudeLostthe loss is ≤ 0; attempts’ negative gains reduce the total
Average driftΣ drift / thermals.count0 with no list; NaN for an empty list
Lowest entry altitudeflight.lowestThermalEntryFix.pressureAltitudeno value without a thermal that is not an attempt
Average diameterInt(Σ radius / count) · 2, altitude unittruncates the radius before doubling

distanceToNextThermal is the haversine distance between consecutive thermal centres (Flight analysis §5.4).

Lowest entry altitude shows lowestThermalEntryFix: the lowest entryAltitude of a thermal that is not an attempt (Flight analysis §5.4). Until Calculation fixes §2 (2026-10-20, both platforms) it showed lowestAltitudeFix, the low point of the flight’s largest altitude gain.

Label (en)Formula
Left turns / Right turns / Mixed turnscount(turn == x) / thermals.count · 100, "%0.2f %"
Average vario (left) / (right)plain mean of thermal.vario over the left or right thermals (not time-weighted); nil when there are none
Failed attemptsflight.thermalAttemptCount: accepted thermals with altitudeGained ≤ 0
Average turn rateInt(Σ turnRate / count), "%i°" (bank angle, B52)
Average airspeedΣ averageAirspeed / count; only when the file has the TAS extension, else nil

The three turn percentages need not add up to 100, because a thermal’s turn can be nil.

A bar chart counts thermals by thermal.vario (m/s):

BucketRange (m/s)Bar colourTitle (m/s units)Title (knots)
0< 0Thermal Sink<0<0
10 ≤ v < 1ramp at 0 %0-10-2
21 ≤ v < 2ramp at 20 %1-22-3
32 ≤ v < 3ramp at 40 %2-33-6
43 ≤ v < 4ramp at 60 %3-46-8
54 ≤ v < 5ramp at 80 %4-58-10
6≥ 5ramp at 100 %5+10+

The ramp is Bad → Average → Good. The bucket edges stay in m/s in every unit; only the titles change, and the knots titles are approximate (PARITY, decisions §9 D9). A NaN vario falls in bucket 0. This is the only place that groups weak climbs; there is no separate “weak climb” figure.

The flight detail summary shows the thermal count, the Average vario above as the headline, and During flight as the detail. The row is hidden when there are no thermals.

Source: vulture-ios/Packages/Flights/Sources/Flights/Models/Flight.swift (averageThermalVario, thermalHeight, thermalHeightLost, thermalDrift, thermalAverageAirspeed, averageDiameter, averageTurnRate, bestThermalVario, thermalPercentage, totalThermallingTime, left/right/mixedThermallingPercentage, averageLeft/RightThermalVario, averageDistanceBetweenThermals); vulture-ios/Packages/FlightScreens/Sources/FlightScreens/FlightDetail/Tabs/DetailTabThermalsViewModel.swift; vulture-ios/Packages/FlightScreens/Sources/FlightScreens/FlightDetail/Summary/DetailSummaryViewModel.swift (thermalsRow); vulture-ios/Packages/DesignSystem/Sources/DesignSystem/Extensions/Double+Percentage.swift; vulture-android/domain/flight/src/main/kotlin/be/fousa/vulture/domain/flight/model/FlightStatistics.kt; vulture-android/domain/flightdetail/src/main/kotlin/be/fousa/vulture/domain/flightdetail/ThermalsTab.kt. Same on Android.

Each thermal is drawn as a filled circle with radius totalRadius (the bounding-box radius of Flight analysis §5.4), 73 points every 5°, at 40 % opacity. Its colour is relative to the flight:

maximumVario = max(thermal.vario) over accepted thermals, starting at −∞
relativeRatio = maximumVario > 0 ? vario / maximumVario : 0 // set on every candidate
colour = vario < 0 ? Thermal Sink : blend(Thermal Bad → Thermal Good, relativeRatio)

The blend uses only two stops (Bad and Good), with no Average. So the same colour means a different climb in different flights, and the map circle and the sheet’s absolute ramp do not match. The layer, filters and hex values are in Map, barogram and airspace §3.4.

Go to › Nearest thermal picks, from the shown thermals: the thermal that contains the current time (entry ≤ t ≤ exit). Otherwise it takes the previous thermal if it ended closer in time than the next one starts, else the next one, or whichever of the two exists. Next and previous take the first thermal with entryTime > t and the last with exitTime < t. The chosen thermal either opens in the sheet or the map moves to its middle fix (fixes.center), depending on the command.

Source: vulture-ios/Modules/App/Sources/Map/Extensions/Thermal+Feature.swift (color); vulture-ios/Packages/Flights/Sources/Flights/Enricher/FlightPhasesEnricher.swift (relativeRatio); vulture-ios/Modules/App/Sources/Map/Flight/FlightMapViewModel.swift (nearestThermal, goToNextThermal); vulture-android/domain/flightmap/src/main/kotlin/be/fousa/vulture/domain/flightmap/geometry/ThermalFeatures.kt; vulture-android/domain/flight/src/main/kotlin/be/fousa/vulture/domain/flight/analysis/FlightPhasesEnricher.kt.

  • The relative strength used to differ (quirk Q8). Both platforms now start the maximum at −∞ and give 0 when no accepted thermal climbed. Android keeps restoreIosQuirks to reproduce the old iOS value in parity tests only.
  • Flight analysis §5.4 still describes maximumVario as starting at Double.leastNormalMagnitude. That is the old iOS behaviour.