Coverage for app/aircraft/gps_import.py: 35%

620 statements  

« prev     ^ index     » next       coverage.py v7.17.0, created at 2026-11-13 03:30 +0000

1"""GPS log file parsing for aircraft logbook import — Phase 30. 

2 

3Supported formats: 

4- GPX 1.1 (SkyDemon, ForeFlight): speed in m/s, UTC timestamps 

5- Garmin GTN/G1000 CSV: 3-row header, local time + UTC offset, GndSpd in kt 

6- KML with gx:Track (SkyDemon): lon/lat/alt order, speed derived from consecutive points 

7""" 

8 

9from __future__ import annotations 

10 

11import bisect 

12import csv 

13import functools 

14import io 

15import itertools 

16import math 

17import os 

18import re 

19from collections.abc import Callable 

20from dataclasses import dataclass 

21from datetime import UTC, date, datetime, time, timedelta 

22from decimal import Decimal 

23from typing import Any 

24from xml.etree.ElementTree import ParseError as _ETParseError 

25 

26import defusedxml.ElementTree as ET # guards against XML bomb / entity expansion 

27 

28# ── Constants ───────────────────────────────────────────────────────────────── 

29 

30_MS_TO_KT = 1.94384 # m/s → knots 

31_FT_TO_M = 0.3048 # ft → metres 

32_KM_PER_NM = 1.852 # km per nautical mile 

33 

34_FLIGHT_SPEED_KT = 30.0 # sustained above this → airborne 

35_GROUND_MOVE_KT = 5.0 # above this (but not 30kt for 30s) → ground movement 

36_FLIGHT_SUSTAIN_S = 30.0 # seconds above 30kt required to classify as "flight" 

37_SEGMENT_GAP_S = 300.0 # 5 min of slow speed or time gap → segment break 

38_MAX_ICAO_DIST_KM = 5.0 # max distance for nearest-airport match 

39_MAX_TRACK_POINTS = 500 # downsample threshold for GeoJSON storage 

40# How many days from a GPS segment's date a hand-entered logbook date may be 

41# off (a typo) and the flight still be offered as its possible match. 

42NEAR_DATE_DAYS = 2 

43# How far a GPS segment may start before / end after a logbook flight's 

44# block times and still be part of what that flight records. 

45_LOGGED_WINDOW_TOLERANCE = timedelta(minutes=15) 

46 

47# ── Data structures ─────────────────────────────────────────────────────────── 

48 

49 

50@dataclass 

51class TrackPoint: 

52 lat: float 

53 lon: float 

54 alt_m: float 

55 speed_kt: float 

56 utc_dt: datetime # always timezone-aware UTC 

57 

58 

59@dataclass 

60class FlightSegment: 

61 trackpoints: list[TrackPoint] 

62 block_off_utc: datetime 

63 takeoff_utc: datetime | None 

64 landing_utc: datetime | None 

65 block_on_utc: datetime 

66 departure_icao: str | None 

67 arrival_icao: str | None 

68 flight_time_raw_h: float # block_on − block_off in decimal hours 

69 flight_time_rounded_h: float # rounded per aircraft precision setting 

70 track_geojson: dict[str, Any] # GeoJSON Feature 

71 landing_count: int 

72 is_ground_only: bool # True when no airborne portion detected 

73 hint_departure_icao: str | None 

74 hint_arrival_icao: str | None 

75 # Set when the segment joins several flights logged as one (see 

76 # merge_segments_logged_as_one): their summed airborne time, which the 

77 # takeoff-to-landing span would overstate by the time on the ground. 

78 airborne_raw_h: float | None = None 

79 

80 

81@dataclass 

82class LoggedFlightWindow: 

83 """A logbook flight's block span and route (UTC), used to recognise GPS 

84 segments that it records as a single flight.""" 

85 

86 start_utc: datetime 

87 end_utc: datetime 

88 departure_icao: str | None 

89 arrival_icao: str | None 

90 

91 

92@dataclass 

93class ParsedGpsFile: 

94 trackpoints: list[TrackPoint] 

95 format: str # "gpx" | "kml" | "garmin_csv" 

96 source_filename: str 

97 classification: str # "flight" | "ground_movement" | "empty" 

98 hint_departure_icao: str | None 

99 hint_arrival_icao: str | None 

100 device_id: str | None = None # avionics unit identifier (e.g. Garmin system_id) 

101 

102 

103# ── Airport database ────────────────────────────────────────────────────────── 

104 

105 

106_NOT_FOR_AEROPLANES = {"heliport", "closed", "balloonport"} 

107 

108 

109@functools.lru_cache(maxsize=1) 

110def _load_airports() -> dict[str, tuple[float, float]]: 

111 """Load app/data/airports.csv once. Returns {icao: (lat, lon)}. 

112 

113 Only 4-letter ICAO codes are included, and only places an aeroplane 

114 lands: not heliports, closed fields or balloon ports — a heliport's 

115 reference point can be closer to an airport's runway than the airport's 

116 own (Oostende Heliport EBNH, 1.6 km from EBOS), so it would name the 

117 wrong field. Returns an empty dict if the data file is missing (ICAO 

118 lookup will return None for all queries). 

119 """ 

120 data_path = os.path.join(os.path.dirname(__file__), "..", "data", "airports.csv") 

121 airports: dict[str, tuple[float, float]] = {} 

122 

123 if os.path.exists(data_path): 

124 with open(data_path, newline="", encoding="utf-8") as f: 

125 reader = csv.DictReader(f) 

126 for row in reader: 

127 ident = row.get("ident", "").strip() 

128 if not re.match(r"^[A-Z]{4}$", ident): 

129 continue 

130 if row.get("type", "") in _NOT_FOR_AEROPLANES: 

131 continue 

132 try: 

133 lat = float(row["latitude_deg"]) 

134 lon = float(row["longitude_deg"]) 

135 except (ValueError, KeyError): 

136 continue 

137 airports[ident] = (lat, lon) 

138 

139 return airports 

140 

141 

142def _reset_airports_cache() -> None: 

143 """Reset the airport cache (for testing).""" 

144 _load_airports.cache_clear() 

145 

146 

147# ── Haversine ───────────────────────────────────────────────────────────────── 

148 

149 

150def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 

151 """Great-circle distance in kilometres.""" 

152 R = 6371.0 

153 phi1 = math.radians(lat1) 

154 phi2 = math.radians(lat2) 

155 dphi = math.radians(lat2 - lat1) 

156 dlambda = math.radians(lon2 - lon1) 

157 a = ( 

158 math.sin(dphi / 2) ** 2 

159 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2 

160 ) 

161 return R * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) 

162 

163 

164# ── Format detection ────────────────────────────────────────────────────────── 

165 

166 

167# File name extensions a GPS upload may have. ".bin": SkyDemon for Android 

168# exports its GPX and KML as "….gpx.bin" / "….kml.bin" — what's inside 

169# decides (detect_format). 

170GPS_UPLOAD_EXTENSIONS = (".gpx", ".kml", ".csv", ".bin") 

171 

172 

173def _xml_root_name(text: str) -> str: 

174 """The name of an XML document's first element, past its declaration, 

175 comments and doctype ("" when there's none). A plain left-to-right scan 

176 rather than a regular expression: one with nested repeats over those 

177 parts backtracks exponentially on crafted input (CodeQL py/redos).""" 

178 pos, end = 0, len(text) 

179 while True: 

180 while pos < end and text[pos].isspace(): 

181 pos += 1 

182 if text.startswith("<?", pos): 

183 close = text.find("?>", pos + 2) 

184 skip = 2 

185 elif text.startswith("<!--", pos): 

186 close = text.find("-->", pos + 4) 

187 skip = 3 

188 elif text.startswith("<!", pos): 

189 close = text.find(">", pos + 2) 

190 skip = 1 

191 elif text.startswith("<", pos): 

192 start = pos = pos + 1 

193 while pos < end and (text[pos].isalnum() or text[pos] in "_:.-"): 

194 pos += 1 

195 return text[start:pos] 

196 else: 

197 return "" 

198 if close < 0: 

199 return "" 

200 pos = close + skip 

201 

202 

203def is_gps_upload_name(filename: str) -> bool: 

204 """Whether *filename*'s extension is one a GPS upload may have.""" 

205 return os.path.splitext(filename.lower())[1] in GPS_UPLOAD_EXTENSIONS 

206 

207 

208# A Garmin data log row: "2026-05-18, 21:38:31, +02:00, ...". 

209_GARMIN_ROW_RE = re.compile(r"\d{4}-\d{2}-\d{2},\s*\d{2}:\d{2}:\d{2},") 

210 

211 

212def detect_format(data: bytes, filename: str) -> str: 

213 """Return "gpx", "kml", or "garmin_csv". Raise ValueError for unknown format. 

214 

215 By extension for .gpx / .kml / .csv; otherwise (e.g. SkyDemon's 

216 ".gpx.bin") by content — an XML document whose root element is <gpx> 

217 or <kml>.""" 

218 ext = os.path.splitext(filename.lower())[1] 

219 if ext == ".gpx": 

220 return "gpx" 

221 if ext == ".kml": 

222 return "kml" 

223 if ext == ".csv": 

224 lines = data.decode("utf-8-sig", errors="replace").splitlines() 

225 if lines and lines[0].startswith("#airframe_info"): 

226 return "garmin_csv" 

227 if any(_GARMIN_ROW_RE.match(line) for line in lines[:10]): 

228 # Rows of a Garmin data log without its header: a fragment the 

229 # logger writes while powering up or down — seconds to a few 

230 # minutes, usually without a GPS position, never a flight. 

231 raise ValueError( 

232 f"{filename!r} is a fragment of a Garmin data log without its " 

233 "header (written while the logger was powering up or down) — " 

234 "nothing to import." 

235 ) 

236 else: 

237 head = data[:4096].decode("utf-8-sig", errors="replace") 

238 root = _xml_root_name(head).rsplit(":", 1)[-1].lower() 

239 if root in ("gpx", "kml"): 

240 return root 

241 if filename.lower().endswith(".flightlog.bin"): 

242 raise ValueError( 

243 f"{filename!r} is SkyDemon's own log format — export the track " 

244 "as GPX (….gpx.bin) or KML from SkyDemon instead." 

245 ) 

246 raise ValueError(f"Unsupported GPS file format: {filename!r}") 

247 

248 

249# ── File classification ─────────────────────────────────────────────────────── 

250 

251 

252def classify_track(trackpoints: list[TrackPoint]) -> str: 

253 """Return "flight", "ground_movement", or "empty".""" 

254 if not trackpoints: 

255 return "empty" 

256 

257 max_speed = max(tp.speed_kt for tp in trackpoints) 

258 if max_speed <= _GROUND_MOVE_KT: 

259 return "empty" 

260 

261 # Check for sustained window above 30kt 

262 fast_window_s = 0.0 

263 for prev, tp in itertools.pairwise(trackpoints): 

264 if tp.speed_kt > _FLIGHT_SPEED_KT: 

265 dt = (tp.utc_dt - prev.utc_dt).total_seconds() 

266 fast_window_s += max(0.0, dt) 

267 if fast_window_s >= _FLIGHT_SUSTAIN_S: 

268 return "flight" 

269 else: 

270 fast_window_s = 0.0 

271 

272 return "ground_movement" 

273 

274 

275# ── GPX parser ──────────────────────────────────────────────────────────────── 

276 

277 

278def _extract_icao_hints(text: str) -> tuple[str | None, str | None]: 

279 """Extract departure and arrival ICAO codes from a track name string.""" 

280 icao_matches = re.findall(r"\b([A-Z]{4})\b", text) 

281 dep = icao_matches[0] if len(icao_matches) >= 1 else None 

282 arr = icao_matches[-1] if len(icao_matches) >= 2 else None 

283 return dep, arr 

284 

285 

286def _parse_xml_up_to_error(text: str) -> Any: 

287 """The XML document's root element — or, when the document breaks off 

288 (seen on real GPX exports missing their closing </gpx> tag), what was 

289 read of it before the error. Raises the parse error when nothing was.""" 

290 try: 

291 return ET.fromstring(text) 

292 except _ETParseError as exc: 

293 error = exc 

294 # Read again incrementally, keeping the root element as soon as it's 

295 # seen; the same error then ends the reading where the document breaks. 

296 root = None 

297 try: 

298 for _event, element in ET.iterparse( 

299 io.BytesIO(text.encode("utf-8")), events=("start",) 

300 ): 

301 if root is None: 

302 root = element 

303 except _ETParseError: 

304 if root is not None: 

305 return root 

306 raise error from None 

307 

308 

309# A jump between two consecutive points implying more than this is a bad 

310# fix, not flight — no light aircraft flies it, even across a recording gap. 

311_MAX_PLAUSIBLE_KT = 600.0 

312# …and covering at least this distance (a stale fix jumps tens of km). 

313_MIN_JUMP_NM = 0.5 

314# What's left between two such jumps, if shorter than this, is the bad fix. 

315_MIN_FIX_RUN_S = 60.0 

316 

317 

318def _drop_bad_fixes( 

319 points: list[tuple[float, float, float, float | None, datetime]], 

320) -> list[tuple[float, float, float, float | None, datetime]]: 

321 """Points (lat, lon, alt, speed, time) in time order, without the bad 

322 fixes cameras and phones record while acquiring the GPS signal: no fix 

323 at all (0°, 0° — often with an unset clock, e.g. a date in another 

324 month), or a stale last-known position the track then jumps away from 

325 (seen on GoPro exports: 41 km in one second). The track is cut at every 

326 implausible jump; pieces under _MIN_FIX_RUN_S are then dropped.""" 

327 ordered = sorted( 

328 (p for p in points if not (p[0] == 0.0 and p[1] == 0.0)), 

329 key=lambda p: p[4], 

330 ) 

331 runs: list[list[tuple[float, float, float, float | None, datetime]]] = [] 

332 for point in ordered: 

333 if runs: 

334 prev = runs[-1][-1] 

335 dt_s = (point[4] - prev[4]).total_seconds() 

336 dist_nm = _haversine_km(prev[0], prev[1], point[0], point[1]) / _KM_PER_NM 

337 # Both far and fast: a few metres of jitter between two 

338 # timestamps a fraction of a second apart (GoPro exports have 

339 # those) computes as hundreds of knots too. 

340 jump = dist_nm > _MIN_JUMP_NM and ( 

341 dt_s <= 0 or dist_nm * 3600.0 / dt_s > _MAX_PLAUSIBLE_KT 

342 ) 

343 if not jump: 

344 runs[-1].append(point) 

345 continue 

346 runs.append([point]) 

347 if len(runs) <= 1: # no implausible jump: nothing to tell apart 

348 return ordered 

349 return [ 

350 p 

351 for run in runs 

352 if (run[-1][4] - run[0][4]).total_seconds() >= _MIN_FIX_RUN_S 

353 for p in run 

354 ] 

355 

356 

357# Speed from positions is measured over at least this long: GPS positions 

358# jitter by a few metres, so point-to-point at 1 Hz turns a parked aircraft's 

359# noise — or one bad fix — into spurious knots. 

360_DERIVED_SPEED_SPAN_S = 5.0 

361 

362 

363def _speeds_from_positions(points: list[tuple[float, float, datetime]]) -> list[float]: 

364 """Ground speed in knots at each (lat, lon, time) point, from the 

365 distance covered since the latest point at least _DERIVED_SPEED_SPAN_S 

366 earlier (the previous point when none is that old yet).""" 

367 speeds: list[float] = [] 

368 j = 0 

369 for i, (lat, lon, when) in enumerate(points): 

370 if i == 0: 

371 speeds.append(0.0) 

372 continue 

373 while ( 

374 j + 1 < i 

375 and (when - points[j + 1][2]).total_seconds() >= _DERIVED_SPEED_SPAN_S 

376 ): 

377 j += 1 

378 k = ( 

379 j 

380 if (when - points[j][2]).total_seconds() >= _DERIVED_SPEED_SPAN_S 

381 else i - 1 

382 ) 

383 dt_s = (when - points[k][2]).total_seconds() 

384 dist_km = _haversine_km(points[k][0], points[k][1], lat, lon) 

385 speeds.append(dist_km / _KM_PER_NM * 3600.0 / dt_s if dt_s > 0 else 0.0) 

386 return speeds 

387 

388 

389def _parse_gpx(data: bytes, filename: str) -> ParsedGpsFile: 

390 """Parse GPX 1.1 track. Speed field is in m/s; converted to kt — or, 

391 when the file has none, derived from the positions.""" 

392 try: 

393 root = _parse_xml_up_to_error(data.decode("utf-8-sig", errors="replace")) 

394 except _ETParseError as exc: 

395 raise ValueError(f"Invalid GPX XML in {filename!r}: {exc}") from exc 

396 

397 # The namespace the file declares: GPX 1.1, GPX 1.0 (same structure, e.g. 

398 # ExifTool exports) — or none at all. 

399 ns = root.tag[1 : root.tag.index("}")] if root.tag.startswith("{") else "" 

400 

401 def q(name: str) -> str: 

402 return f"{{{ns}}}{name}" if ns else name 

403 

404 hint_dep: str | None = None 

405 hint_arr: str | None = None 

406 name_el = root.find(f".//{q('name')}") 

407 if name_el is not None and name_el.text: 

408 hint_dep, hint_arr = _extract_icao_hints(name_el.text) 

409 

410 points: list[tuple[float, float, float, float | None, datetime]] = [] 

411 for trkpt in root.findall(f".//{q('trkpt')}"): 

412 try: 

413 lat = float(trkpt.get("lat", "")) 

414 lon = float(trkpt.get("lon", "")) 

415 except (ValueError, TypeError): 

416 continue 

417 

418 ele_el = trkpt.find(q("ele")) 

419 alt_m = float(ele_el.text) if ele_el is not None and ele_el.text else 0.0 

420 

421 speed_el = trkpt.find(q("speed")) 

422 speed_kt = ( 

423 float(speed_el.text) * _MS_TO_KT 

424 if speed_el is not None and speed_el.text 

425 else None 

426 ) 

427 

428 time_el = trkpt.find(q("time")) 

429 if time_el is None or not time_el.text: 

430 continue 

431 try: 

432 utc_dt = datetime.fromisoformat(time_el.text) 

433 except ValueError: 

434 continue 

435 

436 points.append((lat, lon, alt_m, speed_kt, utc_dt)) 

437 

438 points = _drop_bad_fixes(points) 

439 # GPX 1.1 has no speed element: many writers (e.g. ExifTool exporting 

440 # a GoPro's GPS) leave it out — derive it from the positions then. 

441 if points and all(p[3] is None for p in points): 

442 speeds = _speeds_from_positions([(p[0], p[1], p[4]) for p in points]) 

443 else: 

444 speeds = [p[3] or 0.0 for p in points] 

445 trackpoints = [ 

446 TrackPoint(lat=lat, lon=lon, alt_m=alt_m, speed_kt=kt, utc_dt=when) 

447 for (lat, lon, alt_m, _speed, when), kt in zip(points, speeds, strict=True) 

448 ] 

449 

450 return ParsedGpsFile( 

451 trackpoints=trackpoints, 

452 format="gpx", 

453 source_filename=filename, 

454 classification=classify_track(trackpoints), 

455 hint_departure_icao=hint_dep, 

456 hint_arrival_icao=hint_arr, 

457 ) 

458 

459 

460# ── Garmin CSV parser ───────────────────────────────────────────────────────── 

461 

462_VALID_GPS_FIX = {"3D", "3DDiff"} 

463 

464 

465def _parse_garmin_csv(data: bytes, filename: str) -> ParsedGpsFile: 

466 """Parse Garmin GTN/G1000 CSV with 3-row header. 

467 

468 Row 0: #airframe_info metadata 

469 Row 1: unit labels 

470 Row 2: column names (Lcl Date, Lcl Time, UTCOfst, Latitude, Longitude, AltMSL, GndSpd, …, GPSfix, …) 

471 Only rows with GPSfix in {"3D", "3DDiff"} are used. 

472 Departure ICAO hint is extracted from filename: log_YYMMDD_HHMMSS_ICAO.csv 

473 """ 

474 text = data.decode("utf-8-sig", errors="replace") 

475 lines = text.splitlines() 

476 

477 if len(lines) < 4: 

478 raise ValueError(f"Garmin CSV too short: {filename!r}") 

479 

480 # Device ID from #airframe_info header line 

481 device_id: str | None = None 

482 _did_match = re.search(r'system_id="([^"]+)"', lines[0]) 

483 if _did_match: 

484 device_id = _did_match.group(1) 

485 

486 # Departure ICAO from filename pattern 

487 hint_dep: str | None = None 

488 base = os.path.splitext(os.path.basename(filename))[0] 

489 parts = base.split("_") 

490 if len(parts) >= 4: 

491 candidate = parts[-1].strip() 

492 if re.match(r"^[A-Z]{4}$", candidate): 

493 hint_dep = candidate 

494 

495 # Skip rows 0–1 (metadata + units), use row 2 as header 

496 csv_text = "\n".join(lines[2:]) 

497 reader = csv.DictReader(io.StringIO(csv_text)) 

498 if reader.fieldnames: 

499 reader.fieldnames = [f.strip() for f in reader.fieldnames] 

500 

501 trackpoints: list[TrackPoint] = [] 

502 for row in reader: 

503 gpsfx = row.get("GPSfix", "").strip() 

504 if gpsfx not in _VALID_GPS_FIX: 

505 continue 

506 

507 try: 

508 lat = float(row["Latitude"].strip()) 

509 lon = float(row["Longitude"].strip()) 

510 except (ValueError, KeyError): 

511 continue 

512 

513 try: 

514 alt_m = float(row["AltMSL"].strip()) * _FT_TO_M 

515 except (ValueError, KeyError): 

516 alt_m = 0.0 

517 

518 try: 

519 speed_kt = float(row["GndSpd"].strip()) 

520 except (ValueError, KeyError): 

521 speed_kt = 0.0 

522 

523 try: 

524 date_str = row["Lcl Date"].strip() 

525 time_str = row["Lcl Time"].strip() 

526 utc_off = row["UTCOfst"].strip() 

527 local_dt = datetime.fromisoformat(f"{date_str}T{time_str}{utc_off}") 

528 utc_dt = local_dt.astimezone(UTC) 

529 except (ValueError, KeyError): 

530 continue 

531 

532 trackpoints.append( 

533 TrackPoint(lat=lat, lon=lon, alt_m=alt_m, speed_kt=speed_kt, utc_dt=utc_dt) 

534 ) 

535 

536 return ParsedGpsFile( 

537 trackpoints=trackpoints, 

538 format="garmin_csv", 

539 source_filename=filename, 

540 classification=classify_track(trackpoints), 

541 hint_departure_icao=hint_dep, 

542 hint_arrival_icao=None, 

543 device_id=device_id, 

544 ) 

545 

546 

547# ── KML parser ──────────────────────────────────────────────────────────────── 

548 

549_KML_NS = "http://www.opengis.net/kml/2.2" 

550_GX_NS = "http://www.google.com/kml/ext/2.2" 

551 

552 

553def _parse_kml(data: bytes, filename: str) -> ParsedGpsFile: 

554 """Parse SkyDemon KML with gx:Track. 

555 

556 Coordinate order is lon/lat/alt (note: reversed from GPX). 

557 Speed is derived from consecutive point distance / time delta. 

558 """ 

559 try: 

560 root = ET.fromstring(data.decode("utf-8-sig", errors="replace")) 

561 except _ETParseError as exc: 

562 raise ValueError(f"Invalid KML XML in {filename!r}: {exc}") from exc 

563 

564 hint_dep: str | None = None 

565 hint_arr: str | None = None 

566 for pm in root.findall(f".//{{{_KML_NS}}}Placemark"): 

567 name_el = pm.find(f"{{{_KML_NS}}}name") 

568 if name_el is not None and name_el.text: 

569 dep, arr = _extract_icao_hints(name_el.text) 

570 if dep and arr: 

571 hint_dep, hint_arr = dep, arr 

572 break 

573 

574 track_el = root.find(f".//{{{_GX_NS}}}Track") 

575 if track_el is None: 

576 raise ValueError(f"No gx:Track element in KML: {filename!r}") 

577 

578 whens: list[datetime | None] = [] 

579 coords: list[tuple[float, float, float]] = [] 

580 

581 for child in track_el: 

582 if child.tag == f"{{{_KML_NS}}}when": 

583 if child.text: 

584 try: 

585 dt = datetime.fromisoformat(child.text) 

586 whens.append(dt.astimezone(UTC)) 

587 except ValueError: 

588 whens.append(None) 

589 else: 

590 whens.append(None) 

591 elif child.tag == f"{{{_GX_NS}}}coord": 

592 if child.text: 

593 parts = child.text.strip().split() 

594 if len(parts) >= 3: 

595 try: 

596 lon_c = float(parts[0]) 

597 lat_c = float(parts[1]) 

598 alt_c = float(parts[2]) 

599 except ValueError: 

600 lon_c, lat_c, alt_c = 0.0, 0.0, 0.0 

601 coords.append((lon_c, lat_c, alt_c)) 

602 continue 

603 coords.append((0.0, 0.0, 0.0)) 

604 

605 if len(whens) != len(coords): 

606 raise ValueError( 

607 f"KML when/coord count mismatch in {filename!r}: " 

608 f"{len(whens)} vs {len(coords)}" 

609 ) 

610 

611 # KML carries no speed: derived from the positions, like a GPX file 

612 # without one — with the same bad-fix filtering and smoothing. 

613 points = _drop_bad_fixes( 

614 [ 

615 (lat, lon, alt_m, None, when) 

616 for when, (lon, lat, alt_m) in zip(whens, coords, strict=True) 

617 if when is not None 

618 ] 

619 ) 

620 speeds = _speeds_from_positions([(p[0], p[1], p[4]) for p in points]) 

621 trackpoints = [ 

622 TrackPoint(lat=lat, lon=lon, alt_m=alt_m, speed_kt=kt, utc_dt=when) 

623 for (lat, lon, alt_m, _speed, when), kt in zip(points, speeds, strict=True) 

624 ] 

625 

626 return ParsedGpsFile( 

627 trackpoints=trackpoints, 

628 format="kml", 

629 source_filename=filename, 

630 classification=classify_track(trackpoints), 

631 hint_departure_icao=hint_dep, 

632 hint_arrival_icao=hint_arr, 

633 ) 

634 

635 

636# ── Entry point ─────────────────────────────────────────────────────────────── 

637 

638 

639def parse_gps_file(data: bytes, filename: str) -> ParsedGpsFile: 

640 """Detect format and parse. Raises ValueError on unsupported or invalid data.""" 

641 fmt = detect_format(data, filename) 

642 if fmt == "gpx": 

643 return _parse_gpx(data, filename) 

644 if fmt == "kml": 

645 return _parse_kml(data, filename) 

646 return _parse_garmin_csv(data, filename) 

647 

648 

649# ── Track merge ─────────────────────────────────────────────────────────────── 

650 

651 

652def merge_and_sort(files: list[ParsedGpsFile]) -> list[TrackPoint]: 

653 """Merge non-empty trackpoints from all files, sorted chronologically.""" 

654 all_pts: list[TrackPoint] = [] 

655 for f in files: 

656 if f.classification != "empty": 

657 all_pts.extend(f.trackpoints) 

658 all_pts.sort(key=lambda tp: tp.utc_dt) 

659 return all_pts 

660 

661 

662# ── Segment detection ───────────────────────────────────────────────────────── 

663 

664 

665def _split_into_raw_groups(trackpoints: list[TrackPoint]) -> list[list[TrackPoint]]: 

666 """Split merged trackpoints into groups at slow/time gaps ≥ 5 min. 

667 

668 Only looks for breaks between the first and last fast (≥ 30kt) points, so 

669 pre-flight taxi and post-landing taxi are preserved in the enclosing 

670 segment — and at a break between two flights, each keeps its own taxi 

671 (see _break_points). 

672 """ 

673 n = len(trackpoints) 

674 if n == 0: 

675 return [] 

676 

677 fast_indices = [ 

678 i for i, tp in enumerate(trackpoints) if tp.speed_kt >= _FLIGHT_SPEED_KT 

679 ] 

680 if not fast_indices: 

681 # Ground runs only: still one group per recording session. 

682 sessions: list[list[TrackPoint]] = [] 

683 start = 0 

684 for k in range(n - 1): 

685 if _recording_gap(trackpoints, k): 

686 sessions.append(trackpoints[start : k + 1]) 

687 start = k + 1 

688 sessions.append(trackpoints[start:]) 

689 return sessions 

690 

691 first_fast = fast_indices[0] 

692 last_fast = fast_indices[-1] 

693 

694 groups: list[list[TrackPoint]] = [] 

695 current_start = 0 

696 # Before the first flight and after the last one, a recording gap still 

697 # separates sessions — a ground-only file from days earlier or later 

698 # (e.g. engine run at the parking spot) isn't that flight's taxi. 

699 for k in range(first_fast): 

700 if _recording_gap(trackpoints, k): 

701 groups.append(trackpoints[current_start : k + 1]) 

702 current_start = k + 1 

703 i = first_fast 

704 

705 while i < last_fast: 

706 # Large time gap between consecutive points (gap between uploaded files) 

707 time_gap = (trackpoints[i + 1].utc_dt - trackpoints[i].utc_dt).total_seconds() 

708 if time_gap >= _SEGMENT_GAP_S: 

709 groups.append(trackpoints[current_start : i + 1]) 

710 current_start = i + 1 

711 i += 1 

712 continue 

713 

714 # Slow run starting at i+1 

715 if trackpoints[i + 1].speed_kt < _FLIGHT_SPEED_KT: 

716 j = i + 2 

717 while j <= last_fast and trackpoints[j].speed_kt < _FLIGHT_SPEED_KT: 

718 j += 1 

719 # Elapsed from the last fast point to the next one, so a 

720 # recording gap at either end of the slow run counts too. 

721 slow_dur = (trackpoints[j].utc_dt - trackpoints[i].utc_dt).total_seconds() 

722 if slow_dur >= _SEGMENT_GAP_S: 

723 # Real segment break between two flights — the taxi-in 

724 # stays with the first, the taxi-out with the second. 

725 end_prev, start_next = _break_points(trackpoints, i, j) 

726 groups.append(trackpoints[current_start : end_prev + 1]) 

727 # Recording sessions in between, with no flight in them (a 

728 # taxi to the fuel station, an engine run on another day): 

729 # each its own ground-only group. 

730 if _recording_gap(trackpoints, end_prev): 

731 session_start = end_prev + 1 

732 for k in range(end_prev + 1, start_next): 

733 if _recording_gap(trackpoints, k): 

734 groups.append(trackpoints[session_start : k + 1]) 

735 session_start = k + 1 

736 current_start = start_next 

737 i = j 

738 else: 

739 i = j # short slow run — keep in current segment 

740 else: 

741 i += 1 

742 

743 for k in range(last_fast, n - 1): 

744 if _recording_gap(trackpoints, k): 

745 groups.append(trackpoints[current_start : k + 1]) 

746 current_start = k + 1 

747 groups.append(trackpoints[current_start:]) 

748 return [g for g in groups if g] 

749 

750 

751def _recording_gap(trackpoints: list[TrackPoint], k: int) -> bool: 

752 """Whether the recording stopped for _SEGMENT_GAP_S or more after point k.""" 

753 gap = (trackpoints[k + 1].utc_dt - trackpoints[k].utc_dt).total_seconds() 

754 return gap >= _SEGMENT_GAP_S 

755 

756 

757def _break_points( 

758 trackpoints: list[TrackPoint], last_fast: int, next_fast: int 

759) -> tuple[int, int]: 

760 """Where to split the slow stretch between two flights — the points 

761 strictly between *last_fast* (the first flight's landing roll) and 

762 *next_fast* (the second's takeoff roll). Returns (end of the first 

763 segment, start of the second), both indices into *trackpoints*. 

764 

765 Splitting must keep each flight's taxi with it: the taxi-in carries the 

766 first flight's landing (fast-to-slow transition, see _count_landings) 

767 and its block-on, the taxi-out the second flight's block-off. In order: 

768 - a recording gap (the GPS was off, e.g. one data-log file per 

769 flight): the first flight ends at the first gap, the second starts 

770 after the last one; 

771 - otherwise the longest stop (below _GROUND_MOVE_KT — the aircraft 

772 parked): the first flight ends where it came to rest, the second 

773 starts where it moved off; 

774 - otherwise (slow movement throughout, nothing to go by) the whole 

775 slow stretch is left out of both. 

776 """ 

777 gaps = [ 

778 k 

779 for k in range(last_fast, next_fast) 

780 if (trackpoints[k + 1].utc_dt - trackpoints[k].utc_dt).total_seconds() 

781 >= _SEGMENT_GAP_S 

782 ] 

783 if gaps: 

784 # Anything between the first and last gap is recording sessions 

785 # with no flight in them (e.g. an engine ground run on another day) 

786 # — _split_into_raw_groups keeps each as a ground-only group. 

787 return gaps[0], gaps[-1] + 1 

788 

789 best: tuple[float, int, int] | None = None 

790 k = last_fast + 1 

791 while k < next_fast: 

792 if trackpoints[k].speed_kt >= _GROUND_MOVE_KT: 

793 k += 1 

794 continue 

795 stop_start = k 

796 while k + 1 < next_fast and trackpoints[k + 1].speed_kt < _GROUND_MOVE_KT: 

797 k += 1 

798 stop_s = ( 

799 trackpoints[k].utc_dt - trackpoints[stop_start].utc_dt 

800 ).total_seconds() 

801 if best is None or stop_s > best[0]: 

802 best = (stop_s, stop_start, k) 

803 k += 1 

804 if best is not None: 

805 return best[1], best[2] 

806 return last_fast, next_fast 

807 

808 

809# A segment whose flying starts (ends) this close to its first (last) 

810# point, and that high above its lowest part (≈ the airfield), was recorded 

811# from (until) the air — not from a takeoff roll (until a landing roll), 

812# which is fast too but on the ground (SkyDemon may start logging mid-roll). 

813_IN_FLIGHT_EDGE_S = 10.0 

814_IN_FLIGHT_HEIGHT_M = 150.0 

815_EDGE_POINTS = 10 

816 

817 

818def _median(values: list[float]) -> float: 

819 ordered = sorted(values) 

820 return ordered[len(ordered) // 2] 

821 

822 

823def _high_above_ground(pts: list[TrackPoint], edge: list[TrackPoint]) -> bool: 

824 """Whether the *edge* points (a segment's first or last few) are well 

825 above the segment's low part (its 10th percentile altitude — robust to 

826 the noisy altitudes of cameras and phones).""" 

827 altitudes = sorted(p.alt_m for p in pts) 

828 low = altitudes[len(altitudes) // 10] 

829 return _median([p.alt_m for p in edge]) - low > _IN_FLIGHT_HEIGHT_M 

830 

831 

832# Above _FLIGHT_SPEED_KT for at least this long is flying, below it for at 

833# least this long after flying is a landing: a noisy GPS (a camera's, a 

834# phone's) briefly crosses the threshold while taxiing or in flight. 

835_SUSTAIN_S = 30.0 

836 

837 

838def _airborne_periods(pts: list[TrackPoint]) -> list[tuple[int, int]]: 

839 """(first, last) point indices of each stretch flown above 

840 _FLIGHT_SPEED_KT for at least _SUSTAIN_S, merging stretches separated 

841 by less than _SUSTAIN_S below it (noise, not a landing).""" 

842 runs: list[tuple[int, int]] = [] 

843 i = 0 

844 while i < len(pts): 

845 if pts[i].speed_kt < _FLIGHT_SPEED_KT: 

846 i += 1 

847 continue 

848 j = i 

849 while j + 1 < len(pts) and pts[j + 1].speed_kt >= _FLIGHT_SPEED_KT: 

850 j += 1 

851 if (pts[j].utc_dt - pts[i].utc_dt).total_seconds() >= _SUSTAIN_S: 

852 runs.append((i, j)) 

853 i = j + 1 

854 periods: list[tuple[int, int]] = [] 

855 for first, last in runs: 

856 if ( 

857 periods 

858 and (pts[first].utc_dt - pts[periods[-1][1]].utc_dt).total_seconds() 

859 < _SUSTAIN_S 

860 ): 

861 periods[-1] = (periods[-1][0], last) 

862 else: 

863 periods.append((first, last)) 

864 return periods 

865 

866 

867def _count_landings(pts: list[TrackPoint]) -> int: 

868 """Full-stop landings: airborne periods (see _airborne_periods) the 

869 recording carries on after — i.e. followed by slow points.""" 

870 return sum(1 for _first, last in _airborne_periods(pts) if last + 1 < len(pts)) 

871 

872 

873def detect_segments( 

874 trackpoints: list[TrackPoint], 

875 aircraft_precision: str = "tenth_hour", 

876 hint_dep: str | None = None, 

877 hint_arr: str | None = None, 

878) -> list[FlightSegment]: 

879 """Build FlightSegment objects from merged trackpoints. 

880 

881 hint_dep / hint_arr are optional ICAO codes from GPX/KML track names or 

882 Garmin filename patterns, used as fallback when GPS-proximity lookup fails. 

883 """ 

884 raw_groups = _split_into_raw_groups(trackpoints) 

885 airports = _load_airports() 

886 segments: list[FlightSegment] = [] 

887 

888 for idx, pts in enumerate(raw_groups): 

889 block_off = pts[0].utc_dt 

890 block_on = pts[-1].utc_dt 

891 

892 periods = _airborne_periods(pts) 

893 takeoff_utc: datetime | None = pts[periods[0][0]].utc_dt if periods else None 

894 landing_utc: datetime | None = pts[periods[-1][1]].utc_dt if periods else None 

895 

896 is_ground_only = takeoff_utc is None 

897 landing_count = _count_landings(pts) 

898 

899 raw_h = (block_on - block_off).total_seconds() / 3600.0 

900 rounded_h = round_flight_time(raw_h, aircraft_precision) 

901 

902 # A recording that starts (or ends) already flying — the camera or 

903 # logger switched on in flight — has no departure (arrival) airfield 

904 # to tell: whatever it's flying over isn't one. 

905 started_flying = bool( 

906 periods 

907 and (pts[periods[0][0]].utc_dt - pts[0].utc_dt).total_seconds() 

908 < _IN_FLIGHT_EDGE_S 

909 and _high_above_ground(pts, pts[:_EDGE_POINTS]) 

910 ) 

911 ended_flying = bool( 

912 periods 

913 and (pts[-1].utc_dt - pts[periods[-1][1]].utc_dt).total_seconds() 

914 < _IN_FLIGHT_EDGE_S 

915 and _high_above_ground(pts, pts[-_EDGE_POINTS:]) 

916 ) 

917 # (The file's own hint — a Garmin file name, a GPX track name — 

918 # still counts: it's the recorder's own record of the field.) 

919 dep_icao = ( 

920 None if started_flying else resolve_icao(pts[0].lat, pts[0].lon, airports) 

921 ) or (hint_dep if idx == 0 else None) 

922 arr_icao = ( 

923 None if ended_flying else resolve_icao(pts[-1].lat, pts[-1].lon, airports) 

924 ) or (hint_arr if idx == len(raw_groups) - 1 else None) 

925 

926 downsampled = downsample_track(pts) 

927 geojson = build_geojson(downsampled) 

928 

929 segments.append( 

930 FlightSegment( 

931 trackpoints=pts, 

932 block_off_utc=block_off, 

933 takeoff_utc=takeoff_utc, 

934 landing_utc=landing_utc, 

935 block_on_utc=block_on, 

936 departure_icao=dep_icao, 

937 arrival_icao=arr_icao, 

938 flight_time_raw_h=raw_h, 

939 flight_time_rounded_h=rounded_h, 

940 track_geojson=geojson, 

941 landing_count=landing_count, 

942 is_ground_only=is_ground_only, 

943 hint_departure_icao=hint_dep if idx == 0 else None, 

944 hint_arrival_icao=hint_arr if idx == len(raw_groups) - 1 else None, 

945 ) 

946 ) 

947 

948 return segments 

949 

950 

951def logged_flight_windows(flights: list[Any]) -> list[LoggedFlightWindow]: 

952 """LoggedFlightWindow for each Flight-like object whose block times 

953 (departure/arrival, else takeoff/landing — all UTC times of day) are 

954 both known; flights without them can't vouch for anything.""" 

955 windows: list[LoggedFlightWindow] = [] 

956 for f in flights: 

957 start_t = f.departure_time or f.takeoff_time 

958 end_t = f.arrival_time or f.landing_time 

959 if f.date is None or start_t is None or end_t is None: 

960 continue 

961 start = datetime.combine(f.date, start_t, tzinfo=UTC) 

962 end = datetime.combine(f.date, end_t, tzinfo=UTC) 

963 if end < start: # past midnight UTC 

964 end += timedelta(days=1) 

965 windows.append(LoggedFlightWindow(start, end, f.departure_icao, f.arrival_icao)) 

966 return windows 

967 

968 

969def _same_place(a: str | None, b: str | None) -> bool: 

970 """Equal, or unknown on either side (nothing to contradict).""" 

971 return not a or not b or a == b 

972 

973 

974def _recorded_throughout(times: list[datetime], start: datetime, end: datetime) -> bool: 

975 """True when the recording has no gap of _SEGMENT_GAP_S or more between 

976 *start* and *end* — the avionics stayed on (one data-log file), so the 

977 engine most likely kept running too.""" 

978 lo = bisect.bisect_left(times, start) 

979 hi = bisect.bisect_right(times, end) 

980 return all( 

981 (times[k + 1] - times[k]).total_seconds() < _SEGMENT_GAP_S 

982 for k in range(lo, hi - 1) 

983 ) 

984 

985 

986def _logged_run_end( 

987 segments: list[FlightSegment], 

988 times: list[datetime], 

989 windows: list[LoggedFlightWindow], 

990 i: int, 

991) -> int: 

992 """Index of the last segment of the longest run starting at *i* that a 

993 single logbook flight records as one, or *i* when there is none.""" 

994 best = i 

995 for w in windows: 

996 lo = w.start_utc - _LOGGED_WINDOW_TOLERANCE 

997 hi = w.end_utc + _LOGGED_WINDOW_TOLERANCE 

998 first = segments[i] 

999 if ( 

1000 first.is_ground_only 

1001 or first.block_off_utc < lo 

1002 or not _same_place(first.departure_icao, w.departure_icao) 

1003 ): 

1004 continue 

1005 k = i 

1006 while k + 1 < len(segments): 

1007 prev, nxt = segments[k], segments[k + 1] 

1008 if ( 

1009 nxt.is_ground_only 

1010 or nxt.block_on_utc > hi 

1011 or not _same_place(prev.arrival_icao, nxt.departure_icao) 

1012 or not _recorded_throughout(times, prev.block_on_utc, nxt.block_off_utc) 

1013 ): 

1014 break 

1015 k += 1 

1016 if k > best and _same_place(nxt.arrival_icao, w.arrival_icao): 

1017 best = k 

1018 return best 

1019 

1020 

1021def _merge_run( 

1022 run: list[FlightSegment], trackpoints: list[TrackPoint], precision: str 

1023) -> FlightSegment: 

1024 """One FlightSegment spanning *run*, including the ground stops between 

1025 its legs.""" 

1026 times = [tp.utc_dt for tp in trackpoints] 

1027 lo = bisect.bisect_left(times, run[0].trackpoints[0].utc_dt) 

1028 hi = bisect.bisect_right(times, run[-1].trackpoints[-1].utc_dt) 

1029 pts = trackpoints[lo:hi] 

1030 block_off = run[0].block_off_utc 

1031 block_on = run[-1].block_on_utc 

1032 raw_h = (block_on - block_off).total_seconds() / 3600.0 

1033 airborne_h = sum( 

1034 (s.landing_utc - s.takeoff_utc).total_seconds() / 3600.0 

1035 for s in run 

1036 if s.takeoff_utc and s.landing_utc 

1037 ) 

1038 return FlightSegment( 

1039 trackpoints=pts, 

1040 block_off_utc=block_off, 

1041 takeoff_utc=run[0].takeoff_utc, 

1042 landing_utc=run[-1].landing_utc, 

1043 block_on_utc=block_on, 

1044 departure_icao=run[0].departure_icao, 

1045 arrival_icao=run[-1].arrival_icao, 

1046 flight_time_raw_h=raw_h, 

1047 flight_time_rounded_h=round_flight_time(raw_h, precision), 

1048 track_geojson=build_geojson(downsample_track(pts)), 

1049 landing_count=sum(s.landing_count for s in run), 

1050 is_ground_only=False, 

1051 hint_departure_icao=run[0].hint_departure_icao, 

1052 hint_arrival_icao=run[-1].hint_arrival_icao, 

1053 airborne_raw_h=airborne_h, 

1054 ) 

1055 

1056 

1057def merge_segments_logged_as_one( 

1058 segments: list[FlightSegment], 

1059 trackpoints: list[TrackPoint], 

1060 windows: list[LoggedFlightWindow], 

1061 precision: str = "tenth_hour", 

1062) -> list[FlightSegment]: 

1063 """Join consecutive segments that one logbook flight records as a single 

1064 flight — e.g. a landing to check something with the engine running, 

1065 then on to the destination, logged as one flight with two landings. 

1066 

1067 Segment detection alone splits at any 5-minute stop; only the logbook 

1068 can say the pilot counted it as one flight. Segments are joined when 

1069 they all fall within that flight's block times, chain from its 

1070 departure to its arrival, and the recording never stopped in between 

1071 (a gap means the avionics, and most likely the engine, were switched 

1072 off — a separate flight whatever the logbook says). 

1073 """ 

1074 if len(segments) < 2 or not windows: 

1075 return segments 

1076 times = [tp.utc_dt for tp in trackpoints] 

1077 out: list[FlightSegment] = [] 

1078 i = 0 

1079 while i < len(segments): 

1080 k = _logged_run_end(segments, times, windows, i) 

1081 out.append( 

1082 segments[i] 

1083 if k == i 

1084 else _merge_run(segments[i : k + 1], trackpoints, precision) 

1085 ) 

1086 i = k + 1 

1087 return out 

1088 

1089 

1090# ── ICAO resolution ─────────────────────────────────────────────────────────── 

1091 

1092 

1093def resolve_icao( 

1094 lat: float, 

1095 lon: float, 

1096 airports: dict[str, tuple[float, float]] | None = None, 

1097) -> str | None: 

1098 """Return the nearest ICAO code within 5 km, or None if none is close enough.""" 

1099 if airports is None: 

1100 airports = _load_airports() 

1101 

1102 best_code: str | None = None 

1103 best_dist = _MAX_ICAO_DIST_KM 

1104 

1105 for code, (ap_lat, ap_lon) in airports.items(): 

1106 d = _haversine_km(lat, lon, ap_lat, ap_lon) 

1107 if d < best_dist: 

1108 best_dist = d 

1109 best_code = code 

1110 

1111 return best_code 

1112 

1113 

1114# ── Time rounding ───────────────────────────────────────────────────────────── 

1115 

1116 

1117def round_flight_time(raw_hours: float, precision: str) -> float: 

1118 """Round raw_hours up to the nearest precision boundary. 

1119 

1120 precision="tenth_hour": round up to nearest 0.1 h (6-min boundary). 

1121 precision="minute": round up to nearest 1/60 h (1-min boundary). 

1122 """ 

1123 if raw_hours <= 0: 

1124 return 0.0 

1125 if precision == "minute": 

1126 minutes = math.ceil(raw_hours * 60) 

1127 return round(minutes / 60, 4) 

1128 # tenth_hour: ceiling to nearest 0.1 

1129 return round(math.ceil(raw_hours * 10) / 10, 1) 

1130 

1131 

1132# ── GeoJSON / downsampling ──────────────────────────────────────────────────── 

1133 

1134 

1135@dataclass 

1136class SegmentTimes: 

1137 """A confirmed GPS segment's two duration tracks (see models.Flight's 

1138 docstring): block-off to block-on is the engine side (block time, 

1139 feeds the pilot logbook), the detected takeoff-to-landing span is the 

1140 airborne side the airframe log's flight_time tracks.""" 

1141 

1142 block_time: Decimal 

1143 airborne_time: Decimal | None 

1144 takeoff_time: time | None 

1145 landing_time: time | None 

1146 

1147 

1148def segment_times(seg: dict[str, Any], precision: str) -> SegmentTimes: 

1149 """SegmentTimes for a serialised segment dict (as kept between the 

1150 review and confirm steps), rounded to the aircraft's *precision*.""" 

1151 takeoff = ( 

1152 datetime.fromisoformat(seg["takeoff_utc"]) if seg.get("takeoff_utc") else None 

1153 ) 

1154 landing = ( 

1155 datetime.fromisoformat(seg["landing_utc"]) if seg.get("landing_utc") else None 

1156 ) 

1157 airborne: Decimal | None = None 

1158 if seg.get("airborne_raw_h") is not None: 

1159 airborne = Decimal(str(round_flight_time(seg["airborne_raw_h"], precision))) 

1160 elif takeoff is not None and landing is not None: 

1161 hours = (landing - takeoff).total_seconds() / 3600.0 

1162 airborne = Decimal(str(round_flight_time(hours, precision))) 

1163 return SegmentTimes( 

1164 block_time=Decimal( 

1165 str(round_flight_time(seg.get("flight_time_raw_h", 0), precision)) 

1166 ), 

1167 airborne_time=airborne, 

1168 takeoff_time=takeoff.time().replace(tzinfo=None) if takeoff else None, 

1169 landing_time=landing.time().replace(tzinfo=None) if landing else None, 

1170 ) 

1171 

1172 

1173def downsample_track( 

1174 trackpoints: list[TrackPoint], max_points: int = _MAX_TRACK_POINTS 

1175) -> list[TrackPoint]: 

1176 """Return ≤ max_points trackpoints using uniform stride; first and last preserved.""" 

1177 n = len(trackpoints) 

1178 if n <= max_points: 

1179 return trackpoints 

1180 

1181 stride = n / max_points 

1182 indices: set[int] = {round(i * stride) for i in range(max_points)} 

1183 indices.add(0) 

1184 indices.add(n - 1) 

1185 return [trackpoints[i] for i in sorted(indices) if i < n] 

1186 

1187 

1188def build_geojson(trackpoints: list[TrackPoint]) -> dict[str, Any]: 

1189 """Return a GeoJSON Feature with a LineString geometry. 

1190 

1191 Coordinates: [lon, lat, alt_m] per GeoJSON spec (RFC 7946). 

1192 Properties carry parallel arrays of altitudes_m and speeds_kt for 

1193 colour-gradient rendering in Leaflet. 

1194 """ 

1195 coords = [ 

1196 [round(tp.lon, 6), round(tp.lat, 6), round(tp.alt_m, 1)] for tp in trackpoints 

1197 ] 

1198 return { 

1199 "type": "Feature", 

1200 "geometry": {"type": "LineString", "coordinates": coords}, 

1201 "properties": { 

1202 "altitudes_m": [round(tp.alt_m, 1) for tp in trackpoints], 

1203 "speeds_kt": [round(tp.speed_kt, 1) for tp in trackpoints], 

1204 }, 

1205 } 

1206 

1207 

1208# ── Near-match scoring against existing Flight rows without GPS block data ─── 

1209 

1210 

1211def score_gps_candidates( 

1212 fields: dict[str, Any], 

1213 candidates: list[Any], 

1214 scorer: Callable[[dict[str, Any], Any], float], 

1215 min_score: int, 

1216) -> list[Any]: 

1217 """Rank *candidates* (Flight rows) against a parsed GPS segment's 

1218 *fields* dict using *scorer*, best match first. 

1219 

1220 A GPS segment's block_off_utc/block_on_utc only ever exists on rows 

1221 that themselves came from a GPS import — a flight logged manually or 

1222 via CSV import (airframe or pilot logbook) has neither, so the exact 

1223 block-time overlap check the review routes try first can never find 

1224 it. This is the fallback: reuse the exact same near-match scorers the 

1225 CSV-import review flows already use where possible 

1226 (_score_gps_candidate below, built on 

1227 flights.airframe_import._score_airframe_non_time_signals, for 

1228 aircraft-linked rows; pilots.logbook_import._score_candidate directly 

1229 for standalone ones) so a previously-imported flight is recognised 

1230 instead of silently duplicated — rather than inventing a second scoring 

1231 implementation that could drift from the first. 

1232 """ 

1233 scored = [ 

1234 (score, c) for c in candidates if (score := scorer(fields, c)) >= min_score 

1235 ] 

1236 scored.sort(key=lambda t: -t[0]) 

1237 return [c for _score, c in scored] 

1238 

1239 

1240def near_date_range(gps_date: date) -> tuple[date, date]: 

1241 """First and last logbook date a near-date match may carry.""" 

1242 span = timedelta(days=NEAR_DATE_DAYS) 

1243 return gps_date - span, gps_date + span 

1244 

1245 

1246def rank_near_date_candidates( 

1247 fields: dict[str, Any], 

1248 gps_date: date, 

1249 candidates: list[Any], 

1250 scorer: Callable[[dict[str, Any], Any], float], 

1251 min_score: int, 

1252) -> list[Any]: 

1253 """Flights logged on another date close to a GPS segment's — a typo in 

1254 a hand-entered logbook date — ranked closest date first, then by score. 

1255 

1256 The GPS date is the reliable one, so this is only a fallback for a 

1257 segment nothing matched on its own date, and stricter than a same-day 

1258 match: the route must be known and identical at both ends, and the 

1259 date at most NEAR_DATE_DAYS days off. A flight years away never 

1260 qualifies, whatever else it shares. 

1261 """ 

1262 dep = fields.get("departure_icao") 

1263 arr = fields.get("arrival_icao") 

1264 if not dep or not arr: 

1265 return [] 

1266 pool = [ 

1267 c 

1268 for c in candidates 

1269 if c.date != gps_date 

1270 and abs((c.date - gps_date).days) <= NEAR_DATE_DAYS 

1271 and c.departure_icao == dep 

1272 and c.arrival_icao == arr 

1273 ] 

1274 ranked = score_gps_candidates(fields, pool, scorer, min_score) 

1275 return sorted(ranked, key=lambda c: abs((c.date - gps_date).days)) 

1276 

1277 

1278def _score_gps_candidate( 

1279 fields: dict[str, Any], existing: Any, offset_hours: float 

1280) -> float: 

1281 """Score how likely *existing* (a Flight row) is the same real-world 

1282 flight as *fields* (a parsed GPS segment) — the 5 non-time signals from 

1283 flights.airframe_import._score_airframe_non_time_signals, plus a 

1284 GPS-specific comparison for the other 2. 

1285 

1286 A GPS track's block_off/block_on record a single observed instant per 

1287 end, and — unlike a same-source CSV re-import — there's no guarantee it 

1288 lines up tightly with either departure_time (engine start) or 

1289 takeoff_time (wheels-up): recording can start before the engine does 

1290 (setting up a flight plan on a tablet) and keep running a little after 

1291 landing. So instead of preferring one time field over the other, the 

1292 full-credit window spans whichever of departure_time/takeoff_time (and 

1293 landing_time/arrival_time) the existing row has, widened further on 

1294 each side — see services.time_band_matching.widened_span_score. 

1295 """ 

1296 from flights.airframe_import import ( # pyright: ignore[reportMissingImports] 

1297 _score_airframe_non_time_signals, 

1298 ) 

1299 from services.time_band_matching import ( # pyright: ignore[reportMissingImports] 

1300 offset_ring_step_minutes, 

1301 widened_span_score, 

1302 ) 

1303 

1304 score = _score_airframe_non_time_signals(fields, existing) 

1305 widen_minutes = offset_ring_step_minutes(offset_hours) 

1306 

1307 takeoff_edges = tuple( 

1308 t for t in (existing.departure_time, existing.takeoff_time) if t is not None 

1309 ) 

1310 score += widened_span_score( 

1311 fields.get("takeoff_time"), takeoff_edges, widen_minutes 

1312 ) 

1313 

1314 landing_edges = tuple( 

1315 t for t in (existing.landing_time, existing.arrival_time) if t is not None 

1316 ) 

1317 score += widened_span_score( 

1318 fields.get("landing_time"), landing_edges, widen_minutes 

1319 ) 

1320 

1321 return score