Files
planeMapper/_bmad-output/implementation-artifacts/3-1-stale-state-detection-and-dimmed-display.md
T
Matt Edholm 833a7f0917 feat(3-1): stale state detection and dimmed display
When fetch times out or returns empty after prior data, retain last aircraft
with is_stale=True and render them as black outlines so the display stays
informative rather than blank or crashing.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-22 23:47:04 -04:00

6.1 KiB

Story 3.1: Stale State Detection & Dimmed Display

Status: review

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

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

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

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:

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