- Add polar stereographic coordinate transform (EPSG:3413/3031) in geo.py so geographic bboxes are properly converted to projected meters - Auto-detect image format: PNG for colormapped layers (preserves exact colors for query_point reverse-mapping), JPEG for true-color - get_imagery format parameter now defaults to "auto" instead of "jpeg" - Embed NDVI seasonal timelapse and polar stereo images in README - 96 tests passing
194 lines
5.9 KiB
Python
194 lines
5.9 KiB
Python
"""Tests for mcgibs.geo — geocoding and bbox helpers."""
|
|
|
|
import httpx
|
|
import respx
|
|
|
|
from mcgibs.geo import bbox_from_point, bbox_to_polar, expand_bbox, geocode
|
|
from mcgibs.models import BBox
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Helpers
|
|
# ---------------------------------------------------------------------------
|
|
|
|
NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
|
|
|
|
TOKYO_HIT = {
|
|
"display_name": "Tokyo, Japan",
|
|
"lat": "35.6762",
|
|
"lon": "139.6503",
|
|
"boundingbox": ["35.5191", "35.8170", "139.5601", "139.9200"],
|
|
"osm_type": "relation",
|
|
"importance": 0.82,
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# geocode() tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@respx.mock
|
|
async def test_geocode_success():
|
|
respx.get(NOMINATIM_URL).mock(
|
|
return_value=httpx.Response(200, json=[TOKYO_HIT]),
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
result = await geocode(client, "Tokyo")
|
|
|
|
assert result is not None
|
|
assert result.display_name == "Tokyo, Japan"
|
|
assert result.lat == 35.6762
|
|
assert result.lon == 139.6503
|
|
assert result.osm_type == "relation"
|
|
assert result.importance == 0.82
|
|
|
|
# BBox built from Nominatim's [south, north, west, east] order
|
|
assert result.bbox.south == 35.5191
|
|
assert result.bbox.north == 35.8170
|
|
assert result.bbox.west == 139.5601
|
|
assert result.bbox.east == 139.9200
|
|
|
|
|
|
@respx.mock
|
|
async def test_geocode_no_results():
|
|
respx.get(NOMINATIM_URL).mock(
|
|
return_value=httpx.Response(200, json=[]),
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
result = await geocode(client, "xyznonexistent")
|
|
|
|
assert result is None
|
|
|
|
|
|
@respx.mock
|
|
async def test_geocode_repeated_calls():
|
|
"""Each geocode() call makes an HTTP request (caching is caller's responsibility)."""
|
|
route = respx.get(NOMINATIM_URL).mock(
|
|
return_value=httpx.Response(200, json=[TOKYO_HIT]),
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
first = await geocode(client, "Tokyo")
|
|
second = await geocode(client, "Tokyo")
|
|
|
|
# geocode() no longer caches — GIBSClient.resolve_place() handles that
|
|
assert route.call_count == 2
|
|
assert first is not None
|
|
assert second is not None
|
|
assert first.display_name == second.display_name
|
|
|
|
|
|
@respx.mock
|
|
async def test_geocode_http_error():
|
|
"""A 500 response should return None without raising an exception."""
|
|
respx.get(NOMINATIM_URL).mock(
|
|
return_value=httpx.Response(500, text="Internal Server Error"),
|
|
)
|
|
|
|
async with httpx.AsyncClient() as client:
|
|
result = await geocode(client, "ServerError")
|
|
|
|
assert result is None
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# expand_bbox() tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_expand_bbox():
|
|
bbox = BBox(west=10.0, south=20.0, east=20.0, north=30.0)
|
|
expanded = expand_bbox(bbox, factor=0.1)
|
|
|
|
# Width is 10 deg, so 10% padding = 1 deg each side.
|
|
assert expanded.west < bbox.west
|
|
assert expanded.east > bbox.east
|
|
assert expanded.south < bbox.south
|
|
assert expanded.north > bbox.north
|
|
|
|
# Verify exact amounts (width = 10, dlon = 1; height = 10, dlat = 1).
|
|
assert expanded.west == 9.0
|
|
assert expanded.east == 21.0
|
|
assert expanded.south == 19.0
|
|
assert expanded.north == 31.0
|
|
|
|
|
|
def test_expand_bbox_clamping():
|
|
"""Expanding a bbox near the poles must clamp latitude to [-90, 90]."""
|
|
polar = BBox(west=-170.0, south=85.0, east=170.0, north=89.5)
|
|
expanded = expand_bbox(polar, factor=0.5)
|
|
|
|
assert expanded.north <= 90.0
|
|
assert expanded.south >= -90.0
|
|
# Longitude should also stay within bounds.
|
|
assert expanded.west >= -180.0
|
|
assert expanded.east <= 180.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bbox_from_point() tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_bbox_from_point():
|
|
bbox = bbox_from_point(35.0, 139.0, radius_deg=1.0)
|
|
|
|
assert bbox.south == 34.0
|
|
assert bbox.north == 36.0
|
|
assert bbox.west == 138.0
|
|
assert bbox.east == 140.0
|
|
|
|
|
|
def test_bbox_from_point_clamping():
|
|
"""A point near the pole should have its bbox clamped to valid lat range."""
|
|
bbox = bbox_from_point(89.5, 0.0, radius_deg=2.0)
|
|
|
|
assert bbox.north == 90.0 # clamped, not 91.5
|
|
assert bbox.south == 87.5
|
|
assert bbox.west == -2.0
|
|
assert bbox.east == 2.0
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# bbox_to_polar() tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_polar_full_arctic_extent():
|
|
"""Full Arctic bbox should return the GIBS documented extent."""
|
|
bbox = BBox(west=-180, south=60, east=180, north=90)
|
|
xmin, ymin, xmax, ymax = bbox_to_polar(bbox, "3413")
|
|
|
|
assert xmin == -4194304.0
|
|
assert ymin == -4194304.0
|
|
assert xmax == 4194304.0
|
|
assert ymax == 4194304.0
|
|
|
|
|
|
def test_polar_full_antarctic_extent():
|
|
"""Full Antarctic bbox should return the GIBS documented extent."""
|
|
bbox = BBox(west=-180, south=-90, east=180, north=-60)
|
|
xmin, _ymin, xmax, _ymax = bbox_to_polar(bbox, "3031")
|
|
|
|
assert xmin == -4194304.0
|
|
assert xmax == 4194304.0
|
|
|
|
|
|
def test_polar_subregion_is_bounded():
|
|
"""A small Arctic subregion should produce coordinates smaller than full extent."""
|
|
bbox = BBox(west=-60, south=70, east=-20, north=80)
|
|
xmin, ymin, xmax, ymax = bbox_to_polar(bbox, "3413")
|
|
|
|
assert -4194304 < xmin < xmax < 4194304
|
|
assert -4194304 < ymin < ymax < 4194304
|
|
|
|
|
|
def test_polar_passthrough_for_4326():
|
|
"""Non-polar EPSG should return geographic coords unchanged."""
|
|
bbox = BBox(west=-120, south=30, east=-110, north=40)
|
|
result = bbox_to_polar(bbox, "4326")
|
|
|
|
assert result == (-120, 30, -110, 40)
|