Files
planeMapper/_bmad-output/implementation-artifacts/2-1-aircraft-data-model-and-fetcher.md
T
Matt Edholm 6208134a1c Implement story 2.1: aircraft data model and fetcher
Add HttpFetcher and FileFixtureFetcher with shared _parse_aircraft helper,
DUMP1090_URL constant, realistic fixture data, and full test coverage for
all acceptance criteria (AC1–AC5).

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

9.5 KiB
Raw Blame History

Story 2.1: Aircraft Data Model & Fetcher

Status: review

Story

As the radar system, I want an Aircraft dataclass with safe-default optional fields and a FetcherInterface with both an HttpFetcher (live dump1090) and a FileFixtureFetcher (for testing), So that all downstream rendering code works with typed Aircraft objects and the fetch boundary is cleanly isolated from raw JSON.

Acceptance Criteria

AC1: Given a valid dump1090 JSON response with all fields present When HttpFetcher.fetch() is called Then it returns a list[Aircraft] with all fields populated correctly

AC2: Given the dump1090 response contains aircraft with missing callsign, altitude, or category When HttpFetcher.fetch() is called Then the corresponding fields use safe defaults (callsign="", altitude_ft=0, category="") and no exception is raised

AC3: Given the dump1090 HTTP request exceeds FETCH_TIMEOUT_S (5 seconds) When HttpFetcher.fetch() is called Then a requests.Timeout is raised (not caught here — the loop boundary handles it)

AC4: Given an aircraft entry has the MLAT flag set in the JSON When HttpFetcher.fetch() is called Then the resulting Aircraft has is_mlat=True

AC5: Given a FileFixtureFetcher pointed at tests/fixtures/aircraft_sample.json When .fetch() is called Then it returns the equivalent list[Aircraft] with no network call made

Tasks / Subtasks

  • Task 1: Implement Aircraft dataclass in src/planemapper/models.py (AC: #1, #2, #4)

    • 1.1 Replace the existing stub with the full dataclass as specified in architecture: icao: str, lat: float, lon: float, heading: float = 0.0, altitude_ft: int = 0, callsign: str = "", category: str = "", is_mlat: bool = False, is_stale: bool = False
    • 1.2 Add from __future__ import annotations and from dataclasses import dataclass imports
    • 1.3 Confirm ruff check . passes with zero violations after change
  • Task 2: Add DUMP1090_URL constant to src/planemapper/constants.py (AC: #1, #3)

    • 2.1 Add DUMP1090_URL = "http://localhost:8080/data/aircraft.json" to constants.py
    • 2.2 Confirm existing constants are unaffected and ruff check . passes
  • Task 3: Implement HttpFetcher in src/planemapper/fetcher.py (AC: #1, #2, #3, #4)

    • 3.1 Add imports: import requests, from pathlib import Path, from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S
    • 3.2 Implement _parse_aircraft(entry: dict) -> Aircraft private module-level helper:
      • Map hexicao
      • Map latlat, lonlon
      • Map flightcallsign with .strip() and default ""
      • Map altitudealtitude_ft: use int(val) if isinstance(val, int) else 0 (handles string "ground" and missing)
      • Map categorycategory with default ""
      • Map mlatis_mlat: bool(entry.get("mlat")) (empty list → False, non-empty → True)
      • is_stale always defaults to False
    • 3.3 Implement HttpFetcher class:
      • fetch(self) -> list[Aircraft] calls requests.get(DUMP1090_URL, timeout=FETCH_TIMEOUT_S)
      • Parses top-level JSON key "aircraft" as a list
      • Skips entries missing lat or lon (cannot be plotted)
      • Returns [_parse_aircraft(e) for e in entries]
      • Does NOT catch requests.Timeout — let it propagate
    • 3.4 Confirm HttpFetcher satisfies FetcherInterface structurally (no explicit inheritance needed)
  • Task 4: Implement FileFixtureFetcher in src/planemapper/fetcher.py (AC: #5)

    • 4.1 Add FileFixtureFetcher class after HttpFetcher:
      • Constructor: __init__(self, path: Path) stores self._path = path
      • fetch(self) -> list[Aircraft] reads JSON from self._path, parses with same _parse_aircraft helper
      • Same skip logic for missing lat/lon
    • 4.2 Confirm FileFixtureFetcher satisfies FetcherInterface structurally
  • Task 5: Update tests/fixtures/aircraft_sample.json with realistic dump1090 data (AC: #1, #2, #4, #5)

    • 5.1 Replace the empty {"aircraft": []} stub with a JSON object containing four aircraft entries:
      • Entry 1: complete aircraft — all fields present (hex, lat, lon, flight, altitude, category, mlat: [])
      • Entry 2: missing flight (callsign) — should default to ""
      • Entry 3: missing altitude — should default to altitude_ft=0
      • Entry 4: MLAT aircraft — mlat is a non-empty list (e.g. ["lat", "lon"])
    • 5.2 Ensure all four entries have lat and lon so none are skipped
  • Task 6: Write tests in tests/test_fetcher.py covering all 5 ACs (AC: #1#5)

    • 6.1 Test AC1: use responses library or unittest.mock.patch to mock requests.get; assert all fields on a fully-populated aircraft are correct
    • 6.2 Test AC2: mock a response with missing callsign, altitude, category; assert defaults are applied and no exception raised
    • 6.3 Test AC3: mock requests.get to raise requests.Timeout; assert HttpFetcher.fetch() propagates it (does not catch it)
    • 6.4 Test AC4: mock a response where the MLAT aircraft has "mlat": ["lat", "lon"]; assert is_mlat=True
    • 6.5 Test AC5: point FileFixtureFetcher at tests/fixtures/aircraft_sample.json; assert the expected list[Aircraft] is returned with no requests.get call made
  • Task 7: Update tests/test_models.py — verify dataclass (AC: #1, #2)

    • 7.1 Confirm test_aircraft_defaults and test_aircraft_full (already present from story 1.1 QA) still pass after the full dataclass is in place — no stub test needed, these are real assertions
    • 7.2 Add a test for the altitude edge-case if not already covered: create Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=0) and assert altitude_ft == 0
  • Task 8: Run quality gates

    • 8.1 pytest tests/ — all tests pass, 0 failures
    • 8.2 ruff check . — zero violations
    • 8.3 ruff format --check . — no formatting issues

Dev Notes

Critical Context

DUMP1090_URL constant: Add to src/planemapper/constants.py:

DUMP1090_URL = "http://localhost:8080/data/aircraft.json"

FETCH_TIMEOUT_S = 5 is already present in constants.py — do not duplicate it.

Aircraft dataclass — already partially stubbed: src/planemapper/models.py already contains the full Aircraft dataclass as specified in the architecture. Verify it matches exactly before replacing. The test_models.py tests (test_aircraft_defaults, test_aircraft_full) are also already in place from story 1.1 and test the real dataclass — confirm they pass.

altitude edge case: dump1090 can return altitude as an integer (feet), the string "ground", or omit the field entirely. The safe pattern:

raw_alt = entry.get("altitude", 0)
altitude_ft = int(raw_alt) if isinstance(raw_alt, int) else 0

Do NOT do int(str_val) — the string "ground" will raise ValueError.

flight field whitespace: dump1090 pads callsigns with trailing spaces (e.g. "BAW123 "). Always .strip():

callsign = entry.get("flight", "").strip()

MLAT detection:

is_mlat = bool(entry.get("mlat"))

An empty list [] is falsy → False. A non-empty list ["lat", "lon"] is truthy → True. A missing key defaults to NoneFalse.

Aircraft entries without position: Some dump1090 entries lack lat/lon (e.g. aircraft heard only via Mode S squawk with no ADS-B position). Skip them — they cannot be rendered on the map:

if "lat" not in entry or "lon" not in entry:
    continue

FetcherInterface is a Protocol — no explicit inheritance: HttpFetcher and FileFixtureFetcher satisfy FetcherInterface structurally. Do NOT write class HttpFetcher(FetcherInterface)Protocol subclassing for implementation is an antipattern in Python typing.

Shared parse logic: Both HttpFetcher and FileFixtureFetcher must use the same _parse_aircraft(entry: dict) -> Aircraft helper. Do not duplicate field-mapping logic between the two classes.

Test isolation for FileFixtureFetcher: In the AC5 test, pass the actual path to tests/fixtures/aircraft_sample.json. Use pathlib.Path(__file__).parent / "fixtures" / "aircraft_sample.json" to get an absolute path that works regardless of the working directory when pytest is invoked.

Mock strategy for HttpFetcher tests: Use unittest.mock.patch("planemapper.fetcher.requests.get") to intercept the HTTP call. Configure the mock's return value with .json.return_value = {"aircraft": [...]}. For the timeout test, use side_effect=requests.Timeout.

dump1090 JSON shape (top-level):

{
  "aircraft": [
    {
      "hex": "4ca7f2",
      "lat": 53.3498,
      "lon": -6.2603,
      "flight": "EIN123  ",
      "altitude": 12000,
      "category": "A3",
      "mlat": []
    }
  ]
}

Fixture file — recommended four-entry shape:

{
  "aircraft": [
    {
      "hex": "4ca7f2", "lat": 53.3498, "lon": -6.2603,
      "flight": "EIN123  ", "altitude": 12000, "category": "A3", "mlat": []
    },
    {
      "hex": "4001a1", "lat": 53.4200, "lon": -6.1100,
      "altitude": 5000, "category": "A1", "mlat": []
    },
    {
      "hex": "4002b2", "lat": 53.2800, "lon": -6.4000,
      "flight": "RYR456  ", "category": "A3", "mlat": []
    },
    {
      "hex": "4003c3", "lat": 53.5000, "lon": -5.9000,
      "flight": "MIL001  ", "altitude": 1500, "category": "B1",
      "mlat": ["lat", "lon"]
    }
  ]
}