app/weather/ec.py -- api.weather.gc.ca's MSC GeoMet OGC API
(citypageweather-realtime collection), the modern replacement for the
old dd.weatheroffice.gc.ca XML feed (that host no longer resolves).
Unlike Open-Meteo/NWS's simple lat/lon REST, this collection is only
queryable by bounding box, so _nearest_site widens the box
progressively and picks the closest of the ~844 sites by straight-line
distance -- capped at 300km, calibrated against a real bug caught in
development where an unconditional "nearest site, however far" matched
a Miami, FL query to a site in Ontario 1824km away once the box widened
to cover the whole country.
EC's own numeric icon codes get a small confirmed-against-live-data
mapping table plus the same keyword-on-condition-text fallback NWS
already uses for anything unmapped. Daily periods are named ("Today"/
"Tonight"/"Tuesday"/...) rather than dated, so dates are inferred by
walking them in issued order.
Verified end-to-end against the real live API (Toronto, rural
Saskatchewan, a US border city, and a rejected far-away match) and
through the browser (daily mode, composited panel preview). Test
fixtures mirror the actual response shapes captured live. docs/
widgets.md and CLAUDE.md's TODO updated -- EC is no longer a documented
gap.
65 lines
2.8 KiB
Python
65 lines
2.8 KiB
Python
"""Weather provider registry -- the app/widgets/ "dispatch registry over
|
|
pluggable implementations" pattern applied to weather data sources
|
|
instead of widget types. Three providers, all free/no API key:
|
|
Open-Meteo (app/weather/open_meteo.py, worldwide), NWS (app/weather/
|
|
nws.py, US-only), and Environment Canada (app/weather/ec.py, Canada-only,
|
|
its own bbox/nearest-site lookup shape rather than simple lat/lon REST --
|
|
see that module's own docstring).
|
|
|
|
geocode_city stays Open-Meteo-backed regardless of which provider is
|
|
chosen to actually fetch forecasts -- it's just free-text-name-to-lat/lon
|
|
resolution, done once when a location is added, and Open-Meteo's
|
|
geocoder covers the whole world where NWS's own data plainly doesn't.
|
|
|
|
Every provider module exposes the same four functions:
|
|
geocode_city(name) -> {"label", "latitude", "longitude"} (open_meteo only, see above)
|
|
fetch_current(lat, lon, units) -> {"temp", "category"}
|
|
fetch_hourly(lat, lon, units, hours) -> [{"time", "temp", "category"}, ...]
|
|
fetch_daily(lat, lon, units, days) -> {"YYYY-MM-DD": {"high", "low", "category"}, ...}
|
|
"category" is always one of the shared set (clear/partly_cloudy/cloudy/
|
|
fog/rain/snow/thunderstorm) that app/weather_render.py's icon-drawing
|
|
knows how to draw -- callers never need to know which provider supplied
|
|
an entry.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
class WeatherFetchError(Exception):
|
|
"""Geocoding or forecast fetch failed -- network, no match, an
|
|
unexpected response shape, or (NWS) a location outside its US
|
|
coverage. Raised loudly; callers (the dialog's location-set/preview
|
|
endpoints, get_or_refresh_*_for_widget) decide what to do."""
|
|
|
|
|
|
from . import ec, nws, open_meteo # noqa: E402 -- after WeatherFetchError, which all three submodules import
|
|
|
|
# Re-exported for existing call sites (routers/common.py, routers/
|
|
# api_widgets.py, calendar_render.py) -- all Open-Meteo-only and
|
|
# untouched by the provider abstraction below.
|
|
from .open_meteo import ( # noqa: E402,F401
|
|
CHECK_INTERVAL_S,
|
|
fetch_daily_forecast,
|
|
geocode_city,
|
|
weather_category,
|
|
)
|
|
|
|
PROVIDERS = {"open_meteo": open_meteo, "nws": nws, "ec": ec}
|
|
PROVIDER_LABELS = {
|
|
"open_meteo": "Open-Meteo",
|
|
"nws": "National Weather Service (US)",
|
|
"ec": "Environment Canada",
|
|
}
|
|
|
|
|
|
def fetch_current(provider: str, latitude: float, longitude: float, units: str) -> dict:
|
|
return PROVIDERS[provider].fetch_current(latitude, longitude, units)
|
|
|
|
|
|
def fetch_hourly(provider: str, latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
|
return PROVIDERS[provider].fetch_hourly(latitude, longitude, units, hours)
|
|
|
|
|
|
def fetch_daily(provider: str, latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
|
return PROVIDERS[provider].fetch_daily(latitude, longitude, units, days)
|