feat(2-6): stateful Renderer, DisplayInterface, and pipeline smoke test
Implements story 2-6: Renderer class with per-aircraft trail history (deque capped at TRAIL_MAX_DOTS=5), NullDisplay with DEBUG logging, WaveshareDisplay stub, and end-to-end pipeline smoke test. All 96 tests pass; ruff check and format clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+22
-22
@@ -1,6 +1,6 @@
|
|||||||
# Story 2.6: Stateful Renderer & Display Interface
|
# Story 2.6: Stateful Renderer & Display Interface
|
||||||
|
|
||||||
Status: ready-for-dev
|
Status: review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -22,22 +22,22 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] Task 1: Update `NullDisplay` in `src/planemapper/display.py` (AC: #4)
|
- [x] Task 1: Update `NullDisplay` in `src/planemapper/display.py` (AC: #4)
|
||||||
- [ ] 1.1 Add `import logging` and `log = logging.getLogger(__name__)`
|
- [x] 1.1 Add `import logging` and `log = logging.getLogger(__name__)`
|
||||||
- [ ] 1.2 Update `NullDisplay.show()` to log at DEBUG level:
|
- [x] 1.2 Update `NullDisplay.show()` to log at DEBUG level:
|
||||||
```python
|
```python
|
||||||
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
||||||
```
|
```
|
||||||
- [ ] 1.3 Add `WaveshareDisplay` stub:
|
- [x] 1.3 Add `WaveshareDisplay` stub:
|
||||||
```python
|
```python
|
||||||
class WaveshareDisplay:
|
class WaveshareDisplay:
|
||||||
def show(self, image: Image.Image) -> None:
|
def show(self, image: Image.Image) -> None:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3)
|
- [x] Task 2: Implement `Renderer` in `src/planemapper/renderer/renderer.py` (AC: #1, #2, #3)
|
||||||
- [ ] 2.1 Replace `# stub` with full implementation
|
- [x] 2.1 Replace `# stub` with full implementation
|
||||||
- [ ] 2.2 Imports:
|
- [x] 2.2 Imports:
|
||||||
```python
|
```python
|
||||||
import collections
|
import collections
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
@@ -48,7 +48,7 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render
|
|||||||
from planemapper.renderer.overlay import draw_home_marker
|
from planemapper.renderer.overlay import draw_home_marker
|
||||||
from planemapper.renderer.aircraft import draw_aircraft
|
from planemapper.renderer.aircraft import draw_aircraft
|
||||||
```
|
```
|
||||||
- [ ] 2.3 Implement `__init__` with `base_map`, `bounds`, `_trails` dict:
|
- [x] 2.3 Implement `__init__` with `base_map`, `bounds`, `_trails` dict:
|
||||||
```python
|
```python
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
@@ -59,7 +59,7 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render
|
|||||||
self._bounds = bounds
|
self._bounds = bounds
|
||||||
self._trails: dict[str, collections.deque[tuple[int, int]]] = {}
|
self._trails: dict[str, collections.deque[tuple[int, int]]] = {}
|
||||||
```
|
```
|
||||||
- [ ] 2.4 Implement `render(aircraft_list)` with full pipeline:
|
- [x] 2.4 Implement `render(aircraft_list)` with full pipeline:
|
||||||
```python
|
```python
|
||||||
def render(self, aircraft_list: list[Aircraft]) -> Image.Image:
|
def render(self, aircraft_list: list[Aircraft]) -> Image.Image:
|
||||||
image = self._base_map.copy()
|
image = self._base_map.copy()
|
||||||
@@ -76,14 +76,14 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render
|
|||||||
return image
|
return image
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] Task 3: Write tests in `tests/test_renderer.py` (AC: #1, #2, #3)
|
- [x] Task 3: Write tests in `tests/test_renderer.py` (AC: #1, #2, #3)
|
||||||
- [ ] 3.1 Replace placeholder with full test module
|
- [x] 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)`
|
- [x] 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
|
- [x] 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
|
- [x] 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)
|
- [x] Task 4: Write/update pipeline smoke test in `tests/test_pipeline.py` (AC: #5)
|
||||||
- [ ] 4.1 Replace placeholder with full smoke test:
|
- [x] 4.1 Replace placeholder with full smoke test:
|
||||||
- Use `FileFixtureFetcher(Path("tests/fixtures/aircraft_sample.json"))`
|
- Use `FileFixtureFetcher(Path("tests/fixtures/aircraft_sample.json"))`
|
||||||
- Create a fake 800×480 white RGB base map (`Image.new("RGB", (800, 480), "white")`)
|
- 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)`
|
- Create `Renderer(base_map, bounds)` where `bounds = MapBounds(53.0, -6.0, 100.0)`
|
||||||
@@ -91,12 +91,12 @@ AC5: **Given** the `test_pipeline.py` smoke test (`FileFixtureFetcher → Render
|
|||||||
- Call `renderer.render(fetcher.fetch())`
|
- Call `renderer.render(fetcher.fetch())`
|
||||||
- Assert returned image is `PIL.Image.Image` with size `(800, 480)`
|
- Assert returned image is `PIL.Image.Image` with size `(800, 480)`
|
||||||
- Call `NullDisplay().show(image)` — assert no exception
|
- Call `NullDisplay().show(image)` — assert no exception
|
||||||
- [ ] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called
|
- [x] 4.2 Ensure monkeypatch of `AIRSPACE_PATH` is applied before `render()` is called
|
||||||
|
|
||||||
- [ ] Task 5: Run quality gates
|
- [x] Task 5: Run quality gates
|
||||||
- [ ] 5.1 `python -m pytest tests/` — all tests pass
|
- [x] 5.1 `python -m pytest tests/` — all tests pass
|
||||||
- [ ] 5.2 `python -m ruff check .` — zero violations
|
- [x] 5.2 `python -m ruff check .` — zero violations
|
||||||
- [ ] 5.3 `python -m ruff format --check .` — no formatting issues
|
- [x] 5.3 `python -m ruff format --check .` — no formatting issues
|
||||||
|
|
||||||
## Implementation Notes
|
## Implementation Notes
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@
|
|||||||
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
# - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended)
|
||||||
|
|
||||||
generated: 2026-04-22
|
generated: 2026-04-22
|
||||||
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 ready-for-dev, epic-2 in-progress
|
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 review, epic-2 in-progress
|
||||||
project: planeMapper
|
project: planeMapper
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -58,7 +58,7 @@ development_status:
|
|||||||
2-3-home-marker-and-airspace-outlines: done
|
2-3-home-marker-and-airspace-outlines: done
|
||||||
2-4-altitude-colour-bands-and-aircraft-type-icons: done
|
2-4-altitude-colour-bands-and-aircraft-type-icons: done
|
||||||
2-5-per-aircraft-drawing: done
|
2-5-per-aircraft-drawing: done
|
||||||
2-6-stateful-renderer-and-display-interface: ready-for-dev
|
2-6-stateful-renderer-and-display-interface: review
|
||||||
2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog
|
2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog
|
||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,12 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
from typing import Protocol
|
from typing import Protocol
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class DisplayInterface(Protocol):
|
class DisplayInterface(Protocol):
|
||||||
def show(self, image: Image.Image) -> None: ...
|
def show(self, image: Image.Image) -> None: ...
|
||||||
@@ -9,4 +14,9 @@ class DisplayInterface(Protocol):
|
|||||||
|
|
||||||
class NullDisplay:
|
class NullDisplay:
|
||||||
def show(self, image: Image.Image) -> None:
|
def show(self, image: Image.Image) -> None:
|
||||||
pass
|
log.debug("NullDisplay.show: %dx%d", image.width, image.height)
|
||||||
|
|
||||||
|
|
||||||
|
class WaveshareDisplay:
|
||||||
|
def show(self, image: Image.Image) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -1 +1,33 @@
|
|||||||
# stub
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import collections
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from planemapper.constants import TRAIL_MAX_DOTS
|
||||||
|
from planemapper.models import Aircraft
|
||||||
|
from planemapper.renderer.aircraft import draw_aircraft
|
||||||
|
from planemapper.renderer.airspace import draw_airspace
|
||||||
|
from planemapper.renderer.overlay import draw_home_marker
|
||||||
|
from planemapper.renderer.projection import MapBounds, project
|
||||||
|
|
||||||
|
|
||||||
|
class Renderer:
|
||||||
|
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]]] = {}
|
||||||
|
|
||||||
|
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
|
||||||
|
|||||||
+32
-2
@@ -1,2 +1,32 @@
|
|||||||
def test_placeholder() -> None:
|
from __future__ import annotations
|
||||||
pass
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from planemapper.display import NullDisplay
|
||||||
|
from planemapper.fetcher import FileFixtureFetcher
|
||||||
|
from planemapper.renderer.projection import MapBounds
|
||||||
|
from planemapper.renderer.renderer import Renderer
|
||||||
|
|
||||||
|
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
def test_full_pipeline_smoke(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||||
|
FIXTURE_DIR / "airspace_sample.geojson",
|
||||||
|
)
|
||||||
|
base_map = Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||||
|
bounds = MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||||
|
fetcher = FileFixtureFetcher(FIXTURE_DIR / "aircraft_sample.json")
|
||||||
|
renderer = Renderer(base_map, bounds)
|
||||||
|
display = NullDisplay()
|
||||||
|
|
||||||
|
aircraft_list = fetcher.fetch()
|
||||||
|
result = renderer.render(aircraft_list)
|
||||||
|
|
||||||
|
assert isinstance(result, Image.Image)
|
||||||
|
assert result.size == (800, 480)
|
||||||
|
display.show(result) # should not raise
|
||||||
|
|||||||
+61
-2
@@ -1,2 +1,61 @@
|
|||||||
def test_placeholder() -> None:
|
from __future__ import annotations
|
||||||
pass
|
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from planemapper.models import Aircraft
|
||||||
|
from planemapper.renderer.projection import MapBounds
|
||||||
|
from planemapper.renderer.renderer import Renderer
|
||||||
|
|
||||||
|
FIXTURE_DIR = pathlib.Path(__file__).parent / "fixtures"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def white_base_map() -> Image.Image:
|
||||||
|
return Image.new("RGB", (800, 480), color=(255, 255, 255))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def bounds() -> MapBounds:
|
||||||
|
return MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def renderer(
|
||||||
|
white_base_map: Image.Image, bounds: MapBounds, monkeypatch: pytest.MonkeyPatch
|
||||||
|
) -> Renderer:
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"planemapper.renderer.airspace.AIRSPACE_PATH",
|
||||||
|
FIXTURE_DIR / "airspace_sample.geojson",
|
||||||
|
)
|
||||||
|
return Renderer(white_base_map, bounds)
|
||||||
|
|
||||||
|
|
||||||
|
def _aircraft(icao: str = "ABC123", **kwargs) -> Aircraft:
|
||||||
|
defaults = {"lat": 53.1, "lon": -6.1}
|
||||||
|
defaults.update(kwargs)
|
||||||
|
return Aircraft(icao=icao, **defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_returns_800x480(renderer: Renderer) -> None:
|
||||||
|
result = renderer.render([])
|
||||||
|
assert isinstance(result, Image.Image)
|
||||||
|
assert result.size == (800, 480)
|
||||||
|
|
||||||
|
|
||||||
|
def test_trail_accumulated_across_renders(renderer: Renderer) -> None:
|
||||||
|
ac = _aircraft(icao="ABC123")
|
||||||
|
renderer.render([ac])
|
||||||
|
renderer.render([ac])
|
||||||
|
assert "ABC123" in renderer._trails
|
||||||
|
assert len(renderer._trails["ABC123"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_absent_aircraft_trail_retained(renderer: Renderer) -> None:
|
||||||
|
ac = _aircraft(icao="ABC123")
|
||||||
|
renderer.render([ac])
|
||||||
|
renderer.render([]) # aircraft absent
|
||||||
|
assert "ABC123" in renderer._trails
|
||||||
|
assert len(renderer._trails["ABC123"]) == 1
|
||||||
|
|||||||
Reference in New Issue
Block a user