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.
1. Compare
Section titled “1. Compare”1.1 Columns, rows and the pinned flight
Section titled “1.1 Columns, rows and the pinned flight”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 --.
1.2 The difference under each value
Section titled “1.2 The difference under each value”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.lowerIsBettertext = format(|delta|) in the row's unitThere 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:
| Row | Ranks on (§1.3) | Difference on |
|---|---|---|
| Optimisation rows (1 h, 2 h, free, triangle, out-and-return, straight) | the optimisation’s average speed | its distance |
| Maximum distance out | distance | distance |
| Task flown | best task attempt’s average speed | its distance |
| Task average glide, leg average glide | average cruise speed | glide ratio |
| Performance average glide | glide ratio | glide ratio |
| Leg distance | the leg’s average speed | the leg’s average speed |
| Every other rankable row | its value | its value |
Equality threshold and difference format per kind:
| Kind | Equal when | Formatted 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:
isBetter | Glyph | Colour | Text |
|---|---|---|---|
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>.
1.3 Podium
Section titled “1.3 Podium”With nothing pinned and more than one flight:
- Take the row’s ranked value (
rawValue) for every flight. Drop missing values and values ≤ 0. - Remove duplicates, then sort: ascending when the row is lower-is-better, otherwise descending.
- 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.
1.4 Rankable rows and direction
Section titled “1.4 Rankable rows and direction”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.
1.5 The Rating section
Section titled “1.5 The Rating section”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 / 1000in the band colour, and a part as a bar filled topart / 100in 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 ≤ 0averageSpeed = ((distance / 1000) / (duration / 3600)) / 3.6 (m/s) = distance / durationThe 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 row | Ranked 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 turn | mean thermal turn rate (whole °/s) |
| Average glide | average cruise speed (glide ratio shown) |
| Number of thermals | thermals 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.
1.7 Detours
Section titled “1.7 Detours”- 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.
1.8 Flew alone
Section titled “1.8 Flew alone”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 × 100None 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).
2.1 Fixes used
Section titled “2.1 Fixes used”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.
2.2 Pairwise analysis (LeechingAnalysis)
Section titled “2.2 Pairwise analysis (LeechingAnalysis)”| Constant | Value |
|---|---|
maxRangeMeters (together) | 2,000 m |
minWindowSeconds | 240 |
mergeGapSeconds | 30 |
rearConeDegrees | 45° |
trackMatchDegrees | 45° |
minTrackSpeed | 40 km/h (11.11 m/s) |
lagMinSeconds … lagMaxSeconds | 5 … 90 s |
- Align in time. The overlap runs from
max(firstA, firstB)tomin(lastA, lastB)(rawTime). For each whole secondtin it, take each flight’s fix nearest tot. Keep the pair only when both fixes are within 2 s oft. A second without a pair is dropped, not filled. - Proximity. For each pair,
together = distance(A, B) ≤ 2,000 m. - Bridge gaps. Every run of ≤ 30
falsesamples that is followed by atrueis set totrue. A leading run at the start of the array is bridged too. A trailing run is not. - Windows. Contiguous
trueruns of at least 240 samples. The duration is the sample count, which equals seconds only when there are no dropped seconds. - 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 += 1when the bearing from A to B is within 45° of A’s tail (bearingA + 180°).leadB += 1when the bearing from B to A is within 45° of B’s tail.- Drop the window when
leadA + leadB = 0.
- Leader. A leads when
leadA ≥ leadB(a tie goes to A, the lower index). WindowleadSeconds = max(leadA, leadB)andfollowSeconds = min(leadA, leadB). - 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]withfollower[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].
2.3 Per-pilot summary
Section titled “2.3 Per-pilot summary”Over all windows of all pairs:
leader.leadSeconds += window.leadSecondsfollower.followSeconds += window.leadSeconds (the leader's seconds, not window.followSeconds)both: windowDuration += window.duration; lead/follow window count += 1both: bestCorrelation/bestLagSeconds = the window's when its correlation is higherfollowRatio = followSeconds / (leadSeconds + followSeconds), 0 when the sum is 0| Classification | Rule (total = lead + follow) |
|---|---|
| Independent | total < 60 s |
| Follower | followRatio ≥ 0.75 |
| Leader | followRatio ≤ 0.25 |
| Racing | otherwise |
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.
2.4 Shared thermals (LeaderAnalysis)
Section titled “2.4 Shared thermals (LeaderAnalysis)”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).
3. Race ranking during a replay
Section titled “3. Race ranking during a replay”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 waypointk. - 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 (radiusR1) when it is not a line andA1 ≥ 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) + 1distance = next ≥ waypointCount ? taskDistance : cumulative[next] − greatCircle(fix, waypoint[next]) − startRadiusbest = 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):
| Condition | Phase | Distance | Speed |
|---|---|---|---|
no start, or t < startTime | not started | 0 | none |
finished and t ≥ finishTime | finished | taskDistance | taskDistance / max(finish − start, minimumAATTime ?? 0, 1) |
not finished and t ≥ last sample time | landed out | last best | none |
| otherwise | racing | best 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.
3.2 Area task course (AreaScoring)
Section titled “3.2 Area task course (AreaScoring)”The search runs over the fixes from the start to the landing.
- For each area, the candidates are the fixes inside it. An area is inside when it is a ring
(
A1 ≥ 180,R2 = 0) anddistance ≤ R1, or a sector withinR1and its outline. - Areas reached in order: the earliest candidate after the previous area’s earliest. The search stops at the first area that was not reached.
- 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.
- Each area’s candidates are limited to the window
[earliest, latest].latestis the last candidate that still leaves time for the next area. - Pick one point per area that maximises
start → p1 → … → pn → terminalby dynamic programming, with the points in time order. This runs in two passes: first over everymax(1, count / 150)-th candidate, then over every fix within one stride of the first pass’s choice.terminalis the finish, or the next unreached area’s centre. - The finish fix is the first finish fix after the last chosen point.
3.3 Standings (RaceRanking.standings)
Section titled “3.3 Standings (RaceRanking.standings)”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:
- Group: finished or racing together (0), then landed out (1), then not started (2).
- Group 0: by speed, descending (handicapped speed when a handicap applies). A missing speed counts as 0.
- Group 1: by distance, descending.
- 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 flight | Leader | Gap |
|---|---|---|
| has a speed | has a speed | .speed(max(0, leaderSpeed − speed)), shown −<speed> (for example −9 km/h) |
| landed out | landed out | .distance(max(0, leaderDistance − distance)), shown −<distance> |
| landed out | has a speed | none: 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 (
distanceandwaypoints). iOS usesTask ==, which compares waypoints by identity. - Update rate. Android recomputes the standings in
republish()on every replay position change. No 100 ms throttle like iOSrebuildIntervalwas found in the code.
4. Aligning task start times
Section titled “4. Aligning task start times”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.rawTimefor 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.
5. Logbook statistics
Section titled “5. Logbook statistics”Where the pilot sees it: Statistics, a grid with one column per year (newest first) and one row per statistic.
5.1 Which flights count
Section titled “5.1 Which flights count”- 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.
5.2 Stored per-flight values
Section titled “5.2 Stored per-flight values”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).
| Field | Stored from |
|---|---|
duration | landing.rawTime − takeoff.rawTime; on an update the fallback is the gliding block duration, then 0; on import the fallback is 0 |
optimizedDistance, optimizedAverageSpeed | free-distance optimisation’s distance and average speed (flight-analysis §6) |
isTaskCompleted, taskCompletedDistance, taskAverageSpeed | best 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 |
altitudeGained | the largest single climb (pressure altitude, from a low point to a later high point) within a gliding block |
bestThermalVario | highest thermal vario |
left/rightThermalCount | thermals turned left/right (attempts included) |
isContest, isFieldLanding, waterBalast | entered by the pilot |
5.3 Per-year aggregates
Section titled “5.3 Per-year aggregates”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.
| Group | Row | Value | Format |
|---|---|---|---|
| Flights | Flights | n | count |
| Hours | Σ duration, shown as Int(seconds / 3600) (truncated) | whole hours | |
| Distance | Optimised distance | Σ optimizedDistance | distance, 0 decimals |
| Longest | max optimizedDistance | distance, 0 decimals | |
| Average speed | Σ optimizedAverageSpeed / n | speed | |
| Best average speed | max optimizedAverageSpeed | speed | |
| Task | Completed | count of isTaskCompleted | count |
| Task vs free | completed / n × 100 | "%0.0f %" | |
| Task distance | Σ taskCompletedDistance | distance, 0 decimals | |
| Longest task | max taskCompletedDistance | distance, 0 decimals | |
| Task average speed | Σ taskAverageSpeed / n | speed | |
| Best task speed | max taskAverageSpeed | speed | |
| Performance | Competitions | count of isContest | count |
| Field landings | count of isFieldLanding | count | |
| Average cruise speed | Σ averageCruisingSpeed / n | speed | |
| Best cruise speed | max averageCruisingSpeed | speed | |
| Average water ballast | mean over flights with ballast > 0 only; 0 when none | count (truncated to whole litres) | |
| Thermals | Total altitude | Σ thermalAltitudeGained | altitude |
| Best climb | max bestThermalVario | vertical speed | |
| Right thermals | Σ rightThermalCount | count | |
| Left thermals | Σ leftThermalCount | count |
“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.
5.4 Medals across years
Section titled “5.4 Medals across years”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).
6. Things to know
Section titled “6. Things to know”- “Total altitude” in the Thermals group sums each flight’s stored
thermalAltitudeGained: ΣaltitudeGainedof 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 storesCoreFlight.thermalAltitudeGainedon insert and update (earlier flights get it when next updated); Android stores the nullableflight.thermalAltitudeGained(Room schema 4) and fills it for every flight throughANALYSIS_VERSION2. Before, the row summed the storedaltitudeGained, 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.
