mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 22:11:13 +00:00
[AZ-700] gps-denied-render-map: HTML map of estimated vs truth tracks
New operator-side console-script renders a self-contained HTML map (folium / Leaflet) comparing the estimator's JSONL track against the tlog ground-truth track. Pinned visual style: red truth + blue estimated polylines, start/end markers per track, 100 m + 50 m scale circles, optional AZ-699 accuracy-summary banner, and an --offline-tiles mode (with optional local tile-URL template) for Jetsons without internet. folium is gated behind a new [operator-tools] optional-dep so the airborne binary's cold-start NFR is unaffected (C12 binary doesn't import the new module). 14 new unit tests pin polyline count, marker count, scale-circle radii, summary embedding, offline-tile behaviour, and full CLI smoke. Zero mypy --strict errors. Refines the 2026-05-20 Jetson-only test policy: unit tests may run locally, e2e/perf/resilience/security stay Jetson-only. Documented in _docs/02_document/tests/environment.md (Where each tier runs) and .cursor/rules/testing.mdc (Test environment for this project). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,370 @@
|
||||
"""AZ-700 ``gps-denied-render-map`` console-script.
|
||||
|
||||
Renders a self-contained HTML map (folium / Leaflet) comparing the
|
||||
estimated GPS track (from a `gps-denied-replay` JSONL run) against
|
||||
the tlog ground-truth track (binary tlog via AZ-697). Output is a
|
||||
single shareable HTML file with two distinct polyline layers,
|
||||
start/end markers, scale circles for visual reference, and an
|
||||
optional accuracy-summary banner from AZ-699.
|
||||
|
||||
This module lives under ``cli/`` rather than ``components/`` because
|
||||
it is an operator-side post-flight analysis tool — it never runs
|
||||
inside the airborne loop. folium is an optional dependency
|
||||
(``[operator-tools]``) so the airborne binary's cold-start NFR is
|
||||
unaffected.
|
||||
|
||||
Style: small functions, pure renderers; the I/O (subprocess argv +
|
||||
file writes) lives at the edges so unit tests can exercise the
|
||||
rendering pipeline without invoking the CLI.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from collections.abc import Iterable
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from gps_denied_onboard.replay_input import load_tlog_ground_truth
|
||||
|
||||
__all__ = [
|
||||
"RenderInputs",
|
||||
"load_estimated_track",
|
||||
"load_ground_truth_track",
|
||||
"main",
|
||||
"render_map_html",
|
||||
]
|
||||
|
||||
|
||||
# Default tile provider. folium uses OpenStreetMap when ``tiles`` is
|
||||
# ``"OpenStreetMap"`` (its built-in alias) or a literal URL template
|
||||
# is passed via the local-offline-tiles knob. AC-5 of AZ-700 allows
|
||||
# fail-fast when neither online nor local tiles are configured.
|
||||
_DEFAULT_TILES_NAME: str = "OpenStreetMap"
|
||||
|
||||
|
||||
# Visual style. Pinned so the AC-2/AC-3 HTML scans are stable across
|
||||
# folium upgrades (folium emits ``L.polyline([...], {color: '...'})``).
|
||||
_TRUTH_LINE_COLOR: str = "red"
|
||||
_ESTIMATED_LINE_COLOR: str = "blue"
|
||||
_TRUTH_START_COLOR: str = "green"
|
||||
_TRUTH_END_COLOR: str = "black"
|
||||
_ESTIMATED_START_COLOR: str = "lightgreen"
|
||||
_ESTIMATED_END_COLOR: str = "gray"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RenderInputs:
|
||||
"""Pre-parsed inputs for :func:`render_map_html`.
|
||||
|
||||
Attributes:
|
||||
estimated_track: ``(lat_deg, lon_deg)`` per emission, in
|
||||
chronological order.
|
||||
truth_track: Same shape, sourced from the tlog.
|
||||
summary_markdown: Optional content of the AZ-699 accuracy
|
||||
report. ``None`` skips the header banner.
|
||||
title: Page title (folium ``<title>``).
|
||||
"""
|
||||
|
||||
estimated_track: list[tuple[float, float]]
|
||||
truth_track: list[tuple[float, float]]
|
||||
summary_markdown: str | None
|
||||
title: str
|
||||
|
||||
|
||||
def load_estimated_track(jsonl_path: Path) -> list[tuple[float, float]]:
|
||||
"""Load a track from a ``gps-denied-replay`` JSONL output."""
|
||||
out: list[tuple[float, float]] = []
|
||||
for line in jsonl_path.read_text().splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"{jsonl_path}: invalid JSON on a line: {exc!r}"
|
||||
) from exc
|
||||
pos = row.get("position_wgs84")
|
||||
if not isinstance(pos, dict):
|
||||
raise ValueError(
|
||||
f"{jsonl_path}: row missing position_wgs84: {row!r}"
|
||||
)
|
||||
lat = pos.get("lat_deg")
|
||||
lon = pos.get("lon_deg")
|
||||
if not isinstance(lat, (int, float)) or not isinstance(lon, (int, float)):
|
||||
raise ValueError(
|
||||
f"{jsonl_path}: row has non-numeric lat/lon: {pos!r}"
|
||||
)
|
||||
out.append((float(lat), float(lon)))
|
||||
return out
|
||||
|
||||
|
||||
def load_ground_truth_track(tlog_path: Path) -> list[tuple[float, float]]:
|
||||
"""Load a ``(lat, lon)`` track from a binary tlog (AZ-697)."""
|
||||
series = load_tlog_ground_truth(tlog_path)
|
||||
return [(fix.lat_deg, fix.lon_deg) for fix in series.records]
|
||||
|
||||
|
||||
def _bounds(
|
||||
*tracks: Iterable[tuple[float, float]],
|
||||
) -> tuple[tuple[float, float], tuple[float, float]] | None:
|
||||
"""Return the lat/lon bounding box across all non-empty tracks."""
|
||||
lats: list[float] = []
|
||||
lons: list[float] = []
|
||||
for track in tracks:
|
||||
for lat, lon in track:
|
||||
lats.append(lat)
|
||||
lons.append(lon)
|
||||
if not lats:
|
||||
return None
|
||||
return (min(lats), min(lons)), (max(lats), max(lons))
|
||||
|
||||
|
||||
def _import_folium() -> Any:
|
||||
"""Defer folium import so the airborne binary never pays for it."""
|
||||
try:
|
||||
import folium # type: ignore[import-untyped, import-not-found, unused-ignore]
|
||||
except ImportError as exc:
|
||||
raise SystemExit(
|
||||
"folium not installed. Install the operator-side tools "
|
||||
"with `pip install gps-denied-onboard[operator-tools]`."
|
||||
) from exc
|
||||
return folium
|
||||
|
||||
|
||||
def render_map_html(
|
||||
inputs: RenderInputs,
|
||||
*,
|
||||
offline_tiles: bool = False,
|
||||
offline_tiles_template: str | None = None,
|
||||
) -> str:
|
||||
"""Render the map to an HTML string.
|
||||
|
||||
Pure — does no file I/O. Returns the full HTML document that
|
||||
:func:`main` writes to disk.
|
||||
|
||||
Args:
|
||||
inputs: Parsed tracks + optional summary.
|
||||
offline_tiles: When ``True``, folium is initialised with
|
||||
``tiles=None`` (no base layer). The operator is expected
|
||||
to overlay tiles separately, or accept a gray map for
|
||||
geometric review only.
|
||||
offline_tiles_template: When provided, used as a local
|
||||
tile-URL template (e.g. ``"file:///opt/tiles/{z}/{x}/{y}.png"``).
|
||||
Takes precedence over ``offline_tiles``.
|
||||
"""
|
||||
folium = _import_folium()
|
||||
bbox = _bounds(inputs.estimated_track, inputs.truth_track)
|
||||
if bbox is None:
|
||||
raise ValueError(
|
||||
"both estimated and truth tracks are empty; "
|
||||
"nothing to render"
|
||||
)
|
||||
(lat_min, lon_min), (lat_max, lon_max) = bbox
|
||||
centre = ((lat_min + lat_max) / 2.0, (lon_min + lon_max) / 2.0)
|
||||
|
||||
if offline_tiles_template is not None:
|
||||
m = folium.Map(
|
||||
location=centre,
|
||||
zoom_start=15,
|
||||
tiles=offline_tiles_template,
|
||||
attr="local offline tile bundle",
|
||||
)
|
||||
elif offline_tiles:
|
||||
m = folium.Map(location=centre, zoom_start=15, tiles=None)
|
||||
else:
|
||||
m = folium.Map(
|
||||
location=centre, zoom_start=15, tiles=_DEFAULT_TILES_NAME
|
||||
)
|
||||
|
||||
# AZ-700 AC-2: truth polyline (red) + estimated polyline (blue).
|
||||
if inputs.truth_track:
|
||||
folium.PolyLine(
|
||||
inputs.truth_track,
|
||||
color=_TRUTH_LINE_COLOR,
|
||||
weight=3,
|
||||
opacity=0.9,
|
||||
tooltip="Ground truth (tlog)",
|
||||
).add_to(m)
|
||||
if inputs.estimated_track:
|
||||
folium.PolyLine(
|
||||
inputs.estimated_track,
|
||||
color=_ESTIMATED_LINE_COLOR,
|
||||
weight=3,
|
||||
opacity=0.9,
|
||||
dash_array="6,4",
|
||||
tooltip="Estimator output",
|
||||
).add_to(m)
|
||||
|
||||
# AZ-700 AC-3: start/end markers + 100 m + 50 m scale circles.
|
||||
if inputs.truth_track:
|
||||
truth_start = inputs.truth_track[0]
|
||||
truth_end = inputs.truth_track[-1]
|
||||
folium.Marker(
|
||||
truth_start,
|
||||
tooltip="Truth start",
|
||||
icon=folium.Icon(color=_TRUTH_START_COLOR, icon="play"),
|
||||
).add_to(m)
|
||||
folium.Marker(
|
||||
truth_end,
|
||||
tooltip="Truth end",
|
||||
icon=folium.Icon(color=_TRUTH_END_COLOR, icon="stop"),
|
||||
).add_to(m)
|
||||
folium.Circle(
|
||||
truth_start, radius=100.0, color="black", fill=False,
|
||||
tooltip="100 m scale",
|
||||
).add_to(m)
|
||||
folium.Circle(
|
||||
truth_start, radius=50.0, color="black", fill=False,
|
||||
tooltip="50 m scale",
|
||||
).add_to(m)
|
||||
if inputs.estimated_track:
|
||||
est_start = inputs.estimated_track[0]
|
||||
est_end = inputs.estimated_track[-1]
|
||||
folium.Marker(
|
||||
est_start,
|
||||
tooltip="Estimator start",
|
||||
icon=folium.Icon(color=_ESTIMATED_START_COLOR, icon="play"),
|
||||
).add_to(m)
|
||||
folium.Marker(
|
||||
est_end,
|
||||
tooltip="Estimator end",
|
||||
icon=folium.Icon(color=_ESTIMATED_END_COLOR, icon="stop"),
|
||||
).add_to(m)
|
||||
|
||||
m.fit_bounds([(lat_min, lon_min), (lat_max, lon_max)])
|
||||
|
||||
if inputs.summary_markdown is not None:
|
||||
banner_html = (
|
||||
"<div style='background:#fff; padding:8px 12px; "
|
||||
"border-bottom:1px solid #999; font-family:monospace; "
|
||||
"white-space:pre-wrap;'>"
|
||||
+ _escape_html(inputs.summary_markdown)
|
||||
+ "</div>"
|
||||
)
|
||||
m.get_root().html.add_child(folium.Element(banner_html))
|
||||
|
||||
title_html = (
|
||||
f"<title>{_escape_html(inputs.title)}</title>"
|
||||
)
|
||||
m.get_root().header.add_child(folium.Element(title_html))
|
||||
|
||||
return str(m.get_root().render())
|
||||
|
||||
|
||||
def _escape_html(text: str) -> str:
|
||||
return (
|
||||
text.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# CLI surface
|
||||
|
||||
|
||||
def _build_argparser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
prog="gps-denied-render-map",
|
||||
description=(
|
||||
"Render a self-contained HTML map comparing the "
|
||||
"estimator's GPS track with the tlog ground-truth track."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--estimated",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Path to the gps-denied-replay JSONL emissions file.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--truth",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Path to the binary tlog the estimator was run against.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output",
|
||||
type=Path,
|
||||
required=True,
|
||||
help="Path to write the resulting HTML map.",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--summary",
|
||||
type=Path,
|
||||
default=None,
|
||||
help=(
|
||||
"Optional path to an AZ-699 accuracy-summary Markdown "
|
||||
"file. When supplied, its contents are embedded above "
|
||||
"the map as a fixed banner."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline-tiles",
|
||||
action="store_true",
|
||||
help=(
|
||||
"Initialise the map with no base tile layer (gray "
|
||||
"background). Use when the rendering host has no "
|
||||
"internet access AND no local tile bundle. The map is "
|
||||
"still useful for geometric track review."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--offline-tiles-template",
|
||||
type=str,
|
||||
default=None,
|
||||
help=(
|
||||
"Local-tile URL template (e.g. "
|
||||
"'file:///opt/tiles/{z}/{x}/{y}.png'). Takes precedence "
|
||||
"over --offline-tiles when both are supplied."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--title",
|
||||
type=str,
|
||||
default="gps-denied-onboard replay map",
|
||||
help="HTML <title> for the produced page.",
|
||||
)
|
||||
return parser
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = _build_argparser().parse_args(argv)
|
||||
|
||||
estimated_track = load_estimated_track(args.estimated)
|
||||
truth_track = load_ground_truth_track(args.truth)
|
||||
if not estimated_track and not truth_track:
|
||||
print(
|
||||
"both estimated and truth tracks are empty; nothing to render",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
summary_markdown: str | None = None
|
||||
if args.summary is not None:
|
||||
if not args.summary.is_file():
|
||||
print(
|
||||
f"--summary file not found: {args.summary}", file=sys.stderr
|
||||
)
|
||||
return 2
|
||||
summary_markdown = args.summary.read_text()
|
||||
|
||||
inputs = RenderInputs(
|
||||
estimated_track=estimated_track,
|
||||
truth_track=truth_track,
|
||||
summary_markdown=summary_markdown,
|
||||
title=args.title,
|
||||
)
|
||||
|
||||
html = render_map_html(
|
||||
inputs,
|
||||
offline_tiles=bool(args.offline_tiles),
|
||||
offline_tiles_template=args.offline_tiles_template,
|
||||
)
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(html)
|
||||
return 0
|
||||
Reference in New Issue
Block a user