Create story 2.1: aircraft data model and fetcher
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,176 @@
|
|||||||
|
# Story 2.1: Aircraft Data Model & Fetcher
|
||||||
|
|
||||||
|
Status: ready-for-dev
|
||||||
|
|
||||||
|
## 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 `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`
|
||||||
|
- [ ] 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`:
|
||||||
|
```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"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
@@ -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 # 1-5 review complete, epic-1 done
|
last_updated: 2026-04-22 # 2-1 story created, epic-2 in-progress
|
||||||
project: planeMapper
|
project: planeMapper
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -52,8 +52,8 @@ development_status:
|
|||||||
epic-1-retrospective: optional
|
epic-1-retrospective: optional
|
||||||
|
|
||||||
# Epic 2: Live Radar Display
|
# Epic 2: Live Radar Display
|
||||||
epic-2: backlog
|
epic-2: in-progress
|
||||||
2-1-aircraft-data-model-and-fetcher: backlog
|
2-1-aircraft-data-model-and-fetcher: ready-for-dev
|
||||||
2-2-coordinate-projection-and-base-map-loading: backlog
|
2-2-coordinate-projection-and-base-map-loading: backlog
|
||||||
2-3-home-marker-and-airspace-outlines: backlog
|
2-3-home-marker-and-airspace-outlines: backlog
|
||||||
2-4-altitude-colour-bands-and-aircraft-type-icons: backlog
|
2-4-altitude-colour-bands-and-aircraft-type-icons: backlog
|
||||||
|
|||||||
Reference in New Issue
Block a user