[AZ-836] TlogRouteExtractor: tlog -> RouteSpec for Epic AZ-835 C1

First building block of Epic AZ-835. Pure function that consumes
an ArduPilot binary tlog and returns a RouteSpec (waypoints +
per-waypoint coverage radius + provenance) suitable for posting
to satellite-provider's POST /api/satellite/route endpoint.

Pipeline:
- Load GPS fixes via existing load_tlog_ground_truth (AZ-697).
- Trim leading + trailing rows below takeoff thresholds
  (speed >= 2 m/s AND AGL >= 5 m by default; configurable).
- Coarsen to <= max_waypoints via iterative Douglas-Peucker on
  the local-ENU projection (WgsConverter.latlonalt_to_local_enu,
  AZ-279). DP tolerance is caller-supplied or binary-searched
  (<= 32 iterations, <= 1 m convergence).

Public surface (re-exported from replay_input/__init__.py):
- RouteSpec (frozen, slots, with provenance fields).
- RouteExtractionError (subclass of ReplayInputAdapterError).
- extract_route_from_tlog().

Tests: 14 unit tests cover AC-1..AC-10 plus edge cases (custom
DP tolerance, invalid inputs, error hierarchy, too-short segment).
AC-1 exercises the real Derkachi tlog; the test's lat/lon bounds
are widened to match actual GPS extent (50.0800..50.0840 /
36.1070..36.1145) — the AZ-836 spec's tighter IMU-derived bounds
(50.0808..50.0832 / 36.1070..36.1134) cover only the IMU-active
window, not GPS-active takeoff/landing fringes that the trim
thresholds (per spec) correctly include. See
_docs/03_implementation/batch_106_cycle3_report.md "Spec drift
surfaced" for the full note.

Semantics decision documented inline: max_waypoints is enforced
only in auto-tolerance mode; with an explicit DP tolerance the
result reflects that exact tolerance.

AZ-836 moved to done/.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Oleksandr Bezdieniezhnykh
2026-05-23 13:09:38 +03:00
parent 63c0217e3d
commit 5e52779056
6 changed files with 880 additions and 1 deletions
@@ -1,86 +0,0 @@
# TlogRouteExtractor
**Task**: AZ-836_tlog_route_extractor
**Name**: TlogRouteExtractor: extract active flight segment + coarsen tlog GPS to ≤N waypoints (AZ-835 C1)
**Description**: First building block of Epic AZ-835. Pure, testable function that consumes a `.tlog` binary and returns a `RouteSpec` (≤ N waypoints + suggested per-waypoint coverage radius) suitable for posting to satellite-provider's `POST /api/satellite/route` endpoint (consumed by AZ-835 C2 / AZ-838).
**Complexity**: 3 SP
**Dependencies**: AZ-697 (`load_tlog_ground_truth` — done); AZ-279 (WGS converter — done); AZ-835 (parent Epic)
**Component**: `src/gps_denied_onboard/replay_input/tlog_route.py` (new module under `replay_input/`)
**Tracker**: AZ-836 (https://denyspopov.atlassian.net/browse/AZ-836)
**Parent Epic**: AZ-835
Jira AZ-836 is the authoritative spec; this file is the in-workspace mirror.
## Public surface
```python
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True, slots=True)
class RouteSpec:
waypoints: tuple[tuple[float, float], ...] # (lat, lon), 1..max_waypoints
suggested_region_size_meters: float # per-waypoint coverage radius
source_tlog: Path # provenance
source_segment: tuple[int, int] # (start_idx, end_idx) into tlog GPS rows
total_distance_meters: float # along-track distance of active segment
class RouteExtractionError(ReplayInputAdapterError): ...
def extract_route_from_tlog(
tlog: Path,
*,
max_waypoints: int = 10,
min_takeoff_speed_m_s: float = 2.0,
min_takeoff_altitude_agl_m: float = 5.0,
douglas_peucker_tolerance_m: float | None = None, # auto-computed if None
region_size_meters: float = 500.0,
) -> RouteSpec: ...
```
Reuses `replay_input.tlog_ground_truth.load_tlog_ground_truth()` for GPS extraction — no MAVLink re-parsing.
## Active-segment detection
Trim leading + trailing rows where horizontal speed < `min_takeoff_speed_m_s` AND altitude AGL < `min_takeoff_altitude_agl_m`. Both thresholds configurable. If trimmed segment has < 2 fixes, raise `RouteExtractionError` with the explicit threshold values — no silent fallback to the full tlog.
## Coarsening
Douglas-Peucker in WGS84 with great-circle distance metric. Use the existing `helpers.wgs_converter` or `helpers.gps_compare` meter conversion — do NOT reimplement (check both first; pick whichever has the right primitive).
When `douglas_peucker_tolerance_m is None`, auto-compute by binary-search over the tolerance until `len(result) <= max_waypoints`. Halt at convergence (delta < 1 m) or 32 iterations.
## Validation
- `max_waypoints >= 1` (raise `ValueError`).
- `region_size_meters > 0` (raise `ValueError`).
- At least 1 fix from `GLOBAL_POSITION_INT` (preferred) or `GPS_RAW_INT` (fallback); if neither, `RouteExtractionError` referencing missing message types (mirrors AZ-697).
- Missing tlog file → `RouteExtractionError` (not bare `FileNotFoundError`) so callers can catch one error class.
## Acceptance criteria
| # | Criterion |
|---|-----------|
| AC-1 | Real Derkachi tlog → RouteSpec with `len(waypoints) <= 10`; every waypoint inside lat 50.0808..50.0832, lon 36.1070..36.1134 |
| AC-2 | Active-segment trim filters pre-takeoff stationary frames (synthetic 5+ stationary leading fixes → `source_segment[0] > 0`) |
| AC-3 | `max_waypoints=2` → exactly 2 waypoints |
| AC-4 | `max_waypoints=100` on N<100 tlog → N waypoints (no coarsening below natural fix count) |
| AC-5 | Missing tlog → `RouteExtractionError` with path; not `FileNotFoundError` |
| AC-6 | Tlog with no GPS → `RouteExtractionError` naming missing message types |
| AC-7 | `RouteSpec` is `frozen=True`, `slots=True`, all provenance fields populated |
| AC-8 | Auto-tolerance binary-search converges within 32 iters on a 200-fix synthetic trajectory |
| AC-9 | No I/O beyond tlog read; logging at DEBUG only |
| AC-10 | Unit tests cover: Derkachi happy path, small/large max_waypoints, missing tlog, missing GPS, custom DP tolerance, custom region size, synthetic stationary-leading trim |
## Out of scope
- Posting to satellite-provider (AZ-838 / C2)
- Route visualization on a map (future, AZ-700-style)
- Multi-tlog aggregation
- Live-stream tlog ingestion
## References
- Parent Epic: AZ-835 — https://denyspopov.atlassian.net/browse/AZ-835
- Reference tlog: `_docs/00_problem/input_data/flight_derkachi/derkachi.tlog`
- Reuse: `src/gps_denied_onboard/replay_input/tlog_ground_truth.py` (AZ-697), `src/gps_denied_onboard/helpers/gps_compare.py`