mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-22 17:51:14 +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,373 @@
|
||||
"""AZ-700 — render_map CLI + HTML renderer unit tests.
|
||||
|
||||
Covers AC-1 (CLI smoke + valid HTML), AC-2 (two distinct
|
||||
polylines), AC-3 (4 markers + 100 m + 50 m circles), and AC-4
|
||||
(summary embedding). AC-5 (offline-tiles flag) is exercised via a
|
||||
dedicated test.
|
||||
|
||||
Folium is an optional dependency (``[operator-tools]`` group);
|
||||
these tests skip cleanly when it is not importable so the airborne
|
||||
test suite stays green even when the operator extra is absent.
|
||||
|
||||
Style: every test follows the Arrange / Act / Assert pattern.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
folium = pytest.importorskip(
|
||||
"folium",
|
||||
reason="folium is an operator-only dep; install gps-denied-onboard[operator-tools]",
|
||||
)
|
||||
|
||||
from gps_denied_onboard.cli.render_map import (
|
||||
RenderInputs,
|
||||
_build_argparser,
|
||||
load_estimated_track,
|
||||
load_ground_truth_track,
|
||||
main,
|
||||
render_map_html,
|
||||
)
|
||||
|
||||
|
||||
def _write_minimal_tlog(path: Path, fixes: list[tuple[float, float, float]]) -> None:
|
||||
"""Write a tiny binary tlog with ``GLOBAL_POSITION_INT`` only.
|
||||
|
||||
Format: ``<u64 big-endian timestamp_us><MAVLink2 msg bytes>``,
|
||||
repeated. ``load_tlog_ground_truth`` ignores everything except
|
||||
``GLOBAL_POSITION_INT`` / ``GPS_RAW_INT``, so the minimal schema
|
||||
is just one ``GLOBAL_POSITION_INT`` per fix.
|
||||
"""
|
||||
from pymavlink.dialects.v20 import ardupilotmega as mavlink
|
||||
|
||||
mav = mavlink.MAVLink(file=None, srcSystem=1, srcComponent=1)
|
||||
with path.open("wb") as fp:
|
||||
for i, (lat, lon, alt) in enumerate(fixes):
|
||||
time_boot_ms = i * 500
|
||||
msg = mav.global_position_int_encode(
|
||||
time_boot_ms=time_boot_ms,
|
||||
lat=int(lat * 1e7),
|
||||
lon=int(lon * 1e7),
|
||||
alt=int(alt * 1000),
|
||||
relative_alt=int(alt * 1000),
|
||||
vx=0,
|
||||
vy=0,
|
||||
vz=0,
|
||||
hdg=0,
|
||||
)
|
||||
payload = msg.pack(mav)
|
||||
ts_us = i * 500_000
|
||||
fp.write(struct.pack(">Q", ts_us))
|
||||
fp.write(payload)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# Helpers
|
||||
|
||||
|
||||
def _write_jsonl(path: Path, rows: list[dict[str, object]]) -> None:
|
||||
path.write_text("\n".join(json.dumps(r) for r in rows) + "\n")
|
||||
|
||||
|
||||
def _example_inputs() -> RenderInputs:
|
||||
return RenderInputs(
|
||||
estimated_track=[
|
||||
(50.0, 30.0),
|
||||
(50.001, 30.001),
|
||||
(50.002, 30.002),
|
||||
],
|
||||
truth_track=[
|
||||
(50.0, 30.0),
|
||||
(50.0005, 30.0005),
|
||||
(50.001, 30.001),
|
||||
],
|
||||
summary_markdown=None,
|
||||
title="unit-test",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# load_estimated_track / load_ground_truth_track
|
||||
|
||||
|
||||
def test_load_estimated_track_skips_blank_lines(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
path = tmp_path / "out.jsonl"
|
||||
path.write_text(
|
||||
'{"position_wgs84":{"lat_deg":50.0,"lon_deg":30.0,"alt_m":100}}\n'
|
||||
"\n"
|
||||
'{"position_wgs84":{"lat_deg":50.1,"lon_deg":30.1,"alt_m":110}}\n'
|
||||
)
|
||||
|
||||
# Act
|
||||
track = load_estimated_track(path)
|
||||
|
||||
# Assert
|
||||
assert track == [(50.0, 30.0), (50.1, 30.1)]
|
||||
|
||||
|
||||
def test_load_estimated_track_raises_on_missing_position(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
path = tmp_path / "out.jsonl"
|
||||
path.write_text('{"frame_id":1}\n')
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="missing position_wgs84"):
|
||||
load_estimated_track(path)
|
||||
|
||||
|
||||
def test_load_estimated_track_raises_on_non_numeric_lat(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
path = tmp_path / "out.jsonl"
|
||||
path.write_text(
|
||||
'{"position_wgs84":{"lat_deg":"oops","lon_deg":30.0}}\n'
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="non-numeric lat/lon"):
|
||||
load_estimated_track(path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# render_map_html
|
||||
|
||||
|
||||
def test_render_map_html_emits_two_polylines() -> None:
|
||||
# Act
|
||||
html = render_map_html(_example_inputs())
|
||||
|
||||
# Assert — AC-2: two distinct polyline layers with our pinned colors.
|
||||
assert html.count("L.polyline") == 2, (
|
||||
"expected exactly 2 polylines (truth + estimated); "
|
||||
f"saw {html.count('L.polyline')}"
|
||||
)
|
||||
assert '"color": "red"' in html, "truth polyline (red) missing"
|
||||
assert '"color": "blue"' in html, "estimated polyline (blue) missing"
|
||||
|
||||
|
||||
def test_render_map_html_emits_four_markers_and_two_circles() -> None:
|
||||
# Act
|
||||
html = render_map_html(_example_inputs())
|
||||
|
||||
# Assert — AC-3: 2 markers per track (start + end) = 4 total.
|
||||
assert html.count("L.marker") == 4, (
|
||||
f"expected 4 markers; saw {html.count('L.marker')}"
|
||||
)
|
||||
# Scale circles at the truth start: radius 100 + 50.
|
||||
assert html.count("L.circle") == 2, (
|
||||
f"expected 2 scale circles (100 m + 50 m); "
|
||||
f"saw {html.count('L.circle')}"
|
||||
)
|
||||
assert '"radius": 100.0' in html
|
||||
assert '"radius": 50.0' in html
|
||||
|
||||
|
||||
def test_render_map_html_embeds_summary_when_provided() -> None:
|
||||
# Arrange
|
||||
inputs = RenderInputs(
|
||||
estimated_track=[(50.0, 30.0), (50.001, 30.001)],
|
||||
truth_track=[(50.0, 30.0), (50.0005, 30.0005)],
|
||||
summary_markdown=(
|
||||
"# Real-flight validation — 2026-05-20\n"
|
||||
"**Verdict**: PASS\n"
|
||||
"| Mean | 12.3 |"
|
||||
),
|
||||
title="t",
|
||||
)
|
||||
|
||||
# Act
|
||||
html = render_map_html(inputs)
|
||||
|
||||
# Assert — AC-4: the markdown body shows up in the HTML.
|
||||
assert "Real-flight validation" in html
|
||||
assert "**Verdict**: PASS" in html # noqa: E501 — escape allowed since `*` is not HTML-special
|
||||
# HTML special chars are escaped — pipe characters stay raw, but
|
||||
# the angle brackets used in markdown's emphasis would. We don't
|
||||
# want script injection, so confirm the wrapper div is present.
|
||||
assert "white-space:pre-wrap" in html
|
||||
|
||||
|
||||
def test_render_map_html_raises_on_both_tracks_empty() -> None:
|
||||
# Arrange
|
||||
inputs = RenderInputs(
|
||||
estimated_track=[],
|
||||
truth_track=[],
|
||||
summary_markdown=None,
|
||||
title="empty",
|
||||
)
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(ValueError, match="empty"):
|
||||
render_map_html(inputs)
|
||||
|
||||
|
||||
def test_render_map_html_offline_tiles_omits_openstreetmap() -> None:
|
||||
# Act
|
||||
html_default = render_map_html(_example_inputs())
|
||||
html_offline = render_map_html(_example_inputs(), offline_tiles=True)
|
||||
|
||||
# Assert — `tiles=None` removes the default OpenStreetMap tile URL.
|
||||
assert "openstreetmap" in html_default.lower()
|
||||
assert "openstreetmap" not in html_offline.lower()
|
||||
|
||||
|
||||
def test_render_map_html_offline_tiles_template_uses_local_url() -> None:
|
||||
# Act
|
||||
html = render_map_html(
|
||||
_example_inputs(),
|
||||
offline_tiles_template="file:///opt/tiles/{z}/{x}/{y}.png",
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert "file:///opt/tiles/{z}/{x}/{y}.png" in html
|
||||
assert "local offline tile bundle" in html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# CLI smoke (AC-1)
|
||||
|
||||
|
||||
def test_cli_writes_html_with_default_tiles(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
estimated = tmp_path / "estimator.jsonl"
|
||||
_write_jsonl(
|
||||
estimated,
|
||||
[
|
||||
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
|
||||
{"position_wgs84": {"lat_deg": 50.001, "lon_deg": 30.001, "alt_m": 101}},
|
||||
],
|
||||
)
|
||||
|
||||
truth = tmp_path / "synth.tlog"
|
||||
_write_minimal_tlog(
|
||||
truth,
|
||||
[(50.0, 30.0, 100.0), (50.0005, 30.0005, 100.0), (50.001, 30.001, 100.0)],
|
||||
)
|
||||
|
||||
output = tmp_path / "map.html"
|
||||
|
||||
# Act
|
||||
rc = main(
|
||||
[
|
||||
"--estimated", str(estimated),
|
||||
"--truth", str(truth),
|
||||
"--output", str(output),
|
||||
]
|
||||
)
|
||||
|
||||
# Assert — AC-1: clean exit + non-empty HTML.
|
||||
assert rc == 0
|
||||
assert output.is_file()
|
||||
body = output.read_text()
|
||||
assert body.startswith("<!DOCTYPE html>")
|
||||
assert len(body) > 1000
|
||||
|
||||
|
||||
def test_cli_embeds_summary_when_flag_supplied(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
estimated = tmp_path / "estimator.jsonl"
|
||||
_write_jsonl(
|
||||
estimated,
|
||||
[
|
||||
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
|
||||
{"position_wgs84": {"lat_deg": 50.001, "lon_deg": 30.001, "alt_m": 101}},
|
||||
],
|
||||
)
|
||||
|
||||
truth = tmp_path / "synth.tlog"
|
||||
_write_minimal_tlog(truth, [(50.0, 30.0, 100.0), (50.001, 30.001, 100.0)])
|
||||
|
||||
summary = tmp_path / "real_flight_validation_2026-05-20.md"
|
||||
summary.write_text(
|
||||
"# Real-flight validation — 2026-05-20\n"
|
||||
"**Verdict**: FAIL\n\n"
|
||||
"## Horizontal error (metres)\n"
|
||||
"| Mean | 142.5 |\n"
|
||||
)
|
||||
|
||||
output = tmp_path / "map.html"
|
||||
|
||||
# Act
|
||||
rc = main(
|
||||
[
|
||||
"--estimated", str(estimated),
|
||||
"--truth", str(truth),
|
||||
"--output", str(output),
|
||||
"--summary", str(summary),
|
||||
]
|
||||
)
|
||||
|
||||
# Assert — AC-4
|
||||
assert rc == 0
|
||||
body = output.read_text()
|
||||
assert "Real-flight validation" in body
|
||||
assert "**Verdict**: FAIL" in body
|
||||
assert "Mean | 142.5" in body
|
||||
|
||||
|
||||
def test_cli_fails_fast_when_summary_path_missing(tmp_path: Path) -> None:
|
||||
# Arrange
|
||||
estimated = tmp_path / "estimator.jsonl"
|
||||
_write_jsonl(
|
||||
estimated,
|
||||
[
|
||||
{"position_wgs84": {"lat_deg": 50.0, "lon_deg": 30.0, "alt_m": 100}},
|
||||
],
|
||||
)
|
||||
truth = tmp_path / "synth.tlog"
|
||||
_write_minimal_tlog(truth, [(50.0, 30.0, 100.0), (50.001, 30.001, 100.0)])
|
||||
|
||||
output = tmp_path / "map.html"
|
||||
missing_summary = tmp_path / "does_not_exist.md"
|
||||
|
||||
# Act
|
||||
rc = main(
|
||||
[
|
||||
"--estimated", str(estimated),
|
||||
"--truth", str(truth),
|
||||
"--output", str(output),
|
||||
"--summary", str(missing_summary),
|
||||
]
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert rc == 2
|
||||
assert not output.exists(), "must not write the map when summary path is invalid"
|
||||
|
||||
|
||||
def test_argparser_requires_three_paths() -> None:
|
||||
# Arrange
|
||||
parser = _build_argparser()
|
||||
|
||||
# Act / Assert
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args([])
|
||||
with pytest.raises(SystemExit):
|
||||
parser.parse_args(["--estimated", "/tmp/a.jsonl"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------
|
||||
# load_ground_truth_track integration with AZ-697
|
||||
|
||||
|
||||
def test_load_ground_truth_track_returns_lat_lon_pairs(tmp_path: Path) -> None:
|
||||
# Arrange — synthesize a minimal tlog and round-trip through AZ-697.
|
||||
tlog_path = tmp_path / "synth.tlog"
|
||||
_write_minimal_tlog(
|
||||
tlog_path,
|
||||
[(50.000, 30.000, 100.0), (50.001, 30.001, 101.0), (50.002, 30.002, 102.0)],
|
||||
)
|
||||
|
||||
# Act
|
||||
track = load_ground_truth_track(tlog_path)
|
||||
|
||||
# Assert
|
||||
assert len(track) == 3
|
||||
for lat, lon in track:
|
||||
assert 49.99 < lat < 50.01
|
||||
assert 29.99 < lon < 30.01
|
||||
Reference in New Issue
Block a user