Implement story 2.2: coordinate projection and base map loading
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>
This commit is contained in:
+31
-31
@@ -1,6 +1,6 @@
|
||||
# Story 2.2: Coordinate Projection & Base Map Loading
|
||||
|
||||
Status: ready-for-dev
|
||||
Status: review
|
||||
|
||||
## Story
|
||||
|
||||
@@ -20,41 +20,41 @@ AC4: **Given** `background.png` does not exist at `BACKGROUND_PATH` **When** `ba
|
||||
|
||||
## 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`
|
||||
- [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`
|
||||
|
||||
- [ ] 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)`
|
||||
- [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)`
|
||||
|
||||
- [ ] 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
|
||||
- [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
|
||||
|
||||
- [ ] 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)
|
||||
- [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)
|
||||
|
||||
- [ ] 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
|
||||
- [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
|
||||
|
||||
- [ ] 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
|
||||
- [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
|
||||
|
||||
|
||||
@@ -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 ready-for-dev, epic-2 in-progress
|
||||
last_updated: 2026-04-22 # 2-1 done, 2-2 review, epic-2 in-progress
|
||||
project: planeMapper
|
||||
project_key: NOKEY
|
||||
tracking_system: file-system
|
||||
@@ -54,7 +54,7 @@ development_status:
|
||||
# Epic 2: Live Radar Display
|
||||
epic-2: in-progress
|
||||
2-1-aircraft-data-model-and-fetcher: done
|
||||
2-2-coordinate-projection-and-base-map-loading: ready-for-dev
|
||||
2-2-coordinate-projection-and-base-map-loading: review
|
||||
2-3-home-marker-and-airspace-outlines: backlog
|
||||
2-4-altitude-colour-bands-and-aircraft-type-icons: backlog
|
||||
2-5-per-aircraft-drawing: backlog
|
||||
|
||||
Reference in New Issue
Block a user