Code review of stateful Renderer and DisplayInterface: all 10 review criteria pass with no code changes required. Add two deferred items for WaveshareDisplay NotImplementedError stub (wired in 2-7) and pixel-space trail staleness on re-provisioning. Mark story done in story file and sprint-status.yaml. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7.6 KiB
Story 2.6: Stateful Renderer & Display Interface
Status: done
Story
As the radar loop,
I want a stateful Renderer owning the in-memory tile composite and per-aircraft trail history, and a DisplayInterface protocol with WaveshareDisplay (SPI) and NullDisplay (tests),
So that the render pipeline is fully isolated, testable without hardware, and trail history persists across cycles.
Acceptance Criteria
AC1: Given a Renderer initialised with a loaded base map When renderer.render(aircraft_list) is called Then it returns a PIL.Image (800×480) with base map, airspace outlines, home marker, and all aircraft drawn
AC2: Given an aircraft appears in two consecutive calls to renderer.render() When the second call is made Then its previous position appears as a trail dot And trail length never exceeds TRAIL_MAX_DOTS (5)
AC3: Given an aircraft was present last cycle but absent from current list When renderer.render() is called Then the aircraft does not appear on display And its trail history is retained in dict[str, deque] for when it reappears
AC4: Given a NullDisplay When display.show(image) is called Then it logs image dimensions at DEBUG level and returns without error
AC5: Given the test_pipeline.py smoke test (FileFixtureFetcher → Renderer → NullDisplay) When one full cycle runs Then it completes without exception and the returned image is 800×480
Tasks / Subtasks
-
Task 1: Update
NullDisplayinsrc/planemapper/display.py(AC: #4)- 1.1 Add
import loggingandlog = logging.getLogger(__name__) - 1.2 Update
NullDisplay.show()to log at DEBUG level:log.debug("NullDisplay.show: %dx%d", image.width, image.height) - 1.3 Add
WaveshareDisplaystub:class WaveshareDisplay: def show(self, image: Image.Image) -> None: raise NotImplementedError
- 1.1 Add
-
Task 2: Implement
Rendererinsrc/planemapper/renderer/renderer.py(AC: #1, #2, #3)- 2.1 Replace
# stubwith full implementation - 2.2 Imports:
import collections from PIL import Image from planemapper.models import Aircraft from planemapper.constants import TRAIL_MAX_DOTS from planemapper.renderer.projection import MapBounds, project from planemapper.renderer.airspace import draw_airspace from planemapper.renderer.overlay import draw_home_marker from planemapper.renderer.aircraft import draw_aircraft - 2.3 Implement
__init__withbase_map,bounds,_trailsdict:def __init__( self, base_map: Image.Image, bounds: MapBounds, ) -> None: self._base_map = base_map self._bounds = bounds self._trails: dict[str, collections.deque[tuple[int, int]]] = {} - 2.4 Implement
render(aircraft_list)with full pipeline:def render(self, aircraft_list: list[Aircraft]) -> Image.Image: image = self._base_map.copy() draw_airspace(image, self._bounds) draw_home_marker(image, self._bounds) for aircraft in aircraft_list: pos = project(aircraft.lat, aircraft.lon, self._bounds) trail = self._trails.get(aircraft.icao, collections.deque()) draw_aircraft(image, aircraft, pos, trail) trail.appendleft(pos) while len(trail) > TRAIL_MAX_DOTS: trail.pop() self._trails[aircraft.icao] = trail return image
- 2.1 Replace
-
Task 3: Write tests in
tests/test_renderer.py(AC: #1, #2, #3)- 3.1 Replace placeholder with full test module
- 3.2 Test AC1: create white 800×480 RGB base map, call
render([])with empty aircraft list, assert returned image isPIL.Image.Imagewith size(800, 480) - 3.3 Test AC2: call
render()twice with same aircraft; after second call, assertrenderer._trailshas an entry keyed on the aircraft's ICAO - 3.4 Test AC3: call
render()with one aircraft, then callrender([])with empty list; assertrenderer._trailsstill has the aircraft ICAO entry
-
Task 4: Write/update pipeline smoke test in
tests/test_pipeline.py(AC: #5)- 4.1 Replace placeholder with full smoke test:
- Use
FileFixtureFetcher(Path("tests/fixtures/aircraft_sample.json")) - Create a fake 800×480 white RGB base map (
Image.new("RGB", (800, 480), "white")) - Create
Renderer(base_map, bounds)wherebounds = MapBounds(53.0, -6.0, 100.0) - Monkeypatch
planemapper.renderer.airspace.AIRSPACE_PATHtotests/fixtures/airspace_sample.geojson - Call
renderer.render(fetcher.fetch()) - Assert returned image is
PIL.Image.Imagewith size(800, 480) - Call
NullDisplay().show(image)— assert no exception
- Use
- 4.2 Ensure monkeypatch of
AIRSPACE_PATHis applied beforerender()is called
- 4.1 Replace placeholder with full smoke test:
-
Task 5: Run quality gates
- 5.1
python -m pytest tests/— all tests pass - 5.2
python -m ruff check .— zero violations - 5.3
python -m ruff format --check .— no formatting issues
- 5.1
Implementation Notes
Renderer class — trail management detail
Trail entries are (x, y) pixel positions stored most-recent-first (index 0). After drawing each aircraft:
trail.appendleft(pos)— prepend current positionwhile len(trail) > TRAIL_MAX_DOTS: trail.pop()— trim oldest entries from the rightself._trails[aircraft.icao] = trail— write back (no-op if already the same deque object, but keeps the dict consistent)
Aircraft absent from aircraft_list are not iterated, so their trail deque remains in self._trails untouched, ready to resume when the aircraft reappears.
WaveshareDisplay stub
This story adds only the stub. The real SPI driver implementation (using the Waveshare Python library) is deferred to story 2-7. The stub class must satisfy the DisplayInterface protocol structurally — it has a show(self, image: Image.Image) -> None method — but raises NotImplementedError so it cannot be called accidentally in tests.
NullDisplay logging
NullDisplay lives in src/planemapper/display.py. The module-level logger uses __name__ (planemapper.display). Log format: "NullDisplay.show: %dx%d" with image.width and image.height as positional args (uses %-style lazy formatting, not f-strings, to avoid string construction overhead when DEBUG is not enabled).
render() pipeline order
The pipeline order is significant:
- Copy base map — ensures each cycle starts from the clean pre-rendered tile composite
- Draw airspace outlines — static geometry, drawn once per cycle over the base copy
- Draw home marker — static overlay
- Draw aircraft (with trails) — dynamic, per-cycle
All drawing mutates the image copy in-place. self._base_map is never mutated.
Test fixtures
tests/test_renderer.py does not need any fixture files — it constructs a minimal white Image.new("RGB", (800, 480), "white") base map and uses a handcrafted Aircraft object. Use unittest.mock.patch or pytest monkeypatch to suppress draw_airspace and draw_home_marker if they have external dependencies (e.g. airspace file path), or monkeypatch AIRSPACE_PATH as done in the pipeline smoke test.
Existing files affected
| File | Change |
|---|---|
src/planemapper/display.py |
Add logging; update NullDisplay.show(); add WaveshareDisplay stub |
src/planemapper/renderer/renderer.py |
Replace # stub with full Renderer class |
tests/test_renderer.py |
Replace placeholder with AC1/AC2/AC3 tests |
tests/test_pipeline.py |
Replace placeholder with end-to-end smoke test |