5d307c33b0
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
144 lines
7.6 KiB
Markdown
144 lines
7.6 KiB
Markdown
# Story 2.6: Stateful Renderer & Display Interface
|
||
|
||
Status: ready-for-dev
|
||
|
||
## 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 `NullDisplay` in `src/planemapper/display.py` (AC: #4)
|
||
- [ ] 1.1 Add `import logging` and `log = logging.getLogger(__name__)`
|
||
- [ ] 1.2 Update `NullDisplay.show()` to log at DEBUG level:
|
||
```python
|
||
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
||
```
|
||
- [ ] 1.3 Add `WaveshareDisplay` stub:
|
||
```python
|
||
class WaveshareDisplay:
|
||
def show(self, image: Image.Image) -> None:
|
||
raise NotImplementedError
|
||
```
|
||
|
||
- [ ] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3)
|
||
- [ ] 2.1 Replace `# stub` with full implementation
|
||
- [ ] 2.2 Imports:
|
||
```python
|
||
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__` with `base_map`, `bounds`, `_trails` dict:
|
||
```python
|
||
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:
|
||
```python
|
||
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
|
||
```
|
||
|
||
- [ ] 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 is `PIL.Image.Image` with size `(800, 480)`
|
||
- [ ] 3.3 Test AC2: call `render()` twice with same aircraft; after second call, assert `renderer._trails` has an entry keyed on the aircraft's ICAO
|
||
- [ ] 3.4 Test AC3: call `render()` with one aircraft, then call `render([])` with empty list; assert `renderer._trails` still 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)` where `bounds = MapBounds(53.0, -6.0, 100.0)`
|
||
- Monkeypatch `planemapper.renderer.airspace.AIRSPACE_PATH` to `tests/fixtures/airspace_sample.geojson`
|
||
- Call `renderer.render(fetcher.fetch())`
|
||
- Assert returned image is `PIL.Image.Image` with size `(800, 480)`
|
||
- Call `NullDisplay().show(image)` — assert no exception
|
||
- [ ] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called
|
||
|
||
- [ ] 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
|
||
|
||
## Implementation Notes
|
||
|
||
### `Renderer` class — trail management detail
|
||
|
||
Trail entries are `(x, y)` pixel positions stored most-recent-first (index 0). After drawing each aircraft:
|
||
|
||
1. `trail.appendleft(pos)` — prepend current position
|
||
2. `while len(trail) > TRAIL_MAX_DOTS: trail.pop()` — trim oldest entries from the right
|
||
3. `self._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:
|
||
|
||
1. Copy base map — ensures each cycle starts from the clean pre-rendered tile composite
|
||
2. Draw airspace outlines — static geometry, drawn once per cycle over the base copy
|
||
3. Draw home marker — static overlay
|
||
4. 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 |
|