mirror of
https://github.com/azaion/gps-denied-onboard.git
synced 2026-06-21 22:41:12 +00:00
7f590582cc
load_ground_truth_track now dispatches on truth_path.suffix: - .csv → load_csv_ground_truth (AZ-894) - else (.tlog, .bin, no ext) → load_tlog_ground_truth (AZ-697) Removes the AZ-959 short-circuit in SubprocessReplayRunner. _maybe_render_map so CSV-path replay jobs ship with the same map.html artefact as tlog jobs. Both ground-truth DTOs expose row-aligned (lat_deg, lon_deg) records so the renderer needs no other changes. Touches: - src/gps_denied_onboard/cli/render_map.py: dispatch + source-agnostic tooltip + --truth CLI help expanded - src/gps_denied_onboard/replay_api/app.py: workaround removed, truth_path resolution picks whichever input was uploaded Tests: 44/44 green across test_az700_render_map.py + test_az701_replay_api.py: - 17 pre-existing render-map tests pass unchanged (AC-2) - New test_load_ground_truth_track_dispatches_to_csv_loader (AC-1) - New test_load_ground_truth_track_csv_propagates_schema_error (AC-4: malformed CSV raises ReplayInputAdapterError) - New test_cli_renders_map_with_csv_truth (AC-1 end-to-end) - AZ-959 test_post_replay_csv_path_returns_200... extended to assert map_html_url is now present (AC-3) Bookkeeping: AZ-960 spec moved todo/ → done/, dep-table preamble seventh bump documents the landing + AC coverage, state.md records batch 6 complete with AZ-961 as next. Co-authored-by: Cursor <cursoragent@cursor.com>
463 lines
13 KiB
Python
463 lines
13 KiB
Python
"""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
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# AZ-960 — CSV-path dispatch for --truth
|
|
|
|
|
|
def _write_minimal_csv(path: Path) -> None:
|
|
"""Write a tiny AZ-896-schema CSV with 3 GPS rows for AZ-960 tests."""
|
|
header = (
|
|
"timestamp(ms),Time,"
|
|
"SCALED_IMU2.xacc,SCALED_IMU2.yacc,SCALED_IMU2.zacc,"
|
|
"SCALED_IMU2.xgyro,SCALED_IMU2.ygyro,SCALED_IMU2.zgyro,"
|
|
"GLOBAL_POSITION_INT.lat,GLOBAL_POSITION_INT.lon,"
|
|
"GLOBAL_POSITION_INT.alt,GLOBAL_POSITION_INT.vx,"
|
|
"GLOBAL_POSITION_INT.vy,GLOBAL_POSITION_INT.vz,"
|
|
"GLOBAL_POSITION_INT.hdg"
|
|
)
|
|
rows = [
|
|
"0,0.0,21,-3,-984,52,32,-5,50.000,30.000,141290,0,0,0,35041",
|
|
"100,0.1,-68,-9,-995,58,-17,1,50.0005,30.0005,141360,0,0,0,35042",
|
|
"200,0.2,9,108,-988,69,-65,13,50.001,30.001,141410,0,0,0,35048",
|
|
]
|
|
path.write_text(header + "\n" + "\n".join(rows) + "\n")
|
|
|
|
|
|
def test_load_ground_truth_track_dispatches_to_csv_loader(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# Arrange — AC-1: csv extension routes through load_csv_ground_truth
|
|
csv_path = tmp_path / "data_imu.csv"
|
|
_write_minimal_csv(csv_path)
|
|
|
|
# Act
|
|
track = load_ground_truth_track(csv_path)
|
|
|
|
# Assert
|
|
assert len(track) == 3
|
|
for lat, lon in track:
|
|
assert 49.999 < lat < 50.002
|
|
assert 29.999 < lon < 30.002
|
|
|
|
|
|
def test_load_ground_truth_track_csv_propagates_schema_error(
|
|
tmp_path: Path,
|
|
) -> None:
|
|
# Arrange — AC-4: malformed CSV fails fast via ReplayInputAdapterError
|
|
csv_path = tmp_path / "bad.csv"
|
|
csv_path.write_text(
|
|
"timestamp(ms),SCALED_IMU2.xacc,GLOBAL_POSITION_INT.lat\n"
|
|
"0,0,50.0\n"
|
|
)
|
|
from gps_denied_onboard.replay_input.errors import ReplayInputAdapterError
|
|
|
|
# Act / Assert
|
|
with pytest.raises(ReplayInputAdapterError, match="Time"):
|
|
load_ground_truth_track(csv_path)
|
|
|
|
|
|
def test_cli_renders_map_with_csv_truth(tmp_path: Path) -> None:
|
|
# Arrange — AC-1 end-to-end: CLI accepts --truth foo.csv
|
|
csv_path = tmp_path / "data_imu.csv"
|
|
_write_minimal_csv(csv_path)
|
|
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}},
|
|
],
|
|
)
|
|
output = tmp_path / "map.html"
|
|
|
|
# Act
|
|
rc = main(
|
|
[
|
|
"--estimated", str(estimated),
|
|
"--truth", str(csv_path),
|
|
"--output", str(output),
|
|
]
|
|
)
|
|
|
|
# Assert
|
|
assert rc == 0
|
|
assert output.is_file()
|
|
body = output.read_text()
|
|
assert body.startswith("<!DOCTYPE html>")
|
|
assert "L.polyline" in body
|
|
assert '"color": "red"' in body
|
|
assert '"color": "blue"' in body
|