037ce3e193
Add MapBounds dataclass and equirectangular project() function in projection.py, basemap.load() forcing pixels into memory via .copy(), and full test coverage for both modules (4 new tests). All quality gates pass: 67 tests, ruff clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
88 lines
6.1 KiB
Markdown
88 lines
6.1 KiB
Markdown
# Story 2.2: Coordinate Projection & Base Map Loading
|
||
|
||
Status: review
|
||
|
||
## 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.
|