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:
@@ -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