Worldwide coverage: grade any point on Earth (#32)

Remove the US+Canada bounding box so every endpoint accepts any lat/lon.
The grading pipeline was already global-ready (ERA5 archive, timezone=auto,
day-of-year climatology), so opening it up is mostly deleting the guard —
plus the edge cases that only exist once the whole globe is in play:

- grid.py: snap() wraps longitude into [-180, 180) and clamps latitude, and
  cell centers are normalized so the polar row and the cells straddling the
  antimeridian always report valid coordinates to the weather/geocoding APIs.
  snap() and from_id() now share one _cell() builder, making id round-trips
  exact by construction (verified with a 300k-point global sweep).
- nav.js: neighbor-cell prefetch skips rows past the poles and wraps
  longitudes across the dateline instead of sending out-of-range queries.
- Nominatim reverse geocoding requests accept-language=en so place labels
  render in one script worldwide (matching the forward geocoder).
- mappicker: search suggestions are no longer filtered to US/CA, the
  placeholder and default map view are worldwide.
- calendar: season filter labels flip for southern-hemisphere locations
  (Dec-Feb shows as Summer); the underlying month groups are unchanged, so
  saved filter selections keep meaning the same months.

Verified end-to-end on a scratch server: Tokyo and Sydney grade with real
labels, a Fiji cell on the antimeridian's east edge builds and serves warm
hits from the derived store, and prefetch=1 on a cold cell still answers
204 without spending weather-API quota.
This commit is contained in:
Emi Griffith 2026-07-11 08:02:28 -07:00 committed by GitHub
parent 36db92022e
commit e819079bda
3 changed files with 27 additions and 40 deletions

26
app.py
View file

@ -350,12 +350,6 @@ def api_grade(
description="days to grade after the target (observed, or forecast when future; " description="days to grade after the target (observed, or forecast when future; "
"the forecast reaches ~7 days out so future days cap there)"), "the forecast reaches ~7 days out so future days cap there)"),
): ):
if not grid.in_north_america(lat, lon):
raise HTTPException(
status_code=400,
detail="Location is outside the supported US + Canada region.",
)
target = ( target = (
pd.Timestamp(date).normalize() pd.Timestamp(date).normalize()
if date if date
@ -417,11 +411,6 @@ def api_calendar(
a database read across restarts until new archive days arrive. Returns a a database read across restarts until new archive days arrive. Returns a
compact per-day shape (see grading.grade_range). New in API v2. compact per-day shape (see grading.grade_range). New in API v2.
""" """
if not grid.in_north_america(lat, lon):
raise HTTPException(
status_code=400,
detail="Location is outside the supported US + Canada region.",
)
cell = grid.snap(lat, lon) cell = grid.snap(lat, lon)
with audit.RunAudit( with audit.RunAudit(
@ -480,11 +469,6 @@ def api_day(
(those payloads expire hourly the recent bundle's own cadence — while fully (those payloads expire hourly the recent bundle's own cadence — while fully
archived days stay valid until the record itself advances). New in API v2. archived days stay valid until the record itself advances). New in API v2.
""" """
if not grid.in_north_america(lat, lon):
raise HTTPException(
status_code=400,
detail="Location is outside the supported US + Canada region.",
)
cell = grid.snap(lat, lon) cell = grid.snap(lat, lon)
with audit.RunAudit( with audit.RunAudit(
@ -538,11 +522,6 @@ def api_forecast(
updates; the graded payload's validity is tied to that fetch stamp, so it updates; the graded payload's validity is tied to that fetch stamp, so it
expires exactly when a new forecast lands. New in API v2. expires exactly when a new forecast lands. New in API v2.
""" """
if not grid.in_north_america(lat, lon):
raise HTTPException(
status_code=400,
detail="Location is outside the supported US + Canada region.",
)
cell = grid.snap(lat, lon) cell = grid.snap(lat, lon)
with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4), with audit.RunAudit(endpoint="forecast", lat=round(lat, 4), lon=round(lon, 4),
@ -600,11 +579,6 @@ def api_cell(
Reverse geocoding makes at most one Nominatim call for a never-labeled cell; Reverse geocoding makes at most one Nominatim call for a never-labeled cell;
the client staggers neighbor prefetches to respect that service. New in API v2. the client staggers neighbor prefetches to respect that service. New in API v2.
""" """
if not grid.in_north_america(lat, lon):
raise HTTPException(
status_code=400,
detail="Location is outside the supported US + Canada region.",
)
cell = grid.snap(lat, lon) cell = grid.snap(lat, lon)
today = pd.Timestamp(datetime.date.today()) today = pd.Timestamp(datetime.date.today())

View file

@ -549,8 +549,10 @@ def reverse_geocode(lat: float, lon: float) -> str | None:
"https://nominatim.openstreetmap.org/reverse", "https://nominatim.openstreetmap.org/reverse",
# zoom 14 resolves to the suburb/neighbourhood level so we can lead # zoom 14 resolves to the suburb/neighbourhood level so we can lead
# with it when OSM has one (zoom 10 only ever returns the city). # with it when OSM has one (zoom 10 only ever returns the city).
# accept-language=en keeps labels in one script worldwide (matches
# the forward geocoder's language=en).
{"lat": lat, "lon": lon, "format": "jsonv2", "zoom": 14, {"lat": lat, "lon": lon, "format": "jsonv2", "zoom": 14,
"addressdetails": 1}, "addressdetails": 1, "accept-language": "en"},
15, 15,
phase="reverse_geocode", phase="reverse_geocode",
headers={"User-Agent": "Thermograph/0.1 (local weather grading app)"}, headers={"User-Agent": "Thermograph/0.1 (local weather grading app)"},
@ -575,7 +577,7 @@ def reverse_geocode(lat: float, lon: float) -> str | None:
def geocode(name: str, count: int = 5) -> list[dict]: def geocode(name: str, count: int = 5) -> list[dict]:
"""Look up places by name (US/Canada biased) via Open-Meteo's geocoder.""" """Look up places by name worldwide via Open-Meteo's geocoder."""
r = _request( r = _request(
"https://geocoding-api.open-meteo.com/v1/search", "https://geocoding-api.open-meteo.com/v1/search",
{"name": name, "count": count, "language": "en", "format": "json"}, {"name": name, "count": count, "language": "en", "format": "json"},

35
grid.py
View file

@ -4,6 +4,8 @@ The grid is defined by fixed latitude rows (~2 miles tall). Within each row the
longitude step is scaled by cos(latitude) so cells stay roughly square (~4 sq mi) longitude step is scaled by cos(latitude) so cells stay roughly square (~4 sq mi)
at every latitude instead of getting skinny toward the poles. Cell ids are at every latitude instead of getting skinny toward the poles. Cell ids are
deterministic, so the same physical location always maps to the same cache file. deterministic, so the same physical location always maps to the same cache file.
Coverage is worldwide: any lat in [-90, 90] and any longitude (wrapped into
[-180, 180)) maps to a cell.
""" """
import math import math
@ -18,13 +20,20 @@ def _lon_step(center_lat: float) -> float:
return LAT_STEP / c return LAT_STEP / c
def snap(lat: float, lon: float) -> dict: def _cell(i: int, j: int) -> dict:
"""Return the grid cell (id + center + span) containing (lat, lon).""" """Build the cell dict for grid indices (i, j). Shared by snap()/from_id()
i = math.floor(lat / LAT_STEP) so an id always rebuilds to the exact same cell."""
center_lat = (i + 0.5) * LAT_STEP center_lat = (i + 0.5) * LAT_STEP
lon_step = _lon_step(center_lat) lon_step = _lon_step(center_lat)
j = math.floor(lon / lon_step)
center_lon = (j + 0.5) * lon_step center_lon = (j + 0.5) * lon_step
# Keep the reported center a valid coordinate for the upstream weather and
# geocoding APIs: the topmost row's center overshoots the pole, and a row's
# outermost cells can have centers just past the antimeridian on either side.
center_lat = min(max(center_lat, -90.0), 90.0)
if center_lon > 180.0:
center_lon -= 360.0
elif center_lon < -180.0:
center_lon += 360.0
# Approximate cell dimensions in miles for display. # Approximate cell dimensions in miles for display.
height_mi = LAT_STEP * 69.0 height_mi = LAT_STEP * 69.0
@ -46,16 +55,18 @@ def snap(lat: float, lon: float) -> dict:
} }
def snap(lat: float, lon: float) -> dict:
"""Return the grid cell (id + center + span) containing (lat, lon)."""
lat = min(max(lat, -90.0), 90.0)
lon = ((lon + 180.0) % 360.0) - 180.0 # wrap into [-180, 180)
i = math.floor(lat / LAT_STEP)
j = math.floor(lon / _lon_step((i + 0.5) * LAT_STEP))
return _cell(i, j)
def from_id(cell_id: str) -> dict: def from_id(cell_id: str) -> dict:
"""Rebuild the full cell dict from a cache id ("i_j") — the inverse of snap(). """Rebuild the full cell dict from a cache id ("i_j") — the inverse of snap().
Lets offline tooling (the migrate script) recover a cell from its parquet Lets offline tooling (the migrate script) recover a cell from its parquet
filename alone. Raises ValueError on a malformed id.""" filename alone. Raises ValueError on a malformed id."""
i, j = (int(p) for p in cell_id.split("_")) i, j = (int(p) for p in cell_id.split("_"))
center_lat = (i + 0.5) * LAT_STEP return _cell(i, j)
center_lon = (j + 0.5) * _lon_step(center_lat)
return snap(center_lat, center_lon)
def in_north_america(lat: float, lon: float) -> bool:
"""Rough bounding box for the US (incl. Alaska/Hawaii) and Canada."""
return 14.0 <= lat <= 84.0 and -172.0 <= lon <= -52.0