bmad: create story 3-1 (stale state detection and dimmed display)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+131
@@ -0,0 +1,131 @@
|
|||||||
|
# Story 3.1: Stale State Detection & Dimmed Display
|
||||||
|
|
||||||
|
Status: ready-for-dev
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
As a user whose RTL-SDR has temporarily lost signal,
|
||||||
|
I want the display to retain the last known aircraft positions shown as outlines when dump1090 stops delivering fresh data,
|
||||||
|
So that I know the display is stale without a crash or blank screen.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
AC1: **Given** the radar loop is running with a previous successful fetch **When** `HttpFetcher.fetch()` raises `requests.Timeout` **Then** the exception propagates to the loop boundary, which catches it and marks all retained aircraft as `is_stale=True`
|
||||||
|
|
||||||
|
AC2: **Given** the dump1090 response returns an empty aircraft list when the previous cycle had aircraft **When** the fetcher processes the response **Then** the previous aircraft list is retained with `is_stale=True` on each entry (not replaced with empty)
|
||||||
|
|
||||||
|
AC3: **Given** aircraft with `is_stale=True` are passed to the renderer **When** `renderer.render()` is called **Then** each stale aircraft is drawn as outline only using `COLOUR_STALE_OUTLINE` **And** heading arrow, label, and trail are still rendered at last known positions
|
||||||
|
|
||||||
|
AC4: **Given** a stale render cycle **When** the render loop timing is measured **Then** the loop does not crash (stale path is not a crash path)
|
||||||
|
|
||||||
|
## Tasks / Subtasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Update `_run_one_cycle()` and `main()` in `src/planemapper/main.py` (AC: #1, #2, #4)
|
||||||
|
- [ ] 1.1 Add `last_aircraft: list[Aircraft]` parameter to `_run_one_cycle`; change return type to `list[Aircraft]`
|
||||||
|
- [ ] 1.2 Wrap `fetcher.fetch()` in `try/except requests.Timeout`
|
||||||
|
- [ ] 1.3 Implement stale logic: use `last_aircraft` with `is_stale=True` when timeout or empty+had-previous
|
||||||
|
- [ ] 1.4 Update `main()` to initialise `last: list[Aircraft] = []` and track return value of `_run_one_cycle`
|
||||||
|
- [ ] 1.5 Add `import dataclasses` and `import requests` to `main.py`
|
||||||
|
|
||||||
|
- [ ] Task 2: Update `draw_aircraft()` in `src/planemapper/renderer/aircraft.py` (AC: #3)
|
||||||
|
- [ ] 2.1 Import `COLOUR_STALE_OUTLINE` from `planemapper.constants`
|
||||||
|
- [ ] 2.2 Check `aircraft.is_stale` before calling `_draw_arrow`: if stale, use `COLOUR_STALE_OUTLINE` with forced outline mode
|
||||||
|
|
||||||
|
- [ ] Task 3: Write tests in `tests/test_stale.py` (AC: #1, #2, #3, #4)
|
||||||
|
- [ ] 3.1 Test AC1: mock `fetcher.fetch()` to raise `requests.Timeout`; call `_run_one_cycle` with `last_aircraft=[some_aircraft]`; assert returned list has `is_stale=True`
|
||||||
|
- [ ] 3.2 Test AC2: mock `fetcher.fetch()` to return `[]`; call with non-empty `last_aircraft`; assert returned list has `is_stale=True`
|
||||||
|
- [ ] 3.3 Test AC3: render stale aircraft; assert pixel at aircraft position is not the altitude colour (stale colour is `COLOUR_STALE_OUTLINE` = black)
|
||||||
|
- [ ] 3.4 Test AC4: full stale cycle with renderer completes without exception
|
||||||
|
|
||||||
|
- [ ] Task 4: Run quality gates
|
||||||
|
- [ ] 4.1 `python -m pytest tests/` — all tests pass
|
||||||
|
- [ ] 4.2 `python -m ruff check .` — zero violations
|
||||||
|
- [ ] 4.3 `python -m ruff format --check .` — no formatting issues
|
||||||
|
|
||||||
|
## Implementation Notes
|
||||||
|
|
||||||
|
### Key insight: `is_stale` already exists
|
||||||
|
|
||||||
|
The `is_stale` field already exists on the `Aircraft` dataclass (defaults to `False`). No model changes are needed. The `is_mlat` field already drives outline rendering in `_draw_arrow` — stale aircraft reuse the same outline path but with a different colour.
|
||||||
|
|
||||||
|
### Stale vs MLAT rendering distinction
|
||||||
|
|
||||||
|
| Condition | Fill | Outline colour | Meaning |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Normal | altitude colour | — | Direct ADS-B, current data |
|
||||||
|
| MLAT (`is_mlat=True`) | None | altitude colour | Uncertain position, current data |
|
||||||
|
| Stale (`is_stale=True`) | None | `COLOUR_STALE_OUTLINE` (black) | Last known position, data age unknown |
|
||||||
|
|
||||||
|
Stale takes priority over MLAT in the rendering check: if `aircraft.is_stale` is true, always render outline in `COLOUR_STALE_OUTLINE` regardless of `is_mlat`.
|
||||||
|
|
||||||
|
### `_run_one_cycle` signature change
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _run_one_cycle(
|
||||||
|
renderer: Renderer,
|
||||||
|
fetcher: HttpFetcher,
|
||||||
|
display: DisplayInterface,
|
||||||
|
last_aircraft: list[Aircraft],
|
||||||
|
) -> list[Aircraft]:
|
||||||
|
```
|
||||||
|
|
||||||
|
Returns the aircraft list used for this cycle (caller passes it back as `last_aircraft` next cycle).
|
||||||
|
|
||||||
|
### Stale detection logic in `_run_one_cycle`
|
||||||
|
|
||||||
|
```python
|
||||||
|
try:
|
||||||
|
fresh = fetcher.fetch()
|
||||||
|
except requests.Timeout:
|
||||||
|
log.warning("fetch timeout — using stale data")
|
||||||
|
fresh = []
|
||||||
|
stale_needed = True
|
||||||
|
else:
|
||||||
|
stale_needed = (len(fresh) == 0 and len(last_aircraft) > 0)
|
||||||
|
|
||||||
|
if stale_needed:
|
||||||
|
aircraft_list = [dataclasses.replace(a, is_stale=True) for a in last_aircraft]
|
||||||
|
else:
|
||||||
|
aircraft_list = fresh
|
||||||
|
```
|
||||||
|
|
||||||
|
`dataclasses.replace()` creates a new `Aircraft` instance with only `is_stale` changed — the original `last_aircraft` entries are not mutated.
|
||||||
|
|
||||||
|
### `main()` loop update
|
||||||
|
|
||||||
|
```python
|
||||||
|
last: list[Aircraft] = []
|
||||||
|
while True:
|
||||||
|
last = _run_one_cycle(renderer, fetcher, display, last)
|
||||||
|
time.sleep(REFRESH_INTERVAL_S)
|
||||||
|
```
|
||||||
|
|
||||||
|
Initialise `last` as empty list before the loop. On first cycle, `last_aircraft=[]` means a timeout or empty result produces an empty stale list (no aircraft to retain), which renders a clean empty map — correct behaviour.
|
||||||
|
|
||||||
|
### `draw_aircraft()` stale check
|
||||||
|
|
||||||
|
In `src/planemapper/renderer/aircraft.py`, inside `draw_aircraft()`, before calling `_draw_arrow`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if aircraft.is_stale:
|
||||||
|
_draw_arrow(draw, cx, cy, aircraft.heading, COLOUR_STALE_OUTLINE, is_mlat=True)
|
||||||
|
else:
|
||||||
|
_draw_arrow(draw, cx, cy, aircraft.heading, colour, aircraft.is_mlat)
|
||||||
|
```
|
||||||
|
|
||||||
|
The `is_mlat=True` argument reuses the existing outline-only code path in `_draw_arrow`. The label and trail draw unconditionally at the last known positions — stale state does not suppress them.
|
||||||
|
|
||||||
|
### Required imports in `main.py`
|
||||||
|
|
||||||
|
- `import dataclasses` — for `dataclasses.replace()`
|
||||||
|
- `import requests` — to name `requests.Timeout` in the `except` clause
|
||||||
|
|
||||||
|
Both should be added to the top-level imports alongside the existing ones.
|
||||||
|
|
||||||
|
### Files changed
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|---|---|
|
||||||
|
| `src/planemapper/main.py` | Add params/return to `_run_one_cycle`; stale detection logic; update `main()` loop; add imports |
|
||||||
|
| `src/planemapper/renderer/aircraft.py` | Import `COLOUR_STALE_OUTLINE`; stale check before `_draw_arrow` |
|
||||||
|
| `tests/test_stale.py` | New test module covering all four ACs |
|
||||||
@@ -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 done, 2-7 done, epic-2 done
|
last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 done, 2-5 done, 2-6 done, 2-7 done, epic-2 done, epic-3 in-progress, 3-1 ready-for-dev
|
||||||
project: planeMapper
|
project: planeMapper
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -63,8 +63,8 @@ development_status:
|
|||||||
epic-2-retrospective: optional
|
epic-2-retrospective: optional
|
||||||
|
|
||||||
# Epic 3: Stale Data Resilience
|
# Epic 3: Stale Data Resilience
|
||||||
epic-3: backlog
|
epic-3: in-progress
|
||||||
3-1-stale-state-detection-and-dimmed-display: backlog
|
3-1-stale-state-detection-and-dimmed-display: ready-for-dev
|
||||||
3-2-automatic-recovery-on-fresh-decode: backlog
|
3-2-automatic-recovery-on-fresh-decode: backlog
|
||||||
epic-3-retrospective: optional
|
epic-3-retrospective: optional
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user