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.
Inputs
Section titled “Inputs”| Value | Meaning |
|---|---|
thermal.fixes | The 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, exitAltitude | Pressure altitude of the first and last circling fix (whole metres). |
thermal.entryTime, exitTime | rawTime of the first and last circling fix (s). |
thermal.vario | (exit − entry altitude) / (exit − entry time), Flight analysis §5.4. |
thermal.turn | left, right or mixed, Flight analysis §2.3 and §5.4. |
| Flown fixes | Every 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.vario | The per-fix climb rate, already smoothed (Flight analysis §3.6). |
fix.time | Fix 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.
One thermal: the sheet’s figures
Section titled “One thermal: the sheet’s figures”The thermal sheet’s peek shows a 4 × 2 grid. Each value comes from the Thermal model.
| Label (en) | Value | Formula | Format |
|---|---|---|---|
| Average vario | thermal.vario | Flight analysis §5.4 | vertical-speed unit |
| Left / Right / Mixed turn | thermal.turn | Flight analysis §2.3 | icon and word; hidden when nil |
| Diameter | Int(radius × 2) | see below | altitude unit (PARITY, decisions §9 D9) |
| Turn rate | Int(turnRate) | see below; really the bank angle | "%i°" |
| Average airspeed | averageAirspeed | see below | speed unit |
| Height gained / lost | altitudeGained / altitudeLost | see below | "<gained> / <lost>", altitude unit |
| Duration | exitTime − entryTime | HH:mm:ss | |
| Drift | drift | see below | distance unit |
Height gained
Section titled “Height gained”altitudeGained = exitAltitude − entryAltitude // m, whole metresNet 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).
Height lost in a thermal
Section titled “Height lost in a thermal”altitudeLost = Σ (alt[i+1] − alt[i]) for i in 0 ..< fixes.count − 1, only where the difference < 0The 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.
Diameter and turn rate
Section titled “Diameter and turn rate”radius = mean(turnRadiusValues) // m; 0 when emptyturnRate = deg(atan(avgSpeed² / (9.81 · radius))) // degrees; 0 when no speeds or radius ≤ 0diameter = Int(radius · 2) // truncatedturnRadiusValues 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.
Average airspeed
Section titled “Average airspeed”averageAirspeed = Σ fix.trueAirspeed / fixes.count // m/sOnly 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 ≤ 2The 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.
Paging and the sheet title
Section titled “Paging and the sheet title”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.
The Climb chart
Section titled “The Climb chart”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.
Samples
Section titled “Samples”One sample per flown fix, none dropped:
offset = fix.time − first.time // s since entryaltitude = Double(fix.pressureAltitude) // mrate = fix.vario // m/s| Derived | Formula |
|---|---|
minimumAltitude, maximumAltitude | min and max of altitude (0 when empty) |
altitudeSpan | max(maximumAltitude − minimumAltitude, 1), so it is never zero |
bestRate | max of rate (0 when empty) |
duration | offset 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.
Columns (bins)
Section titled “Columns (bins)”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 ptcolumnWidth = max(plotWidth / columnCount − 2, 1) // 2 pt gapcolumns = 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 ofmeanat 80 % opacity. The vertical scale runs fromminimumAltitudetominimumAltitude + 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
maximumand a solid bar (80 %) from 0 tomean, both coloured bymean. 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/sstripHeight = min(max(chartHeight · 0.26, 26), 140) // pt- Scrub: a finger at fraction
fselects sampleround(f · (n − 1)), clamped to0…n−1. The readout shows that sample’s rate, altitude andm:ssoffset. The strip’s marker sits on the mean of columnmin(Int(f · columns), columns − 1). Scrubbing moves the map’s glider to that fix.
Climb colour (absolute ramp)
Section titled “Climb colour (absolute ramp)”The sheet colours rate on a fixed scale, unlike the map’s relative one:
rate not finite → Thermal Badrate < 0 → Thermal Sinkelse → position = clamp(rate / 4.0 · 2, 0, 2) blend stops [Bad, Average, Good] between floor(position) and the next, by the fraction| Constant | Value |
|---|---|
highestRate | 4.0 m/s (the ramp is fully Good from here) |
| Stops | Thermal Bad, Thermal Average, Thermal Good (RGB linear blend) |
| Below zero | Thermal Sink |
So 0 m/s is Bad, 2 m/s is Average and 4 m/s or more is Good.
Leaving the thermal
Section titled “Leaving the thermal”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, integerstrailingRate = mean(rate of samples with offset ≥ max(duration − trailingWindow, 0)) // 0 if noneaverageRate = mean(rate of all samples) // plain per-fix mean, not thermal.variodeparture = averageRate > 0 && trailingRate < 0.7 · averageRate ? late : timely| Constant | Value |
|---|---|
| Window | a quarter of the climb, at least 30 s, at most 90 s, rounded to 15 s |
| Late threshold | trailing 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.
Centring score and drift
Section titled “Centring score and drift”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.
1. Local projection
Section titled “1. Local projection”Each fix is placed in metres east and north of the first fix, on a flat earth:
north = (lat − lat0) · 111 320east = (lon − lon0) · 111 320 · cos(lat0)2. Wind (least-squares drift)
Section titled “2. Wind (least-squares drift)”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/swind.north = Σ (tᵢ − t̄)(nᵢ − n̄) / S // m/s; (0, 0) when S = 0 or fewer than 2 fixesA 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.
3. Drift removed and centred
Section titled “3. Drift removed and centred”corrected[i] = offset[i] − wind · (time[i] − time[0])points = corrected − mean(corrected) // drift removed, about their own middledriftedPoints = offset − mean(offset) // as flown, about their own middleEach point keeps its fix’s pressure altitude and vario.
4. Radius, core and score
Section titled “4. Radius, core and score”radius = mean(√(e² + n²)) over points // m, the circle actually flowncore = Σ (pᵢ · rateᵢ²) / Σ rateᵢ² over points with rate > 0 // (0, 0) when none climbedcoreDistance = |core| // mcoreBearing = 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) : 0Squaring 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.
5. Drift
Section titled “5. Drift”span = max(last.time − first.time, 1) // sdriftDistance = |wind| · span // mdriftBearing = bearing of wind, as coreBearing // the direction the glider drifted towardsCaption
Section titled “Caption”| State | Caption (en) |
|---|---|
| Wind correction off | Drift <driftDistance> |
On, coreDistance ≤ 1 m | Centred on the core · Drift <d> |
| On, otherwise | Centred <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
Section titled “The 3D view”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 // mscale = min((width − axisWidth − 16) / (across · 1.1), 1.4) // pt per mrise = max(height − 30 − 26, 20) / (maxAlt − minAlt) // pt per m, altitude range at least 1 mx = centreX + (east · cos a − north · sin a) · scaley = baseY − (altitude − minAlt) · risedepth = east · sin a + north · cos a // painter's sort, far segments firstThe 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’s Thermals page
Section titled “The flight’s Thermals page”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.
Thermals section
Section titled “Thermals section”| Label (en) | Formula | Edge cases |
|---|---|---|
| Number of thermals | thermals.count | |
| Total duration | Σ duration (s), HH:mm:ss | 0 for an empty list |
| Average vario | Σ altitudeGained / Σ duration, time-weighted | nil 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 vario | max(thermal.vario) | |
| Distance between thermals | Σ distanceToNextThermal / thermals.count | the last thermal has no next and adds 0, but still counts in the divisor; NaN for an empty list |
| Height gained / lost | Σ altitudeGained / Σ altitudeLost | the loss is ≤ 0; attempts’ negative gains reduce the total |
| Average drift | Σ drift / thermals.count | 0 with no list; NaN for an empty list |
| Lowest entry altitude | flight.lowestThermalEntryFix.pressureAltitude | no value without a thermal that is not an attempt |
| Average diameter | Int(Σ radius / count) · 2, altitude unit | truncates 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.
Performance section
Section titled “Performance section”| Label (en) | Formula |
|---|---|
| Left turns / Right turns / Mixed turns | count(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 attempts | flight.thermalAttemptCount: accepted thermals with altitudeGained ≤ 0 |
| Average turn rate | Int(Σ 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.
Number of thermals / vario
Section titled “Number of thermals / vario”A bar chart counts thermals by thermal.vario (m/s):
| Bucket | Range (m/s) | Bar colour | Title (m/s units) | Title (knots) |
|---|---|---|---|---|
| 0 | < 0 | Thermal Sink | <0 | <0 |
| 1 | 0 ≤ v < 1 | ramp at 0 % | 0-1 | 0-2 |
| 2 | 1 ≤ v < 2 | ramp at 20 % | 1-2 | 2-3 |
| 3 | 2 ≤ v < 3 | ramp at 40 % | 2-3 | 3-6 |
| 4 | 3 ≤ v < 4 | ramp at 60 % | 3-4 | 6-8 |
| 5 | 4 ≤ v < 5 | ramp at 80 % | 4-5 | 8-10 |
| 6 | ≥ 5 | ramp 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.
Summary row
Section titled “Summary row”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.
Thermals on the map
Section titled “Thermals on the map”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 candidatecolour = 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.
Platform differences
Section titled “Platform differences”- 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
restoreIosQuirksto reproduce the old iOS value in parity tests only. - Flight analysis §5.4 still describes
maximumVarioas starting atDouble.leastNormalMagnitude. That is the old iOS behaviour.
