Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
6.1 KiB
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
MapBoundsdataclass insrc/planemapper/renderer/projection.py(AC: #1, #2)- 1.1 Replace
# stubwith full implementation - 1.2 Add imports:
from __future__ import annotations,from dataclasses import dataclass, field,import math - 1.3 Define
MapBoundsdataclass fields:home_lat: float,home_lon: float,radius_nm: float,width: int = DISPLAY_WIDTH,height: int = DISPLAY_HEIGHT - 1.4 Import
DISPLAY_WIDTHandDISPLAY_HEIGHTfromplanemapper.constants
- 1.1 Replace
-
Task 2: Implement
project()insrc/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)
- 2.1 Signature:
-
Task 3: Implement
basemap.load()insrc/planemapper/renderer/basemap.py(AC: #3, #4)- 3.1 Replace
# stubwith 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
FileNotFoundErrorpropagate naturally — do NOT catch it
- 3.1 Replace
-
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), callproject(53.0, -6.0, bounds), assert result is(400, 240)exactly (home maps to centre) - 4.3 Test AC2: Call
projectwith a point well outside bounds (e.g. 10 degrees away), assert returned pixel is outside display dimensions (< 0 or > 800 or > 480)
- 4.1 Replace the existing placeholder test (
-
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, monkeypatchplanemapper.renderer.basemap.BACKGROUND_PATHto that path, callbasemap.load(), assert returnsImageof size(800, 480) - 5.3 Test AC4: Monkeypatch
BACKGROUND_PATHto a nonexistent path, callbasemap.load(), assertFileNotFoundErroris raised
- 5.1 Create
-
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
- 6.1
Dev Notes
Critical Context
Module locations (both exist as # stub):
src/planemapper/renderer/projection.py— addMapBoundsdataclass +project()functionsrc/planemapper/renderer/basemap.py— addload()function
Constants already defined in src/planemapper/constants.py:
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.