Skip to content

01 — Flight-analysis domain layer (IGC → analysed flight)

Moved from vulture-android/docs/analysis/01-flight-domain.md

Source of truth: /Users/fousa/Projects/vulture-ios (read-only). Every path below is relative to that repo unless it says otherwise. All line numbers were read on 2026-10-10.

Scope: Packages/Flights, Packages/Waypoints (task model, CUP/XCTrack decoding, observation-zone geometry), Packages/Map (geometry helpers), Packages/TrackShape (simplifier), Packages/Gliders, Packages/Formatting + Packages/Preferences/Units (units), and Packages/FlightRecording where it writes IGC.

Golden rule for the port. The iOS code has many quirks: off-by-one indexes, mixed Earth radii, km/m mix-ups, sentinel values and odd loop bounds. The test suites pin them. To produce the same numbers on Android, port each algorithm verbatim first and turn the Swift tests into Kotlin golden tests. Fix a quirk only later, and on both platforms at once. Section 13 lists the quirks.


ItemValue
Portable Swift source in scopeFlights ≈ 8.1k lines (≈ 7.5k without Satellite/), Waypoints ≈ 2.1k, Map geometry ≈ 0.4k, TrackShape simplifier ≈ 0.2k, units ≈ 0.4k. Much of it is doc comments.
Tests to portFlights ≈ 461 tests / 4.3k lines; Waypoints ≈ 124; TrackShape 21 (9 simplifier); Map 5 (bounds)
Fixtures15 IGC files (≈ 17 MB) plus 7 *.elevation.json SRTM caches (Packages/Flights/Tests/Flights/)
Pipelineparse (Flight.init) → sport detection → elevation → “improve” (time zone + pressure correction) → times (takeoff/release/landing/engine/gliding block/altitude gain) → phases (thermals, wind, legs) → distance optimisations → task scoring → speed (avg speed, best 1 h / 2 h)
External calls1 HTTP: GET https://vulture.aero/api/elevations/{TILE} (zip holding a .hgt). Time zone is an offline nearest-city table (timezones.json). The flight pipeline does no reverse geocoding.
ScoringFree distance (≤ 5 TPs, sub-sampled DP), out (longest leg), out-and-return, closed “triangle” (no FAI 28 % rule), 3-TP distance, max distance from start, best 1 h / 2 h speed. No OLC/WeGlide points, handicap or FAI-triangle validation.
Task scoringZone polygons (5° sampled arcs, ray-cast in lat/lon degrees) and line-crossing tests; attempts; PEV cylinder start (FAI Annex A 7.4.4); AAT minimum time; minimum finish altitude. Task distance is the path through the credited fixes.
Estimated Kotlin size≈ 5–6k lines of main code plus ≈ 5k lines of tests. ≈ 4–6 engineer-weeks to golden parity (section 15).

iOS fileResponsibilityKotlin target
Packages/Flights/Sources/Flights/Decorder/FlightDecoder.swiftEntry point: loads content and runs Flight.init plus FlightEnricherigc/FlightDecoder.kt
…/Models/Flight.swiftIGC line parser (init, l.166–506) plus ~60 lazy statisticsmodel/Flight.kt + igc/IgcParser.kt + stats/FlightStatistics.kt
…/Models/Fix.swiftB-record parser, vario/speed/bearing, extension valuesmodel/Fix.kt + igc/BRecordParser.kt
…/Models/{Event,Extension,Prefix,HeaderPrefix,BRecordColumn,FixValidity,ParseWarning}.swiftRecord enums and columnsigc/*
…/Extensions/String+{Parsing,Range,Convert,Regex,Flaps}.swift, Int+Time.swiftLow-level field parsing and time formattingigc/IgcStrings.kt, util/TimeFormat.kt
…/Extensions/Task+Parsing.swift, Waypoint+Parsing.swiftIGC C-record task buildingigc/IgcTaskParser.kt
…/Enricher/FlightEnricher.swiftOrchestrates the pipeline, sport detection, improveFlightDataanalysis/FlightEnricher.kt
…/Enricher/SportProfile.swiftSport thresholdsanalysis/SportProfile.kt
…/Enricher/FlightElevationEnricher.swift, Elevation/Elevation.swift, Service/*SRTM tileselevation/*
…/Enricher/FlightTimesEnricher.swiftTakeoff, landing, release, engine runs, gliding block, altitude gainanalysis/TimesEnricher.kt
…/Enricher/FlightTakeoffEnricher.swiftGeneric takeoff/release detector for live fixes (not in the IGC pipeline)analysis/LiveTakeoffDetector.kt
…/Enricher/FlightPhasesEnricher.swift, Extensions/CLLocationCoordinate2D+{Thermal,Region}.swiftThermals, wind, legsanalysis/PhasesEnricher.kt
…/Enricher/DistanceEnricher.swiftReusable 5-TP DP optimiser (legs, task helpers)analysis/DistanceOptimizer.kt
…/Enricher/FlightDistanceEnricher.swiftFlight optimisations (free, triangle, O&R, out, 3-TP, max out)analysis/FlightDistanceEnricher.kt
…/Enricher/FlightTaskEnricher.swift, Models/TaskAttempt.swift, Extensions/CLLocationCoordinate2D+{Line,Calculations}.swiftTask scoringtask/*
…/Enricher/FlightSpeedEnricher.swiftPer-fix average speed and best 1 h / 2 hanalysis/SpeedEnricher.kt
…/Enricher/FlightMetadataEnricher.swiftLight pipeline (times + distance), used for previewsanalysis/MetadataEnricher.kt
…/Models/{Thermal,Leg,EngineRun,Wind,Phase,Optimization,OptimizationItem,TimedPhaseConvertible}.swiftAnalysis modelsmodel/*
…/Models/{RaceProgress,RaceRanking,LeaderAnalysis,LeechingAnalysis,LeechingAnalysisResult}.swiftMulti-flight competition analysiscompare/*
…/Models/Flight+Identity.swiftFNV-1a 64-bit flight idmodel/FlightIdentifier.kt
…/Vendor/TimeZoneLocate/*, Resources/timezones.jsonOffline time-zone lookuptz/TimeZoneLocator.kt + asset
…/Satellite/*SkySight satellite tiles (weather; not analysis)out of scope (weather doc)
Packages/Waypoints/Sources/Waypoints/Models/{Task,Waypoint,Zone,ZoneType,ObservationZoneType,LocationAltitude,ExternalTask}.swiftTask modelwaypoints/model/*
…/Extensions/Waypoint+ObservationZone.swiftZone polygon/line generationwaypoints/geometry/ZoneGeometry.kt
…/WaypointsDecoder.swift, XCTrackTaskDecoder.swift, WaypointsEncoder.swiftCUP and XCTrack QR decode, CUP/Nano/FLARM encodewaypoints/io/*
Packages/Map/Sources/Map/Helpers/*, Coordinates/*Haversine (6371 km), bearing, destination point, sector arcs, half-angle haversine (6378.137 km)geo/*
Packages/TrackShape/Sources/TrackShape/{TrackSimplifier,SimplifiedTrack,RowTrackShape}.swiftDouglas–Peucker ranking for thumbnailstrackshape/*
Packages/Preferences/Sources/Preferences/Units/*Units and the formatterunits/*
Packages/Formatting/Sources/Formatting/*Coordinate, date and name formattingformat/*
Packages/Gliders/*Bundled glider/handicap DBgliders/*

2. Domain models (enough for Kotlin data classes)

Section titled “2. Domain models (enough for Kotlin data classes)”

Units, unless stated otherwise: m, m/s, seconds, °C, degrees. Altitudes are integers. Packages/Waypoints/Sources/Waypoints/Aliases.swift:12 redefines CLLocationAltitude = Int (Swift Int is 64-bit). TimeInSeconds = Int.

Kotlin type mapping: Swift Int → Kotlin Long where overflow or sentinels matter (the id, the distance-cache key, Int.max sentinels); Int elsewhere. Swift Double.leastNormalMagnitude → java.lang.Double.MIN_NORMAL (2.2250738585072014e-308). leastNonzeroMagnitude → Double.MIN_VALUE. greatestFiniteMagnitude → Double.MAX_VALUE.

2.1 Flight (Packages/Flights/Sources/Flights/Models/Flight.swift)

Section titled “2.1 Flight (Packages/Flights/Sources/Flights/Models/Flight.swift)”

This is a mutable class, and the enrichers mutate it. Keep it a class in Kotlin, or use a builder plus an immutable result. Fix.thermal/Fix.leg back-references make immutability awkward.

FieldTypeOrigin
dateInstant (UTC midnight of HFDTE)parse
filenameString (given, or "{CID}_{ddMMyyyy}.igc"; date formatted in device local TZ, see §13)parse
idLong (FNV-1a, §2.10)parse
contentString (raw IGC)parse
pilotName, passengerName, gliderType, gliderRegistration, loggerType, loggerSerialNumber, gpsManufacturer, pressureAltitudeSensor, mopSensor, competitionIdentification, competitionClass, flarmVersion, flarmSerialNumberString?headers / A / LLXVFLARM:NG
taskTask? (setting it resets taskAttempts, bestTaskAttempt and every fix’s averageTaskSpeed = 0)C/L records or assigned
fixesListparse
warningsListparse
engineLevelThresholdInt = 200 + Σ ENL / fixCount (integer division, ENL only, not MOP)parse (l.496)
takeoffTypeenum unknown, winch, tow, selfLaunchtimes
min/maxGPSAltitude, min/maxPressureAltitudeIntparse, then “improve”
maximumGroundspeed, maximumAirspeed, min/maxVario, min/maxTotalEnergy, min/maxOutsideAirTemperature (Int), min/maxGForce, min/maxVerticalAcceleration, min/maxVerticalAirMovement, min/maxAttitudePitchAngle, min/maxAttitudeRollAngle, min/maxMeansOfPropulsion (Int), min/maxRPM (Int), maximumElectricalVolts/Current, maximumFuelLevel/Flow, maximumTemperature{ExhaustGas,Motor,JetPipe,Controller} (Int)numericparse (on raw, unsmoothed per-fix values; §3.6)
flapsList (distinct flap labels sorted by flapValue desc; empty if ≤ 1)parse
startRecordingTime, endRecordingTimeInt (rawTime of first/last fix)parse
thermalAttemptCountInt?phases
detectedSportProfileSportProfileenricher
thermalsList?phases
legsList?phases
windMap<Int, Wind> (key = thermal entry time; see §5.4)phases
engineRunsList?times
taskAttempts, bestTaskAttemptList?, TaskAttempt?task
startGlidingBlockIndex, endGlidingBlockIndexInt? (internal)times
best1hSpeedOptimization, best2hSpeedOptimization, maximumDistanceOutOptimization, freeDistanceOptimization, outDistanceOptimization, outAndReturnDistanceOptimization, triangleDistanceOptimization, threeTurnpointDistanceOptimizationOptimization?distance/speed
lowestThermalEntryFix, lowestAltitudeFix, highestAltitudeFixFix?phases/times
isElevationAvailableBooleanelevation
availableExtensionsList (§3.4)parse

Derived (lazy) statistics. Compute them once after enrichment, or as Kotlin by lazy (Flight.swift:553–1025):

PropertyExact formula
durationlandingFix.rawTime − takeoffFix.rawTime
flightDurationlanding.rawTime − (release ?? takeoff).rawTime − (engineRunTime ?? 0)
glidingBlockRange / glidingBlockDurationindices / endFix.rawTime − startFix.rawTime
phasesthermals + cruise legs + engine runs + event fixes, sorted() with Phase ordering (§2.6)
altitudeGainedhighestAltitudeFix.pressureAltitude − lowestAltitudeFix.pressureAltitude (0 if nil)
fixesWithEvents, takeoffFix, releaseFix, landingFix, pilotEventFixesfirst fix with that event
furthestPointFromHomeFixmaximumDistanceOutOptimization.fixes.last
takeoffPerformance(release.pressAlt − takeoff.pressAlt)/(release.time − takeoff.time). If release.pressureAltitude == 0, use GPS altitude (nil if either side is missing). Note: uses .time (local), not rawTime.
averageThermalVarioΣ altitudeGained / Σ duration (time-weighted)
thermalHeight, thermalHeightLostΣ altitudeGained / Σ altitudeLost
thermalDriftΣ drift / count (no empty guard → NaN if 0 thermals)
thermalAverageAirspeedonly if TAS is available: mean of thermal.averageAirspeed
averageDiameterInt(Σ radius / n) * 2
averageTurnRateInt(Σ turnRate / n)
bestThermalVariomax thermal.vario
thermalPercentagetotalThermallingTime / duration * 100
totalThermallingTime, thermalCount, thermalAttempts (altitudeGained ≤ 0), actualThermals
left/rightThermals, …Count, average{Left,Right}ThermalVario (plain mean), {left,right}ThermalHeight
totalCruisingTime, totalCruisingDistanceΣ over legs
averageCruisingSpeed(dist/1000)/(t/3600)/3.6 (m/s)
averageGlideRatioInt(Σdist / ΣaltDiff) * −1 (truncation, then negation)
engineRunTimeΣ run durations, nil if 0
{left,right,mixed}ThermallingPercentageshare of thermals by turn, × 100
averageDistanceBetweenThermalsΣ (distanceToNextThemal ?? 0) / thermals.count (divides by the full count, including the last thermal)
engineRegistrationType"ENL", "MOP" or "ENL + MOP" depending on whether any fix has the extension
mappedWindTimes, min/max/averageWindSpeedover the wind map
maximumAverageSpeed, maximumAverageTaskSpeedmax over fixes
startRecordingDate/endRecordingDateCalendar.current.startOfDay(for: date) + rawTime s (device TZ)
fix(nearest:)binary search, ties go to the earlier fix
contains(time:)start ≤ t ≤ end

Hashable/Equatable compare id only.

FieldTypeNotes
rawTimeIntseconds since UTC midnight, +86400 if time < firstFix.rawTime (wrap)
timeIntun-wrapped UTC seconds, then + local TZ offset (improve step)
coordinatelat/lon Double
eventEvent?
gpsAltitudeInt?nil when validity byte = V
fixValiditythreeDimensional / twoDimensionalA → 3D, V → 2D, anything else → 3D
pressureAltitudeIntcorrected in the improve step
elevationIntSRTM terrain (0 if unknown)
totalEnergy (m/s), outsideAirTemperature (Int? °C), gForce, verticalAcceleration, verticalAirMovement, attitudePitchAngle, attitudeRollAngle, electricalCurrent, electricalVolts, fuelLevel, fuelFlow, temperatureExhaustGas/Motor/Controller/JetPipe (Int), batteryLeft, flapPosition (String?), flapPositionValue (Double?), flapPositionIndex (Int?), engineRPMextension values (§3.4)
varioDoublesmoothed (§3.6)
groundspeedDouble m/ssmoothed
trueAirspeedDouble m/sTAS / 100 / 3.6
thermal / legback-references
distanceTraveledDouble, km despite its doc (“meters”)speed enricher
optimizedDistanceTraveledinternal, km
averageSpeed, averageTaskSpeedm/sspeed enricher
bearingInt?Int(prev.bearing(to: this))
isEngineNoiseLevelEnabled, engineNoiseLevel, isMeansOfPropulsionEnabled, meansOfPropulsionLevel
heartRate, bloodOxygenLevelInt?from the preceding L records
engineLevel (lazy)max(ENL, MOP)

Comparable by rawTime. FlightFix protocol (Models/FlightFix.swift): coordinate, altidude [sic], speed, time, engineLevel, vario.

id: UUID, centerCoordinate, totalRadius (m), vario, bestVario, relativeRatio (default 0.5), altitudeGained, entryAltitude, exitAltitude, entryTime, exitTime (rawTime), turn: Turn? (left/right/mixed), distanceToNextThemal [sic], fixes, internal turnRadiusValues, speedValues, firstFixIndex, lastFixIndex.

Derived values:

  • isAttempt = altitudeGained ≤ 0
  • duration = exit − entry
  • radius = mean(turnRadiusValues)
  • turnRate = deg(atan(avgSpeed² / (9.81 · radius))). This is the bank angle, despite the name.
  • minimumAltitude/maximumAltitude over fixes
  • drift = distance(first, last), only when there are more than 2 fixes
  • averageAirspeed = Σ TAS / count
  • altitudeLost = Σ negative consecutive pressure deltas (≤ 0)
  • circleCoordinates: 73 points every 5° (radius 6371000)

Turn(turns:): right if left ≤ right && left/n < 0.2; left if right < left && right/n < 0.2; otherwise mixed. With n = 0 this gives mixed.

fixes (> 1), distance = DistanceEnricher(fixes).optimalDistance().distance: a 5-turnpoint optimised distance in metres, not the path length.

Derived values:

  • startFix, endFix, duration (rawTime)
  • altitudeDifference = end.pressAlt − start.pressAlt
  • averageSpeed = (d/1000)/(t/3600)/3.6
  • averageGlideRatio = Int(d/altDiff) * −1; nil if d ≤ 0 or altDiff == 0
  • EngineRun: startFix, endFix, firstFixIndex, lastFixIndex, duration.
  • Wind: speed (m/s), direction (Int, degrees). The direction is the bearing from the thermal’s last fix to its first fix, so it points to where the wind comes from. The doc comment says “towards”; it is wrong.

event(fix) | engine(run) | thermal(t) | cruise(leg). time = fix.rawTime / start.rawTime / entryTime / leg start.

Ordering: by time. On equal time an event comes first; any other tie compares false, so the stable sort keeps the original order. Original order = thermals, cruise, engine, events (Swift’s sort is stable in practice; use Kotlin sortedWith, which is stable).

2.7 Optimization (Models/Optimization.swift)

Section titled “2.7 Optimization (Models/Optimization.swift)”

Fields: indexes, fixes, distance (input km, stored ×1000 = m), averageSpeed = (km / (dt/3600)) / 3.6 m/s. The init fails (null) unless there are more than 1 fix and end.rawTime > start.rawTime. OptimizationItem protocol: indexes, fixes, distance, averageSpeed, startAltitude?, startGroundSpeed?, finishAltitude?, minimumFinishAltitudeDifference?, minimumAATDurationDifference?. All the optional ones are nil for an Optimization.

2.8 TaskAttempt (Models/TaskAttempt.swift)

Section titled “2.8 TaskAttempt (Models/TaskAttempt.swift)”
FieldMeaning
waypointCrossingIndexes: [[Int]]fix indices per waypoint
waypointIndexprogress cursor
waypointCoordinates
minimumAltitude (finish zone Elev), minimumAATDuration
indexes, fixescredited fixes
distance (m)DistanceEnricher(fixes).trueDistance() (path through credited fixes, 6378.137 km radius) ×1000
averageSpeedkm/h/3.6
isCompletedevery waypoint crossed at least once
startAltitude = first fix pressure altitude (?? gps, though pressure is never nil); startGroundSpeed
finishAltitude, finishGroundSpeedonly when completed; fix at index waypointCount−1
minimumFinishAltitudeDifference = finishAlt − minAlt
minimumAATDurationDifference = duration − minAAT (completed only)
thermalAverageVario, thermalDiameter, thermalTurnRate, thermalCount, thermalAttemtCount [sic], thermalHeightGained/Lostthermals fully inside [start, end]
averageGlideRatio, averageCruiseSpeedlegs fully inside [start, end]
isPEVStart
  • RacePosition{phase(notStarted|racing|finished|landedOut), distance, taskTime?, speed?}
  • RaceProgress: minimumSpeedDuration = 900 s, centre-to-centre task distance, monotone best distance
  • RaceRanking / RaceStanding / RaceGap(speed|distance)
  • LeaderAnalysis (maxCenterDistance 1000 m, maxTimeGap 120 s, greedy matching by closest centre)
  • LeechingAnalysis (maxRange 2000 m, minWindow 240 s, mergeGap 30 s, rearCone 45°, trackMatch 45°, minTrackSpeed 40/3.6, lag 5…90 s Pearson). PilotSummary.classification: independent if total < 60 s; follower if followRatio ≥ 0.75; leader if ≤ 0.25; otherwise racing.

These are pure functions. Port them as-is (Models/RaceProgress.swift, RaceRanking.swift, LeaderAnalysis.swift, LeechingAnalysis*.swift).

2.10 Flight id (Models/Flight+Identity.swift)

Section titled “2.10 Flight id (Models/Flight+Identity.swift)”

64-bit FNV-1a, offset 0xcbf29ce484222325, prime 0x100000001b3.

Input bytes, in order:

  1. filename UTF-8 bytes
  2. date.timeIntervalSince1970 as a Double bit pattern (doubleToRawLongBits), least-significant byte first
  3. Int64(startRecordingTime), least-significant byte first
  4. Int64(fixCount), least-significant byte first

The result is reinterpreted as a signed Long. The id is persisted (scroll-position memory), so match it exactly.

2.11 Waypoints models (Packages/Waypoints/Sources/Waypoints/Models/)

Section titled “2.11 Waypoints models (Packages/Waypoints/Sources/Waypoints/Models/)”
  • Task (class): name?, distance (m, recomputed when waypoints is replaced), minimumAATDuration? (s), waypoints, notes?, inactiveAirspace?. Equality = hash of (distance, waypoints). append(waypoint:) sets the leg distance/bearing on the waypoint and order.
  • Waypoint (class): name?, code?, country?, coordinate, elevation: String? (e.g. “123m”/“400ft”), style: Style? (CUP 0…17), runwayDirection?, runwayLength?, frequency?, desc?; task context: distance?, bearing: Int?, zone: Zone?, order (−1 outside a task). waypointName = name if it contains a letter, otherwise localized “Turnpoint {order+1}”.
  • Zone (class): type: ZoneType = symmetric, isLine, isAAT, R1, A1, R2, A2, A12, maximumAltitude: Int = 0, minimumAltitude: Int?.
  • ZoneType (Int raw): fixed=0, symmetric=1, next=2, previous=3, start=4 (CUP Style).
  • ObservationZoneType: start=0, waypoint=1, finish=2.
  • Style enum (Int raw 0…17): unknown, waypoint, airfieldWithGrassRunway, outlanding, glidingAirfield, airfieldWithSolidRunway, mountainPass, mountainTop, transmitterMast, vor, ndb, coolingTower, dam, tunnel, bridge, powerPlant, castle, intersection.
  • LocationAltitude{agl?, fl?, msl?}, ExternalTask{task, externalVersion?, externalName?, externalURL}.

{type, model, manufacturer, handicap}, all Strings, from Sources/Gliders/Resources/gliders.json (58 KB), sorted by model. The bundled JSON is the whole package: copy it to Android assets.


3. IGC parsing (Flight.init, Flight.swift:166–506, plus Fix.init)

Section titled “3. IGC parsing (Flight.init, Flight.swift:166–506, plus Fix.init)”
  • Data (Extensions/Data+Loading.swift): try UTF-8, then ASCII, ISO-8859-1, ISO-8859-2, MacRoman. The first successful decode wins. In practice UTF-8 succeeds or Latin-1 always succeeds. On Android: CharsetDecoder with REPORT for UTF-8, then fall back to ISO-8859-1.
  • File URL (URL+Load.swift): encoding auto-detect first, then ASCII, UTF-8, Latin-1, Latin-2, MacRoman.
  • A remote URL is rejected with DecoderError.remoteURL.
  • Split on any newline grapheme (\n, \r\n (counts once), \r, U+2028…), keeping empty lines, so the 1-based line numbers match an editor.
  • trimmedString() (whitespace + newlines) on every line.
  • Dispatch on the first character. Empty lines are skipped silently.
  • Swift string subscripts in this code are by Character (String+Range.swift). If the lower bound is past the end, the result is nil. If the upper bound is past the end, the result is the truncated remainder (not nil). Replicate this exactly: safeSub(start, end).
RecordHandlingCode
AserialNumber(): from index 4 until the first , - : or whitespace. Nil if count ≤ 4 or empty. Example: ALXV4YE,FLIGHT:1 → 4YE.String+Parsing.swift:99
Hline[2..<5] is the subtype (any second char: F/O/P). Known: DTE PLT CM2 GTY GID FTY GPS PRS MOP CID CCL. Value = text after the first :, trimmed; nil if missing or empty. Unknown subtype → unknownRecordType('H', line) warning.Flight.swift:240–260, HeaderPrefix.swift
HFDTEIf the line has : and ,: take the substring between them (HFDTEDATE:220619,01). Otherwise take the text after the first "DTE" (HFDTE220619). Must be exactly 6 chars DDMMYY. Year: YY ≥ 80 → 19YY, else 20YY (fixed window). Built in a UTC gregorian calendar. HFDTEDATE:070719 (no comma) yields nil: the suffix after DTE is DATE:070719, so the line is ignored (wim40.igc relies on a second HFDTE070719 line). A later HFDTE line overwrites an earlier one. Foundation calendars normalise out-of-range day/month (month 13 → January next year). java.time throws, so emulate with LocalDate.of(y,1,1).plusMonths(m-1).plusDays(d-1).String+Parsing.swift:21–57
Iextensions(): NN = line[1..<3], then NN groups of 7 chars SSEETTT (start/end 1-based inclusive columns, 3-letter code). Unknown codes are skipped. The I record replaces the extension list.String+Parsing.swift:63–87, Extension.swift
JNot handled. J is in Prefix but has no case in the switch, so it falls to default → unknownRecordType('J'). K records are not parsed either.
C (first)Regex C[0-9]{6}[0-9]{6}[0-9]{6}[0-9]{4}[0-9]{2}$ (unanchored at the start, $ at the end). Starts a new Task(), numberOfTaskWaypoints = line[23..<25]. A declaration line with trailing text (e.g. C070719093932000000000003Task, wim40.igc) does not match, so the task stays nil and every C line is dropped.Flight.swift:262–264
C (others)Only once a task exists. Waypoint(taskLine:): coordinate(from: 1) (same parser as B); name = text from index 18 (nil if empty). 0000000N00000000E placeholders give coordinate (0,0) → nil → not appended. order = task.waypoints.count.Waypoint+Parsing.swift
Eevent(): code from index 7 up to the first space; Event(rawValue:) (unknown → nil). Remembered and attached to the next B record, then cleared. A second E before a B overwrites it.String+Parsing.swift:122–135
BSee §3.5. Malformed → droppedFix(line, "malformed B-record") warning.Fix.swift:111–267
L LLXVFLP flapPosition = rest of the line after "LLXVFLP ", trimmed. Applies to following fixes until changed.l.369
L LNAV::HRT (count > 10)heartRate = Int(line[10..<14])l.371
L LLXVSPO (count == 15 exactly)heartRate = Int(line[13..<15]), bloodOxygenLevel = Int(line[8..<10])l.376
L LLXVOZ / LNAVOZN / LSEEYOU OZObservation zones. The first prefix seen wins; later other prefixes are ignored. task.applyStyle(components split by ",", prefix, indexOffset: 1) (§7.2).l.385–393
L LLXVTSK / LNAVTSKN / LSEEYOU TSKtask.applyTask: TaskTime= → minimumAATDuration if > 0l.394
L LLXVFLARM:NGText from index 13 split by , → flarmVersion, flarmSerialNumberl.399
any other L, G, D, F, K, J, unknownunknownRecordType(firstChar, line) warningl.405

Event raw values: PEV ATS BFION BFIOFF BFIUN CGD FIN FLP GSP MAC LOV ONT STA TPC TRT UND. Generated events: VultureTakeoff, VultureRelease, VultureLanding, VultureAltitudeGainLowPoint, VultureAltitudeGainHighPoint, VultureFurthestPointFromHome (isGenerated).

3.4 Extensions (I record) and per-fix conversions (Fix.swift:195–266)

Section titled “3.4 Extensions (I record) and per-fix conversions (Fix.swift:195–266)”

Range = (start−1)..<end (character indices), read with the truncating substring.

CodeFieldConversion
FXA, GAL, SIU, GSP, TRT, HDT, LAD, LOD, …—ignored (FXA/GAL are in the enum but have no handler)
ENLengineNoiseLevelInt (nil → 0); sets isEngineNoiseLevelEnabled
MOPmeansOfPropulsionLevelInt; sets isMeansOfPropulsionEnabled
RPMengineRPMInt
TAStrueAirspeedDouble / 100 / 3.6 (km/h × 100 → m/s)
VATtotalEnergy/100
OAToutsideAirTemperatureInt / 10 (integer division). ?-filled → nil, no warning. Malformed or absent (short line) → nil plus malformedField(line, "OAT", first 8 chars)
ACTgForceDouble
ACZverticalAcceleration/100
NETverticalAirMovement/100
AOPattitudePitchAnglevalue if > −99, else 0
AORattitudeRollAnglevalue if > −999, else 0
CUR, VOLelectricalCurrent / VoltsDouble
FLE, FFLfuelLevel / fuelFlow/1000
EGT, MOT, COT, JPTtemperaturesInt
LEBbatteryLeftDouble
FLPflapPosition (raw string), flapPositionValue = flapValue
HR, SPO2listed as types, but the 3-letter parser never matches HR/SPO2. Values come only from L records.

availableExtensions = informative list filtered:

  • TAS: declared and maximumAirspeed > 0
  • FLP: declared or flaps not empty
  • HR/SPO2: L records seen
  • others: declared in the last I record

flapValue: S → −10, S1 → −9, S2 → −8, −2, −1, N/+ → −0.5, 0…5 → itself, L → 100, otherwise Double(s) ?? 0.

Numeric parsing (String+Convert.swift): Swift Int(s) and Double(s) are strict. No whitespace is allowed. A leading +/- is fine. Swift Double accepts nan, inf, 1e3 and hex 0x10; it rejects 1.0f and " 1" (verified with the local Swift toolchain). Kotlin toDoubleOrNull() trims whitespace and accepts 1.0f/1d/NaN, so write a strict parser. ?-only strings are classified as unavailable.

Columns, 0-based (BRecordColumn.swift):

FieldColumnsNotes
time1..<7HHMMSS → h*3600 + m*60 + s, via Int(raw).fixTime(), so non-digits → nil
latitude7–14DD MM mmm N/S
longitude15–23DDD MM mmm E/W
validity24A/V
pressure altitude25..<305 chars, Int
GNSS altitude30..<355 chars, Int
  • Coordinate: deg + (min + frac/1000)/60. Negated only for exactly S/W; any other hemisphere char is positive. (0,0) exactly → nil.
  • Altitude: requires start + 5 ≤ utf8.count. Negative values such as -0151 parse.
  • Rejected (nil → dropped-fix warning) when any of these is missing or malformed, or when the coordinate lies within 1,000 km of (0,0). The check is CLCircularRegion(center 0,0, radius 1e6 m).contains. Use the haversine distance ≤ 1e6 (Apple’s exact metric is unknown; boundary cases are not realistic).
  • Midnight wrap: if time < firstFix.rawTime, then rawTime = time + 86400. The comparison is against the first fix, not the previous one. time keeps the un-wrapped value.
  • gpsAltitude = nil when validity is V.

3.6 Per-fix derived values (order matters)

Section titled “3.6 Per-fix derived values (order matters)”

With prev = the last accepted fix:

  1. bearing = Int(prev.coord.bearing(to: coord)) (truncation; range 0…360).

  2. Altitude delta: if pressure == 0 && prev.pressure == 0, use GPS (nil if either is nil); else use pressure.

  3. dt = rawTime − prev.rawTime. If dt > 0: vario = Δalt/dt (when Δalt is non-nil) and groundspeed = max(0, haversine(prev, this)/dt) (6371 km sphere).

  4. Smoothing: if there are ≥ 2 previous fixes, prev.vario = (this.vario + prevprev.vario + prev.vario)/3. prevprev is already smoothed. Groundspeed gets the same treatment.

    The fix’s own value is still raw when Flight.init aggregates min/max vario and groundspeed, so the flight min/max statistics use unsmoothed values, apart from what the smoothing touches before the comparison. Replicate by doing the aggregation immediately after constructing each fix, exactly as in Flight.swift:293–360.

  5. nonMonotonicTime(previous, current, line) warning if rawTime ≤ prev.rawTime. The fix is kept.

  • No date → DecoderError.missingDate. Fewer than 2 fixes → DecoderError.invalidFixes.
  • Filename: the given one, else [CID, date.ddMMyyyy].compactMap.joined("_") + ".igc". The DateFormatter uses the device’s local time zone, so west of UTC the generated name shows the previous day.
  • Task post-processing (Flight.swift:434–454):
    • If no waypoint has a zone, NN > 0, count > 2 and NN + 2 < count, drop the first and last (takeoff/landing).
    • Then re-append every waypoint via task.append (recomputes leg distance/bearing/order).
    • Then invalidateWaypointsWithAvailableStyling(). When 0 < styled < count, drop the first and last and shift each kept waypoint’s zone to the previous waypoint’s zone.
  • engineLevelThreshold = 200 + ΣENL / count (integer division).
  • No G-record (security) validation. The application never verifies signatures.

ParseWarning:

  • unknownRecordType(Char, line)
  • droppedFix(line, reason)
  • malformedField(line, field, value≤8 chars)
  • nonMonotonicTime(previous, current, line)

4. Enrichment pipeline (Enricher/FlightEnricher.swift:85–151)

Section titled “4. Enrichment pipeline (Enricher/FlightEnricher.swift:85–151)”
FlightDecoder.decode()
├─ Flight.init(filename, content) // §3 parse
└─ if isEnrichingEnabled: FlightEnricher(flight, cache, defaultZones, sportProfile).enrich()
0. sportProfile = given ?? detectSportProfile(flight) → flight.detectedSportProfile
1. FlightElevationEnricher(flight, cache).enrich() // §8.1 (network!)
2. improveFlightData() // TZ offset, pressure correction, flap index
3. FlightTimesEnricher(flight, sportProfile).enrich() // takeoff/landing/release/engine/gliding block/alt gain
4. FlightPhasesEnricher(flight, range=times.flightRange, sportProfile).enrich() // thermals, wind, legs
5. FlightDistanceEnricher(flight).enrich() // optimisations
6. FlightTaskEnricher(flight, defaultZones).enrich() // task attempts
7. FlightSpeedEnricher(flight).enrich() // per-fix avg speed, best 1h/2h

FlightMetadataEnricher (Quick Look / thumbnails) runs only steps 3 and 5, with the glider profile and no elevation/improve step.

The app re-runs steps 6 and 7 when the user assigns another task (Modules/App/Sources/Map/Flight/Flight/FlightViewModel.swift:342–362) with ObservationZoneType.defaultZones from preferences. It can also re-apply elevations later (l.170–200) without re-running step 2.

Every app call site passes defaultZones = user-preference zones (Preferences/Extensions/Zone+Defaults.swift) and sportProfile = Preferences.parsingSportProfile: nil when “detect sport automatically” is on (the default), otherwise the selected sport (Preferences/Models/Sport.swift:61).

4.0 Sport detection (FlightEnricher.swift:58–81)

Section titled “4.0 Sport detection (FlightEnricher.swift:58–81)”
  1. Take the smoothed groundspeeds of all fixes where speed > 0.
  2. Sort them; p95 = sorted[Int(n * 0.95)].
  3. Classify: p95 > 100/3.6 → glider; > 80/3.6 → delta (hang glider); otherwise parapente.

SportProfile constants (SportProfile.swift):

Fieldgliderparapentedelta
minimumSpeed (m/s)30/3.618/3.625/3.6
takeoffDuration (s)15810
landingDuration (s)301520
thermalTurnRadiusLimit (m)200150300
thermalClusteringDistance (m)500300500
minimumTurnCount1068
minimumThermalDuration (s)201520
minimumWindThermalDuration (s)906090
minimumLegDuration (s)603045

4.2 improveFlightData (FlightEnricher.swift:156–219)

Section titled “4.2 improveFlightData (FlightEnricher.swift:156–219)”
  1. convertPressureToGPS = (minPressure == 0 && maxPressure == 0). If true, min/maxPressure = min/maxGPS.
  2. Time zone: TimeZoneLocate.timeZoneWithLocation(firstFix) (§8.2), then offset = tz.secondsFromGMT(for: flight.date). flight.date is UTC midnight of the flight date, so the DST state is taken at 00:00 UTC on that day.
  3. startCorrection = fix0.pressure − fix0.elevation. With isCorrectingPressureAltitude (always true): correctionDifference = (last.pressure − last.elevation) − startCorrection.
  4. For each fix i (index offset, n = count):
    • fix.time += offset.
    • If convertPressureToGPS: pressure = gps (skipped for 2D fixes).
    • Else, if isElevationAvailable: pressure −= startCorrection + Int(Double(correctionDifference) * i/n) (Int truncation toward zero). This calibrates the baro altitude so the first fix sits on the terrain and the drift is spread linearly to the last fix. So all analysed altitudes depend on whether elevation data was available.
    • If elevation is available: track minPressure = min(pressure, elevation) and maxPressure.
    • flapPositionIndex = flight.flaps.indexOf(flapPosition).
  5. If elevation is available: minPressure = max(0, min) and maxPressure = max.

5.1 FlightTimesEnricher (Enricher/FlightTimesEnricher.swift)

Section titled “5.1 FlightTimesEnricher (Enricher/FlightTimesEnricher.swift)”

Engine threshold constant engineLevelThreshold = 700 (takeoff and release classification), as opposed to flight.engineLevelThreshold (engine runs).

processTakeoff (l.69–113)

  1. Scan the fixes. When groundspeed > minSpeed, remember the first index of the fast run (minIdx).
  2. When fix.rawTime − fixes[minIdx].rawTime ≥ takeoffDuration: set takeoffIndex = minIdx.
  3. Classify the takeoff:
    • average engineLevel over fixes[minIdx...i] (integer mean) ≥ 700 → selfLaunch
    • else if (fix.pressure − minFix.pressure)/(dt) ≥ 8 → winch
    • else → tow
  4. break. A slow fix resets minIdx = nil.

fixes[takeoffIndex].event = .takeoff is set even if nothing was detected (index 0).

processLanding (l.115–147). Quirky; port verbatim.

for (index, fix) in fixes[takeoffIndex...].reversed().enumerated() {
if fix.groundspeed > minSpeed {
if let m = minIdx {
let mFix = fixes[fixes.count - m] // NOTE: = fixes[k+1], k = enumeration index when m was set
if fix.rawTime - mFix.rawTime >= landingDuration { break }
} else { minIdx = fixes.count - index - 1 } // absolute index of this (late) fast fix
} else { minIdx = nil }
}
landingIndex = minIdx ?? count-1 ; that fix.event = .landing

Effect: mFix is a fix near the start of the file, so the break fires at the second consecutive fast fix met going backwards. Landing ≈ the last fast fix that is preceded by another fast fix.

processRelease (l.149–279). The loop runs over fixes[(takeoff+1)...] with correctedIndex = enumIndex + takeoffIndex, which is one less than the real index. Keep this off-by-one. startAltitude = fixes[0].pressure.

  • selfLaunch: engineLevel < 700 && pressure − startAlt > 200, held for ≥ 10 s → releaseIndex = first index of that run.
  • winch: vario < 0 held for ≥ 5 s → release at the start of the run.
  • tow: only while pressure − startAlt > 200.
    • vario < 0 held for ≥ 10 s → release at the start of the run.
    • Otherwise, if 0 < ci ≤ count−2, fit a circle (§5.2 formula) through fixes[ci−1], fixes[ci], fixes[ci+1]. If the radius < 50 m → releaseIndex = ci − 1.
  • unknown: nothing.

If releaseIndex > takeoffIndex, that fix gets .release.

processEngineRuns (l.281–336)

  1. Loop i from max(takeoff, release)…landingIndex.
  2. While gliding, engineLevel > flight.engineLevelThreshold → record an on index.
  3. While running, engineLevel < threshold (strict) → record an off index.
  4. If there are more ons than offs, drop the last on.
  5. Keep only pairs with offTime − onTime > 20 s.

persistEngineRuns builds EngineRun(start, end, firstFixIndex, lastFixIndex).

processGlidingBlock (l.354–416). Picks the longest engine-free block. Without engine runs it is [max(takeoff,release) … landing]. With engine runs:

  • The first block is start → first on.
  • The loop compares fixes[off[i+1]].rawTime − fixes[on[i]].rawTime (on purpose, as written) against the maximum and adds the ranges off[i]...on[i+1].
  • Then the last off → landing (+86400 if it wraps).

The result goes to flight.start/endGlidingBlockIndex. The ranges are stored for the altitude gain.

processAltitudeGained (l.424–467)

  1. For each gliding range: running minimum of the pressure altitude, and best fix − runningMin.
  2. Pick the range with the maximum gain.
  3. Set lowestAltitudeFix / highestAltitudeFix and their events.

flightRange = max(takeoff, release) ... min(count−1, landing).

5.2 Circle fit used for circling detection (Extensions/CLLocationCoordinate2D+Thermal.swift)

Section titled “5.2 Circle fit used for circling detection (Extensions/CLLocationCoordinate2D+Thermal.swift)”

Planar lat/lon perpendicular-bisector intersection (degrees, no projection):

ps = (cur.lon−prev.lon)/(cur.lat−prev.lat) ; ns = (next.lon−cur.lon)/(next.lat−cur.lat)
cLat = (ps*ns*(prev.lon−next.lon) + ns*(prev.lat+cur.lat) − ps*(cur.lat+next.lat)) / (2*(ns−ps))
cLon = −(cLat − (prev.lat+cur.lat)/2)/ps + (prev.lon+cur.lon)/2
radius = haversine(cur, center) // 6371 km

Division by zero gives NaN or Inf. NaN < limit is false, so the triple is skipped (Kotlin behaves the same).

5.3 Thermal detection (FlightPhasesEnricher.processThermalAttempts, l.48–101)

Section titled “5.3 Thermal detection (FlightPhasesEnricher.processThermalAttempts, l.48–101)”

For each i in flightRange, excluding the first and last fix of the file:

  1. Fit the circle through i−1, i, i+1. Skip unless radius < thermalTurnRadiusLimit.

  2. speed = TAS > 0 ? TAS : groundspeed.

  3. Clustering: if there is an active thermal and haversine(activeThermal.fixes.last.coordinate, newCenter) < thermalClusteringDistance, append to it:

    • exitAltitude = pressure (only if pressure ≠ 0), exitTime = rawTime
    • bestVario = max(vario, bestVario)
    • append the fix, lastFixIndex = i
    • append the radius and the speed

    Otherwise start a new Thermal with entry values and append it to the list.

    The active thermal is never closed by a non-circling fix. Gaps are allowed; only the distance criterion splits thermals. The distance is measured from the last fix’s position to the new centre.

5.4 Thermal filtering, turn, wind (process(thermals:), l.103–219)

Section titled “5.4 Thermal filtering, turn, wind (process(thermals:), l.103–219)”
  • isLargeInterval = (fix[1].rawTime − fix[0].rawTime) > 4.

  • Each attempt: altitudeGained = exit − entry.

  • Rejected if:

    • its entryTime lies inside an engine run (start ≤ t ≤ end), or
    • duration ≤ minimumThermalDuration (the test is strict >), or
    • it has fewer than 2 coordinates (no region).
  • Turn: for k in 1 ..< max(1, fixes.count − 2) (the last two fixes are excluded on purpose): cross = (cur.lat−prev.lat)*(next.lon−prev.lon) − (cur.lon−prev.lon)*(next.lat−prev.lat), turns += cross ≥ 0 (true = right). Then Turn(turns).

  • Rejected if !isLargeInterval && turns.count < minimumTurnCount.

  • Centre/radius: a bounding-box “region”. Compute two variants:

    1. raw
    2. negative longitudes +360, and the centre −360 when > 180

    Pick the one with the smaller longitude span. center = max − span/2; totalRadius = haversine(center, (center.lat + spanLat/2, center.lon + spanLon/2)). (Extensions/CLLocationCoordinate2D+Region.swift)

  • vario = altitudeGained / (exit − entry) if the duration is > 0. maximumVario starts at -Double.infinity, and relativeRatio(vario:maximumVario:) gives 0 when the maximum is ≤ 0 (Q8, fixed on both platforms; it used to start at Double.leastNormalMagnitude).

  • Wind: if duration > minimumWindThermalDuration and there are > 1 fixes: speed = haversine(first,last)/(last.rawTime − first.rawTime), direction = Int(last.bearing(to: first)). Key = first.rawTime, except that the first wind entry is keyed by the flight’s first fix rawTime.

  • previousThermal.distanceToNextThemal = haversine(prev.center, this.center). Only thermals that passed every filter are linked, attempts (gain ≤ 0) included.

  • lowestThermalEntryFix: lowest entryAltitude among non-attempt thermals.

  • After the reduce:

    • relativeRatio = vario / maximumVario on all candidate thermals (rejected ones too).
    • thermalAttemptCount = accepted thermals with gain ≤ 0.
    • fix.thermal is assigned for all candidates (including rejected ones). Note: this means fix.thermal != nil does not imply the thermal is in flight.thermals.
    • flight.thermals = accepted thermals.

5.5 Legs / glides (processLegs, l.221–265)

Section titled “5.5 Legs / glides (processLegs, l.221–265)”
  • timedPhases = accepted thermals + engine runs, sorted by time (entry / engine start).
  • The leg before the first phase uses fixes [flightRange.first ..< phase.firstFixIndex].
  • The leg between phases uses [prev.lastFixIndex+1 ..< next.firstFixIndex], only if start+1 < end.
  • The leg after the last phase uses [last.lastFixIndex+1 ..< flightRange.last].
  • Array.filter(by:) returns nil unless lower ≥ 0 && upper < count − 1.
  • Leg(fixes) requires more than 1 fix.
  • fix.leg is assigned for all legs. flight.legs = legs with duration > minimumLegDuration.
  • With no thermals and no engine runs there are no legs at all.
  • Glide ratio and cruise speed come from the leg (§2.4) and the flight aggregates (§2.1).

6. Distance optimisation (OLC-style, FlightDistanceEnricher.swift and DistanceEnricher.swift)

Section titled “6. Distance optimisation (OLC-style, FlightDistanceEnricher.swift and DistanceEnricher.swift)”

Shared primitives:

  • d(i,j) = half central angle (radians) atan2(√a, √(1−a)), from precomputed lat radians, lon radians and cos lat.
  • distance(for: idx[]) = Σ d × 6378.137 × 2 → km (Map/Helpers/CLLocationDistance+Km.swift). This differs from the haversine elsewhere, which uses 6371 km.
  • calculationStep = 51 (fix sub-sampling).
  • cachedDistance key = min*1_000_000 + max. Use Long (Int32 overflows above 2147 fixes).

populate(indexes, indexDistances, start, end) (DP layer)

for i = start; i <= end; i += 51:
best = 0, bestIdx = i
for j = i+51; j < end; j += 51:
dd = cached(j,i); tot = dd + indexDistances[j]
if tot > best: best = tot, bestIdx = j
if dd > longestLeg: longestLeg = dd, longestLegIdx = [i,j]
indexDistances[i] = best; indexes[i] = bestIdx

It is called 1–4 times on the same indexDistances (layers 1…4). The initial indexDistances[i] = d(i, end).

finalIndex(): argmax over i = start+51, +51 … ≤ end of d(start, i) + indexDistances[i].

optimise(indexes): for each interior point k, scan j ∈ [max(idx[k−1], idx[k]−102) … min(idx[k]+102, idx[k+1])] and keep the j maximising d(prev, j) + d(j, next). A Swift ClosedRange with lower > upper would trap. Kotlin a..b is empty, so the behaviour differs only on paths that crash on iOS.

refinePositions:

  • The first point moves within [idx0 … idx1] to maximise d(i, idx1).
  • The last point moves within [idx[n−2] … idx[n−1]] to maximise d(i, idx[n−2]).

Flight results. All are Optimization(indexes, fixes, distance_km) and use the gliding block [S, E]:

ResultConstruction
triangleDistanceOptimizationClosing detection (below), then layers 1–2 on [cs, ce], then [cs, two[cs], one[two[cs]], ce] optimised (no refine). Distance = cs→A→B→ce, so the closing gap is not a leg and there are no FAI checks.
outDistanceOptimizationlongestLegIndexes (global maximum pair seen during any populate, triangle layers included)
outAndReturnDistanceOptimization[S, one[S], E] optimised + refined
(two-TP)computed but not stored
threeTurnpointDistanceOptimization[S, three[S], two[…], one[…], E] optimised + refined
freeDistanceOptimization[S, f, four[f], three[…], two[…], one[…], E] with f = finalIndex(), optimised + refined (7 fixes)
maximumDistanceOutOptimizationAmong the free-distance indexes, the one with max d(S, i). Optimization(indexes:[S], fixes:[fix_S, fix_far], km). That fix gets .furthestPointFromHome if it has no event yet.

Triangle closing detection (l.91–128), verbatim:

closed[i] = d(i,i+1) + closed[i+1] (suffix path length, i from E−1 down to S)
minClosed = 0.5/6371
if d(S,E) > minClosed:
i = S; total = 0
while i < E:
if closed[i] < total: break
j = E; local = 99999
while j > i:
dist = closed[i] − closed[j]; if dist <= total: break
dd = d(i,j); local = min(local, dd)
if dd < minClosed: cs=i; ce=j; total=dist; break
j -= Int(dd/minClosed/2) + 1
i += Int(local/minClosed/2) + 1

The triangle exists only if d(S,E) > minClosed (the start and end are not already close) and a closing pair is found.

DistanceEnricher (generic, for coordinates):

  • optimalDistance() = the free-distance algorithm above on 0…n−1 (layers 1–4, finalIndex, optimise, refine) → metres.
  • trueDistance() = path length km over all points.
  • optimalIndex() = [0, one[0], n−1] optimised + refined, returns the middle index.
  • closestToNextIndex(coord) = argmin of d.

7. Tasks, observation zones and task scoring

Section titled “7. Tasks, observation zones and task scoring”

7.1 Geometry primitives (Packages/Map/Sources/Map/Helpers/)

Section titled “7.1 Geometry primitives (Packages/Map/Sources/Map/Helpers/)”
FunctionFormula
distance(from:)haversine, R = 6371 km, 2·atan2(√a, √(1−a))
bearing(to:)atan2(sinΔλ·cosφ2, cosφ1·sinφ2 − sinφ1·cosφ2·cosΔλ) in degrees; > 0 ? d : d+360 (so 0 maps to 360)
coordinate(at:direction:)spherical destination, R = 6371000
center(to:)great-circle midpoint
validAngle+360 if < 0, −360 if > 360 (single wrap; 360 stays 360)
sectorCoordinates(r, from, to)`dest = (to==from) ? from+360 : to; if to<from dest+=360; n = ceil(
circleValues(r, from, to, clockwise)same, with an optional counter-clockwise direction
Polygon contains(coordinate) (Flights/Extensions/CLLocationCoordinate2D+Calculations.swift)Even-odd ray casting in raw lat/lon degrees, starting with last as the previous vertex
Line isCrossed(from, to, lineBearing) (…+Line.swift)1. Source fix within lineLength/2 of the line midpoint (great-circle midpoint, haversine). 2. Planar lat/lon segment-intersection coefficients both strictly in (0,1). 3. Movement bearing within ±90° of lineBearing: ((b − lb + 540) mod 360) − 180 ∈ (−90, 90).

7.2 Zone polygons (Packages/Waypoints/Sources/Waypoints/Extensions/Waypoint+ObservationZone.swift)

Section titled “7.2 Zone polygons (Packages/Waypoints/Sources/Waypoints/Extensions/Waypoint+ObservationZone.swift)”

Orientation bearing(zone, prev, next, start):

  • fixed (when A12 > 0) → A12 + 180
  • next (when next exists) → next.bearing(to: self)
  • previous (when prev exists) → prev.bearing(to: self)
  • start → start.bearing(to: self)
  • Otherwise:
    • both prev and next exist: the bisector. With b1 = bearing(self→prev) and b2 = bearing(self→next), take mid = (max−min)/2 + min; the result is max−min ≥ 180 ? mid : mid−180.
    • only next: next→self
    • only prev: prev→self
    • neither: nil (no zone)

Shape:

  • isLine → 2 points at R1, bearings ±90 (validAngle).
  • Else if A1 > 0 && A2 > 0 && R2 > 0 → multi-zone:
    • A1 ≥ 180 → circle R1 (73 points)
    • else if A2 ≥ 180 → sector R2 from end→start, + duplicate last, + sector R1 start→end, + first
    • else → sector R2 over ±A2
  • Else: A1 ≥ 180 → circle R1; otherwise [centre] + sector R1 over [bearing−A1, bearing−A1+2·A1] + [centre].
  • Fewer than 2 points → nil.

7.3 CUP / IGC zone keys (Packages/Waypoints/Sources/Waypoints/Models/Task.swift:135–171, Extensions/String+Parsing.swift)

Section titled “7.3 CUP / IGC zone keys (Packages/Waypoints/Sources/Waypoints/Models/Task.swift:135–171, Extensions/String+Parsing.swift)”

Components are split by ,, each key=value. The first matching key wins per component.

KeyParsing
{prefix}=NSelects waypoints[N + indexOffset] (IGC: offset 1; CUP: 0). Creates a Zone if it has none. IGC LXNAV LLXVOZ=-1 is the start, because C-record placeholders are dropped.
Style=ZoneType raw (0–4)
R1=, R2=contains km → ×1000; else strip every m and parse Double
A1=, A2=, A12=Double
Line=, AAT=Int == 1
Maxa=Int altitude (km → ×1000)
Elev=Int, only if > 0 → minimumAltitude (used as the minimum finish altitude)
TaskTime= (applyTask)HH:MM:SS, or contains s → seconds; must be > 0

Other keys (Autonext, Lat, Lon, …) are ignored.

  • IGC C records: §3.3 and §3.7.
  • CUP (WaypointsDecoder.swift):
    1. Split on -----Related Tasks-----.
    2. The waypoint part goes through the CSV reader (CSV.swift / yaslab). If the first line starts with title,code,country,latitude,longitude (case-insensitive), replace it with name,code,country,lat,lon,elev,style,rwdir,rwlen,freq,desc. If there is no name,code,country,lat,lon header, prepend one.
    3. Latitude DDMM.mmmN (≥ 9 chars; sign is + only for a trailing N); longitude DDDMM.mmmE (≥ 10 chars;
      • only for E). An invalid coordinate throws invalidCoordinate.
    4. Task part: non-empty lines are parsed as CSV.
      • Options… → applyTask on the last task.
      • ObsZone… → applyStyle(prefix "ObsZone", offset 0) on the last task.
      • Otherwise a task row: name, then waypoint names resolved by the first waypoint with that name; unknown names are skipped. Each waypoint is duplicate(order) (zone dropped).
    5. removeStartLandingPoints() (only when count > 3): drop the last if name[n−1] == name[n−2]; drop the first if name[0] == name[1].
    6. Task distance = sum of haversine.
    7. Port the CSV quirks with the yaslab defaults: quoted fields, no trimming, empty → nil when decoding optionals. Verify against WaypointsDecoderTests.
  • XCTrack QR (XCTrackTaskDecoder.swift):
    • Prefix XCTSK: (JSON) or XCTSKZ: (base64 → zlib; strip the 2-byte header, raw inflate; use java.util.zip.Inflater(nowrap=true) on data.drop(2), or Inflater() on the full data). A missing prefix is accepted as JSON.
    • t[] turnpoints: v2 packed z = Google-polyline ints [lon·1e5, lat·1e5, alt, radius]; v1 uses waypoint{lat, lon, altSmoothed, name, description} + radius. Turnpoints without a position are dropped; none at all → invalidData.
    • Start = first t == 2 (else 0); finish = last t == 3 (else last).
    • Zone: R1 = radius, A1 = 180, type next (start), symmetric (TP) or previous (finish). The finish is a line if g.t == 1. TPs are AAT if o.tt is present. Per-TP o{l, aat, a1, a2, a12 (→ type fixed), r2} overrides.
    • minimumAATDuration = o.tt (s). Waypoint: code = name, elevation = "{alt}m", style = waypoint.
  • Encoders (WaypointsEncoder.swift, Waypoint+CUP.swift): CUP (with ObsZone= lines), LXNAV Nano C records + LLXVOZ=, FLARM $PFLAC lines. They assign the name "Turnpoint N" to unnamed waypoints in place (a side effect). Not needed for analysis, but part of Waypoints parity.

7.5 Task scoring (FlightTaskEnricher.swift)

Section titled “7.5 Task scoring (FlightTaskEnricher.swift)”
  1. No task → return.

  2. complete(waypoints:): a waypoint without a zone gets defaultZones[start | finish | waypoint] (first, last, middle). This mutates the task’s waypoints.

  3. Quick reject if the Mercator bounding boxes of the waypoints and the fixes don’t intersect (lat/lon bounding boxes are equivalent outside the antimeridian).

  4. Build the polygon for every waypoint (§7.2). If any waypoint has no polygon (e.g. defaultZones empty and no IGC zones), return with no attempts.

  5. Crossings: for each fix index i ∈ [glidingStart, glidingEnd] and each zone:

    • Line zones (2 points): bearing = the finish’s previous→finish leg, otherwise this→next. Call isCrossed(fix_i, fix_{i+1}, bearing).
    • Polygon zones: contains(fix_i).

    Append i.

  6. Attempts (l.144–213). Flatten to (fixIndex, wpIndex) pairs; sort by fixIndex (the sort is stable, so for equal fix indices the wp order is kept). Start with one attempt. For each pair:

    • wp == 0 && isOnTask: when lastStartCrossing == nil || fix > lastStartCrossing + 1, append a new attempt.
    • wp == 0 → lastStartCrossing = fix.
    • For every attempt:
      • wp == 0 && att.wpIndex == 0 → isOnTask = false, add to crossings[0]
      • wp == att.wpIndex → add
      • wp == att.wpIndex + 1 → isOnTask = true, wpIndex = wp, add
  7. Optimise each attempt (l.215–263):

    • Start:
      • none → stop.
      • If the start zone is a cylinder (!isLine && A1 ≥ 180) and some fix in [firstCrossing … lastCrossing] carries a PEV event → take the last such fix and set isPEVStart = true.
      • Else if no later waypoint was ever crossed → the first crossing.
      • Else → the last start crossing.
    • Middle TP:
      • no crossings → closestIndex(after: prev, to: wpCoord) (argmin over [prev … glidingEnd] with the half-angle metric), then stop.
      • else → DistanceEnricher(fixes[firstCross … lastCross]).optimalIndex(), i.e. the out-and-return-style optimal point inside the crossing window (it ignores the neighbouring TPs).
    • Finish: no crossings → closest index, then stop; else the first crossing.
    • finalize(fixes) (§2.8).
  8. taskAttempts = all attempts. best = the first completed attempt in the list sorted by distance (descending), else the longest one.

The AAT flag does not change the scoring: no area optimisation, and the distance is the path through the credited fixes. The minimum AAT time only produces minimumAATDurationDifference. The minimum finish altitude comes from the finish zone’s Elev.

7.6 Speed enricher (FlightSpeedEnricher.swift)

Section titled “7.6 Speed enricher (FlightSpeedEnricher.swift)”

Runs for the free-distance indexes (with best speeds) and for bestTaskAttempt.indexes (averageTaskSpeed only).

For each fix index from idx[0] to idx.last:

  1. ref = the first optimised fix with rawTime > fix.rawTime (must be > 0).
  2. pre = path km (6378.137) over idx[0..<ref].
  3. travelled = pre + haversine(prevTP, nextTP)/1000 − haversine(fix, nextTP)/1000 (6371 km for the last two terms). Store it in fix.distanceTraveled (km).
  4. If duration > 0:
    • delta = travelled − previousTravelled
    • add delta to the 60-minute and 120-minute windows
    • if fix − window60Start ≥ 3600, subtract window60Start.delta (likewise for 7200)
    • fix.averageSpeed = (sum60 / (max(1, fix − start60)/3600)) / 3.6
    • track the best 60 and 120 sums with their start/end indices; the starting best is Double.MIN_VALUE
  5. After each fix: advance the window starts by one index when ≥ 3600 / 7200.

Best results: Optimization(indexes: start..<end, fixes, distance: bestSum km).

7.7 Live takeoff detector (Enricher/FlightTakeoffEnricher.swift)

Section titled “7.7 Live takeoff detector (Enricher/FlightTakeoffEnricher.swift)”

A generic version over FlightFix for the in-flight screen and the watch. It mirrors processTakeoff and processRelease with these differences:

  • uses fix.time and altidude
  • winch release duration 5 s, tow 10 s
  • the tow branch applies the turning check for winch too
  • turn radius threshold 100 m (not 50)
  • final guard: if release < takeoff then release = takeoff

  • Endpoint: GET https://vulture.aero/api/elevations/{TILE}. The base URL is in Service/ElevationServiceConfiguration.swift:12; the path is appended via appendingPathComponent (Packages/Networking/Sources/Networking/Request+URLRequest.swift:40).
  • Headers: Accept: application/json and a fixed Authorization bearer-like JWT string (copy it from that file; do not invent it). networkServiceType = .responsiveData.
  • TILE = CLLocationCoordinate2D.filename (Extensions/CLLocationCoordinate2D+Elevation.swift): N|S + 2-digit |floor(lat)| + E|W + 3-digit |floor(lon)|, e.g. N50E005, S34W071. Note: south uses floor, so −33.2 → S34. These are the unique tiles over all fixes.
  • Response: a zip archive containing {TILE}.hgt.
    • Status ≥ 400 → error.
    • Empty body → error.
    • Extract the entry {TILE}.hgt (CRC skipped) to Caches/elevations/{TILE}.hgt.
  • Tile decoding: big-endian Int16 (Data+Int16.swift), 1201×1201.
    • row = Int(frac(−lat) · 1201), col = Int(frac(lon) · 1201), where frac(x) = x − floor(x).
    • index = 1201·row + col; out of range → 0.
    • Void (−32768) is not filtered.
  • Concurrency: all missing tiles are requested in parallel, with a 10 s total wait (DispatchGroup.wait). Any failure → nil, and the whole flight gets no elevations (isElevationAvailable = false).
  • Cache: if FlightCache.elevationCache.count == fixes.count, use it (persisted per flight in Core Data storedData.elevations; Packages/Persistence/.../CoreFlight+CoreData.swift:164).
  • On macOS/tests no download happens (#if os(iOS)); the tests feed <name>.elevation.json.

Correction (2026-10-10, enrichers part A): the nearest-city search must use the same distance as iOS (CLLocation.distance, an ellipsoidal distance). Lambert’s formula on WGS84 picks the same zone as iOS on all 38,398 points of a world grid; haversine picks a different one on 83. The Kotlin port uses Lambert.

  • Offline: Vendor/TimeZoneLocate (APTimeZones, MIT) with Resources/timezones.json: 419 rows {countryCode, latitude, longitude, comments, zone}.
  • Algorithm: the row with minimum CLLocation.distance to the first fix → TimeZone(identifier: zone). If that fails, TimeZone.current (the device zone).
  • Android: ship the same JSON; use haversine for nearest; ZoneId.of(zone); zone.rules.getOffset(Instant(flight.date)).totalSeconds.
  • geocodeTimeZone (MapKit reverse geocoding) exists but is not used by the pipeline.

None in this layer: no reverse geocoding of takeoff/landing places, no OLC/WeGlide calls (those live in Packages/WeGlide, SkyLines, etc.). Satellite/SatelliteTileManager (SkySight tiles, 15 s request / 60 s resource timeout) belongs to the weather area.


9.1 Settings (Packages/Preferences/Sources/Preferences/Units/)

Section titled “9.1 Settings (Packages/Preferences/Sources/Preferences/Units/)”
UnitTypeOptions (index order)DefaultPersisted key
windkph, mph, ktkphVultureSkySightWindSpeedUnit (UserDefaults + iCloud KVS)
verticalSpeedms, kt, ftsmsVultureSkySightVerticalSpeedUnit
altitudem, ftmVultureSkySightAltitudeUnit
speedkph, mph, ktkphVultureSpeedUnit (shared app-group defaults)
distancekm, mi, nmkmVultureDistanceUnit (shared)
temperaturecelsius, fahrenheitcelsiusVultureTemperatureUnit (shared)

Also relevant: graphDataUnit (default .pressureAltitude), sport, detectsSportAutomatically (default on), and the default zones per kind (VultureWaypoint{Start,,Finish}Zone{Type,Line,R1,A1,R2,A2,A12}). The zone defaults are all 0 / symmetric / not a line when never set.

9.2 Conversions (Units/Extensions/Double+Unit.swift); input is SI (m/s, m, °C)

Section titled “9.2 Conversions (Units/Extensions/Double+Unit.swift); input is SI (m/s, m, °C)”

kph ×3.6, kt ×1.943844, mph ×2.23693629, ms ×1, fts ×196.850394 (actually ft/min; the label key is settings.labels.unit.fts), ft ×3.2808399, m ×1, km /1000, mi ×0.000621371192, nm ×0.000539956803, °F ×9/5+32, inHg /33.863886666667 (from hPa), the rest ×1.

9.3 UnitFormatter (Units/Formatter/UnitFormatter.swift)

Section titled “9.3 UnitFormatter (Units/Formatter/UnitFormatter.swift)”

Format strings are C printf:

UnitFormatDecimals
kph, kt, mph, ft, m, °C, °F, inHg, gravity, voltage, ampere, liter, none"%0.00f"0
ms, km, mi, nm"%0.0{n}f", n = numberOfFractionDigits (default 2)n (0 for the rounded… variants)
fts, hPa, percentage"%.0f"0 (was "%f", 6 decimals; fixed on both, FIXED(B9))

Output is "{value} {localizedUnit}" unless isUnitVisible = false.

Helpers (Int+Formatter.swift, Double+Formatter.swift):

  • ldString "%i:1"
  • bearingString / degreesString "%i°"
  • Double.stringValue "%0.2f"
  • Int.timeValue "HH:mm:ss" (wraps negatives and > 86400 by one day)
  • shortTimeValue 3h05m / 3h / 05m
  • veryShortTimeValue 1h 5m 30s (space-separated non-zero parts) (Flights/Extensions/Int+Time.swift)

Rounding risk: C printf rounds the exact binary value, so ties go half-even on exact halves (2.5 → “2”, 3.5 → “4”, 0.125 → “0.12”; verified with the local Swift toolchain). Java/Kotlin String.format uses HALF_UP (2.5 → “3”, 0.125 → “0.13”). Use BigDecimal(value).setScale(n, RoundingMode.HALF_EVEN).toPlainString() (BigDecimal of the double, not valueOf). Swift String(format:) without a locale always uses ”.” as the decimal separator, so format with Locale.ROOT on Android.

9.4 Other formatters (Packages/Formatting)

Section titled “9.4 Other formatters (Packages/Formatting)”
  • CLLocationCoordinate2D.formattedString → "45°30′N, 6°7′E". Whole degrees; minutes rounded (.rounded() = half away from zero), carrying 60 into degrees; zero minutes omitted.
  • CLLocationDistance.preciseDistanceString = MKDistanceFormatter(.metric), locale-specific Apple output with no Android twin. Approximate it (m below 1 km, else km with 1 decimal) and accept the difference.
  • FlightConvertible:
    • pilots = capitalised pilot and passenger (passenger dropped if “Nil”), joined with ", ", or "--"
    • glider = "{REG uppercased} - {type}" or "--"
    • registration = CID uppercased, else the last 2 chars of the registration uppercased, else pilotInitials (PersonNameComponentsFormatter .abbreviated, roughly the first letters of given + family name; _ treated as a space)
  • Date+Formatters.swift: localized styles plus fixed formats (yyyy-MM-dd UTC POSIX, dd-MM-yy, WeGlide yyyy-MM-dd'T'HH:mm:ss (device TZ), HHmm, YYYY-MM-dd (week-year!)).
  • IGC dates for writing (Waypoints/Extensions/Date+Formatter.swift): igcDate/igcTime in UTC (ddMMyy, HHmmss).

9.5 IGC writing (Packages/FlightRecording/.../FixRecord+IGC.swift) — for round-trip parity

Section titled “9.5 IGC writing (Packages/FlightRecording/.../FixRecord+IGC.swift) — for round-trip parity”

Line order:

  1. AVUL{vendorUUID}
  2. HFDTE{ddMMyy}
  3. HFPLTPILOT:, HFCM2CREW2:, HFGTYGLIDERTYPE:, HFGIDGLIDERID:, HFCIDCOMPETITIONID:, HFCCLCOMPETITIONCLASS:, HFRFWFIRMWAREVERSION:, HFFTYFRTYPE:Vulture
  4. I013638FXA
  5. Optional task:
    • C{date}{time}000000 0001 {NN=count−2} (written without spaces)
    • placeholder C0000000N00000000E
    • one C{lat DDMMmmm N}{lon DDDMMmmm E}{name} per waypoint
    • placeholder
    • LNAVOZN={i−1},Style=…,R1=…m,…
  6. Per fix:
    • optional E{time}PEV
    • optional LLXVSPO2{%03d SpO2×10}HR{hr}
    • B{time}{lat}{lon}A{%05d gps}{%05d gps}007 (pressure = GPS)

Coordinates are truncated (not rounded) to 1/1000 minute.

Note: the LLXVSPO2…HR.. line is only parsed back when its total length is 15 chars (2-digit HR), and then SpO2 is read from chars 8–9. Round-trip is lossy.


10. Track simplification (Packages/TrackShape/Sources/TrackShape/TrackSimplifier.swift)

Section titled “10. Track simplification (Packages/TrackShape/Sources/TrackShape/TrackSimplifier.swift)”

Used for list and gallery thumbnails. It is not part of the analysis, but its output is stored.

  1. Project to Web-Mercator MKMapPoint. World size 268435456. x = (lon+180)/360·W; y = (0.5 − ln((1+sinφ)/(1−sinφ))/(4π))·W.
  2. Normalise to the unit square. An axis without extent maps to 0.5.
  3. Iterative Douglas–Peucker ranking:
    • The endpoints get rank ∞.
    • Each split point’s rank = min(maxDeviation, parentLimit).
    • Point-to-segment distance with clamped t.
    • minimumSignificance = 0.001.
    • Stack = LIFO (pop last, push left then right).
  4. Keep the points with rank > 0.001, sorted by rank descending (sort stability matters for ties; Swift sorted is stable in practice, so use a stable sort). Take maxPointCount (default 200), then re-sort by index.
  5. aspectRatio = width/height (Float), or 1 if either is 0.
  6. Encoding SimplifiedTrack.encodedData(): byte 0x01 version, LE Float32 aspect, then LE Float32 x, y pairs.
  7. RowTrackShape.task(through:) closes the loop when there are more than 2 points and first ≠ last; maxPointCount = 32.

11. Test fixtures and golden tests to port

Section titled “11. Test fixtures and golden tests to port”

Fixtures: Packages/Flights/Tests/Flights/. Copy them byte-for-byte into flight-domain/src/test/resources/igc/.

FixtureSizeFeaturesUsed by
koen.igc (+.elevation.json)1.5 MBENL+MOP self-launch, LLXVOZ lines (start + finish), 2 engine runsTimes, Phases, Distance, Task, Speed, Flight, Values
jelle500.igc (+elev)0.9 MB5-TP task without OZ (wrapper strip), VATsame
jelle600.igc (+elev)1.7 MB5-TP tasksame
tijl1000.igc (+elev)3.2 MBTAS/OAT/ACZ/HDT, sector TP (A1=45, R1=30 km), finish Elev=22msame
wim1000.igc (+elev)2.3 MBMOP, 5-TP tasksame
wim40.igc (+elev)66 KBHFDTEDATE:070719 without comma; declaration with text → no taskFlight, Times
ben300.igc (+elev)1.0 MBLXNAV start line, many extensionsFlight
iv_egc_task6.igc1.5 MBEGC cylinder start R1 = 10 km, finish 5 km, HFDTEDATE:220726,1TaskEnricher EGC
pev_cylinder_start.igc3.7 KBSynthetic PEV inside a cylinder startTask PEV
cylinder_start_no_pev.igc3.7 KBSynthetic, no PEVTask fallback
hang_glider.igc, para_freeflight.igc, para_marcourt.igc, slow_glider.igc0.2–0.3 MB (marcourt 8 KB)Sport detectionSportProfileTests

Harness (Tests/FlightsTests/Helpers/FlightManager.swift):

  • Decode with filename, cache = ElevationFixtureCache(elevationCache: <json ints>) and defaultZones = [:].
  • PEV fixtures: isEnrichingEnabled = false, then only FlightTaskEnricher.
  • Tolerance helper isClose(v, e, within: 1e-4) (distances 0.1).
  • Tests compare fix.time.timeValue strings, which are local-time strings, so they depend on the bundled time-zone table.

Suites (Packages/Flights/Tests/FlightsTests/, ≈ 461 tests):

Suite#Asserts
Decoder/FlightDecoderHeaderTests19every H field, the A serial, generated filename FU_22062019.igc, date epoch 1561161600, HFDTEDATE:…,01 form, 2-digit year window (99 → 1999, 80 → 1980, 00 → 2000, 24 → 2024)
Decoder/FlightDecoderTaskTests5C-record task count, names, coordinates
Decoder/FlightDecoderTests1remote URL rejected
Models/FlightWarningsTests10exact warnings and line numbers (unknown Z, dropped fix, duplicate / out-of-order / midnight wrap)
Models/FixTests20B parse, validity A/V/other, vario and bearing, ENL/MOP, OAT ? vs malformed
Models/ExtensionTests29I-record ranges and types
Models/EventTests, SerialNumberTests6 + 4E code parsing, A serial
Models/FlightTests26engine threshold, ENL/MOP values, parsed zone styles per fixture, GPS min/max skipping 2D, fix(nearest:), takeoffPerformance
Models/FlightIdentityTests4id stability and input sensitivity
Models/PhaseTests, ThermalTests, TaskTests4 + 4 + 5ordering, turn classification, task model
Enricher/FlightTimesEnricherTests65per fixture: event count, takeoff / release / landing local times, gliding block, engine runs, ENL/MOP type, alt-gain fixes, altitude gained (e.g. koen takeoff 11:55:09, release 11:57:27, landing 19:43:32, 2 engine runs = 00:02:16, gain 1225 m)
Enricher/FlightPhasesEnricherTests145per fixture: thermal count (koen 78), attempts (8), total thermal / cruise time (10454 / 17201 s), avg cruise speed, L/D (49), avg distance between thermals, wind count / speed / direction, per-thermal centre, radius, vario, relative ratio, entry / exit, turn, turn radius / rate, legs
Enricher/FlightDistanceEnricherTests30each optimisation: fix count, distance (±0.1 m), speed (e.g. koen free 573311.6829 m)
Enricher/FlightTaskEnricherTests32attempts, completed, index count, distance, speed; PEV start; EGC zones
Enricher/FlightSpeedEnricherTests10per-fix avg speed and task speed
Enricher/FlightValuesTests4TAS / VAT values
Enricher/FlightMetadataEnricherTests3light pipeline equals full pipeline for free distance and times
Enricher/SportProfileTests14detection per fixture, forced profile, threshold comparisons
Models/RaceProgressTests, RaceRankingTests15 + 8replay positions and ranking
LeaderAnalysisTests, LeechingAnalysisTests13 + 11matching and lag correlation
SatellitesTests8weather area; skip here

Other packages:

  • Packages/Waypoints/Tests/WaypointsTests/: CUP decode (12), CUP tasks (20), encoder (13), XCTrack (26: v1/v2/z, polyline, goal line, AAT, zone overrides), lat/lon parsing (14 + 3), Zone (9), Waypoint (15), names (6), zone polygons (6).

    Note (CLAUDE.md l.130–134): 15 Waypoints tests depend on the localized strings in .xcstrings.

  • Packages/TrackShape/Tests/TrackShapeTests/: TrackSimplifier (9), SimplifiedTrack encoding (5), RowTrackShape task (3).
  • Packages/Map/Tests/MapTests/CoordinateBoundsTests (5).
  • Formatting/Tests (coordinate formatter 5, dates 3).
  • Preferences/Tests/SportParsingProfileTests (2).

Recommendation. Also generate a golden snapshot JSON per fixture from iOS before porting: every fix’s time, vario, groundspeed, pressureAltitude, event, thermal id, leg id, averageSpeed plus all flight aggregates. The existing tests sample only a few values; a per-fix diff catches smoothing and index off-by-ones at once. This needs a small throwaway Swift test, run in a scratch copy of the iOS repo (never commit to it). Without elevation, use the existing .elevation.json caches.


Section titled “12. Recommended Kotlin structure and libraries”

Pure-JVM modules with no Android dependencies, so the golden tests run on the JVM fast:

:core:geo geo/ LatLng(value class pair), Haversine(6371), HalfAngle(6378.137), bearing, destination,
midpoint, sector/circle arcs, polygon contains, line crossing, Mercator, BoundingBox
:core:units units/ Unit, UnitType, conversions, UnitFormatter (BigDecimal HALF_EVEN), time formats
:domain:waypoints waypoints/model (Task, Waypoint, Zone, ZoneType, Style), io/CupDecoder, CupEncoder,
XcTrackTaskDecoder, NanoEncoder, FlarmEncoder, geometry/ZoneGeometry
:domain:flight igc/ (IgcParser, BRecord, Extensions, Events, Warnings, strict number parsing)
model/ (Flight, Fix, Thermal, Leg, EngineRun, Wind, Phase, Optimization, TaskAttempt, ids)
analysis/ (FlightEnricher, SportProfile, TimesEnricher, PhasesEnricher, DistanceOptimizer,
FlightDistanceEnricher, SpeedEnricher, MetadataEnricher, LiveTakeoffDetector)
task/ (TaskEnricher, crossings)
elevation/ (ElevationRepository interface + SrtmTile decoder; HTTP impl in :data)
tz/ (TimeZoneLocator + timezones.json resource)
compare/ (RaceProgress, RaceRanking, LeaderAnalysis, LeechingAnalysis)
:domain:trackshape TrackSimplifier, SimplifiedTrack codec
:domain:gliders Glider + gliders.json
:data:elevation Ktor/OkHttp client → vulture.aero, java.util.zip unzip, file cache (context.noBackupFilesDir/elevations: survives restarts, excluded from Auto Backup)

Libraries:

  • Kotlin 2.x, kotlinx-coroutines (the elevation download: parallel with a 10 s overall withTimeout, failure → no elevations)
  • kotlinx-serialization-json (XCTrack, timezones, gliders, elevation fixtures)
  • java.util.zip (ZipInputStream, Inflater)
  • OkHttp or Ktor client
  • A strict CSV reader (kotlin-csv, or hand-rolled to mirror yaslab CSV.swift behaviour; the hand-rolled one is preferred for parity)
  • Tests: JUnit5 / kotest with a isClose helper, one fixture loader, parameterised by fixture

Avoid: android.location.Location.distanceBetween (WGS84 ellipsoid, gives different numbers), JTS/Turf geometry (different algorithms), String.format for numbers, Double.toDouble lenient parsing, and java.time.LocalDate.of for HFDTE (it throws where Foundation normalises).

Design notes:

  • Keep a mutable Flight/Fix during enrichment to mirror the side effects (event assignment, back-references, pressure correction).
  • Expose an immutable AnalysedFlight snapshot to the UI layer (Compose state), converted once after enrich().
  • Run the pipeline on Dispatchers.Default. The free distance and per-leg optimisations are CPU-heavy (O(n²/51²) per populate; legs re-run the 5-TP optimiser).
  • Make the elevation step an injectable suspend interface. The UI must accept that analysis numbers change after elevations arrive (the same as iOS).

13. Quirks to reproduce (or fix deliberately on both platforms)

Section titled “13. Quirks to reproduce (or fix deliberately on both platforms)”
  1. Landing detection reads fixes[count − m], an index near the start of the file (§5.1).
  2. Release index off-by-one (correctedIndex = enum + takeoffIndex) (§5.1).
  3. Wrap detection only against the first fix; time is not wrapped.
  4. Smoothing reuses the already-smoothed prevprev value. The flight min/max use raw values.
  5. Mixed Earth radii: 6371 km (haversine, groundspeed, thermals, zones) vs 6378.137 km (optimisations, task distance, speed pre-legs).
  6. Fix.distanceTraveled is km. Optimization takes km and stores m.
  7. Wind.direction = the upwind bearing (last→first); the first wind key = the flight’s first fix time.
  8. relativeRatio uses leastNormalMagnitude as the starting maximum; with all-negative thermals the ratio is huge. Fixed on both platforms (Q8, see §5.4).
  9. fix.thermal is set for rejected thermal candidates too. distanceToNextThemal links only thermals that passed every filter, but those include “attempts” (gain ≤ 0).
  10. Thermal turn-direction loop excludes the last 2 fixes.
  11. Legs: none when there are no thermals and no engine runs. The filter(by:) upper bound is < count−1. The leg distance is a 5-TP optimised distance.
  12. C declaration with a trailing task name → the task is ignored (wim40).
  13. HFDTEDATE:ddmmyy without a comma → no date.
  14. Generated filename and startRecordingDate use the device time zone (DateFormatter/Calendar.current).
  15. The time-zone offset is evaluated at 00:00 UTC of the flight date.
  16. Default zones are all-zero only in the analysis package itself (and its tests). The app seeds them on first launch (see data/prefs/README.md, startZone/waypointZone/finishZone: e.g. turnpoints symmetric sector R1 3000 m, A1 45°), so a real install scores with those. With zero radius nothing is “contained”, so tasks then depend on line crossings or closest-point fallbacks only. (Clarified 2026-10-10 from the :data:prefs port.)
  17. Triangle = start→A→B→end with no closing leg or FAI rules. “Out distance” = the global longest sub-sampled pair.
  18. fts is ft/min. ft/min, hPa and percentages used to print 6 decimals (%f); both platforms print 0 now (B9).
  19. FlightTakeoffEnricher (live) uses a 100 m turn radius vs 50 m in the IGC times enricher.
  20. 64-bit Int everywhere: use Long for the id and the distance-cache key; Int.max sentinels (OAT min when no OAT is present stay Int.MAX → must map to the same “no value” semantics in the UI).
  21. Swift ClosedRange traps when lower > upper (optimise, refinePositions, closestIndex). Kotlin silently iterates nothing. Equal results on valid inputs.
  22. The elevation download has a hard 10 s budget. A partial failure → no elevations for the whole flight, which disables the pressure correction.

  1. :core:geo + tests (haversine, bearing incl. the 0 → 360 rule, destination, arcs (Swift stride semantics, FMA), polygon, line crossing, half-angle distance). Cross-check against values printed by Swift.
  2. Strict string/number helpers + Int.timeValue formats + :core:units (HALF_EVEN formatter).
  3. :domain:waypoints models, Task.applyStyle/applyTask, zone geometry, CUP decoder, XCTrack decoder (port the 124 Waypoints tests).
  4. IGC parser (Flight.init + Fix.init + warnings + C-task post-processing) and the decoder/header/fix/ extension/warning tests.
  5. TimeZoneLocator + improveFlightData + elevation interface (use the fixture cache in tests).
  6. TimesEnricher → PhasesEnricher → DistanceOptimizer/FlightDistanceEnricher → TaskEnricher → SpeedEnricher, each gated by its golden suite on the 7 elevation-backed fixtures.
  7. Flight lazy statistics, Phase ordering, FlightIdentifier.
  8. SportProfile detection tests (para / hang / slow-glider fixtures), MetadataEnricher.
  9. Competition analyses (RaceProgress / Ranking / Leader / Leeching).
  10. TrackShape simplifier + codec. Gliders DB. IGC writer (for the recording feature).
  11. HTTP elevation implementation + disk cache + Android integration (WorkManager not required; it is in-process).

ModuleKotlin main LOC (est.)Test LOC (est.)Effort
geo + units + formatting helpers6005003 d
waypoints (models, zones, CUP, XCTrack, encoders)1,1001,2005 d
IGC parser + models1,2009005 d
enrichers (times, phases, distance, task, speed, improve, sport)1,8002,000 (mostly golden assertions)8–10 d
elevation + tz3502002 d
competition analyses5005002 d
trackshape + gliders + IGC writer3002501.5 d
Total≈ 5.8k≈ 5.5k≈ 4–6 engineer-weeks to green parity, plus a buffer for chasing floating-point and order-of-operations diffs (the biggest unknown)