bmad: create story 2-2 (coordinate projection & base map loading)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+87
@@ -0,0 +1,87 @@
|
|||||||
|
# Story 2.2: Coordinate Projection & Base Map Loading
|
||||||
|
|
||||||
|
Status: ready-for-dev
|
||||||
|
|
||||||
|
## Story
|
||||||
|
|
||||||
|
As the renderer,
|
||||||
|
I want a `MapBounds` dataclass and a `project()` function converting `(lat, lon)` to pixel `(x, y)`, and a basemap module that loads `background.png` into memory once,
|
||||||
|
So that all rendering uses consistent coordinates and the base map is always available without disk I/O in the loop.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
AC1: **Given** a `MapBounds` from home lat/lon and coverage radius **When** `project(lat, lon, bounds)` is called with the home location **Then** it returns pixel coordinates at the centre of the 800×480 display (±2px)
|
||||||
|
|
||||||
|
AC2: **Given** `project()` is called with a position outside the map bounds **When** the result is used **Then** the returned pixel coordinate is outside display dimensions — no clamping, callers handle clipping
|
||||||
|
|
||||||
|
AC3: **Given** `background.png` exists at `BACKGROUND_PATH` **When** `basemap.load()` is called **Then** it returns a `PIL.Image` (800×480) loaded into memory
|
||||||
|
|
||||||
|
AC4: **Given** `background.png` does not exist at `BACKGROUND_PATH` **When** `basemap.load()` is called **Then** it raises `FileNotFoundError` (logged as ERROR by the caller)
|
||||||
|
|
||||||
|
## Tasks / Subtasks
|
||||||
|
|
||||||
|
- [ ] Task 1: Define `MapBounds` dataclass in `src/planemapper/renderer/projection.py` (AC: #1, #2)
|
||||||
|
- [ ] 1.1 Replace `# stub` with full implementation
|
||||||
|
- [ ] 1.2 Add imports: `from __future__ import annotations`, `from dataclasses import dataclass, field`, `import math`
|
||||||
|
- [ ] 1.3 Define `MapBounds` dataclass fields: `home_lat: float`, `home_lon: float`, `radius_nm: float`, `width: int = DISPLAY_WIDTH`, `height: int = DISPLAY_HEIGHT`
|
||||||
|
- [ ] 1.4 Import `DISPLAY_WIDTH` and `DISPLAY_HEIGHT` from `planemapper.constants`
|
||||||
|
|
||||||
|
- [ ] Task 2: Implement `project()` in `src/planemapper/renderer/projection.py` (AC: #1, #2)
|
||||||
|
- [ ] 2.1 Signature: `def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]:`
|
||||||
|
- [ ] 2.2 Equirectangular linear mapping: map `(home_lat, home_lon)` to `(width//2, height//2)`
|
||||||
|
- [ ] 2.3 Scale: `deg_per_nm_lat = 1/60`; `deg_per_nm_lon = 1/(60 * math.cos(math.radians(bounds.home_lat)))`
|
||||||
|
- [ ] 2.4 Pixel scale: `px_per_nm_x = (bounds.width / 2) / bounds.radius_nm`; `px_per_nm_y = (bounds.height / 2) / bounds.radius_nm`
|
||||||
|
- [ ] 2.5 Convert: `x = bounds.width // 2 + int((lon - bounds.home_lon) / deg_per_nm_lon * px_per_nm_x)`; `y = bounds.height // 2 - int((lat - bounds.home_lat) / deg_per_nm_lat * px_per_nm_y)` (y-axis inverted — screen Y increases downward)
|
||||||
|
- [ ] 2.6 Return `(x, y)`
|
||||||
|
|
||||||
|
- [ ] Task 3: Implement `basemap.load()` in `src/planemapper/renderer/basemap.py` (AC: #3, #4)
|
||||||
|
- [ ] 3.1 Replace `# stub` with full implementation
|
||||||
|
- [ ] 3.2 Add imports: `from PIL import Image`; `from planemapper.constants import BACKGROUND_PATH`
|
||||||
|
- [ ] 3.3 Signature: `def load() -> Image.Image:`
|
||||||
|
- [ ] 3.4 Open with `Image.open(BACKGROUND_PATH)`, call `.copy()` to force full load into memory (avoids lazy read)
|
||||||
|
- [ ] 3.5 Let `FileNotFoundError` propagate naturally — do NOT catch it
|
||||||
|
|
||||||
|
- [ ] Task 4: Write tests in `tests/test_projection.py` (AC: #1, #2)
|
||||||
|
- [ ] 4.1 Replace the existing placeholder test (`def test_placeholder(): pass`)
|
||||||
|
- [ ] 4.2 Test AC1: Create `MapBounds(home_lat=53.0, home_lon=-6.0, radius_nm=100.0)`, call `project(53.0, -6.0, bounds)`, assert result is `(400, 240)` exactly (home maps to centre)
|
||||||
|
- [ ] 4.3 Test AC2: Call `project` with a point well outside bounds (e.g. 10 degrees away), assert returned pixel is outside display dimensions (< 0 or > 800 or > 480)
|
||||||
|
|
||||||
|
- [ ] Task 5: Write tests in `tests/test_basemap.py` (AC: #3, #4)
|
||||||
|
- [ ] 5.1 Create `tests/test_basemap.py`
|
||||||
|
- [ ] 5.2 Test AC3: Create a real 800×480 PNG in `tmp_path`, monkeypatch `planemapper.renderer.basemap.BACKGROUND_PATH` to that path, call `basemap.load()`, assert returns `Image` of size `(800, 480)`
|
||||||
|
- [ ] 5.3 Test AC4: Monkeypatch `BACKGROUND_PATH` to a nonexistent path, call `basemap.load()`, assert `FileNotFoundError` is raised
|
||||||
|
|
||||||
|
- [ ] Task 6: Run quality gates
|
||||||
|
- [ ] 6.1 `python -m pytest tests/` — all tests pass, 0 failures
|
||||||
|
- [ ] 6.2 `python -m ruff check .` — zero violations
|
||||||
|
- [ ] 6.3 `python -m ruff format --check .` — no formatting issues
|
||||||
|
|
||||||
|
## Dev Notes
|
||||||
|
|
||||||
|
### Critical Context
|
||||||
|
|
||||||
|
**Module locations (both exist as `# stub`):**
|
||||||
|
- `src/planemapper/renderer/projection.py` — add `MapBounds` dataclass + `project()` function
|
||||||
|
- `src/planemapper/renderer/basemap.py` — add `load()` function
|
||||||
|
|
||||||
|
**Constants already defined in `src/planemapper/constants.py`:**
|
||||||
|
```python
|
||||||
|
DISPLAY_WIDTH = 800
|
||||||
|
DISPLAY_HEIGHT = 480
|
||||||
|
BACKGROUND_PATH = Path("/etc/planemapper/background.png")
|
||||||
|
```
|
||||||
|
|
||||||
|
**Projection formula detail:**
|
||||||
|
The equirectangular projection maps a ~100nm radius around home to the full display. Latitude is simple (1 nm = 1/60 degree). Longitude must account for convergence at non-equatorial latitudes: `deg_per_nm_lon = 1 / (60 * cos(home_lat_radians))`. The y-axis is inverted because screen pixel Y increases downward while geographic latitude increases upward.
|
||||||
|
|
||||||
|
**`MapBounds` default field values** use `DISPLAY_WIDTH` (800) and `DISPLAY_HEIGHT` (480) as defaults — these are module-level constants, safe to use as default values in a dataclass without `field(default_factory=...)`.
|
||||||
|
|
||||||
|
**`basemap.load()` must force pixels into memory.** `PIL.Image.open()` is lazy by default — the file handle stays open and the pixel data is not read until accessed. Calling `.copy()` forces an immediate full decode and returns a new in-memory `Image`. Do not use `.load()` alone (it reads pixels but keeps the original file handle open).
|
||||||
|
|
||||||
|
**`FileNotFoundError` from `basemap.load()`.** `PIL.Image.open()` raises `FileNotFoundError` naturally when the path does not exist. Do not add a try/except — the caller (the radar loop) is responsible for logging ERROR and handling the failure.
|
||||||
|
|
||||||
|
**Coordinate convention:** internal code uses `(lat, lon)` order throughout — do not swap to `(lon, lat)`.
|
||||||
|
|
||||||
|
**Existing test file:** `tests/test_projection.py` contains only `def test_placeholder(): pass` — replace it entirely; do not add alongside the placeholder.
|
||||||
|
|
||||||
|
**No existing `tests/test_basemap.py`** — create this file from scratch.
|
||||||
@@ -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 # 2-1 done, epic-2 in-progress
|
last_updated: 2026-04-22 # 2-1 done, 2-2 ready-for-dev, epic-2 in-progress
|
||||||
project: planeMapper
|
project: planeMapper
|
||||||
project_key: NOKEY
|
project_key: NOKEY
|
||||||
tracking_system: file-system
|
tracking_system: file-system
|
||||||
@@ -54,7 +54,7 @@ development_status:
|
|||||||
# Epic 2: Live Radar Display
|
# Epic 2: Live Radar Display
|
||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-aircraft-data-model-and-fetcher: done
|
2-1-aircraft-data-model-and-fetcher: done
|
||||||
2-2-coordinate-projection-and-base-map-loading: backlog
|
2-2-coordinate-projection-and-base-map-loading: ready-for-dev
|
||||||
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
|
||||||
2-5-per-aircraft-drawing: backlog
|
2-5-per-aircraft-drawing: backlog
|
||||||
|
|||||||
Reference in New Issue
Block a user