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:
parent
36db92022e
commit
e819079bda
3 changed files with 27 additions and 40 deletions
26
app.py
26
app.py
|
|
@ -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())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
35
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)
|
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
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue