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

177 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
- [x] Task 1: Implement `Aircraft` dataclass in `src/planemapper/models.py` (AC: #1, #2, #4)
- [x] 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`
- [x] 1.2 Add `from __future__ import annotations` and `from dataclasses import dataclass` imports
- [x] 1.3 Confirm `ruff check .` passes with zero violations after change
- [x] Task 2: Add `DUMP1090_URL` constant to `src/planemapper/constants.py` (AC: #1, #3)
- [x] 2.1 Add `DUMP1090_URL = "http://localhost:8080/data/aircraft.json"` to `constants.py`
- [x] 2.2 Confirm existing constants are unaffected and `ruff check .` passes
- [x] Task 3: Implement `HttpFetcher` in `src/planemapper/fetcher.py` (AC: #1, #2, #3, #4)
- [x] 3.1 Add imports: `import requests`, `from pathlib import Path`, `from planemapper.constants import DUMP1090_URL, FETCH_TIMEOUT_S`
- [x] 3.2 Implement `_parse_aircraft(entry: dict) -> Aircraft` private module-level helper:
- Map `hex``icao`
- Map `lat``lat`, `lon``lon`
- Map `flight``callsign` with `.strip()` and default `""`
- Map `altitude``altitude_ft`: use `int(val)` if `isinstance(val, int)` else `0` (handles string `"ground"` and missing)
- Map `category``category` with default `""`
- Map `mlat``is_mlat`: `bool(entry.get("mlat"))` (empty list → `False`, non-empty → `True`)
- `is_stale` always defaults to `False`
- [x] 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
- [x] 3.4 Confirm `HttpFetcher` satisfies `FetcherInterface` structurally (no explicit inheritance needed)
- [x] Task 4: Implement `FileFixtureFetcher` in `src/planemapper/fetcher.py` (AC: #5)
- [x] 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`
- [x] 4.2 Confirm `FileFixtureFetcher` satisfies `FetcherInterface` structurally
- [x] Task 5: Update `tests/fixtures/aircraft_sample.json` with realistic dump1090 data (AC: #1, #2, #4, #5)
- [x] 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"]`)
- [x] 5.2 Ensure all four entries have `lat` and `lon` so none are skipped
- [x] Task 6: Write tests in `tests/test_fetcher.py` covering all 5 ACs (AC: #1#5)
- [x] 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
- [x] 6.2 Test AC2: mock a response with missing `callsign`, `altitude`, `category`; assert defaults are applied and no exception raised
- [x] 6.3 Test AC3: mock `requests.get` to raise `requests.Timeout`; assert `HttpFetcher.fetch()` propagates it (does not catch it)
- [x] 6.4 Test AC4: mock a response where the MLAT aircraft has `"mlat": ["lat", "lon"]`; assert `is_mlat=True`
- [x] 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
- [x] Task 7: Update `tests/test_models.py` — verify dataclass (AC: #1, #2)
- [x] 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
- [x] 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`
- [x] Task 8: Run quality gates
- [x] 8.1 `pytest tests/` — all tests pass, 0 failures
- [x] 8.2 `ruff check .` — zero violations
- [x] 8.3 `ruff format --check .` — no formatting issues
## Dev Notes
### Critical Context
**DUMP1090_URL constant:**
Add to `src/planemapper/constants.py`:
```python
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:
```python
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()`:
```python
callsign = entry.get("flight", "").strip()
```
**MLAT detection:**
```python
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 `None``False`.
**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:
```python
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):**
```json
{
"aircraft": [
{
"hex": "4ca7f2",
"lat": 53.3498,
"lon": -6.2603,
"flight": "EIN123 ",
"altitude": 12000,
"category": "A3",
"mlat": []
}
]
}
```
**Fixture file — recommended four-entry shape:**
```json
{
"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"]
}
]
}
```