# Replay map visualization (estimated vs ground-truth tracks)

**Task**: AZ-700_replay_map_visualization
**Name**: HTML map showing estimated GPS track vs tlog ground-truth track
**Description**: New `gps-denied-render-map` console script. Takes a JSONL of estimator output + a tlog (or CSV fallback) and renders a single-file HTML map (folium / Leaflet) with both tracks in distinct colors, start/end markers, and an embedded accuracy summary from AZ-699.
**Complexity**: 3 points
**Dependencies**: AZ-699
**Component**: cli (offline analysis surface)
**Tracker**: AZ-700
**Epic**: AZ-696

## Problem

Today the only feedback from a replay run is a JSONL file. There is no
way to visually verify whether the estimator is drifting, jumping, or
roughly tracking the real flight. A human reading the JSONL cannot
quickly answer "does this make sense geographically?"

The user's pipeline explicitly calls for: "and then show both points on
the map."

## Outcome

- A standalone CLI `gps-denied-render-map` produces a self-contained
  HTML map of the estimated track + the tlog ground-truth track for any
  prior replay run.
- The map is shareable as a single file (no server required); developers
  open it locally; AZ-701's HTTP API serves it back to API consumers.

## Scope

### Included
- New module `src/gps_denied_onboard/cli/render_map.py`.
- New console script `gps-denied-render-map` in `pyproject.toml`.
- folium dependency pin in the appropriate `[project.optional-dependencies]` group (NOT in airborne-binary deps — operator-side only).
- Default map style + tile provider (OpenStreetMap fallback documented for offline use).
- Auto-fit bounds; distance circles (100 m, 50 m) around start point for scale.
- Accuracy summary banner (read from `_docs/06_metrics/real_flight_validation_{date}.md` when `--summary` is passed).

### Excluded
- Interactive time-slider playback (deferred follow-up).
- Embedded altitude profile chart.
- Animated marker traversal.

## Acceptance Criteria

**AC-1: CLI produces self-contained HTML**
Given a JSONL + tlog
When `gps-denied-render-map --estimated out.jsonl --truth derkachi.tlog --output map.html` runs
Then `map.html` exists, parses as valid HTML, exits 0

**AC-2: Two distinct tracks visible**
Given the rendered map opened in a browser
When inspected
Then it contains exactly two polyline layers (red = truth, blue = estimated) with start/end markers

**AC-3: Markers + scale circles**
Given the rendered map
When parsed
Then it contains the start (green) + end (black) markers + 100 m + 50 m scale circles

**AC-4: Accuracy summary inclusion**
Given `--summary _docs/06_metrics/real_flight_validation_2026-XX-XX.md`
When the map renders
Then the HTML header contains the accuracy metrics table

**AC-5: Offline fallback documented**
Given an environment without internet access
When the map is rendered with `--offline-tiles`
Then tile loading uses a documented fallback (or fails fast with a clear error if no fallback is configured)

## Non-Functional Requirements

**Compatibility**
- Output HTML must render in Chrome 110+ and Firefox 110+ without console errors.

**Performance**
- For a 60 s flight (~600 truth points + ~600 estimated points), render time < 5 s on Tier-1 hardware.

## Unit Tests

| AC Ref | What to Test | Required Outcome |
|--------|-------------|-----------------|
| AC-1 | CLI invocation with synthetic data | Output HTML file exists + non-empty |
| AC-2 | Parse output HTML | Exactly 2 polyline layers + 4 expected markers |
| AC-4 | Summary embed | Markdown summary metrics present in HTML |

## Blackbox Tests

| AC Ref | Initial Data/Conditions | What to Test | Expected Behavior | NFR References |
|--------|------------------------|-------------|-------------------|----------------|
| AC-1 | Real Derkachi replay JSONL + tlog | End-to-end render | HTML opens in browser, both tracks visible | Compat |

## Constraints

- folium MUST be in the operator-only dep group; airborne binary cold-start regression test must remain green.
- HTML output MUST be self-contained — embedded JS/CSS, no per-page CDN calls in `--offline-tiles` mode.
- Console script naming follows the project pattern (`gps-denied-<verb>`).

## Risks & Mitigation

**Risk 1: folium dep size**
- *Risk*: folium pulls ~5 MB of JS. Adding to airborne deps would regress cold-start.
- *Mitigation*: optional-dependencies group + ADR-002 build-time exclusion principle.

**Risk 2: CDN dependency at render time**
- *Risk*: Default folium uses Leaflet via CDN — fails on offline Jetsons.
- *Mitigation*: Document `--offline-tiles` flag; provide bundled assets path or fail-fast.

---

## Implementation Notes (Batch 101 — Cycle 2)

**Status**: In Testing (Jira AZ-700).

### Files changed

Production:
- `src/gps_denied_onboard/cli/render_map.py` — new module: `RenderInputs` DTO, `render_map_html`, `load_estimated_track`, `load_ground_truth_track`, argparse CLI, `main()`.
- `pyproject.toml` — new `[project.optional-dependencies] operator-tools = ["folium>=0.16,<1.0"]` group; new console script `gps-denied-render-map = "gps_denied_onboard.cli.render_map:main"`.

Tests:
- `tests/unit/test_az700_render_map.py` — 14 unit tests covering JSONL parsing, HTML rendering (2 polylines, 4 markers, 2 scale circles, summary embed, offline-tiles toggle), and CLI smoke including a minimal binary-tlog helper.

### AC coverage

| AC   | Test / Artefact                                                                          | Result |
| ---- | ---------------------------------------------------------------------------------------- | ------ |
| AC-1 | `test_cli_writes_html_with_default_tiles`                                                | PASS (local). The Jetson e2e visual smoke is `AC-4` and is operator-driven on Tier-2. |
| AC-2 | `test_render_map_html_emits_two_polylines`, `…emits_four_markers_and_two_circles`        | PASS   |
| AC-3 | `test_render_map_html_emits_two_polylines`, `…emits_four_markers_and_two_circles`        | PASS — output HTML contains exactly 2 polyline layers (red + blue) and 4 markers + 2 scale circles. |
| AC-4 | Visual smoke on Tier-2 Jetson (operator opens `map.html` produced by AZ-699's e2e run)   | DEFERRED to Jetson — wired and ready. |
| AC-5 | `test_render_map_html_offline_tiles_omits_openstreetmap`, `…_template_uses_local_url`    | PASS   |

### Test results

`pytest tests/unit/test_az700_render_map.py` → 14 passed in 2.5 s. Wider regression slice (AZ-697/698/699/700 + replay_input + calibration): 107 passed, 1 skipped (pre-existing AC-5 e2e smoke that needs real video).

### Strict typing

`mypy --strict src/gps_denied_onboard/cli/render_map.py` → **Success: no issues found in 1 source file.** Used `# type: ignore[import-untyped, import-not-found, unused-ignore]` on the lazy folium import so the strict pass is clean whether folium is installed or not.

### Design notes

- folium 0.20 (the latest in the pinned range) was used. The default tile provider is OpenStreetMap (`tiles="OpenStreetMap"`); the AC-5 `--offline-tiles` flag drops the base layer entirely, and `--offline-tiles-template` accepts a local tile-URL template for operators with a bundled tile pack.
- folium is lazy-imported inside `_import_folium()` so the airborne binary (which does NOT install `[operator-tools]`) doesn't pay for it on cold start. The C12 cold-start NFR is unaffected.
- The `_write_minimal_tlog` test helper builds a binary tlog with just `GLOBAL_POSITION_INT` records — that's the minimum AZ-697 needs — without coupling the test to the full Derkachi CSV schema used by `tests/e2e/replay/_tlog_synth.py`.
- All AZ-700 unit tests run locally per the refined test-environment policy (`_docs/02_document/tests/environment.md` § Where each tier runs); the Tier-2 visual-smoke AC-4 stays on the Jetson.
