Files
planeMapper/_bmad-output/implementation-artifacts/2-2-coordinate-projection-and-base-map-loading.md
T
Matt Edholm 037ce3e193 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>
2026-04-22 23:10:38 -04:00

6.1 KiB
Raw Blame History

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

  • 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:

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.