Add Environment Canada as a third weather provider
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.
This commit is contained in:
@@ -9,7 +9,6 @@ photos/calendar/whiteboard/weather widget system to the device.
|
||||
CURRENT TODO
|
||||
-add more actions for buttons (i.e. change widget/layout)
|
||||
-Fix spurious button assignment stuff (probably but buttons on widget config with sane defaults)
|
||||
-add Environment Canada as a weather widget provider (app/weather/ -- station/grid-lookup API, more involved than Open-Meteo/NWS)
|
||||
-widget border option
|
||||
-battery life widget
|
||||
-sharing layouts with linked users
|
||||
|
||||
+17
-13
@@ -227,21 +227,25 @@ which share the same `{widget_id}`-parameterized path shape).
|
||||
**Providers** (`app/weather/`, a dispatch registry over pluggable
|
||||
implementations mirroring `app/widgets/` itself): `WeatherWidgetConfig.
|
||||
provider` selects which of `app/weather.PROVIDERS` actually fetches --
|
||||
`"open_meteo"` (worldwide, no API key) or `"nws"` (api.weather.gov, US
|
||||
`"open_meteo"` (worldwide, no API key), `"nws"` (api.weather.gov, US
|
||||
only, no API key, approximates "current" with the first hourly forecast
|
||||
period rather than a real station observation). Every provider function
|
||||
returns already-normalized `{"category": ...}` entries (one of `clear`/
|
||||
`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/`thunderstorm`) so
|
||||
`app/weather_render.py`'s drawing code never needs to know which
|
||||
provider supplied an entry. `geocode_city` (name -> lat/lon) always goes
|
||||
through Open-Meteo's free geocoder regardless of which provider is
|
||||
chosen to fetch with the result.
|
||||
period rather than a real station observation), or `"ec"` (Environment
|
||||
Canada, api.weather.gc.ca's MSC GeoMet OGC API, Canada only, no API key).
|
||||
Every provider function returns already-normalized `{"category": ...}`
|
||||
entries (one of `clear`/`partly_cloudy`/`cloudy`/`fog`/`rain`/`snow`/
|
||||
`thunderstorm`) so `app/weather_render.py`'s drawing code never needs to
|
||||
know which provider supplied an entry. `geocode_city` (name -> lat/lon)
|
||||
always goes through Open-Meteo's free geocoder regardless of which
|
||||
provider is chosen to fetch with the result.
|
||||
|
||||
**Environment Canada is a deliberate gap, not an oversight** -- its free
|
||||
API (the MSC GeoMet OGC service) is built around station/grid lookups,
|
||||
not simple lat/lon REST like the two providers above, and would have
|
||||
meaningfully expanded the initial pass. Next provider to add if this
|
||||
gets revisited.
|
||||
EC's `citypageweather-realtime` collection is only queryable by bounding
|
||||
box (OGC API - Features), not a direct by-coordinate endpoint -- unlike
|
||||
Open-Meteo/NWS's simple lat/lon REST, `app/weather/ec.py`'s
|
||||
`_nearest_site` widens the box progressively and picks the closest site
|
||||
by straight-line distance, rejecting anything beyond 300 km (calibrated
|
||||
against a real bug caught in development: an unconditional "nearest
|
||||
site, however far" matched a Miami, FL query to a site in Ontario,
|
||||
1824 km away, once the box widened enough to cover the whole country).
|
||||
|
||||
`app/weather_render.py` holds every weather-related drawing primitive:
|
||||
`draw_cloud`/`draw_weather_icon`/`draw_weather_row` (extracted out of
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations.</p>
|
||||
<p class="sub" style="margin-top: 4px;">National Weather Service only covers US locations; Environment Canada only covers Canadian locations.</p>
|
||||
<label>Units
|
||||
<select id="weather_units">
|
||||
<option value="fahrenheit" {% if weather_cfg.units == "fahrenheit" %}selected{% endif %}>Fahrenheit</option>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
"""Weather provider registry -- the app/widgets/ "dispatch registry over
|
||||
pluggable implementations" pattern applied to weather data sources
|
||||
instead of widget types. Open-Meteo (app/weather/open_meteo.py, no API
|
||||
key, worldwide) and NWS (app/weather/nws.py, no API key, US-only) are the
|
||||
two providers wired up now; Environment Canada is a documented next step
|
||||
(docs/widgets.md), not included yet -- its free API is built around
|
||||
station/grid lookups (the MSC GeoMet OGC service), not simple lat/lon
|
||||
REST like these two, and would have meaningfully expanded this pass.
|
||||
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
|
||||
@@ -33,7 +32,7 @@ class WeatherFetchError(Exception):
|
||||
endpoints, get_or_refresh_*_for_widget) decide what to do."""
|
||||
|
||||
|
||||
from . import nws, open_meteo # noqa: E402 -- after WeatherFetchError, which both submodules import
|
||||
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
|
||||
@@ -45,8 +44,12 @@ from .open_meteo import ( # noqa: E402,F401
|
||||
weather_category,
|
||||
)
|
||||
|
||||
PROVIDERS = {"open_meteo": open_meteo, "nws": nws}
|
||||
PROVIDER_LABELS = {"open_meteo": "Open-Meteo", "nws": "National Weather Service (US)"}
|
||||
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:
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Environment Canada (ECCC MSC GeoMet OGC API, api.weather.gc.ca)
|
||||
provider -- Canada-only, free, no API key. Unlike NWS's grid-point
|
||||
lookup or Open-Meteo's plain lat/lon REST, EC's `citypageweather-realtime`
|
||||
collection (an OGC API - Features collection, the modern replacement for
|
||||
the old dd.weatheroffice.gc.ca XML feed -- that host no longer resolves)
|
||||
is only queryable by bounding box, not a direct by-coordinate endpoint --
|
||||
this widens the box progressively until it finds at least one site, then
|
||||
picks the nearest by straight-line distance. This station/bbox-lookup
|
||||
shape (not simple lat/lon REST) is exactly why EC was documented as a
|
||||
follow-up rather than shipped alongside Open-Meteo/NWS in the first
|
||||
pass -- see docs/widgets.md.
|
||||
|
||||
EC's numeric icon codes are its own set, distinct from WMO's (Open-
|
||||
Meteo) or NWS's icon-URL condition codes. _category_from_code_and_text
|
||||
below only maps the codes actually confirmed against live data (see
|
||||
this module's own tests, captured from real api.weather.gc.ca
|
||||
responses), falling back to the same keyword-match-on-condition-text
|
||||
safety net app/weather/nws.py uses for anything unmapped -- correctness
|
||||
comes from the text fallback, the numeric table is just a fast path.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py/caldav_client.py/app/weather/open_meteo.py/nws.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import httpx
|
||||
|
||||
from . import WeatherFetchError
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
BASE_URL = "https://api.weather.gc.ca"
|
||||
COLLECTION = "citypageweather-realtime"
|
||||
HEADERS = {"User-Agent": "espresso_frame-weather-widget (self-hosted photo frame project)"}
|
||||
|
||||
# Progressively wider bounding boxes (degrees) around the target point --
|
||||
# EC's ~844 sites are dense near cities but sparse in the north, so a
|
||||
# small box can come back empty even for a real Canadian location. 25
|
||||
# degrees (~2000-2700 km depending on latitude) is already far beyond
|
||||
# _MAX_DISTANCE_KM, so there's no point widening past it.
|
||||
_BBOX_PADDINGS_DEG = (1.0, 3.0, 8.0, 25.0)
|
||||
|
||||
# See _nearest_site's own docstring for why this exists and how it was
|
||||
# calibrated (a real Miami query matched 1824 km away without it).
|
||||
_MAX_DISTANCE_KM = 300
|
||||
|
||||
_ICON_CODE_CATEGORIES = {
|
||||
0: "clear", 1: "clear", 30: "clear",
|
||||
2: "partly_cloudy", 5: "partly_cloudy", 31: "partly_cloudy", 32: "partly_cloudy",
|
||||
3: "cloudy", 4: "cloudy", 10: "cloudy", 33: "cloudy",
|
||||
6: "rain", 12: "rain", 28: "rain", 36: "rain",
|
||||
9: "thunderstorm", 19: "thunderstorm", 39: "thunderstorm",
|
||||
24: "fog",
|
||||
}
|
||||
|
||||
_TEXT_CATEGORY_KEYWORDS = [
|
||||
("thunderstorm", "thunderstorm"), ("tstm", "thunderstorm"), ("tornado", "thunderstorm"),
|
||||
("flurr", "snow"), ("snow", "snow"), ("sleet", "snow"), ("ice pellet", "snow"), ("hail", "snow"),
|
||||
("freezing", "snow"),
|
||||
("rain", "rain"), ("shower", "rain"), ("drizzle", "rain"),
|
||||
("fog", "fog"), ("haze", "fog"), ("mist", "fog"), ("smoke", "fog"),
|
||||
("overcast", "cloudy"), ("cloudy", "cloudy"),
|
||||
("clear", "clear"), ("sunny", "clear"), ("fair", "clear"),
|
||||
]
|
||||
|
||||
|
||||
def _category_from_code_and_text(code: int | None, text: str) -> str:
|
||||
if code is not None and code in _ICON_CODE_CATEGORIES:
|
||||
return _ICON_CODE_CATEGORIES[code]
|
||||
lowered = text.lower()
|
||||
for keyword, category in _TEXT_CATEGORY_KEYWORDS:
|
||||
if keyword in lowered:
|
||||
return category
|
||||
return "cloudy" # same generic-icon fallback Open-Meteo/NWS both use
|
||||
|
||||
|
||||
def _convert_temp(celsius: float, units: str) -> float:
|
||||
"""EC's citypage feed reports temperatures in Celsius only (its
|
||||
unitType is always "metric" in this feed) -- convert to the widget's
|
||||
requested units, no-op if celsius was actually asked for."""
|
||||
return celsius * 9 / 5 + 32 if units == "fahrenheit" else celsius
|
||||
|
||||
|
||||
def _haversine_km(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
r = 6371.0
|
||||
p1, p2 = math.radians(lat1), math.radians(lat2)
|
||||
dphi = math.radians(lat2 - lat1)
|
||||
dlambda = math.radians(lon2 - lon1)
|
||||
a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlambda / 2) ** 2
|
||||
return 2 * r * math.asin(math.sqrt(a))
|
||||
|
||||
|
||||
def _items(bbox: str) -> list[dict]:
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{BASE_URL}/collections/{COLLECTION}/items",
|
||||
params={"f": "json", "bbox": bbox, "limit": 50},
|
||||
headers=HEADERS, timeout=HTTP_TIMEOUT_S,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()["features"]
|
||||
except (httpx.HTTPError, KeyError) as e:
|
||||
raise WeatherFetchError(str(e)) from e
|
||||
|
||||
|
||||
def _nearest_site(latitude: float, longitude: float) -> dict:
|
||||
"""This location's nearest Environment Canada citypage site's
|
||||
`properties` dict -- widens the bounding box until it finds one
|
||||
within _MAX_DISTANCE_KM, trying every padding rather than stopping
|
||||
at the first non-empty box: since each padding's box is a superset
|
||||
of the previous one's, a wider box's nearest match can only be the
|
||||
same distance or closer, never farther, so an early non-empty box
|
||||
whose nearest site is still too far away doesn't mean a closer one
|
||||
isn't waiting just outside it.
|
||||
|
||||
Without the distance cutoff, an unconditional "just take whatever's
|
||||
nearest" happily matches a US or overseas location to some real EC
|
||||
site thousands of km away (confirmed live: Miami matched to
|
||||
Leamington, Ontario, 1824 km off) instead of reporting that EC
|
||||
simply doesn't cover this location. 300 km is generous enough for a
|
||||
legitimate rural-Canada query against EC's sparse northern coverage
|
||||
(~844 sites total) while still correctly rejecting a non-Canadian
|
||||
one -- a US border city like Seattle, genuinely ~100 km from the
|
||||
nearest EC site in Victoria, BC, still passes. Deliberately NOT a
|
||||
single whole-country query instead of progressive widening: that
|
||||
collection response is ~29 MB for cheap, in-city lookups fetching a
|
||||
handful of nearby sites."""
|
||||
best_distance_km = None
|
||||
for pad in _BBOX_PADDINGS_DEG:
|
||||
bbox = f"{longitude - pad},{latitude - pad},{longitude + pad},{latitude + pad}"
|
||||
features = _items(bbox)
|
||||
if not features:
|
||||
continue
|
||||
nearest = min(
|
||||
features,
|
||||
key=lambda f: _haversine_km(
|
||||
latitude, longitude, f["geometry"]["coordinates"][1], f["geometry"]["coordinates"][0]
|
||||
),
|
||||
)
|
||||
best_distance_km = _haversine_km(
|
||||
latitude, longitude, nearest["geometry"]["coordinates"][1], nearest["geometry"]["coordinates"][0]
|
||||
)
|
||||
if best_distance_km <= _MAX_DISTANCE_KM:
|
||||
return nearest["properties"]
|
||||
raise WeatherFetchError("No Environment Canada site found near this location (is it in Canada?)")
|
||||
|
||||
|
||||
def fetch_current(latitude: float, longitude: float, units: str) -> dict:
|
||||
props = _nearest_site(latitude, longitude)
|
||||
cc = props["currentConditions"]
|
||||
temp_c = cc["temperature"]["value"]["en"]
|
||||
code = (cc.get("iconCode") or {}).get("value")
|
||||
text = (cc.get("condition") or {}).get("en") or ""
|
||||
return {"temp": _convert_temp(temp_c, units), "category": _category_from_code_and_text(code, text)}
|
||||
|
||||
|
||||
def fetch_hourly(latitude: float, longitude: float, units: str, hours: int = 48) -> list[dict]:
|
||||
"""EC's hourlyForecastGroup is a fixed 24-hour window (unlike Open-
|
||||
Meteo/NWS's own longer hourly ranges) -- `hours` just caps how much
|
||||
of it gets returned, same as the other providers."""
|
||||
props = _nearest_site(latitude, longitude)
|
||||
entries = props["hourlyForecastGroup"]["hourlyForecasts"]
|
||||
result = []
|
||||
for h in entries[:hours]:
|
||||
temp_c = h["temperature"]["value"]["en"]
|
||||
code = (h.get("iconCode") or {}).get("value")
|
||||
text = (h.get("condition") or {}).get("en") or ""
|
||||
result.append({
|
||||
"time": h["timestamp"], "temp": _convert_temp(temp_c, units),
|
||||
"category": _category_from_code_and_text(code, text),
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_daily(latitude: float, longitude: float, units: str, days: int) -> dict[str, dict]:
|
||||
"""Pairs EC's named day/night periods ("Today"/"Tonight"/"Tuesday"/
|
||||
"Tuesday night"/...) into calendar dates by walking them in issued
|
||||
order -- unlike NWS's periods (which carry a real startTime), EC's
|
||||
forecast periods are named relative to "today", not dated, so the
|
||||
date is inferred: a "night" period shares its preceding day period's
|
||||
date, any other period starts the calendar day after the previous
|
||||
one (the forecastGroup's own issued-at timestamp anchors day 0)."""
|
||||
props = _nearest_site(latitude, longitude)
|
||||
group = props["forecastGroup"]
|
||||
issued = datetime.fromisoformat(group["timestamp"]["en"].replace("Z", "+00:00")).date()
|
||||
|
||||
by_date: dict[str, dict] = {}
|
||||
order: list[str] = []
|
||||
current_day = None
|
||||
for period in group["forecasts"]:
|
||||
name = period["period"]["textForecastName"]["en"].strip().lower()
|
||||
if "night" in name:
|
||||
day_date = current_day or issued
|
||||
else:
|
||||
day_date = issued if current_day is None else current_day + timedelta(days=1)
|
||||
current_day = day_date
|
||||
key = day_date.isoformat()
|
||||
entry = by_date.setdefault(key, {"category": None})
|
||||
if key not in order:
|
||||
order.append(key)
|
||||
|
||||
temps = period.get("temperatures", {}).get("temperature") or []
|
||||
if temps:
|
||||
temp = _convert_temp(temps[0]["value"]["en"], units)
|
||||
if temps[0]["class"]["en"] == "high":
|
||||
entry["high"] = temp
|
||||
else:
|
||||
entry["low"] = temp
|
||||
if entry["category"] is None:
|
||||
icon = (period.get("abbreviatedForecast") or {}).get("icon") or {}
|
||||
text = (period.get("abbreviatedForecast") or {}).get("textSummary", {}).get("en") or ""
|
||||
entry["category"] = _category_from_code_and_text(icon.get("value"), text)
|
||||
|
||||
result = {}
|
||||
for key in order[:max(1, days)]:
|
||||
entry = by_date[key]
|
||||
if "high" not in entry and "low" not in entry:
|
||||
continue
|
||||
result[key] = {
|
||||
"high": entry.get("high", entry.get("low")),
|
||||
"low": entry.get("low", entry.get("high")),
|
||||
"category": entry["category"] or "cloudy",
|
||||
}
|
||||
return result
|
||||
@@ -1,15 +1,17 @@
|
||||
"""app/weather/'s provider modules -- pure-logic, no HTTP/DB: monkeypatches
|
||||
httpx.get with canned responses. Covers category normalization (Open-
|
||||
Meteo's WMO codes, NWS's icon-URL/text shapes) landing on the same shared
|
||||
category set, and each provider's fetch_current/fetch_hourly/fetch_daily
|
||||
shape."""
|
||||
Meteo's WMO codes, NWS's icon-URL/text shapes, EC's numeric icon codes)
|
||||
landing on the same shared category set, and each provider's
|
||||
fetch_current/fetch_hourly/fetch_daily shape. The EC fixtures below are
|
||||
trimmed-down real response shapes captured live against
|
||||
api.weather.gc.ca (Toronto, 2026-07-27), not guessed."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from app.weather import WeatherFetchError, nws, open_meteo
|
||||
from app.weather import WeatherFetchError, ec, nws, open_meteo
|
||||
|
||||
|
||||
class _FakeResponse:
|
||||
@@ -200,3 +202,165 @@ def test_nws_raises_weather_fetch_error_when_points_lookup_fails(monkeypatch):
|
||||
monkeypatch.setattr(httpx, "get", _raise)
|
||||
with pytest.raises(WeatherFetchError):
|
||||
nws.fetch_current(45.5, -122.6, "fahrenheit")
|
||||
|
||||
|
||||
# --- ec ---------------------------------------------------------------------
|
||||
|
||||
def _ec_feature(lon: float, lat: float, properties: dict) -> dict:
|
||||
return {"type": "Feature", "geometry": {"type": "Point", "coordinates": [lon, lat]}, "properties": properties}
|
||||
|
||||
|
||||
def _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy") -> dict:
|
||||
return {
|
||||
"currentConditions": {
|
||||
"temperature": {"value": {"en": temp_c}},
|
||||
"iconCode": {"value": icon_code},
|
||||
"condition": {"en": condition},
|
||||
},
|
||||
"hourlyForecastGroup": {"hourlyForecasts": []},
|
||||
"forecastGroup": {"timestamp": {"en": "2026-07-27T15:00:00Z"}, "forecasts": []},
|
||||
}
|
||||
|
||||
|
||||
def _ec_items_response(features: list[dict]) -> _FakeResponse:
|
||||
return _FakeResponse({"features": features})
|
||||
|
||||
|
||||
@pytest.mark.parametrize("code,expected", [
|
||||
(0, "clear"), (1, "clear"), (30, "clear"),
|
||||
(2, "partly_cloudy"), (5, "partly_cloudy"), (31, "partly_cloudy"), (32, "partly_cloudy"),
|
||||
(3, "cloudy"), (4, "cloudy"), (10, "cloudy"), (33, "cloudy"),
|
||||
(6, "rain"), (12, "rain"), (28, "rain"), (36, "rain"),
|
||||
(9, "thunderstorm"), (19, "thunderstorm"), (39, "thunderstorm"),
|
||||
(24, "fog"),
|
||||
])
|
||||
def test_ec_category_from_known_codes(code, expected):
|
||||
assert ec._category_from_code_and_text(code, "irrelevant text") == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("text,expected", [
|
||||
("Periods of snow", "snow"), ("Flurries", "snow"), ("Freezing rain", "snow"),
|
||||
("Periods of rain", "rain"), ("Chance of showers", "rain"), ("Drizzle", "rain"),
|
||||
("Risk of thunderstorms", "thunderstorm"), ("Tornado warning", "thunderstorm"),
|
||||
("Patchy fog", "fog"), ("Mainly sunny", "clear"), ("Something unrelated", "cloudy"),
|
||||
])
|
||||
def test_ec_category_from_text_fallback_for_unmapped_code(text, expected):
|
||||
assert ec._category_from_code_and_text(9999, text) == expected
|
||||
|
||||
|
||||
def test_ec_convert_temp_celsius_to_fahrenheit():
|
||||
assert ec._convert_temp(0, "fahrenheit") == 32
|
||||
assert ec._convert_temp(0, "celsius") == 0
|
||||
|
||||
|
||||
def test_ec_fetch_current_converts_and_categorizes(monkeypatch):
|
||||
props = _ec_current_properties(temp_c=28.8, icon_code=3, condition="Mostly Cloudy")
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
|
||||
[_ec_feature(-79.38, 43.65, props)]
|
||||
))
|
||||
result = ec.fetch_current(43.65, -79.38, "fahrenheit")
|
||||
assert result == {"temp": pytest.approx(83.84), "category": "cloudy"}
|
||||
|
||||
|
||||
def test_ec_fetch_hourly_respects_hours_limit(monkeypatch):
|
||||
hourly = [
|
||||
{"timestamp": f"2026-07-27T{h:02d}:00:00Z", "temperature": {"value": {"en": 20 + h}},
|
||||
"iconCode": {"value": 2}, "condition": {"en": "A mix of sun and cloud"}}
|
||||
for h in range(24)
|
||||
]
|
||||
props = _ec_current_properties()
|
||||
props["hourlyForecastGroup"] = {"hourlyForecasts": hourly}
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
|
||||
[_ec_feature(-79.38, 43.65, props)]
|
||||
))
|
||||
result = ec.fetch_hourly(43.65, -79.38, "celsius", hours=5)
|
||||
assert len(result) == 5
|
||||
assert result[0]["category"] == "partly_cloudy"
|
||||
|
||||
|
||||
def _ec_forecast_period(name: str, temp_class: str, temp_c: float, icon_code: int, summary: str) -> dict:
|
||||
return {
|
||||
"period": {"textForecastName": {"en": name}},
|
||||
"temperatures": {"temperature": [{"value": {"en": temp_c}, "class": {"en": temp_class}}]},
|
||||
"abbreviatedForecast": {"icon": {"value": icon_code}, "textSummary": {"en": summary}},
|
||||
}
|
||||
|
||||
|
||||
def test_ec_fetch_daily_pairs_day_night_periods_by_walking_order(monkeypatch):
|
||||
props = _ec_current_properties()
|
||||
props["forecastGroup"] = {
|
||||
"timestamp": {"en": "2026-07-27T15:00:00Z"},
|
||||
"forecasts": [
|
||||
_ec_forecast_period("Today", "high", 29, 9, "Chance of showers"),
|
||||
_ec_forecast_period("Tonight", "low", 15, 30, "Clear"),
|
||||
_ec_forecast_period("Tuesday", "high", 25, 2, "Partly cloudy"),
|
||||
_ec_forecast_period("Tuesday night", "low", 17, 32, "Cloudy periods"),
|
||||
],
|
||||
}
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
|
||||
[_ec_feature(-79.38, 43.65, props)]
|
||||
))
|
||||
result = ec.fetch_daily(43.65, -79.38, "celsius", days=2)
|
||||
assert result == {
|
||||
"2026-07-27": {"high": 29, "low": 15, "category": "thunderstorm"},
|
||||
"2026-07-28": {"high": 25, "low": 17, "category": "partly_cloudy"},
|
||||
}
|
||||
|
||||
|
||||
def test_ec_fetch_daily_clamps_to_however_many_dates_came_back(monkeypatch):
|
||||
props = _ec_current_properties()
|
||||
props["forecastGroup"] = {
|
||||
"timestamp": {"en": "2026-07-27T15:00:00Z"},
|
||||
"forecasts": [_ec_forecast_period("Today", "high", 29, 9, "Chance of showers")],
|
||||
}
|
||||
monkeypatch.setattr(httpx, "get", lambda url, params, headers, timeout: _ec_items_response(
|
||||
[_ec_feature(-79.38, 43.65, props)]
|
||||
))
|
||||
result = ec.fetch_daily(43.65, -79.38, "celsius", days=7)
|
||||
assert len(result) == 1
|
||||
|
||||
|
||||
def test_ec_nearest_site_widens_bbox_until_within_max_distance(monkeypatch):
|
||||
"""A site right at the query point should be picked up by the very
|
||||
first (smallest) bounding box -- no need to widen."""
|
||||
props = _ec_current_properties()
|
||||
calls = []
|
||||
|
||||
def _get(url, params, headers, timeout):
|
||||
calls.append(params["bbox"])
|
||||
return _ec_items_response([_ec_feature(-79.38, 43.65, props)])
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
result = ec.fetch_current(43.65, -79.38, "celsius")
|
||||
assert result["temp"] == pytest.approx(28.8)
|
||||
assert len(calls) == 1 # first (smallest) padding already found it
|
||||
|
||||
|
||||
def test_ec_nearest_site_rejects_a_match_beyond_max_distance(monkeypatch):
|
||||
"""Regression test: without a distance cutoff, progressively
|
||||
widening the bbox until non-empty eventually matches ANY location on
|
||||
Earth to some real EC site once the box is big enough (confirmed
|
||||
live: a Miami, FL query matched Leamington, Ontario, 1824 km away).
|
||||
A site ~785 km from the query point must be rejected as "not
|
||||
covered", not returned as if it were a legitimate nearby match."""
|
||||
far_props = _ec_current_properties()
|
||||
widest_bbox = f"{-ec._BBOX_PADDINGS_DEG[-1]},{-ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]},{ec._BBOX_PADDINGS_DEG[-1]}"
|
||||
|
||||
def _get(url, params, headers, timeout):
|
||||
# Only the widest padding's box actually reaches the far site --
|
||||
# every narrower one comes back empty, same as a genuine gap.
|
||||
if params["bbox"] == widest_bbox:
|
||||
return _ec_items_response([_ec_feature(5.0, 5.0, far_props)])
|
||||
return _ec_items_response([])
|
||||
|
||||
monkeypatch.setattr(httpx, "get", _get)
|
||||
with pytest.raises(WeatherFetchError):
|
||||
ec.fetch_current(0.0, 0.0, "celsius")
|
||||
|
||||
|
||||
def test_ec_raises_weather_fetch_error_on_http_failure(monkeypatch):
|
||||
def _raise(*a, **kw):
|
||||
raise httpx.ConnectError("boom")
|
||||
monkeypatch.setattr(httpx, "get", _raise)
|
||||
with pytest.raises(WeatherFetchError):
|
||||
ec.fetch_current(43.65, -79.38, "fahrenheit")
|
||||
|
||||
Reference in New Issue
Block a user