Files
planeMapper/_bmad-output/implementation-artifacts/2-2-coordinate-projection-and-base-map-loading.md
T
Matt Edholm 34e3736c10 Review story 2.2: coordinate projection and base map loading passes all ACs
All 10 review criteria pass without fixes. Deferred two tech-debt items
(equirectangular distortion at high latitudes, missing dimension assertion
in basemap.load()). Story and sprint-status marked done.

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

88 lines
6.1 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.2: Coordinate Projection & Base Map Loading
Status: done
## 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
- [x] Task 1: Define `MapBounds` dataclass in `src/planemapper/renderer/projection.py` (AC: #1, #2)
- [x] 1.1 Replace `# stub` with full implementation
- [x] 1.2 Add imports: `from __future__ import annotations`, `from dataclasses import dataclass, field`, `import math`
- [x] 1.3 Define `MapBounds` dataclass fields: `home_lat: float`, `home_lon: float`, `radius_nm: float`, `width: int = DISPLAY_WIDTH`, `height: int = DISPLAY_HEIGHT`
- [x] 1.4 Import `DISPLAY_WIDTH` and `DISPLAY_HEIGHT` from `planemapper.constants`
- [x] Task 2: Implement `project()` in `src/planemapper/renderer/projection.py` (AC: #1, #2)
- [x] 2.1 Signature: `def project(lat: float, lon: float, bounds: MapBounds) -> tuple[int, int]:`
- [x] 2.2 Equirectangular linear mapping: map `(home_lat, home_lon)` to `(width//2, height//2)`
- [x] 2.3 Scale: `deg_per_nm_lat = 1/60`; `deg_per_nm_lon = 1/(60 * math.cos(math.radians(bounds.home_lat)))`
- [x] 2.4 Pixel scale: `px_per_nm_x = (bounds.width / 2) / bounds.radius_nm`; `px_per_nm_y = (bounds.height / 2) / bounds.radius_nm`
- [x] 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)
- [x] 2.6 Return `(x, y)`
- [x] Task 3: Implement `basemap.load()` in `src/planemapper/renderer/basemap.py` (AC: #3, #4)
- [x] 3.1 Replace `# stub` with full implementation
- [x] 3.2 Add imports: `from PIL import Image`; `from planemapper.constants import BACKGROUND_PATH`
- [x] 3.3 Signature: `def load() -> Image.Image:`
- [x] 3.4 Open with `Image.open(BACKGROUND_PATH)`, call `.copy()` to force full load into memory (avoids lazy read)
- [x] 3.5 Let `FileNotFoundError` propagate naturally — do NOT catch it
- [x] Task 4: Write tests in `tests/test_projection.py` (AC: #1, #2)
- [x] 4.1 Replace the existing placeholder test (`def test_placeholder(): pass`)
- [x] 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)
- [x] 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)
- [x] Task 5: Write tests in `tests/test_basemap.py` (AC: #3, #4)
- [x] 5.1 Create `tests/test_basemap.py`
- [x] 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)`
- [x] 5.3 Test AC4: Monkeypatch `BACKGROUND_PATH` to a nonexistent path, call `basemap.load()`, assert `FileNotFoundError` is raised
- [x] Task 6: Run quality gates
- [x] 6.1 `python -m pytest tests/` — all tests pass, 0 failures
- [x] 6.2 `python -m ruff check .` — zero violations
- [x] 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.