diff --git a/app.py b/app.py index d7dd6a6..6ec8e65 100644 --- a/app.py +++ b/app.py @@ -350,12 +350,6 @@ def api_grade( description="days to grade after the target (observed, or forecast when future; " "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 = ( pd.Timestamp(date).normalize() if date @@ -417,11 +411,6 @@ def api_calendar( a database read — across restarts — until new archive days arrive. Returns a 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) with audit.RunAudit( @@ -480,11 +469,6 @@ def api_day( (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. """ - 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) with audit.RunAudit( @@ -538,11 +522,6 @@ def api_forecast( 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. """ - 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) 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; 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) today = pd.Timestamp(datetime.date.today()) diff --git a/climate.py b/climate.py index 8febef2..7e4cb73 100644 --- a/climate.py +++ b/climate.py @@ -549,8 +549,10 @@ def reverse_geocode(lat: float, lon: float) -> str | None: "https://nominatim.openstreetmap.org/reverse", # 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). + # accept-language=en keeps labels in one script worldwide (matches + # the forward geocoder's language=en). {"lat": lat, "lon": lon, "format": "jsonv2", "zoom": 14, - "addressdetails": 1}, + "addressdetails": 1, "accept-language": "en"}, 15, phase="reverse_geocode", 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]: - """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( "https://geocoding-api.open-meteo.com/v1/search", {"name": name, "count": count, "language": "en", "format": "json"}, diff --git a/grid.py b/grid.py index 421e8cf..c038ca6 100644 --- a/grid.py +++ b/grid.py @@ -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) 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. +Coverage is worldwide: any lat in [-90, 90] and any longitude (wrapped into +[-180, 180)) maps to a cell. """ import math @@ -18,13 +20,20 @@ def _lon_step(center_lat: float) -> float: return LAT_STEP / c -def snap(lat: float, lon: float) -> dict: - """Return the grid cell (id + center + span) containing (lat, lon).""" - i = math.floor(lat / LAT_STEP) +def _cell(i: int, j: int) -> dict: + """Build the cell dict for grid indices (i, j). Shared by snap()/from_id() + so an id always rebuilds to the exact same cell.""" center_lat = (i + 0.5) * LAT_STEP lon_step = _lon_step(center_lat) - j = math.floor(lon / 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. 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: """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 filename alone. Raises ValueError on a malformed id.""" i, j = (int(p) for p in cell_id.split("_")) - center_lat = (i + 0.5) * LAT_STEP - 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 + return _cell(i, j)