Skip to content

Compare, ranking and statistics

This page covers the numbers that need more than one flight: the Compare table, the race ranking during a replay, the leader and follower analysis, task start alignment, and the yearly logbook statistics. The per-flight values these read (thermals, glides, optimisations, task attempts) are specified in Flight analysis. The rating and its four parts are on Rating and Debrief.

All values are SI internally: metres, seconds, m/s. Formatting follows flight-analysis §9.

Where the pilot sees it: Compare, opened from a map with several flights.

  • Column order. When every compared flight has a competition rank, the columns are sorted by rank (ascending, stable). Otherwise they keep the map’s order.
  • Task rows. The Task filter and its rows exist only when every flight has a task and all tasks are equal to the first flight’s (see platform differences).
  • Leg rows. The task group has one block of seven leg rows per leg of the flight whose task optimisation has the most legs.
  • Pinned flight (baseline). With more than one flight, the first column is pinned when the screen opens. A tap on a column’s pill pins that flight: it moves to the first column, and the other columns keep their input order behind it. A tap on the pinned flight unpins it.
  • Pinned or podium. While a flight is pinned, every other cell shows its difference to the pinned flight (§1.2), and no podium is shown. With nothing pinned, each rankable row shows a podium (§1.3) instead.

Rows that read the slow analyses (whole-flight detour, the leader rows, flew alone) stay empty until those analyses have run in the background. After that, a missing value reads --.

For every rankable row (§1.4), and for each flight that is not the baseline:

delta = mine − baseline (both in the row's shown quantity, SI)
equal = |delta| below the kind's threshold (table)
isBetter = equal ? none : (delta > 0) XOR row.lowerIsBetter
text = format(|delta|) in the row's unit

There is no difference when either value is missing, for the baseline itself, or for a row that does not rank.

The difference is taken on the shown number, which is not always the number the row ranks on:

RowRanks on (§1.3)Difference on
Optimisation rows (1 h, 2 h, free, triangle, out-and-return, straight)the optimisation’s average speedits distance
Maximum distance outdistancedistance
Task flownbest task attempt’s average speedits distance
Task average glide, leg average glideaverage cruise speedglide ratio
Performance average glideglide ratioglide ratio
Leg distancethe leg’s average speedthe leg’s average speed
Every other rankable rowits valueits value

Equality threshold and difference format per kind:

KindEqual whenFormatted as
distance, altitude`delta
duration`round(delta)
seconds (thermal delay)`round(delta)
count, rating points`round(delta)
glide ratio`round(delta)
turn rate`round(delta)
speed, vertical speed`delta
percentage`delta

round is half away from zero.

Sign and colour. The text carries no sign. Direction is shown by a glyph and colour, and it means better or worse in the row’s own direction, not larger or smaller:

isBetterGlyphColourText
true▲ (arrowtriangle.up.fill)brand secondary tint (blue)the difference
false▼ (arrowtriangle.down.fill)orange, RGB (0.76, 0.25, 0.05)the difference
none (equal)=secondary label (grey)none, the glyph alone

So on a lower-is-better row (for example the number of thermals), a flight with 2 fewer thermals shows ▲ 2. The bottom line of the cell reads <secondary value> · <glyph> <difference>.

With nothing pinned and more than one flight:

  1. Take the row’s ranked value (rawValue) for every flight. Drop missing values and values ≤ 0.
  2. Remove duplicates, then sort: ascending when the row is lower-is-better, otherwise descending.
  3. A flight whose value is at position 0, 1 or 2 gets place 1, 2 or 3. Equal values share a place.

A flight with a value ≤ 0 is never placed, so a flight with 0 s engine time gets no medal, even though lower engine time is better.

Not rankable (no podium, no difference): date, pilot, glider type, left and right turn percentage, average thermal turn, declared distance, task start time, start altitude, finish altitude, task average turn, classification.

Lower is better: engine duration, number of thermals, thermal percentage, task number of thermals, leg thermal percentage, leg average turn, leg number of thermals, the three detour rows, follow time, follow ratio, thermal delay.

Everything else is higher-is-better. Flew alone is higher-is-better.

Shown under the Flight filter, when at least one flight has a rating. Rows: the score, then one row for each part (climbing, gliding, speed, decisions) that at least one flight was rated on.

  • Each flight is rated with FlightRating.rate(flight, among: allComparedFlights). The speed part ranks the flight’s cross-country speed among the other compared flights (see Rating and Debrief).
  • Ranked value and difference: the score (0–1000) or the part’s score (0–100), as points.
  • Cell graph: the score as a gauge filled to score / 1000 in the band colour, and a part as a bar filled to part / 100 in the part’s colour.
  • A flight without a rating, or not rated on a part, reads -- and does not rank.

1.6 Task leg rows: how a leg ranks its speed

Section titled “1.6 Task leg rows: how a leg ranks its speed”

The legs come from the best task attempt’s fixes at the optimised crossings: start, each turnpoint, finish. For each pair of consecutive fixes p (previous) and f:

duration = f.rawTime − p.rawTime (s)
distance = greatCircle(p, f) (m)
skip the leg when duration ≤ 0 or distance ≤ 0
averageSpeed = ((distance / 1000) / (duration / 3600)) / 3.6 (m/s) = distance / duration

The leg distance row shows the leg’s distance with its average speed below, and ranks on averageSpeed, higher is better. The difference is in speed too. Before 2026-10-07 the row ranked on the cruise speed; the current code ranks on the leg’s speed.

The other leg rows rank on:

Leg rowRanked value
Thermal percentageΣ duration of thermals that lie fully inside the leg’s time span / leg duration × 100; none when that sum is 0
Average climbΣ thermal height gained / Σ thermal duration, same thermals
Average turnmean thermal turn rate (whole °/s)
Average glideaverage cruise speed (glide ratio shown)
Number of thermalsthermals that are not attempts
Detour§1.7

A task waypoint that was never reached gets a leg with only its declared figures. Its measured values are empty, so it does not rank.

Edge case: a skipped leg (zero duration or distance) is not appended, so later legs shift one index down. Compare row legX(i) then reads the next leg of that flight. The code does not guard against this.

  • Leg detour. Walk the leg’s track fixes. Inside a thermal, replace the circling with one straight line from the fix before the thermal to the thermal’s last fix. Sum the great-circle segments. detour = flown − leg distance. The detour counts only when it is more than 100 m; otherwise it is none.
  • Task detour. The sum of the leg detours that exist, or none when no leg has one.
  • Whole-flight detour. The same walk over all of the flight’s fixes, minus the free-distance optimisation’s distance. None when there are fewer than 2 fixes, when there is no free distance or it is ≤ 0, or when the detour is ≤ 100 m.

For each flight, take the fixes of its best task attempt (first to last attempt index), or all fixes when it has no attempt. Each of its fixes is not alone when, in at least one other flight’s fixes, the nearest fix by time is within 10 s and within 1,000 m. The other flight’s fixes are also task-scoped.

flewAlone = aloneFixCount / fixCount × 100

None when the flight has fewer than 2 fixes or there is no other flight. The nearest fix is found by binary search on rawTime. Then the found index and its two neighbours are checked, and the one closest in time wins.

Source (Compare)

  • vulture-ios/Packages/FlightScreens/Sources/FlightScreens/Compare/CompareFlightsViewModel.swift (init, column order, containsTaskInformation, default pin)
  • .../Compare/CompareFlightsViewModel+Columns.swift (pin(item:), unpin(), rebuildColumns())
  • .../Compare/CompareFlightsViewModel+Differences.swift (difference(for:), CompareMetric.Kind.isEqual/format)
  • .../Compare/CompareFlightsViewModel+Ranking.swift (isRankable, lowerIsBetter, dataRank(for:), rawValue)
  • .../Compare/CompareFlightsViewModel+DataItems.swift, +Values.swift (rating rows, graphs)
  • .../Compare/CompareAnalyses.swift, +Proximity.swift (detour, leeching summaries, flew alone)
  • .../Hosting/Compare/CompareDifferenceStyle.swift, Cells/CompareFlightValueCollectionViewCell.swift (glyph, colour)
  • .../Models/DetailOptimizationItem+Legs.swift (makeLegs, thermalStats, detourDistance)
  • vulture-android/domain/compare/src/main/kotlin/be/fousa/vulture/domain/compare/ (CompareData, CompareGridModel, CompareRanking.kt, CompareDifferences.kt, CompareValues.kt)
  • vulture-android/domain/debrief/src/main/kotlin/be/fousa/vulture/domain/debrief/CompareAnalyses.kt

Platform differences. Android ports Compare one to one, with the same thresholds, directions and default pin. One difference: the “same task” test (containsTaskInformation) compares tasks by value on Android (B20). iOS compares the waypoints by object identity, so two separately decoded but equal tasks do not match there. The task rows and filter only appear when every flight was given the same task object, for example through an override.

2. Leader and follower (“who followed whom”)

Section titled “2. Leader and follower (“who followed whom”)”

Where the pilot sees it: Compare’s Leader group (classification, lead time, follow time, follow ratio, thermal delay, flew alone).

Each flight’s fixes are scoped to its best task attempt (first to last attempt index), or the whole flight when it has no attempt. Every pair of flights (i, j) with i < j is analysed.

ConstantValue
maxRangeMeters (together)2,000 m
minWindowSeconds240
mergeGapSeconds30
rearConeDegrees45°
trackMatchDegrees45°
minTrackSpeed40 km/h (11.11 m/s)
lagMinSeconds … lagMaxSeconds5 … 90 s
  1. Align in time. The overlap runs from max(firstA, firstB) to min(lastA, lastB) (rawTime). For each whole second t in it, take each flight’s fix nearest to t. Keep the pair only when both fixes are within 2 s of t. A second without a pair is dropped, not filled.
  2. Proximity. For each pair, together = distance(A, B) ≤ 2,000 m.
  3. Bridge gaps. Every run of ≤ 30 false samples that is followed by a true is set to true. A leading run at the start of the array is bridged too. A trailing run is not.
  4. Windows. Contiguous true runs of at least 240 samples. The duration is the sample count, which equals seconds only when there are no dropped seconds.
  5. Lead tally per window. Count only samples where both fixes are out of thermals, both ground speeds are ≥ 11.11 m/s, both have a bearing, and the headings differ by ≤ 45°:
    • leadA += 1 when the bearing from A to B is within 45° of A’s tail (bearingA + 180°).
    • leadB += 1 when the bearing from B to A is within 45° of B’s tail.
    • Drop the window when leadA + leadB = 0.
  6. Leader. A leads when leadA ≥ leadB (a tie goes to A, the lower index). Window leadSeconds = max(leadA, leadB) and followSeconds = min(leadA, leadB).
  7. Circling lag. Build 0/1 circling signals (fix in a thermal) for the leader and the follower over the window. For each lag 5…90 s, compute the Pearson correlation of leader[0..<n−lag] with follower[lag..<n], and keep the best. When the window has ≤ 90 samples, the result is (0, 0). A lag with zero variance is skipped. correlation = max(0, best).

Angle difference: |a − b| mod 360, folded to [0, 180].

Over all windows of all pairs:

leader.leadSeconds += window.leadSeconds
follower.followSeconds += window.leadSeconds (the leader's seconds, not window.followSeconds)
both: windowDuration += window.duration; lead/follow window count += 1
both: bestCorrelation/bestLagSeconds = the window's when its correlation is higher
followRatio = followSeconds / (leadSeconds + followSeconds), 0 when the sum is 0
ClassificationRule (total = lead + follow)
Independenttotal < 60 s
FollowerfollowRatio ≥ 0.75
LeaderfollowRatio ≤ 0.25
Racingotherwise

Shown values: lead and follow time as durations, follow ratio × 100 as a percentage, and thermal delay as bestLagSeconds with an s. Follow ratio reads -- when windowDuration = 0. Thermal delay reads -- when windowDuration = 0 or the lag is 0.

A separate, thermal-based pairing exists in LeaderAnalysis.swift. Two thermals match when their time windows are at most 120 s apart (max(entries) − min(exits) ≤ 120) and their centres are ≤ 1,000 m apart. Matching is greedy by smallest centre distance, and each thermal is used once. It yields who entered and left first (entryDelta, exitDelta), and the wait times. Compare does not read it. No caller outside the type was found in iOS Packages/Modules. Its Debrief use is not determined from the code read here.

Source: vulture-ios/Packages/Flights/Sources/Flights/Models/LeechingAnalysis.swift, LeechingAnalysisResult.swift, LeaderAnalysis.swift, .../FlightScreens/Compare/CompareAnalyses.swift; vulture-android/domain/flight/src/main/kotlin/be/fousa/vulture/domain/flight/compare/LeechingAnalysis.kt.

Platform differences: same as iOS (a port with the same constants and the same follower crediting).

Where the pilot sees it: the ranking card on a map with two or more flights that share one task with at least two waypoints (canShowRanking), and the place line (3rd · −9 km/h) on each flight’s card in the panel.

3.1 Progress along the task (RaceProgress)

Section titled “3.1 Progress along the task (RaceProgress)”

Built once per flight, in the background, whenever the flights or the task change.

Never starts when the flight has no fixes, no best task attempt, or no fix crossing the start. Its course distance is still computed from the waypoints.

Course.

  • Racing task: through the waypoint centres. cumulative[k] is the great-circle distance from the start to waypoint k.
  • Assigned area task (any intermediate zone is AAT): through the point the flight is credited with in each area (§3.2). Each flight then has its own course.
  • taskDistance = max(0, cumulative[last] − startRadius − finishRadius). A start or finish counts as a ring (radius R1) when it is not a line and A1 ≥ 180°; a line or a sector has radius 0.

Reached turnpoints (racing task). For each waypoint after the start, the first fix of its crossings, at least the start index. The list stops at the first waypoint that was never crossed. The flight is finished when every waypoint after the start was reached, and the finish fix is the last of them.

Samples. For each fix from the start index to the finish (or else the landing fix, or else the last fix):

next = (number of waypoints reached at or before this fix) + 1
distance = next ≥ waypointCount
? taskDistance
: cumulative[next] − greatCircle(fix, waypoint[next]) − startRadius
best = min(max(best, distance), taskDistance) (never goes back, starts at 0)

Position at a time t (seconds since midnight UTC, on the flight’s own clock):

ConditionPhaseDistanceSpeed
no start, or t < startTimenot started0none
finished and t ≥ finishTimefinishedtaskDistancetaskDistance / max(finish − start, minimumAATTime ?? 0, 1)
not finished and t ≥ last sample timelanded outlast bestnone
otherwiseracingbest at the last sample ≤ t (binary search)best / max(t − startTime, 900)

The 900 s (minimumSpeedDuration, 15 min) floor keeps a few kilometres just after a high start from reading as an impossible speed.

Handicap. When the flight has a handicap h > 0 (from the competition day), its speed is speed × 100 / h. Distance is unchanged. Flights without a handicap keep their raw speed, even in a class where others have one.

The search runs over the fixes from the start to the landing.

  1. For each area, the candidates are the fixes inside it. An area is inside when it is a ring (A1 ≥ 180, R2 = 0) and distance ≤ R1, or a sector within R1 and its outline.
  2. Areas reached in order: the earliest candidate after the previous area’s earliest. The search stops at the first area that was not reached.
  3. The flight is finished when every area was reached and a finish fix comes after the last area’s earliest point. The last area’s candidates are then cut to those before the last finish. A finish is inside a ring or sector, or across a line in the direction of the last leg.
  4. Each area’s candidates are limited to the window [earliest, latest]. latest is the last candidate that still leaves time for the next area.
  5. Pick one point per area that maximises start → p1 → … → pn → terminal by dynamic programming, with the points in time order. This runs in two passes: first over every max(1, count / 150)-th candidate, then over every fix within one stride of the first pass’s choice. terminal is the finish, or the next unreached area’s centre.
  6. The finish fix is the first finish fix after the last chosen point.

Every flight’s position is read at its own time: t_own = replayTime − timeOffset (§4). Before the glider has moved, every flight is not started.

Sort order:

  1. Group: finished or racing together (0), then landed out (1), then not started (2).
  2. Group 0: by speed, descending (handicapped speed when a handicap applies). A missing speed counts as 0.
  3. Group 1: by distance, descending.
  4. Ties, and group 2: by the map’s flight order.

Places run 1, 2, … over every flight that has started. Flights that have not started get no place and sit at the foot. The leader is the first started flight.

Gap to the leader (none for the leader and for flights not started):

This flightLeaderGap
has a speedhas a speed.speed(max(0, leaderSpeed − speed)), shown −<speed> (for example −9 km/h)
landed outlanded out.distance(max(0, leaderDistance − distance)), shown −<distance>
landed outhas a speednone: the row shows its distance

The leading − is U+2212 (minus sign), followed by the unit-formatted value.

Row value: the gap when there is one. Otherwise the speed (finished or racing), the distance (landed out), or nothing (not started). The row also shows the raw speed, the handicapped speed (in a class where any flight has a handicap), and the distance flown. The status is the current vario while racing, finished <hh:mm> in local time of the flight, or “landed out”.

Subtitle: “aligned by task start” when aligned; otherwise “handicapped” when any flight has a handicap, else “speed”.

Update rate. The map recomputes the standings, for the panel’s place line, on every replay time change. The ranking card’s rows are rebuilt at most every 100 ms (rebuildInterval). A call inside the interval schedules one rebuild at its end, using the latest time. Rows are republished only when they changed.

Source: vulture-ios/Packages/Flights/Sources/Flights/Models/RaceProgress.swift, RaceRanking.swift, AreaScoring.swift; vulture-ios/Packages/FlightScreens/Sources/FlightScreens/RaceRanking/RaceRankingViewModel.swift (rebuild, scheduleRebuild, RaceRankingFormat); vulture-ios/Modules/App/Sources/Map/Flight/FlightMapViewModel+Ranking.swift (sharedTask, invalidateRaceProgress, invalidateRaceStandings), Flight/FlightViewModel.swift (racePlaceString); vulture-android/domain/flight/src/main/kotlin/be/fousa/vulture/domain/flight/compare/RaceProgress.kt, RaceRanking.kt, AreaScoring.kt; vulture-android/domain/flightmap/.../replay/ReplayRanking.kt; vulture-android/feature/flightmap/.../FlightMapViewModel.kt (republish).

Platform differences

  • Engine runs (B21). Android ends the scored flight at the first fix of the first engine run that starts after the credited start. Turnpoints and a finish reached from then on do not count, and the flight lands out there. iOS scores up to the landing, whatever the engine did.
  • Shared task (B20). Android compares the tasks by value (distance and waypoints). iOS uses Task ==, which compares waypoints by identity.
  • Update rate. Android recomputes the standings in republish() on every replay position change. No 100 ms throttle like iOS rebuildInterval was found in the code.

Where the pilot sees it: “Align start” in the map’s menu, offered when more than one flight is on the map.

reference = min over flights of bestTaskAttempt.fixes.first.rawTime
for each flight with a task start s: timeOffset = reference − s (≤ 0)

A flight without a task attempt is not shifted. It keeps its current offset, which is 0 unless the map was already aligned. When no flight has a task start, nothing changes. Reset sets every offset to 0.

The replay uses one clock. Each flight shows its fix for replayTime − timeOffset, so at replayTime = reference every flight is at its own start. The recording range becomes min(start + offset) … max(end + offset). The ranking reads positions at the same shifted time (§3.3).

The barogram’s T+ readout counts from min(taskStart + offset) over the flights with a task start, as max(0, time − reference). It is used only when any offset is non-zero.

Source: vulture-ios/Modules/App/Sources/Map/Flight/FlightMapViewModel.swift (alignTaskStartTimes(), resetTaskStartTimeAlignment(), goTo, go(to:)), Panel/Graph/GraphDataSource.swift (alignedTaskStartTime(for:isAligned:)); vulture-android/domain/flightmap/src/main/kotlin/be/fousa/vulture/domain/flightmap/replay/TaskAlignment.kt.

Platform differences: same as iOS.

Where the pilot sees it: Statistics, a grid with one column per year (newest first) and one row per statistic.

  • Logbook flights only: a stored flight with its recording (data != nil). Flights kept only as recents (opened from a service) are excluded.
  • A flight without a takeoff time is excluded.
  • Each flight counts in the calendar year of takeoffAt, in the device’s calendar and time zone.

The grid reads values stored with the flight when it was imported or updated, not a fresh analysis. A missing value is stored as 0 (or false).

FieldStored from
durationlanding.rawTime − takeoff.rawTime; on an update the fallback is the gliding block duration, then 0; on import the fallback is 0
optimizedDistance, optimizedAverageSpeedfree-distance optimisation’s distance and average speed (flight-analysis §6)
isTaskCompleted, taskCompletedDistance, taskAverageSpeedbest task attempt: completed, distance, average speed (flight-analysis §7.5–7.6)
averageCruisingSpeedΣ glide distance / Σ glide time over all legs, none when either is 0
altitudeGainedthe largest single climb (pressure altitude, from a low point to a later high point) within a gliding block
bestThermalVariohighest thermal vario
left/rightThermalCountthermals turned left/right (attempts included)
isContest, isFieldLanding, waterBalastentered by the pilot

n is the number of flights in the year. Every mean is unweighted (each flight counts once, not by distance or time) and includes zeros: a flight without a task adds 0 to the task speed mean. Water ballast is the exception.

GroupRowValueFormat
FlightsFlightsncount
HoursΣ duration, shown as Int(seconds / 3600) (truncated)whole hours
DistanceOptimised distanceΣ optimizedDistancedistance, 0 decimals
Longestmax optimizedDistancedistance, 0 decimals
Average speedΣ optimizedAverageSpeed / nspeed
Best average speedmax optimizedAverageSpeedspeed
TaskCompletedcount of isTaskCompletedcount
Task vs freecompleted / n × 100"%0.0f %"
Task distanceΣ taskCompletedDistancedistance, 0 decimals
Longest taskmax taskCompletedDistancedistance, 0 decimals
Task average speedΣ taskAverageSpeed / nspeed
Best task speedmax taskAverageSpeedspeed
PerformanceCompetitionscount of isContestcount
Field landingscount of isFieldLandingcount
Average cruise speedΣ averageCruisingSpeed / nspeed
Best cruise speedmax averageCruisingSpeedspeed
Average water ballastmean over flights with ballast > 0 only; 0 when nonecount (truncated to whole litres)
ThermalsTotal altitudeΣ thermalAltitudeGainedaltitude
Best climbmax bestThermalVariovertical speed
Right thermalsΣ rightThermalCountcount
Left thermalsΣ leftThermalCountcount

“Completed” counts only completed tasks. The task distance and task speed rows, however, include every flight’s best attempt: taskCompletedDistance and taskAverageSpeed are stored whether or not the attempt finished.

Speeds, distances and altitudes are converted to the user’s unit from the full double, and rounded only once there. Hours and counts stay whole.

Only when there is more than one year. For each row, take the year values > 0, remove duplicates, and sort them (ascending only for field landings, otherwise descending). The top three distinct values get medals 1–3, and equal values share a medal.

Source: vulture-ios/Packages/FlightScreens/Sources/FlightScreens/Statistics/FlightsStatisticsViewModel.swift (flightsByYear, statistics(for:), formattedValue, rank(for:)), FlightsStatisticsProviding.swift, Hosting/Statistics/FlightsStatisticsProvider.swift; vulture-ios/Packages/Persistence/Sources/Persistence/Extensions/CoreData/CoreFlight+CoreData.swift (stored values), CoreFlight+Recents.swift (logbookPredicate), vulture-ios/Packages/Flights/Sources/Flights/Enricher/FlightTimesEnricher.swift (processAltitudeGained); vulture-android/domain/logbook/src/main/kotlin/be/fousa/vulture/domain/logbook/LogbookStatistics.kt, StatisticsDataItem.kt, StatisticsFlight.kt; vulture-android/feature/statistics/.../StatisticsStore.kt.

Platform differences: the aggregation is the same as iOS, with the same operation order. Rounding (B10) and the single-year corners (B14) are now the same on both. The current iOS formattedValue converts from the double, and position(for:) handles a single column. Android groups by the system default time zone, which is the same as iOS Calendar.current in practice. Android also updates live while the screen is open (decisions §7).

  • “Total altitude” in the Thermals group sums each flight’s stored thermalAltitudeGained: Σ altitudeGained of its thermals that are not attempts (Calculation fixes §3). A flight without the value (stored before it existed) adds 0. Since 2026-10-20 on both platforms: iOS stores CoreFlight.thermalAltitudeGained on insert and update (earlier flights get it when next updated); Android stores the nullable flight.thermalAltitudeGained (Room schema 4) and fills it for every flight through ANALYSIS_VERSION 2. Before, the row summed the stored altitudeGained, the flight’s largest single climb within a gliding block (§5.2).
  • On iOS, a stored flight’s duration falls back to the gliding block duration only on update, not on import.
  • The leg skipping in §1.6 can misalign the leg rows across flights.