From b9ccdc49162847f928a6adbcfc390e31bd509708 Mon Sep 17 00:00:00 2001 From: Matt Edholm Date: Wed, 22 Apr 2026 23:19:33 -0400 Subject: [PATCH] bmad: create story 2-4 (altitude colour bands and aircraft type icons) Co-Authored-By: Claude Sonnet 4.6 --- ...de-colour-bands-and-aircraft-type-icons.md | 116 ++++++++++++++++++ .../sprint-status.yaml | 4 +- 2 files changed, 118 insertions(+), 2 deletions(-) create mode 100644 _bmad-output/implementation-artifacts/2-4-altitude-colour-bands-and-aircraft-type-icons.md diff --git a/_bmad-output/implementation-artifacts/2-4-altitude-colour-bands-and-aircraft-type-icons.md b/_bmad-output/implementation-artifacts/2-4-altitude-colour-bands-and-aircraft-type-icons.md new file mode 100644 index 0000000..2add946 --- /dev/null +++ b/_bmad-output/implementation-artifacts/2-4-altitude-colour-bands-and-aircraft-type-icons.md @@ -0,0 +1,116 @@ +# Story 2.4: Altitude Colour Bands & Aircraft Type Icons + +Status: ready-for-dev + +## Story + +As the renderer, +I want pure functions mapping an aircraft's altitude to a display colour and its ADS-B category/callsign to an icon type, +So that every aircraft is consistently colour-coded and type-classified with all logic centralised. + +## Acceptance Criteria + +AC1: **Given** `altitude_ft` values at the exact boundaries in `ALTITUDE_BANDS_FT` **When** `altitude_to_colour(altitude_ft)` is called **Then** the correct `ALTITUDE_COLOURS` entry is returned for each boundary and above/below it — all 6 palette colours reachable + +AC2: **Given** an `Aircraft` with `category="A1"` (light aircraft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.GA_LIGHT` + +AC3: **Given** an `Aircraft` with a BA callsign pattern (e.g. `"BAW123"`) and no category **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.COMMERCIAL` + +AC4: **Given** an `Aircraft` with `category="A7"` (helicopter) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.HELICOPTER` + +AC5: **Given** an `Aircraft` with no category and `altitude_ft=5000` (< 10,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.GA_LIGHT` (altitude fallback) + +AC6: **Given** an `Aircraft` with no category and `altitude_ft=18000` (10,000–30,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.PRIVATE_JET` + +AC7: **Given** an `Aircraft` with no category and `altitude_ft=38000` (> 30,000ft) **When** `classify_aircraft_type(aircraft)` is called **Then** it returns `AircraftType.AIRLINER` + +## Tasks / Subtasks + +- [ ] Task 1: Implement `altitude_to_colour()` in `src/planemapper/renderer/colours.py` (AC: #1) + - [ ] 1.1 Replace `# stub` with full implementation + - [ ] 1.2 Imports: `from planemapper.constants import ALTITUDE_BANDS_FT, ALTITUDE_COLOURS` + - [ ] 1.3 Signature: `def altitude_to_colour(altitude_ft: int) -> tuple[int, int, int]:` + - [ ] 1.4 Iterate `ALTITUDE_BANDS_FT` with `enumerate`; return `ALTITUDE_COLOURS[i]` for the first band where `altitude_ft <= band` + +- [ ] Task 2: Implement `AircraftType` enum and `classify_aircraft_type()` in `src/planemapper/renderer/icons.py` (AC: #2–#7) + - [ ] 2.1 Replace `# stub` with full implementation + - [ ] 2.2 Imports: `from enum import Enum`; `from planemapper.models import Aircraft` + - [ ] 2.3 Define `AircraftType` enum: + ```python + from enum import Enum + class AircraftType(Enum): + GA_LIGHT = "ga_light" + COMMERCIAL = "commercial" + PRIVATE_JET = "private_jet" + AIRLINER = "airliner" + HELICOPTER = "helicopter" + MILITARY = "military" + UNKNOWN = "unknown" + ``` + - [ ] 2.4 Define `AIRLINE_PREFIXES` as a `frozenset` of 3-letter ICAO codes: `{"BAW", "EIN", "RYR", "EZY", "THY", "DLH", "AFR", "IBE", "KLM", "UAE", "SWR"}` + - [ ] 2.5 Signature: `def classify_aircraft_type(aircraft: Aircraft) -> AircraftType:` + - [ ] 2.6 Implement priority logic (in order): + 1. `category == "A7"` → `HELICOPTER` + 2. `category` in `{"A1", "A2"}` → `GA_LIGHT` + 3. `category` in `{"A3", "A4", "A5"}` → `COMMERCIAL` + 4. Callsign starts with a known airline prefix (first 3 chars in `AIRLINE_PREFIXES`) → `COMMERCIAL` + 5. Altitude fallback (no category, no recognised callsign): + - `altitude_ft < 10000` → `GA_LIGHT` + - `10000 <= altitude_ft < 30000` → `PRIVATE_JET` + - `altitude_ft >= 30000` → `AIRLINER` + 6. Default → `UNKNOWN` + +- [ ] Task 3: Write tests in `tests/test_colours.py` (AC: #1) + - [ ] 3.1 Replace `def test_placeholder(): pass` with real tests + - [ ] 3.2 Test boundary values: 0, 1500, 1501, 5000, 5001, 10000, 10001, 20000, 20001, 35000, 35001 — assert each returns the expected colour from `ALTITUDE_COLOURS` + - `0` → `COLOUR_GREEN` (≤ 1500) + - `1500` → `COLOUR_GREEN` (boundary inclusive) + - `1501` → `COLOUR_BLUE` (≤ 5000) + - `5000` → `COLOUR_BLUE` (boundary inclusive) + - `5001` → `COLOUR_YELLOW` (≤ 10000) + - `10000` → `COLOUR_YELLOW` (boundary inclusive) + - `10001` → `COLOUR_RED` (≤ 20000) + - `20000` → `COLOUR_RED` (boundary inclusive) + - `20001` → `COLOUR_BLACK` (≤ 35000) + - `35000` → `COLOUR_BLACK` (boundary inclusive) + - `35001` → `COLOUR_WHITE` (≤ 99999, last band catches all) + - [ ] 3.3 Verify all 6 colours are reachable (assert the union of test return values equals the full `ALTITUDE_COLOURS` set) + +- [ ] Task 4: Write tests in `tests/test_icons.py` (AC: #2–#7) + - [ ] 4.1 Replace `def test_placeholder(): pass` with real tests + - [ ] 4.2 One test function per AC: + - `test_category_a1_returns_ga_light` — `Aircraft(icao="X", lat=0.0, lon=0.0, category="A1")` → `AircraftType.GA_LIGHT` (AC2) + - `test_ba_callsign_returns_commercial` — `Aircraft(icao="X", lat=0.0, lon=0.0, callsign="BAW123")` → `AircraftType.COMMERCIAL` (AC3) + - `test_category_a7_returns_helicopter` — `Aircraft(icao="X", lat=0.0, lon=0.0, category="A7")` → `AircraftType.HELICOPTER` (AC4) + - `test_no_category_low_altitude_returns_ga_light` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=5000)` → `AircraftType.GA_LIGHT` (AC5) + - `test_no_category_mid_altitude_returns_private_jet` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=18000)` → `AircraftType.PRIVATE_JET` (AC6) + - `test_no_category_high_altitude_returns_airliner` — `Aircraft(icao="X", lat=0.0, lon=0.0, altitude_ft=38000)` → `AircraftType.AIRLINER` (AC7) + - [ ] 4.3 Use `Aircraft(icao="X", lat=0.0, lon=0.0, ...)` to construct test aircraft; supply only the fields relevant to each AC + +- [ ] Task 5: Run quality gates + - [ ] 5.1 `python -m pytest tests/` — all tests pass + - [ ] 5.2 `python -m ruff check .` — zero violations + - [ ] 5.3 `python -m ruff format --check .` — no formatting issues + +## Implementation Notes + +### `altitude_to_colour()` boundary behaviour + +`ALTITUDE_BANDS_FT = [1500, 5000, 10000, 20000, 35000, 99999]` + +The function iterates the list and returns the colour for the **first** band where `altitude_ft <= band`. The final band `99999` is a catch-all, so values above 35000ft still map to `COLOUR_WHITE` (index 5). Values above 99999ft (if any) would also resolve to `COLOUR_WHITE` via the last band. + +### `classify_aircraft_type()` priority + +Category takes precedence over callsign, which takes precedence over altitude fallback. The `altitude_ft` field may be `None` on the `Aircraft` model; guard with `is not None` before numeric comparison in the altitude fallback branch. + +### `AIRLINE_PREFIXES` frozenset + +Using `frozenset` ensures O(1) membership testing and signals the constant is immutable: +```python +AIRLINE_PREFIXES: frozenset[str] = frozenset( + {"BAW", "EIN", "RYR", "EZY", "THY", "DLH", "AFR", "IBE", "KLM", "UAE", "SWR"} +) +``` + +Callsign prefix matching: `aircraft.callsign and aircraft.callsign[:3].upper() in AIRLINE_PREFIXES`. diff --git a/_bmad-output/implementation-artifacts/sprint-status.yaml b/_bmad-output/implementation-artifacts/sprint-status.yaml index b3eb9f9..fcbc96d 100644 --- a/_bmad-output/implementation-artifacts/sprint-status.yaml +++ b/_bmad-output/implementation-artifacts/sprint-status.yaml @@ -35,7 +35,7 @@ # - Dev moves story to 'review', then runs code-review (fresh context, different LLM recommended) generated: 2026-04-22 -last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, epic-2 in-progress +last_updated: 2026-04-22 # 2-1 done, 2-2 done, 2-3 done, 2-4 ready-for-dev, epic-2 in-progress project: planeMapper project_key: NOKEY tracking_system: file-system @@ -56,7 +56,7 @@ development_status: 2-1-aircraft-data-model-and-fetcher: done 2-2-coordinate-projection-and-base-map-loading: done 2-3-home-marker-and-airspace-outlines: done - 2-4-altitude-colour-bands-and-aircraft-type-icons: backlog + 2-4-altitude-colour-bands-and-aircraft-type-icons: ready-for-dev 2-5-per-aircraft-drawing: backlog 2-6-stateful-renderer-and-display-interface: backlog 2-7-operational-radar-loop-startup-screen-and-systemd-wiring: backlog