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.
0. Executive summary
Section titled “0. Executive summary”| Item | Value |
|---|---|
| Portable Swift source in scope | Flights ≈ 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 port | Flights ≈ 461 tests / 4.3k lines; Waypoints ≈ 124; TrackShape 21 (9 simplifier); Map 5 (bounds) |
| Fixtures | 15 IGC files (≈ 17 MB) plus 7 *.elevation.json SRTM caches (Packages/Flights/Tests/Flights/) |
| Pipeline | parse (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 calls | 1 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. |
| Scoring | Free 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 scoring | Zone 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). |
1. Package and file map
Section titled “1. Package and file map”| iOS file | Responsibility | Kotlin target |
|---|---|---|
Packages/Flights/Sources/Flights/Decorder/FlightDecoder.swift | Entry point: loads content and runs Flight.init plus FlightEnricher | igc/FlightDecoder.kt |
…/Models/Flight.swift | IGC line parser (init, l.166–506) plus ~60 lazy statistics | model/Flight.kt + igc/IgcParser.kt + stats/FlightStatistics.kt |
…/Models/Fix.swift | B-record parser, vario/speed/bearing, extension values | model/Fix.kt + igc/BRecordParser.kt |
…/Models/{Event,Extension,Prefix,HeaderPrefix,BRecordColumn,FixValidity,ParseWarning}.swift | Record enums and columns | igc/* |
…/Extensions/String+{Parsing,Range,Convert,Regex,Flaps}.swift, Int+Time.swift | Low-level field parsing and time formatting | igc/IgcStrings.kt, util/TimeFormat.kt |
…/Extensions/Task+Parsing.swift, Waypoint+Parsing.swift | IGC C-record task building | igc/IgcTaskParser.kt |
…/Enricher/FlightEnricher.swift | Orchestrates the pipeline, sport detection, improveFlightData | analysis/FlightEnricher.kt |
…/Enricher/SportProfile.swift | Sport thresholds | analysis/SportProfile.kt |
…/Enricher/FlightElevationEnricher.swift, Elevation/Elevation.swift, Service/* | SRTM tiles | elevation/* |
…/Enricher/FlightTimesEnricher.swift | Takeoff, landing, release, engine runs, gliding block, altitude gain | analysis/TimesEnricher.kt |
…/Enricher/FlightTakeoffEnricher.swift | Generic takeoff/release detector for live fixes (not in the IGC pipeline) | analysis/LiveTakeoffDetector.kt |
…/Enricher/FlightPhasesEnricher.swift, Extensions/CLLocationCoordinate2D+{Thermal,Region}.swift | Thermals, wind, legs | analysis/PhasesEnricher.kt |
…/Enricher/DistanceEnricher.swift | Reusable 5-TP DP optimiser (legs, task helpers) | analysis/DistanceOptimizer.kt |
…/Enricher/FlightDistanceEnricher.swift | Flight optimisations (free, triangle, O&R, out, 3-TP, max out) | analysis/FlightDistanceEnricher.kt |
…/Enricher/FlightTaskEnricher.swift, Models/TaskAttempt.swift, Extensions/CLLocationCoordinate2D+{Line,Calculations}.swift | Task scoring | task/* |
…/Enricher/FlightSpeedEnricher.swift | Per-fix average speed and best 1 h / 2 h | analysis/SpeedEnricher.kt |
…/Enricher/FlightMetadataEnricher.swift | Light pipeline (times + distance), used for previews | analysis/MetadataEnricher.kt |
…/Models/{Thermal,Leg,EngineRun,Wind,Phase,Optimization,OptimizationItem,TimedPhaseConvertible}.swift | Analysis models | model/* |
…/Models/{RaceProgress,RaceRanking,LeaderAnalysis,LeechingAnalysis,LeechingAnalysisResult}.swift | Multi-flight competition analysis | compare/* |
…/Models/Flight+Identity.swift | FNV-1a 64-bit flight id | model/FlightIdentifier.kt |
…/Vendor/TimeZoneLocate/*, Resources/timezones.json | Offline time-zone lookup | tz/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}.swift | Task model | waypoints/model/* |
…/Extensions/Waypoint+ObservationZone.swift | Zone polygon/line generation | waypoints/geometry/ZoneGeometry.kt |
…/WaypointsDecoder.swift, XCTrackTaskDecoder.swift, WaypointsEncoder.swift | CUP and XCTrack QR decode, CUP/Nano/FLARM encode | waypoints/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}.swift | Douglas–Peucker ranking for thumbnails | trackshape/* |
Packages/Preferences/Sources/Preferences/Units/* | Units and the formatter | units/* |
Packages/Formatting/Sources/Formatting/* | Coordinate, date and name formatting | format/* |
Packages/Gliders/* | Bundled glider/handicap DB | gliders/* |
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.
| Field | Type | Origin |
|---|---|---|
date | Instant (UTC midnight of HFDTE) | parse |
filename | String (given, or "{CID}_{ddMMyyyy}.igc"; date formatted in device local TZ, see §13) | parse |
id | Long (FNV-1a, §2.10) | parse |
content | String (raw IGC) | parse |
pilotName, passengerName, gliderType, gliderRegistration, loggerType, loggerSerialNumber, gpsManufacturer, pressureAltitudeSensor, mopSensor, competitionIdentification, competitionClass, flarmVersion, flarmSerialNumber | String? | headers / A / LLXVFLARM:NG |
task | Task? (setting it resets taskAttempts, bestTaskAttempt and every fix’s averageTaskSpeed = 0) | C/L records or assigned |
fixes | List | parse |
warnings | List | parse |
engineLevelThreshold | Int = 200 + Σ ENL / fixCount (integer division, ENL only, not MOP) | parse (l.496) |
takeoffType | enum unknown, winch, tow, selfLaunch | times |
min/maxGPSAltitude, min/maxPressureAltitude | Int | parse, 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) | numeric | parse (on raw, unsmoothed per-fix values; §3.6) |
flaps | ListflapValue desc; empty if ≤ 1) | parse |
startRecordingTime, endRecordingTime | Int (rawTime of first/last fix) | parse |
thermalAttemptCount | Int? | phases |
detectedSportProfile | SportProfile | enricher |
thermals | List | phases |
legs | List | phases |
wind | Map<Int, Wind> (key = thermal entry time; see §5.4) | phases |
engineRuns | List | times |
taskAttempts, bestTaskAttempt | List | task |
startGlidingBlockIndex, endGlidingBlockIndex | Int? (internal) | times |
best1hSpeedOptimization, best2hSpeedOptimization, maximumDistanceOutOptimization, freeDistanceOptimization, outDistanceOptimization, outAndReturnDistanceOptimization, triangleDistanceOptimization, threeTurnpointDistanceOptimization | Optimization? | distance/speed |
lowestThermalEntryFix, lowestAltitudeFix, highestAltitudeFix | Fix? | phases/times |
isElevationAvailable | Boolean | elevation |
availableExtensions | List | parse |
Derived (lazy) statistics. Compute them once after enrichment, or as Kotlin by lazy
(Flight.swift:553–1025):
| Property | Exact formula |
|---|---|
duration | landingFix.rawTime − takeoffFix.rawTime |
flightDuration | landing.rawTime − (release ?? takeoff).rawTime − (engineRunTime ?? 0) |
glidingBlockRange / glidingBlockDuration | indices / endFix.rawTime − startFix.rawTime |
phases | thermals + cruise legs + engine runs + event fixes, sorted() with Phase ordering (§2.6) |
altitudeGained | highestAltitudeFix.pressureAltitude − lowestAltitudeFix.pressureAltitude (0 if nil) |
fixesWithEvents, takeoffFix, releaseFix, landingFix, pilotEventFixes | first fix with that event |
furthestPointFromHomeFix | maximumDistanceOutOptimization.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) |
thermalAverageAirspeed | only if TAS is available: mean of thermal.averageAirspeed |
averageDiameter | Int(Σ radius / n) * 2 |
averageTurnRate | Int(Σ turnRate / n) |
bestThermalVario | max thermal.vario |
thermalPercentage | totalThermallingTime / 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) |
averageGlideRatio | Int(Σdist / ΣaltDiff) * −1 (truncation, then negation) |
engineRunTime | Σ run durations, nil if 0 |
{left,right,mixed}ThermallingPercentage | share 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/averageWindSpeed | over the wind map |
maximumAverageSpeed, maximumAverageTaskSpeed | max over fixes |
startRecordingDate/endRecordingDate | Calendar.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.
2.2 Fix (Models/Fix.swift)
Section titled “2.2 Fix (Models/Fix.swift)”| Field | Type | Notes |
|---|---|---|
rawTime | Int | seconds since UTC midnight, +86400 if time < firstFix.rawTime (wrap) |
time | Int | un-wrapped UTC seconds, then + local TZ offset (improve step) |
coordinate | lat/lon Double | |
event | Event? | |
gpsAltitude | Int? | nil when validity byte = V |
fixValidity | threeDimensional / twoDimensional | A → 3D, V → 2D, anything else → 3D |
pressureAltitude | Int | corrected in the improve step |
elevation | Int | SRTM 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?), engineRPM | extension values (§3.4) | |
vario | Double | smoothed (§3.6) |
groundspeed | Double m/s | smoothed |
trueAirspeed | Double m/s | TAS / 100 / 3.6 |
thermal / leg | back-references | |
distanceTraveled | Double, km despite its doc (“meters”) | speed enricher |
optimizedDistanceTraveled | internal, km | |
averageSpeed, averageTaskSpeed | m/s | speed enricher |
bearing | Int? | Int(prev.bearing(to: this)) |
isEngineNoiseLevelEnabled, engineNoiseLevel, isMeansOfPropulsionEnabled, meansOfPropulsionLevel | ||
heartRate, bloodOxygenLevel | Int? | 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.
2.3 Thermal (Models/Thermal.swift)
Section titled “2.3 Thermal (Models/Thermal.swift)”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 ≤ 0duration = exit − entryradius = mean(turnRadiusValues)turnRate = deg(atan(avgSpeed² / (9.81 · radius))). This is the bank angle, despite the name.minimumAltitude/maximumAltitudeover fixesdrift= distance(first, last), only when there are more than 2 fixesaverageAirspeed = Σ TAS / countaltitudeLost= Σ 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.
2.4 Leg (Models/Leg.swift)
Section titled “2.4 Leg (Models/Leg.swift)”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.pressAltaverageSpeed = (d/1000)/(t/3600)/3.6averageGlideRatio = Int(d/altDiff) * −1; nil if d ≤ 0 or altDiff == 0
2.5 EngineRun, Wind
Section titled “2.5 EngineRun, Wind”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.
2.6 Phase (Models/Phase.swift)
Section titled “2.6 Phase (Models/Phase.swift)”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)”| Field | Meaning |
|---|---|
waypointCrossingIndexes: [[Int]] | fix indices per waypoint |
waypointIndex | progress cursor |
waypointCoordinates | |
minimumAltitude (finish zone Elev), minimumAATDuration | |
indexes, fixes | credited fixes |
distance (m) | DistanceEnricher(fixes).trueDistance() (path through credited fixes, 6378.137 km radius) ×1000 |
averageSpeed | km/h/3.6 |
isCompleted | every waypoint crossed at least once |
startAltitude = first fix pressure altitude (?? gps, though pressure is never nil); startGroundSpeed | |
finishAltitude, finishGroundSpeed | only when completed; fix at index waypointCount−1 |
minimumFinishAltitudeDifference = finishAlt − minAlt | |
minimumAATDurationDifference = duration − minAAT (completed only) | |
thermalAverageVario, thermalDiameter, thermalTurnRate, thermalCount, thermalAttemtCount [sic], thermalHeightGained/Lost | thermals fully inside [start, end] |
averageGlideRatio, averageCruiseSpeed | legs fully inside [start, end] |
isPEVStart |
2.9 Competition models
Section titled “2.9 Competition models”RacePosition{phase(notStarted|racing|finished|landedOut), distance, taskTime?, speed?}RaceProgress:minimumSpeedDuration = 900 s, centre-to-centre task distance, monotone best distanceRaceRanking/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 sPearson).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:
- filename UTF-8 bytes
date.timeIntervalSince1970as a Double bit pattern (doubleToRawLongBits), least-significant byte firstInt64(startRecordingTime), least-significant byte firstInt64(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 whenwaypointsis replaced),minimumAATDuration?(s),waypoints,notes?,inactiveAirspace?. Equality = hash of (distance, waypoints).append(waypoint:)sets the legdistance/bearingon the waypoint andorder.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(CUPStyle).ObservationZoneType:start=0, waypoint=1, finish=2.Styleenum (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}.
2.12 Glider (Packages/Gliders)
Section titled “2.12 Glider (Packages/Gliders)”{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)”3.1 Text loading
Section titled “3.1 Text loading”- 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:CharsetDecoderwith 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.
3.2 Line handling
Section titled “3.2 Line handling”- 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).
3.3 Records
Section titled “3.3 Records”| Record | Handling | Code |
|---|---|---|
| A | serialNumber(): from index 4 until the first , - : or whitespace. Nil if count ≤ 4 or empty. Example: ALXV4YE,FLIGHT:1 → 4YE. | String+Parsing.swift:99 |
| H | line[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 |
| HFDTE | If 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 |
| I | extensions(): 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 |
| J | Not 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 |
| E | event(): 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 |
| B | See §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 OZ | Observation 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 TSK | task.applyTask: TaskTime= → minimumAATDuration if > 0 | l.394 |
L LLXVFLARM:NG | Text from index 13 split by , → flarmVersion, flarmSerialNumber | l.399 |
| any other L, G, D, F, K, J, unknown | unknownRecordType(firstChar, line) warning | l.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.
| Code | Field | Conversion |
|---|---|---|
| FXA, GAL, SIU, GSP, TRT, HDT, LAD, LOD, … | — | ignored (FXA/GAL are in the enum but have no handler) |
| ENL | engineNoiseLevel | Int (nil → 0); sets isEngineNoiseLevelEnabled |
| MOP | meansOfPropulsionLevel | Int; sets isMeansOfPropulsionEnabled |
| RPM | engineRPM | Int |
| TAS | trueAirspeed | Double / 100 / 3.6 (km/h × 100 → m/s) |
| VAT | totalEnergy | /100 |
| OAT | outsideAirTemperature | Int / 10 (integer division). ?-filled → nil, no warning. Malformed or absent (short line) → nil plus malformedField(line, "OAT", first 8 chars) |
| ACT | gForce | Double |
| ACZ | verticalAcceleration | /100 |
| NET | verticalAirMovement | /100 |
| AOP | attitudePitchAngle | value if > −99, else 0 |
| AOR | attitudeRollAngle | value if > −999, else 0 |
| CUR, VOL | electricalCurrent / Volts | Double |
| FLE, FFL | fuelLevel / fuelFlow | /1000 |
| EGT, MOT, COT, JPT | temperatures | Int |
| LEB | batteryLeft | Double |
| FLP | flapPosition (raw string), flapPositionValue = flapValue | |
| HR, SPO2 | listed 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
flapsnot 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.
3.5 B record (Fix.init)
Section titled “3.5 B record (Fix.init)”Columns, 0-based (BRecordColumn.swift):
| Field | Columns | Notes |
|---|---|---|
| time | 1..<7 | HHMMSS → h*3600 + m*60 + s, via Int(raw).fixTime(), so non-digits → nil |
| latitude | 7–14 | DD MM mmm N/S |
| longitude | 15–23 | DDD MM mmm E/W |
| validity | 24 | A/V |
| pressure altitude | 25..<30 | 5 chars, Int |
| GNSS altitude | 30..<35 | 5 chars, Int |
- Coordinate:
deg + (min + frac/1000)/60. Negated only for exactlyS/W; any other hemisphere char is positive.(0,0)exactly → nil. - Altitude: requires
start + 5 ≤ utf8.count. Negative values such as-0151parse. - 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 haversinedistance ≤ 1e6(Apple’s exact metric is unknown; boundary cases are not realistic). - Midnight wrap: if
time < firstFix.rawTime, thenrawTime = time + 86400. The comparison is against the first fix, not the previous one.timekeeps the un-wrapped value. gpsAltitude = nilwhen validity isV.
3.6 Per-fix derived values (order matters)
Section titled “3.6 Per-fix derived values (order matters)”With prev = the last accepted fix:
-
bearing = Int(prev.coord.bearing(to: coord))(truncation; range 0…360). -
Altitude delta: if
pressure == 0 && prev.pressure == 0, use GPS (nil if either is nil); else use pressure. -
dt = rawTime − prev.rawTime. Ifdt > 0:vario = Δalt/dt(when Δalt is non-nil) andgroundspeed = max(0, haversine(prev, this)/dt)(6371 km sphere). -
Smoothing: if there are ≥ 2 previous fixes,
prev.vario = (this.vario + prevprev.vario + prev.vario)/3.prevprevis already smoothed. Groundspeed gets the same treatment.The fix’s own value is still raw when
Flight.initaggregates 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 inFlight.swift:293–360. -
nonMonotonicTime(previous, current, line)warning ifrawTime ≤ prev.rawTime. The fix is kept.
3.7 Validation and post-processing
Section titled “3.7 Validation and post-processing”- 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 > 2andNN + 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.
- If no waypoint has a zone,
engineLevelThreshold = 200 + ΣENL / count(integer division).- No G-record (security) validation. The application never verifies signatures.
3.8 Warnings model
Section titled “3.8 Warnings model”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/2hFlightMetadataEnricher (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)”- Take the smoothed groundspeeds of all fixes where speed > 0.
- Sort them;
p95 = sorted[Int(n * 0.95)]. - Classify:
p95 > 100/3.6→ glider;> 80/3.6→ delta (hang glider); otherwise parapente.
SportProfile constants (SportProfile.swift):
| Field | glider | parapente | delta |
|---|---|---|---|
| minimumSpeed (m/s) | 30/3.6 | 18/3.6 | 25/3.6 |
| takeoffDuration (s) | 15 | 8 | 10 |
| landingDuration (s) | 30 | 15 | 20 |
| thermalTurnRadiusLimit (m) | 200 | 150 | 300 |
| thermalClusteringDistance (m) | 500 | 300 | 500 |
| minimumTurnCount | 10 | 6 | 8 |
| minimumThermalDuration (s) | 20 | 15 | 20 |
| minimumWindThermalDuration (s) | 90 | 60 | 90 |
| minimumLegDuration (s) | 60 | 30 | 45 |
4.2 improveFlightData (FlightEnricher.swift:156–219)
Section titled “4.2 improveFlightData (FlightEnricher.swift:156–219)”convertPressureToGPS = (minPressure == 0 && maxPressure == 0). If true,min/maxPressure = min/maxGPS.- Time zone:
TimeZoneLocate.timeZoneWithLocation(firstFix)(§8.2), thenoffset = tz.secondsFromGMT(for: flight.date).flight.dateis UTC midnight of the flight date, so the DST state is taken at 00:00 UTC on that day. startCorrection = fix0.pressure − fix0.elevation. WithisCorrectingPressureAltitude(always true):correctionDifference = (last.pressure − last.elevation) − startCorrection.- For each fix
i(indexoffset, 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)andmaxPressure. flapPositionIndex = flight.flaps.indexOf(flapPosition).
- If elevation is available:
minPressure = max(0, min)andmaxPressure = max.
5. Times, thermals, glides and legs
Section titled “5. Times, thermals, glides and legs”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)
- Scan the fixes. When
groundspeed > minSpeed, remember the first index of the fast run (minIdx). - When
fix.rawTime − fixes[minIdx].rawTime ≥ takeoffDuration: settakeoffIndex = minIdx. - Classify the takeoff:
- average
engineLeveloverfixes[minIdx...i](integer mean)≥ 700→ selfLaunch - else if
(fix.pressure − minFix.pressure)/(dt) ≥ 8→ winch - else → tow
- average
break. A slow fix resetsminIdx = 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 = .landingEffect: 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 < 0held for ≥ 5 s → release at the start of the run. - tow: only while
pressure − startAlt > 200.vario < 0held for ≥ 10 s → release at the start of the run.- Otherwise, if
0 < ci ≤ count−2, fit a circle (§5.2 formula) throughfixes[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)
- Loop
ifrommax(takeoff, release)…landingIndex. - While gliding,
engineLevel > flight.engineLevelThreshold→ record an on index. - While running,
engineLevel < threshold(strict) → record an off index. - If there are more ons than offs, drop the last on.
- Keep only pairs with
offTime − onTime > 20s.
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 rangesoff[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)
- For each gliding range: running minimum of the pressure altitude, and best
fix − runningMin. - Pick the range with the maximum gain.
- Set
lowestAltitudeFix/highestAltitudeFixand 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)/2radius = haversine(cur, center) // 6371 kmDivision 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:
-
Fit the circle through
i−1, i, i+1. Skip unlessradius < thermalTurnRadiusLimit. -
speed = TAS > 0 ? TAS : groundspeed. -
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 = rawTimebestVario = max(vario, bestVario)- append the fix,
lastFixIndex = i - append the radius and the speed
Otherwise start a new
Thermalwith 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
entryTimelies inside an engine run (start ≤ t ≤ end), or duration ≤ minimumThermalDuration(the test is strict>), or- it has fewer than 2 coordinates (no region).
- its
-
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). ThenTurn(turns). -
Rejected if
!isLargeInterval && turns.count < minimumTurnCount. -
Centre/radius: a bounding-box “region”. Compute two variants:
- raw
- 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.maximumVariostarts at-Double.infinity, andrelativeRatio(vario:maximumVario:)gives 0 when the maximum is ≤ 0 (Q8, fixed on both platforms; it used to start atDouble.leastNormalMagnitude). -
Wind: if
duration > minimumWindThermalDurationand 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: lowestentryAltitudeamong non-attempt thermals. -
After the reduce:
relativeRatio = vario / maximumVarioon all candidate thermals (rejected ones too).thermalAttemptCount= accepted thermals with gain ≤ 0.fix.thermalis assigned for all candidates (including rejected ones). Note: this meansfix.thermal != nildoes not imply the thermal is inflight.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 bytime(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 ifstart+1 < end. - The leg after the last phase uses
[last.lastFixIndex+1 ..< flightRange.last]. Array.filter(by:)returns nil unlesslower ≥ 0 && upper < count − 1.Leg(fixes)requires more than 1 fix.fix.legis assigned for all legs.flight.legs= legs withduration > 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).cachedDistancekey =min*1_000_000 + max. UseLong(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] = bestIdxIt 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 maximised(i, idx1). - The last point moves within
[idx[n−2] … idx[n−1]]to maximised(i, idx[n−2]).
Flight results. All are Optimization(indexes, fixes, distance_km) and use the gliding block [S, E]:
| Result | Construction |
|---|---|
| triangleDistanceOptimization | Closing 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. |
| outDistanceOptimization | longestLegIndexes (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) |
| maximumDistanceOutOptimization | Among 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/6371if 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) + 1The 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 on0…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 ofd.
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/)”| Function | Formula |
|---|---|
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(whenA12 > 0) →A12 + 180next(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)andb2 = bearing(self→next), takemid = (max−min)/2 + min; the result ismax−min ≥ 180 ? mid : mid−180. - only next:
next→self - only prev:
prev→self - neither: nil (no zone)
- both prev and next exist: the bisector. With
Shape:
isLine→ 2 points atR1, 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.
| Key | Parsing |
|---|---|
{prefix}=N | Selects 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.
7.4 Task decoding sources
Section titled “7.4 Task decoding sources”- IGC C records: §3.3 and §3.7.
- CUP (
WaypointsDecoder.swift):- Split on
-----Related Tasks-----. - 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 withname,code,country,lat,lon,elev,style,rwdir,rwlen,freq,desc. If there is noname,code,country,lat,lonheader, prepend one. - Latitude
DDMM.mmmN(≥ 9 chars; sign is + only for a trailingN); longitudeDDDMM.mmmE(≥ 10 chars;- only for
E). An invalid coordinate throwsinvalidCoordinate.
- only for
- Task part: non-empty lines are parsed as CSV.
Options…→applyTaskon 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).
removeStartLandingPoints()(only when count > 3): drop the last ifname[n−1] == name[n−2]; drop the first ifname[0] == name[1].- Task distance = sum of haversine.
- Port the CSV quirks with the yaslab defaults: quoted fields, no trimming, empty → nil when decoding optionals.
Verify against
WaypointsDecoderTests.
- Split on
- XCTrack QR (
XCTrackTaskDecoder.swift):- Prefix
XCTSK:(JSON) orXCTSKZ:(base64 → zlib; strip the 2-byte header, raw inflate; usejava.util.zip.Inflater(nowrap=true)ondata.drop(2), orInflater()on the full data). A missing prefix is accepted as JSON. t[]turnpoints: v2 packedz= Google-polyline ints[lon·1e5, lat·1e5, alt, radius]; v1 useswaypoint{lat, lon, altSmoothed, name, description}+radius. Turnpoints without a position are dropped; none at all →invalidData.- Start = first
t == 2(else 0); finish = lastt == 3(else last). - Zone:
R1 = radius,A1 = 180, typenext(start),symmetric(TP) orprevious(finish). The finish is a line ifg.t == 1. TPs are AAT ifo.ttis present. Per-TPo{l, aat, a1, a2, a12 (→ type fixed), r2}overrides. minimumAATDuration = o.tt(s). Waypoint:code = name,elevation = "{alt}m",style = waypoint.
- Prefix
- Encoders (
WaypointsEncoder.swift,Waypoint+CUP.swift): CUP (withObsZone=lines), LXNAV Nano C records +LLXVOZ=, FLARM$PFLAClines. 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)”-
No task → return.
-
complete(waypoints:): a waypoint without a zone getsdefaultZones[start | finish | waypoint](first, last, middle). This mutates the task’s waypoints. -
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).
-
Build the polygon for every waypoint (§7.2). If any waypoint has no polygon (e.g.
defaultZonesempty and no IGC zones), return with no attempts. -
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. - Line zones (2 points): bearing = the finish’s previous→finish leg, otherwise this→next. Call
-
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: whenlastStartCrossing == 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→ addwp == att.wpIndex + 1→isOnTask = true,wpIndex = wp, add
-
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 aPEVevent → take the last such fix and setisPEVStart = 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).
- no crossings →
- Finish: no crossings → closest index, then stop; else the first crossing.
finalize(fixes)(§2.8).
- Start:
-
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:
ref= the first optimised fix withrawTime > fix.rawTime(must be > 0).pre= path km (6378.137) overidx[0..<ref].travelled = pre + haversine(prevTP, nextTP)/1000 − haversine(fix, nextTP)/1000(6371 km for the last two terms). Store it infix.distanceTraveled(km).- If
duration > 0:delta = travelled − previousTravelled- add
deltato the 60-minute and 120-minute windows - if
fix − window60Start ≥ 3600, subtractwindow60Start.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
- 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.timeandaltidude - 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
8. External calls
Section titled “8. External calls”8.1 Terrain elevation (SRTM3)
Section titled “8.1 Terrain elevation (SRTM3)”- Endpoint:
GET https://vulture.aero/api/elevations/{TILE}. The base URL is inService/ElevationServiceConfiguration.swift:12; the path is appended viaappendingPathComponent(Packages/Networking/Sources/Networking/Request+URLRequest.swift:40). - Headers:
Accept: application/jsonand a fixedAuthorizationbearer-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 usesfloor, 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) toCaches/elevations/{TILE}.hgt.
- Tile decoding: big-endian Int16 (
Data+Int16.swift), 1201×1201.row = Int(frac(−lat) · 1201),col = Int(frac(lon) · 1201), wherefrac(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 DatastoredData.elevations;Packages/Persistence/.../CoreFlight+CoreData.swift:164). - On macOS/tests no download happens (
#if os(iOS)); the tests feed<name>.elevation.json.
8.2 Time zone
Section titled “8.2 Time zone”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) withResources/timezones.json: 419 rows{countryCode, latitude, longitude, comments, zone}. - Algorithm: the row with minimum
CLLocation.distanceto 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.
8.3 Other
Section titled “8.3 Other”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. Units and formatting
Section titled “9. Units and formatting”9.1 Settings (Packages/Preferences/Sources/Preferences/Units/)
Section titled “9.1 Settings (Packages/Preferences/Sources/Preferences/Units/)”| UnitType | Options (index order) | Default | Persisted key |
|---|---|---|---|
| wind | kph, mph, kt | kph | VultureSkySightWindSpeedUnit (UserDefaults + iCloud KVS) |
| verticalSpeed | ms, kt, fts | ms | VultureSkySightVerticalSpeedUnit |
| altitude | m, ft | m | VultureSkySightAltitudeUnit |
| speed | kph, mph, kt | kph | VultureSpeedUnit (shared app-group defaults) |
| distance | km, mi, nm | km | VultureDistanceUnit (shared) |
| temperature | celsius, fahrenheit | celsius | VultureTemperatureUnit (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:
| Unit | Format | Decimals |
|---|---|---|
| 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)shortTimeValue3h05m/3h/05mveryShortTimeValue1h 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, elsepilotInitials(PersonNameComponentsFormatter.abbreviated, roughly the first letters of given + family name;_treated as a space)
Date+Formatters.swift: localized styles plus fixed formats (yyyy-MM-ddUTC POSIX,dd-MM-yy, WeGlideyyyy-MM-dd'T'HH:mm:ss(device TZ),HHmm,YYYY-MM-dd(week-year!)).- IGC dates for writing (
Waypoints/Extensions/Date+Formatter.swift):igcDate/igcTimein 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:
AVUL{vendorUUID}HFDTE{ddMMyy}HFPLTPILOT:,HFCM2CREW2:,HFGTYGLIDERTYPE:,HFGIDGLIDERID:,HFCIDCOMPETITIONID:,HFCCLCOMPETITIONCLASS:,HFRFWFIRMWAREVERSION:,HFFTYFRTYPE:VultureI013638FXA- 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,…
- 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)
- optional
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.
- Project to Web-Mercator
MKMapPoint. World size 268435456.x = (lon+180)/360·W;y = (0.5 − ln((1+sinφ)/(1−sinφ))/(4π))·W. - Normalise to the unit square. An axis without extent maps to 0.5.
- 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).
- Keep the points with rank > 0.001, sorted by rank descending (sort stability matters for ties; Swift
sortedis stable in practice, so use a stable sort). TakemaxPointCount(default 200), then re-sort by index. aspectRatio = width/height(Float), or 1 if either is 0.- Encoding
SimplifiedTrack.encodedData(): byte0x01version, LE Float32 aspect, then LE Float32 x, y pairs. 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/.
| Fixture | Size | Features | Used by |
|---|---|---|---|
koen.igc (+.elevation.json) | 1.5 MB | ENL+MOP self-launch, LLXVOZ lines (start + finish), 2 engine runs | Times, Phases, Distance, Task, Speed, Flight, Values |
jelle500.igc (+elev) | 0.9 MB | 5-TP task without OZ (wrapper strip), VAT | same |
jelle600.igc (+elev) | 1.7 MB | 5-TP task | same |
tijl1000.igc (+elev) | 3.2 MB | TAS/OAT/ACZ/HDT, sector TP (A1=45, R1=30 km), finish Elev=22m | same |
wim1000.igc (+elev) | 2.3 MB | MOP, 5-TP task | same |
wim40.igc (+elev) | 66 KB | HFDTEDATE:070719 without comma; declaration with text → no task | Flight, Times |
ben300.igc (+elev) | 1.0 MB | LXNAV start line, many extensions | Flight |
iv_egc_task6.igc | 1.5 MB | EGC cylinder start R1 = 10 km, finish 5 km, HFDTEDATE:220726,1 | TaskEnricher EGC |
pev_cylinder_start.igc | 3.7 KB | Synthetic PEV inside a cylinder start | Task PEV |
cylinder_start_no_pev.igc | 3.7 KB | Synthetic, no PEV | Task fallback |
hang_glider.igc, para_freeflight.igc, para_marcourt.igc, slow_glider.igc | 0.2–0.3 MB (marcourt 8 KB) | Sport detection | SportProfileTests |
Harness (Tests/FlightsTests/Helpers/FlightManager.swift):
- Decode with
filename,cache = ElevationFixtureCache(elevationCache: <json ints>)anddefaultZones = [:]. - PEV fixtures:
isEnrichingEnabled = false, then onlyFlightTaskEnricher. - Tolerance helper
isClose(v, e, within: 1e-4)(distances 0.1). - Tests compare
fix.time.timeValuestrings, which are local-time strings, so they depend on the bundled time-zone table.
Suites (Packages/Flights/Tests/FlightsTests/, ≈ 461 tests):
| Suite | # | Asserts |
|---|---|---|
Decoder/FlightDecoderHeaderTests | 19 | every 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/FlightDecoderTaskTests | 5 | C-record task count, names, coordinates |
Decoder/FlightDecoderTests | 1 | remote URL rejected |
Models/FlightWarningsTests | 10 | exact warnings and line numbers (unknown Z, dropped fix, duplicate / out-of-order / midnight wrap) |
Models/FixTests | 20 | B parse, validity A/V/other, vario and bearing, ENL/MOP, OAT ? vs malformed |
Models/ExtensionTests | 29 | I-record ranges and types |
Models/EventTests, SerialNumberTests | 6 + 4 | E code parsing, A serial |
Models/FlightTests | 26 | engine threshold, ENL/MOP values, parsed zone styles per fixture, GPS min/max skipping 2D, fix(nearest:), takeoffPerformance |
Models/FlightIdentityTests | 4 | id stability and input sensitivity |
Models/PhaseTests, ThermalTests, TaskTests | 4 + 4 + 5 | ordering, turn classification, task model |
Enricher/FlightTimesEnricherTests | 65 | per 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/FlightPhasesEnricherTests | 145 | per 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/FlightDistanceEnricherTests | 30 | each optimisation: fix count, distance (±0.1 m), speed (e.g. koen free 573311.6829 m) |
Enricher/FlightTaskEnricherTests | 32 | attempts, completed, index count, distance, speed; PEV start; EGC zones |
Enricher/FlightSpeedEnricherTests | 10 | per-fix avg speed and task speed |
Enricher/FlightValuesTests | 4 | TAS / VAT values |
Enricher/FlightMetadataEnricherTests | 3 | light pipeline equals full pipeline for free distance and times |
Enricher/SportProfileTests | 14 | detection per fixture, forced profile, threshold comparisons |
Models/RaceProgressTests, RaceRankingTests | 15 + 8 | replay positions and ranking |
LeaderAnalysisTests, LeechingAnalysisTests | 13 + 11 | matching and lag correlation |
SatellitesTests | 8 | weather 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.
12. Recommended Kotlin structure and libraries
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 overallwithTimeout, 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
isClosehelper, 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/Fixduring enrichment to mirror the side effects (event assignment, back-references, pressure correction). - Expose an immutable
AnalysedFlightsnapshot to the UI layer (Compose state), converted once afterenrich(). - 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)”- Landing detection reads
fixes[count − m], an index near the start of the file (§5.1). - Release index off-by-one (
correctedIndex = enum + takeoffIndex) (§5.1). - Wrap detection only against the first fix;
timeis not wrapped. - Smoothing reuses the already-smoothed
prevprevvalue. The flight min/max use raw values. - Mixed Earth radii: 6371 km (haversine, groundspeed, thermals, zones) vs 6378.137 km (optimisations, task distance, speed pre-legs).
Fix.distanceTraveledis km.Optimizationtakes km and stores m.Wind.direction= the upwind bearing (last→first); the first wind key = the flight’s first fix time.Fixed on both platforms (Q8, see §5.4).relativeRatiousesleastNormalMagnitudeas the starting maximum; with all-negative thermals the ratio is huge.fix.thermalis set for rejected thermal candidates too.distanceToNextThemallinks only thermals that passed every filter, but those include “attempts” (gain ≤ 0).- Thermal turn-direction loop excludes the last 2 fixes.
- 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. - C declaration with a trailing task name → the task is ignored (wim40).
HFDTEDATE:ddmmyywithout a comma → no date.- Generated filename and
startRecordingDateuse the device time zone (DateFormatter/Calendar.current). - The time-zone offset is evaluated at 00:00 UTC of the flight date.
- 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.) - Triangle = start→A→B→end with no closing leg or FAI rules. “Out distance” = the global longest sub-sampled pair.
ftsis ft/min. ft/min, hPa and percentages used to print 6 decimals (%f); both platforms print 0 now (B9).FlightTakeoffEnricher(live) uses a 100 m turn radius vs 50 m in the IGC times enricher.- 64-bit
Inteverywhere: useLongfor the id and the distance-cache key;Int.maxsentinels (OAT min when no OAT is present stayInt.MAX→ must map to the same “no value” semantics in the UI). - Swift
ClosedRangetraps whenlower > upper(optimise,refinePositions,closestIndex). Kotlin silently iterates nothing. Equal results on valid inputs. - The elevation download has a hard 10 s budget. A partial failure → no elevations for the whole flight, which disables the pressure correction.
14. Porting order
Section titled “14. Porting order”:core:geo+ tests (haversine, bearing incl. the 0 → 360 rule, destination, arcs (Swiftstridesemantics, FMA), polygon, line crossing, half-angle distance). Cross-check against values printed by Swift.- Strict string/number helpers +
Int.timeValueformats +:core:units(HALF_EVEN formatter). :domain:waypointsmodels,Task.applyStyle/applyTask, zone geometry, CUP decoder, XCTrack decoder (port the 124 Waypoints tests).- IGC parser (
Flight.init+Fix.init+ warnings + C-task post-processing) and the decoder/header/fix/ extension/warning tests. - TimeZoneLocator +
improveFlightData+ elevation interface (use the fixture cache in tests). - TimesEnricher → PhasesEnricher → DistanceOptimizer/FlightDistanceEnricher → TaskEnricher → SpeedEnricher, each gated by its golden suite on the 7 elevation-backed fixtures.
- Flight lazy statistics,
Phaseordering, FlightIdentifier. - SportProfile detection tests (para / hang / slow-glider fixtures), MetadataEnricher.
- Competition analyses (RaceProgress / Ranking / Leader / Leeching).
- TrackShape simplifier + codec. Gliders DB. IGC writer (for the recording feature).
- HTTP elevation implementation + disk cache + Android integration (WorkManager not required; it is in-process).
15. Size estimate
Section titled “15. Size estimate”| Module | Kotlin main LOC (est.) | Test LOC (est.) | Effort |
|---|---|---|---|
| geo + units + formatting helpers | 600 | 500 | 3 d |
| waypoints (models, zones, CUP, XCTrack, encoders) | 1,100 | 1,200 | 5 d |
| IGC parser + models | 1,200 | 900 | 5 d |
| enrichers (times, phases, distance, task, speed, improve, sport) | 1,800 | 2,000 (mostly golden assertions) | 8–10 d |
| elevation + tz | 350 | 200 | 2 d |
| competition analyses | 500 | 500 | 2 d |
| trackshape + gliders + IGC writer | 300 | 250 | 1.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) |
