env-canada 0.7.2__py3-none-any.whl → 0.9.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- env_canada/__init__.py +11 -1
- env_canada/constants.py +1 -1
- env_canada/ec_aqhi.py +6 -6
- env_canada/ec_cache.py +2 -1
- env_canada/ec_historical.py +44 -34
- env_canada/ec_hydro.py +2 -3
- env_canada/ec_radar.py +7 -7
- env_canada/ec_weather.py +161 -113
- {env_canada-0.7.2.dist-info → env_canada-0.9.0.dist-info}/METADATA +79 -29
- env_canada-0.9.0.dist-info/RECORD +16 -0
- {env_canada-0.7.2.dist-info → env_canada-0.9.0.dist-info}/WHEEL +1 -1
- {env_canada-0.7.2.dist-info → env_canada-0.9.0.dist-info/licenses}/LICENSE +1 -1
- env_canada-0.7.2.dist-info/RECORD +0 -16
- {env_canada-0.7.2.dist-info → env_canada-0.9.0.dist-info}/top_level.txt +0 -0
env_canada/__init__.py
CHANGED
@@ -1,5 +1,15 @@
|
|
1
|
+
__all__ = [
|
2
|
+
"ECAirQuality",
|
3
|
+
"ECHistorical",
|
4
|
+
"ECHistoricalRange",
|
5
|
+
"ECHydro",
|
6
|
+
"ECRadar",
|
7
|
+
"ECWeather",
|
8
|
+
"ECWeatherUpdateFailed",
|
9
|
+
]
|
10
|
+
|
1
11
|
from .ec_aqhi import ECAirQuality
|
2
12
|
from .ec_historical import ECHistorical, ECHistoricalRange
|
3
13
|
from .ec_hydro import ECHydro
|
4
14
|
from .ec_radar import ECRadar
|
5
|
-
from .ec_weather import ECWeather
|
15
|
+
from .ec_weather import ECWeather, ECWeatherUpdateFailed
|
env_canada/constants.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
USER_AGENT = "env_canada/0.
|
1
|
+
USER_AGENT = "env_canada/0.8.0"
|
env_canada/ec_aqhi.py
CHANGED
@@ -1,10 +1,10 @@
|
|
1
|
-
from datetime import datetime, timezone
|
2
1
|
import logging
|
2
|
+
from datetime import datetime, timezone
|
3
3
|
|
4
|
+
import voluptuous as vol
|
4
5
|
from aiohttp import ClientSession
|
5
|
-
import defusedxml.ElementTree as et
|
6
6
|
from geopy import distance
|
7
|
-
import
|
7
|
+
from lxml import etree as et
|
8
8
|
|
9
9
|
from .constants import USER_AGENT
|
10
10
|
|
@@ -43,7 +43,7 @@ async def get_aqhi_regions(language):
|
|
43
43
|
)
|
44
44
|
result = await response.read()
|
45
45
|
|
46
|
-
site_xml = result
|
46
|
+
site_xml = result
|
47
47
|
xml_object = et.fromstring(site_xml)
|
48
48
|
|
49
49
|
for zone in xml_object.findall("./EC_administrativeZone"):
|
@@ -83,7 +83,7 @@ async def find_closest_region(language, lat, lon):
|
|
83
83
|
return min(region_list, key=site_distance)
|
84
84
|
|
85
85
|
|
86
|
-
class ECAirQuality
|
86
|
+
class ECAirQuality:
|
87
87
|
"""Get air quality data from Environment Canada."""
|
88
88
|
|
89
89
|
def __init__(self, **kwargs):
|
@@ -152,7 +152,7 @@ class ECAirQuality(object):
|
|
152
152
|
return None
|
153
153
|
|
154
154
|
result = await response.read()
|
155
|
-
aqhi_xml = result
|
155
|
+
aqhi_xml = result
|
156
156
|
return et.fromstring(aqhi_xml)
|
157
157
|
|
158
158
|
async def update(self):
|
env_canada/ec_cache.py
CHANGED
env_canada/ec_historical.py
CHANGED
@@ -1,26 +1,28 @@
|
|
1
|
+
import asyncio
|
1
2
|
import copy
|
2
3
|
import csv
|
4
|
+
import logging
|
3
5
|
from datetime import datetime
|
4
|
-
from dateutil.relativedelta import relativedelta
|
5
6
|
from io import StringIO
|
6
|
-
import logging
|
7
|
-
import xml.etree.ElementTree as et
|
8
|
-
import pandas as pd
|
9
|
-
import asyncio
|
10
7
|
|
11
|
-
from aiohttp import ClientSession
|
12
|
-
from dateutil import parser, tz
|
13
|
-
import defusedxml.ElementTree as et
|
14
8
|
import lxml.html
|
9
|
+
import pandas as pd
|
15
10
|
import voluptuous as vol
|
11
|
+
from aiohttp import ClientSession
|
12
|
+
from dateutil import parser, tz
|
13
|
+
from dateutil.relativedelta import relativedelta
|
14
|
+
from lxml import etree as et
|
16
15
|
|
17
16
|
from .constants import USER_AGENT
|
18
17
|
|
19
|
-
|
20
18
|
STATIONS_URL = "https://climate.weather.gc.ca/historical_data/search_historic_data_stations_{}.html"
|
21
19
|
|
22
20
|
WEATHER_URL = "https://climate.weather.gc.ca/climate_data/bulk_data_{}.html"
|
23
21
|
|
22
|
+
_TODAY = datetime.today().date()
|
23
|
+
_ONE_YEAR_AGO = _TODAY - relativedelta(years=1, months=1, day=1)
|
24
|
+
_YEAR = datetime.today().year
|
25
|
+
|
24
26
|
LOG = logging.getLogger(__name__)
|
25
27
|
|
26
28
|
__all__ = ["ECHistorical"]
|
@@ -126,7 +128,7 @@ async def get_historical_stations(
|
|
126
128
|
coordinates,
|
127
129
|
radius=25,
|
128
130
|
start_year=1840,
|
129
|
-
end_year=
|
131
|
+
end_year=_YEAR,
|
130
132
|
limit=25,
|
131
133
|
language="english",
|
132
134
|
timeframe=2,
|
@@ -204,8 +206,7 @@ async def get_historical_stations(
|
|
204
206
|
return stations
|
205
207
|
|
206
208
|
|
207
|
-
class ECHistorical
|
208
|
-
|
209
|
+
class ECHistorical:
|
209
210
|
"""Get historical weather data from Environment Canada."""
|
210
211
|
|
211
212
|
def __init__(self, **kwargs):
|
@@ -353,30 +354,30 @@ def flip_daterange(f):
|
|
353
354
|
return wrapper
|
354
355
|
|
355
356
|
|
356
|
-
class ECHistoricalRange
|
357
|
+
class ECHistoricalRange:
|
357
358
|
"""Get historical weather data from Environment Canada in the given range for the given station.
|
358
359
|
|
359
|
-
|
360
|
+
options are daily or hourly data
|
360
361
|
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
362
|
+
Example:
|
363
|
+
import pandas as pd
|
364
|
+
import asyncio
|
365
|
+
from env_canada import ECHistoricalRange, get_historical_stations
|
366
|
+
from datetime import datetime
|
366
367
|
|
367
|
-
|
368
|
+
coordinates = ['48.508333', '-68.467667']
|
368
369
|
|
369
|
-
|
370
|
-
|
370
|
+
stations = pd.DataFrame(asyncio.run(get_historical_stations(coordinates, start_year=2022,
|
371
|
+
end_year=2022, radius=200, limit=100))).T
|
371
372
|
|
372
|
-
|
373
|
-
|
373
|
+
ec = ECHistoricalRange(station_id=int(stations.iloc[0,2]), timeframe="hourly",
|
374
|
+
daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)))
|
374
375
|
|
375
|
-
|
376
|
+
ec.get_data()
|
376
377
|
|
377
|
-
|
378
|
-
|
379
|
-
|
378
|
+
ec.xml #yield an XML formatted str. For more options, use ec.to_xml(*arg, **kwargs) with pandas options
|
379
|
+
=
|
380
|
+
ec.csv #yield an CSV formatted str. For more options, use ec.to_csv(*arg, **kwargs) with pandas options
|
380
381
|
"""
|
381
382
|
|
382
383
|
@flip_daterange
|
@@ -384,8 +385,8 @@ class ECHistoricalRange(object):
|
|
384
385
|
self,
|
385
386
|
station_id,
|
386
387
|
daterange=(
|
387
|
-
|
388
|
-
|
388
|
+
_ONE_YEAR_AGO,
|
389
|
+
_TODAY,
|
389
390
|
),
|
390
391
|
language="english",
|
391
392
|
timeframe="daily",
|
@@ -409,7 +410,14 @@ class ECHistoricalRange(object):
|
|
409
410
|
self.months = self.monthlist(daterange=daterange)
|
410
411
|
self.language = language
|
411
412
|
_tf = {"hourly": 1, "daily": 2, "monthly": 3}
|
412
|
-
|
413
|
+
timeframe_int = _tf[timeframe]
|
414
|
+
if timeframe_int == 2:
|
415
|
+
# prune the months list so it only has unique years. if daily is selected.
|
416
|
+
years = set()
|
417
|
+
for year, _ in self.months:
|
418
|
+
years.add(year)
|
419
|
+
self.months = [(year, 1) for year in years]
|
420
|
+
self.timeframe = timeframe_int
|
413
421
|
|
414
422
|
def get_data(self):
|
415
423
|
"""
|
@@ -419,7 +427,6 @@ class ECHistoricalRange(object):
|
|
419
427
|
"""
|
420
428
|
if not self.df.empty:
|
421
429
|
self.df = pd.DataFrame()
|
422
|
-
|
423
430
|
ec = [
|
424
431
|
ECHistorical(
|
425
432
|
station_id=self.station_id,
|
@@ -437,7 +444,7 @@ class ECHistoricalRange(object):
|
|
437
444
|
self.df = pd.concat((self.df, pd.read_csv(data.station_data)))
|
438
445
|
|
439
446
|
self.df = self.df.set_index(
|
440
|
-
self.df.filter(regex="Date/*", axis=1).columns.
|
447
|
+
self.df.filter(regex="Date/*", axis=1).columns.to_numpy()[0]
|
441
448
|
)
|
442
449
|
self.df.index = pd.to_datetime(self.df.index)
|
443
450
|
|
@@ -477,7 +484,10 @@ class ECHistoricalRange(object):
|
|
477
484
|
@flip_daterange
|
478
485
|
def monthlist(self, daterange):
|
479
486
|
startdate, stopdate = daterange
|
480
|
-
|
487
|
+
|
488
|
+
def total_months(dt):
|
489
|
+
return dt.month + 12 * dt.year
|
490
|
+
|
481
491
|
mlist = []
|
482
492
|
for tot_m in range(total_months(startdate) - 1, total_months(stopdate)):
|
483
493
|
y, m = divmod(tot_m, 12)
|
env_canada/ec_hydro.py
CHANGED
@@ -1,10 +1,10 @@
|
|
1
1
|
import csv
|
2
2
|
import io
|
3
3
|
|
4
|
+
import voluptuous as vol
|
4
5
|
from aiohttp import ClientSession
|
5
6
|
from dateutil.parser import isoparse
|
6
7
|
from geopy import distance
|
7
|
-
import voluptuous as vol
|
8
8
|
|
9
9
|
from .constants import USER_AGENT
|
10
10
|
|
@@ -54,8 +54,7 @@ async def closest_site(lat, lon):
|
|
54
54
|
return closest
|
55
55
|
|
56
56
|
|
57
|
-
class ECHydro
|
58
|
-
|
57
|
+
class ECHydro:
|
59
58
|
"""Get hydrometric data from Environment Canada."""
|
60
59
|
|
61
60
|
def __init__(self, **kwargs):
|
env_canada/ec_radar.py
CHANGED
@@ -1,16 +1,16 @@
|
|
1
1
|
import asyncio
|
2
|
-
from datetime import date, timedelta
|
3
2
|
import logging
|
4
3
|
import math
|
5
4
|
import os
|
5
|
+
from datetime import date, timedelta
|
6
6
|
from io import BytesIO
|
7
7
|
from typing import cast
|
8
8
|
|
9
9
|
import dateutil.parser
|
10
|
-
import defusedxml.ElementTree as et
|
11
10
|
import voluptuous as vol
|
12
11
|
from aiohttp import ClientSession
|
13
12
|
from aiohttp.client_exceptions import ClientConnectorError
|
13
|
+
from lxml import etree as et
|
14
14
|
from PIL import Image, ImageDraw, ImageFont
|
15
15
|
|
16
16
|
from .constants import USER_AGENT
|
@@ -114,7 +114,7 @@ async def _get_resource(url, params, bytes=True):
|
|
114
114
|
return await response.text()
|
115
115
|
|
116
116
|
|
117
|
-
class ECRadar
|
117
|
+
class ECRadar:
|
118
118
|
def __init__(self, **kwargs):
|
119
119
|
"""Initialize the radar object."""
|
120
120
|
|
@@ -195,7 +195,7 @@ class ECRadar(object):
|
|
195
195
|
return Cache.add("basemap", base_bytes, timedelta(days=7))
|
196
196
|
|
197
197
|
except ClientConnectorError as e:
|
198
|
-
logging.warning("Map from %s could not be retrieved: %s"
|
198
|
+
logging.warning("Map from %s could not be retrieved: %s", map_url, e)
|
199
199
|
|
200
200
|
async def _get_legend(self):
|
201
201
|
"""Fetch legend image."""
|
@@ -226,7 +226,7 @@ class ECRadar(object):
|
|
226
226
|
if not (capabilities_xml := Cache.get(capabilities_cache_key)):
|
227
227
|
capabilities_params["layer"] = precip_layers[self._precip_type_actual]
|
228
228
|
capabilities_xml = await _get_resource(
|
229
|
-
geomet_url, capabilities_params, bytes=
|
229
|
+
geomet_url, capabilities_params, bytes=True
|
230
230
|
)
|
231
231
|
Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
|
232
232
|
|
@@ -236,9 +236,9 @@ class ECRadar(object):
|
|
236
236
|
)
|
237
237
|
if dimension_string is not None:
|
238
238
|
if dimension_string := dimension_string.text:
|
239
|
-
start, end =
|
239
|
+
start, end = (
|
240
240
|
dateutil.parser.isoparse(t) for t in dimension_string.split("/")[:2]
|
241
|
-
|
241
|
+
)
|
242
242
|
self.timestamp = end.isoformat()
|
243
243
|
return (start, end)
|
244
244
|
return None
|
env_canada/ec_weather.py
CHANGED
@@ -1,13 +1,19 @@
|
|
1
1
|
import csv
|
2
|
-
import datetime
|
3
2
|
import logging
|
4
3
|
import re
|
5
|
-
|
6
|
-
from
|
7
|
-
from dateutil import parser, relativedelta, tz
|
8
|
-
import defusedxml.ElementTree as et
|
9
|
-
from geopy import distance
|
4
|
+
from dataclasses import dataclass
|
5
|
+
from datetime import datetime, timedelta, timezone
|
10
6
|
import voluptuous as vol
|
7
|
+
from aiohttp import (
|
8
|
+
ClientConnectorDNSError,
|
9
|
+
ClientResponseError,
|
10
|
+
ClientSession,
|
11
|
+
ClientTimeout,
|
12
|
+
)
|
13
|
+
from dateutil import parser, tz
|
14
|
+
from geopy import distance
|
15
|
+
from lxml import etree as et
|
16
|
+
from lxml.etree import _Element
|
11
17
|
|
12
18
|
from . import ec_exc
|
13
19
|
from .constants import USER_AGENT
|
@@ -16,6 +22,8 @@ SITE_LIST_URL = "https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv
|
|
16
22
|
|
17
23
|
WEATHER_URL = "https://dd.weather.gc.ca/citypage_weather/xml/{}_{}.xml"
|
18
24
|
|
25
|
+
CLIENT_TIMEOUT = ClientTimeout(10)
|
26
|
+
|
19
27
|
LOG = logging.getLogger(__name__)
|
20
28
|
|
21
29
|
ATTRIBUTION = {
|
@@ -24,7 +32,17 @@ ATTRIBUTION = {
|
|
24
32
|
}
|
25
33
|
|
26
34
|
|
27
|
-
__all__ = ["ECWeather"]
|
35
|
+
__all__ = ["ECWeather", "ECWeatherUpdateFailed"]
|
36
|
+
|
37
|
+
|
38
|
+
@dataclass
|
39
|
+
class MetaData:
|
40
|
+
attribution: str
|
41
|
+
timestamp: datetime = datetime(1970, 1, 1, 0, 0, tzinfo=timezone.utc)
|
42
|
+
station: str | None = None
|
43
|
+
location: str | None = None
|
44
|
+
cache_returned_on_update: int = 0 # Resets to 0 after successful update
|
45
|
+
last_update_error: str = ""
|
28
46
|
|
29
47
|
|
30
48
|
conditions_meta = {
|
@@ -179,35 +197,30 @@ summary_meta = {
|
|
179
197
|
"label": {"english": "Forecast", "french": "Prévision"},
|
180
198
|
}
|
181
199
|
|
182
|
-
|
183
|
-
"
|
184
|
-
"
|
185
|
-
"
|
186
|
-
|
187
|
-
|
188
|
-
},
|
189
|
-
},
|
190
|
-
"watches": {
|
191
|
-
"english": {"label": "Watches", "pattern": ".*WATCH((?!ENDED).)*$"},
|
192
|
-
"french": {"label": "Veilles", "pattern": ".*VEILLE((?!TERMINÉ).)*$"},
|
193
|
-
},
|
194
|
-
"advisories": {
|
195
|
-
"english": {"label": "Advisories", "pattern": ".*ADVISORY((?!ENDED).)*$"},
|
196
|
-
"french": {"label": "Avis", "pattern": ".*AVIS((?!TERMINÉ).)*$"},
|
197
|
-
},
|
198
|
-
"statements": {
|
199
|
-
"english": {"label": "Statements", "pattern": ".*STATEMENT((?!ENDED).)*$"},
|
200
|
-
"french": {"label": "Bulletins", "pattern": ".*BULLETIN((?!TERMINÉ).)*$"},
|
200
|
+
ALERTS_INIT = {
|
201
|
+
"english": {
|
202
|
+
"warnings": {"label": "Warnings", "value": []},
|
203
|
+
"watches": {"label": "Watches", "value": []},
|
204
|
+
"advisories": {"label": "Advisories", "value": []},
|
205
|
+
"statements": {"label": "Statements", "value": []},
|
206
|
+
"endings": {"label": "Endings", "value": []},
|
201
207
|
},
|
202
|
-
"
|
203
|
-
"
|
204
|
-
"
|
208
|
+
"french": {
|
209
|
+
"warnings": {"label": "Alertes", "value": []},
|
210
|
+
"watches": {"label": "Veilles", "value": []},
|
211
|
+
"advisories": {"label": "Avis", "value": []},
|
212
|
+
"statements": {"label": "Bulletins", "value": []},
|
213
|
+
"endings": {"label": "Terminaisons", "value": []},
|
205
214
|
},
|
206
215
|
}
|
207
216
|
|
208
|
-
|
209
|
-
|
210
|
-
"
|
217
|
+
# Maps "type" in XML alert attribute to name used
|
218
|
+
ALERT_TYPE_TO_NAME = {
|
219
|
+
"advisory": "advisories",
|
220
|
+
"ending": "endings",
|
221
|
+
"statement": "statements",
|
222
|
+
"warning": "warnings",
|
223
|
+
"watch": "watches",
|
211
224
|
}
|
212
225
|
|
213
226
|
|
@@ -220,8 +233,15 @@ def validate_station(station):
|
|
220
233
|
return station
|
221
234
|
|
222
235
|
|
223
|
-
def
|
224
|
-
|
236
|
+
def _parse_timestamp(time_str: str | None) -> datetime | None:
|
237
|
+
if time_str is not None:
|
238
|
+
return parser.parse(time_str).replace(tzinfo=tz.UTC)
|
239
|
+
return None
|
240
|
+
|
241
|
+
|
242
|
+
def _get_xml_text(xml_root: _Element, xpath: str) -> str | None:
|
243
|
+
element = xml_root.find(xpath)
|
244
|
+
return None if element is None or element.text is None else element.text
|
225
245
|
|
226
246
|
|
227
247
|
async def get_ec_sites():
|
@@ -231,7 +251,7 @@ async def get_ec_sites():
|
|
231
251
|
|
232
252
|
async with ClientSession(raise_for_status=True) as session:
|
233
253
|
response = await session.get(
|
234
|
-
SITE_LIST_URL, headers={"User-Agent": USER_AGENT}, timeout=
|
254
|
+
SITE_LIST_URL, headers={"User-Agent": USER_AGENT}, timeout=CLIENT_TIMEOUT
|
235
255
|
)
|
236
256
|
sites_csv_string = await response.text()
|
237
257
|
|
@@ -259,7 +279,7 @@ def closest_site(site_list, lat, lon):
|
|
259
279
|
return "{}/{}".format(closest["Province Codes"], closest["Codes"])
|
260
280
|
|
261
281
|
|
262
|
-
class ECWeather
|
282
|
+
class ECWeather:
|
263
283
|
"""Get weather data from Environment Canada."""
|
264
284
|
|
265
285
|
def __init__(self, **kwargs):
|
@@ -292,7 +312,7 @@ class ECWeather(object):
|
|
292
312
|
|
293
313
|
self.language = kwargs["language"]
|
294
314
|
self.max_data_age = kwargs["max_data_age"]
|
295
|
-
self.metadata =
|
315
|
+
self.metadata = MetaData(ATTRIBUTION[self.language])
|
296
316
|
self.conditions = {}
|
297
317
|
self.alerts = {}
|
298
318
|
self.daily_forecasts = []
|
@@ -309,9 +329,29 @@ class ECWeather(object):
|
|
309
329
|
self.lat = kwargs["coordinates"][0]
|
310
330
|
self.lon = kwargs["coordinates"][1]
|
311
331
|
|
312
|
-
|
332
|
+
def handle_error(self, err: Exception | None, msg: str) -> None:
|
333
|
+
"""
|
334
|
+
Handle an known error, returning previous results if they have not expired, or
|
335
|
+
raising an exception if previous results have expired. Set the last_update_error
|
336
|
+
to the error no matter what.
|
337
|
+
|
338
|
+
On returning previous results, bump the cache returned counter else clear the counter.
|
339
|
+
"""
|
340
|
+
expiry = self.metadata.timestamp + timedelta(hours=self.max_data_age)
|
341
|
+
self.metadata.last_update_error = msg
|
342
|
+
if expiry > datetime.now(timezone.utc):
|
343
|
+
self.metadata.cache_returned_on_update += 1
|
344
|
+
return
|
345
|
+
|
346
|
+
self.metadata.cache_returned_on_update = 0
|
347
|
+
raise ECWeatherUpdateFailed(msg) from err
|
348
|
+
|
349
|
+
async def update(self) -> None:
|
313
350
|
"""Get the latest data from Environment Canada."""
|
314
351
|
|
352
|
+
# Clear error at start, any error that is handled will set it
|
353
|
+
self.metadata.last_update_error = ""
|
354
|
+
|
315
355
|
# Determine station ID or coordinates if not provided
|
316
356
|
if not self.site_list:
|
317
357
|
self.site_list = await get_ec_sites()
|
@@ -335,40 +375,43 @@ class ECWeather(object):
|
|
335
375
|
)
|
336
376
|
|
337
377
|
# Get weather data
|
338
|
-
|
339
|
-
|
340
|
-
|
341
|
-
|
342
|
-
|
378
|
+
try:
|
379
|
+
async with ClientSession(raise_for_status=True) as session:
|
380
|
+
response = await session.get(
|
381
|
+
WEATHER_URL.format(self.station_id, self.language[0]),
|
382
|
+
headers={"User-Agent": USER_AGENT},
|
383
|
+
timeout=CLIENT_TIMEOUT,
|
384
|
+
)
|
385
|
+
weather_xml = await response.text()
|
386
|
+
except (ClientConnectorDNSError, TimeoutError) as err:
|
387
|
+
return self.handle_error(err, f"Unable to retrieve weather: {err}")
|
388
|
+
except ClientResponseError as err:
|
389
|
+
return self.handle_error(
|
390
|
+
err,
|
391
|
+
f"Unable to retrieve weather '{err.request_info.url}': {err.message} ({err.status})",
|
343
392
|
)
|
344
|
-
result = await response.read()
|
345
|
-
weather_xml = result.decode()
|
346
393
|
|
347
394
|
try:
|
348
|
-
weather_tree = et.fromstring(weather_xml)
|
349
|
-
except et.ParseError:
|
350
|
-
|
351
|
-
|
352
|
-
|
353
|
-
|
354
|
-
element = weather_tree.find(meta["xpath"])
|
355
|
-
if element is not None:
|
356
|
-
self.metadata[m] = weather_tree.find(meta["xpath"]).text
|
357
|
-
if m == "timestamp":
|
358
|
-
self.metadata[m] = parse_timestamp(self.metadata[m])
|
359
|
-
else:
|
360
|
-
self.metadata[m] = None
|
361
|
-
|
362
|
-
# Check data age
|
363
|
-
if self.metadata["timestamp"] is None:
|
364
|
-
raise ECWeatherUpdateFailed("Weather update failed; no timestamp found")
|
365
|
-
|
366
|
-
max_age = datetime.datetime.now(
|
367
|
-
datetime.timezone.utc
|
368
|
-
) - relativedelta.relativedelta(hours=self.max_data_age)
|
395
|
+
weather_tree = et.fromstring(bytes(weather_xml, encoding="utf-8"))
|
396
|
+
except et.ParseError as err:
|
397
|
+
# Parse error happens when data return is malformed (truncated, possibly because of network error)
|
398
|
+
return self.handle_error(
|
399
|
+
err, f"Could not parse retrieved weather; length {len(weather_xml)}"
|
400
|
+
)
|
369
401
|
|
370
|
-
|
371
|
-
|
402
|
+
timestamp = _parse_timestamp(
|
403
|
+
_get_xml_text(weather_tree, "./dateTime/timeStamp")
|
404
|
+
)
|
405
|
+
if timestamp is None:
|
406
|
+
return self.handle_error(
|
407
|
+
None, "Timestamp not found in retrieved weather; response ignored"
|
408
|
+
)
|
409
|
+
expiry = timestamp + timedelta(hours=self.max_data_age)
|
410
|
+
if expiry < datetime.now(timezone.utc):
|
411
|
+
return self.handle_error(
|
412
|
+
None,
|
413
|
+
f"Outdated conditions returned from Environment Canada '{timestamp}'; not used",
|
414
|
+
)
|
372
415
|
|
373
416
|
# Parse condition
|
374
417
|
def get_condition(meta):
|
@@ -402,21 +445,17 @@ class ECWeather(object):
|
|
402
445
|
elif meta["type"] == "str":
|
403
446
|
condition["value"] = element.text
|
404
447
|
elif meta["type"] == "timestamp":
|
405
|
-
condition["value"] =
|
448
|
+
condition["value"] = _parse_timestamp(element.text)
|
406
449
|
|
407
450
|
return condition
|
408
451
|
|
409
452
|
# Update current conditions
|
410
|
-
|
453
|
+
current_conditions = weather_tree.find("./currentConditions")
|
454
|
+
if current_conditions is not None and len(current_conditions) > 0:
|
411
455
|
for c, meta in conditions_meta.items():
|
412
456
|
self.conditions[c] = {"label": meta[self.language]}
|
413
457
|
self.conditions[c].update(get_condition(meta))
|
414
458
|
|
415
|
-
# Update station metadata
|
416
|
-
self.metadata["station"] = weather_tree.find(
|
417
|
-
"./currentConditions/station"
|
418
|
-
).text
|
419
|
-
|
420
459
|
# Update text summary
|
421
460
|
period = get_condition(summary_meta["forecast_period"])["value"]
|
422
461
|
summary = get_condition(summary_meta["text_summary"])["value"]
|
@@ -427,67 +466,76 @@ class ECWeather(object):
|
|
427
466
|
}
|
428
467
|
|
429
468
|
# Update alerts
|
430
|
-
|
431
|
-
self.alerts[category] = {"value": [], "label": meta[self.language]["label"]}
|
432
|
-
|
469
|
+
self.alerts = ALERTS_INIT[self.language].copy()
|
433
470
|
alert_elements = weather_tree.findall("./warnings/event")
|
434
|
-
|
435
|
-
|
436
|
-
|
437
|
-
|
438
|
-
|
439
|
-
|
440
|
-
|
441
|
-
"
|
442
|
-
"date": a.find("./dateTime[last()]/textSummary").text,
|
471
|
+
for alert in alert_elements:
|
472
|
+
title = alert.attrib.get("description")
|
473
|
+
type_ = alert.attrib.get("type")
|
474
|
+
if title is not None and type_ is not None and type_ in ALERT_TYPE_TO_NAME:
|
475
|
+
self.alerts[ALERT_TYPE_TO_NAME[type_]]["value"].append( # type: ignore[attr-defined]
|
476
|
+
{
|
477
|
+
"title": title.strip().title(),
|
478
|
+
"date": _get_xml_text(alert, "./dateTime[last()]/textSummary"),
|
443
479
|
}
|
444
|
-
|
480
|
+
)
|
445
481
|
|
446
482
|
# Update forecasts
|
447
|
-
self.forecast_time =
|
448
|
-
weather_tree
|
483
|
+
self.forecast_time = _parse_timestamp(
|
484
|
+
_get_xml_text(weather_tree, "./forecastGroup/dateTime/timeStamp")
|
449
485
|
)
|
450
486
|
self.daily_forecasts = []
|
451
487
|
self.hourly_forecasts = []
|
452
488
|
|
453
489
|
# Update daily forecasts
|
454
|
-
|
455
|
-
|
456
|
-
|
457
|
-
|
458
|
-
|
459
|
-
|
460
|
-
|
461
|
-
|
462
|
-
|
463
|
-
"
|
464
|
-
|
465
|
-
|
466
|
-
|
467
|
-
|
468
|
-
|
469
|
-
|
470
|
-
|
471
|
-
|
472
|
-
|
490
|
+
if self.forecast_time is not None:
|
491
|
+
forecast_time = self.forecast_time
|
492
|
+
for f in weather_tree.findall("./forecastGroup/forecast"):
|
493
|
+
temperature_element = f.find("./temperatures/temperature")
|
494
|
+
self.daily_forecasts.append(
|
495
|
+
{
|
496
|
+
"period": f.findtext("period"),
|
497
|
+
"text_summary": f.findtext("textSummary"),
|
498
|
+
"icon_code": f.findtext("./abbreviatedForecast/iconCode"),
|
499
|
+
"temperature": int(
|
500
|
+
f.findtext("./temperatures/temperature") or 0
|
501
|
+
),
|
502
|
+
"temperature_class": temperature_element.attrib.get("class")
|
503
|
+
if temperature_element is not None
|
504
|
+
else None,
|
505
|
+
"precip_probability": int(
|
506
|
+
f.findtext("./abbreviatedForecast/pop") or "0"
|
507
|
+
),
|
508
|
+
"timestamp": forecast_time,
|
509
|
+
}
|
510
|
+
)
|
511
|
+
if self.daily_forecasts[-1]["temperature_class"] == "low":
|
512
|
+
forecast_time = forecast_time + timedelta(days=1)
|
473
513
|
|
474
514
|
# Update hourly forecasts
|
475
515
|
for f in weather_tree.findall("./hourlyForecastGroup/hourlyForecast"):
|
476
516
|
wind_speed_text = f.findtext("./wind/speed")
|
477
517
|
self.hourly_forecasts.append(
|
478
518
|
{
|
479
|
-
"period":
|
519
|
+
"period": _parse_timestamp(f.attrib.get("dateTimeUTC")),
|
480
520
|
"condition": f.findtext("./condition"),
|
481
521
|
"temperature": int(f.findtext("./temperature") or 0),
|
482
522
|
"icon_code": f.findtext("./iconCode"),
|
483
523
|
"precip_probability": int(f.findtext("./lop") or "0"),
|
484
|
-
"wind_speed": int(
|
485
|
-
|
486
|
-
|
524
|
+
"wind_speed": int(wind_speed_text)
|
525
|
+
if wind_speed_text and wind_speed_text.isnumeric()
|
526
|
+
else 0,
|
487
527
|
"wind_direction": f.findtext("./wind/direction"),
|
488
528
|
}
|
489
529
|
)
|
490
530
|
|
531
|
+
# Update metadata at the end
|
532
|
+
self.metadata.cache_returned_on_update = 0
|
533
|
+
self.metadata.timestamp = timestamp
|
534
|
+
self.metadata.location = _get_xml_text(weather_tree, "./location/name")
|
535
|
+
self.metadata.station = _get_xml_text(
|
536
|
+
weather_tree, "./currentConditions/station"
|
537
|
+
)
|
538
|
+
|
491
539
|
|
492
540
|
class ECWeatherUpdateFailed(Exception):
|
493
541
|
"""Raised when an update fails to get usable data."""
|
@@ -1,34 +1,72 @@
|
|
1
|
-
Metadata-Version: 2.
|
1
|
+
Metadata-Version: 2.4
|
2
2
|
Name: env_canada
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.9.0
|
4
4
|
Summary: A package to access meteorological data from Environment Canada
|
5
|
-
|
6
|
-
|
7
|
-
|
8
|
-
|
5
|
+
Author-email: Michael Davie <michael.davie@gmail.com>
|
6
|
+
Maintainer-email: Michael Davie <michael.davie@gmail.com>
|
7
|
+
License: Copyright (c) 2018 The Python Packaging Authority
|
8
|
+
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
11
|
+
in the Software without restriction, including without limitation the rights
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
14
|
+
furnished to do so, subject to the following conditions:
|
15
|
+
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
17
|
+
copies or substantial portions of the Software.
|
18
|
+
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
25
|
+
SOFTWARE
|
26
|
+
|
27
|
+
Project-URL: Homepage, https://github.com/michaeldavie/env_canada
|
28
|
+
Project-URL: Documentation, https://github.com/michaeldavie/env_canada
|
29
|
+
Project-URL: Repository, https://github.com/michaeldavie/env_canada
|
30
|
+
Project-URL: Issues, https://github.com/michaeldavie/env_canada/issues
|
31
|
+
Project-URL: Changelog, https://github.com/michaeldavie/env_canada/blob/master/CHANGELOG.md
|
9
32
|
Classifier: Programming Language :: Python :: 3
|
10
33
|
Classifier: License :: OSI Approved :: MIT License
|
11
34
|
Classifier: Operating System :: OS Independent
|
35
|
+
Requires-Python: >=3.11
|
12
36
|
Description-Content-Type: text/markdown
|
13
37
|
License-File: LICENSE
|
14
|
-
Requires-Dist: aiohttp
|
15
|
-
Requires-Dist:
|
16
|
-
Requires-Dist:
|
17
|
-
Requires-Dist:
|
18
|
-
Requires-Dist:
|
19
|
-
Requires-Dist:
|
20
|
-
Requires-Dist:
|
21
|
-
Requires-Dist:
|
22
|
-
Requires-Dist:
|
23
|
-
|
38
|
+
Requires-Dist: aiohttp>=3.9.0
|
39
|
+
Requires-Dist: geopy>=2.4.1
|
40
|
+
Requires-Dist: imageio>=2.28.0
|
41
|
+
Requires-Dist: lxml>=5.3.0
|
42
|
+
Requires-Dist: numpy>=1.22.2
|
43
|
+
Requires-Dist: pandas>=2.2.3
|
44
|
+
Requires-Dist: Pillow>=10.0.1
|
45
|
+
Requires-Dist: python-dateutil>=2.9
|
46
|
+
Requires-Dist: voluptuous>=0.15.2
|
47
|
+
Dynamic: license-file
|
24
48
|
|
25
49
|
# Environment Canada (env_canada)
|
26
50
|
|
27
51
|
[](https://badge.fury.io/py/env-canada)
|
28
|
-
[](https://snyk.io/vuln/pip:env-canada@0.
|
52
|
+
[](https://snyk.io/vuln/pip:env-canada@0.8.0?utm_source=badge)
|
53
|
+
[](../../actions/workflows/python-app.yml)
|
29
54
|
|
30
55
|
This package provides access to various data sources published by [Environment and Climate Change Canada](https://www.canada.ca/en/environment-climate-change.html).
|
31
56
|
|
57
|
+
> [!IMPORTANT]
|
58
|
+
> If you're using the library in a Jupyter notebook, replace `asyncio.run(...)` with `await ...` in the examples below. For example:
|
59
|
+
>
|
60
|
+
> ```python
|
61
|
+
> asyncio.run(ec_en.update())
|
62
|
+
> ```
|
63
|
+
>
|
64
|
+
> becomes
|
65
|
+
>
|
66
|
+
> ```python
|
67
|
+
> await ec_en.update()
|
68
|
+
> ```
|
69
|
+
|
32
70
|
## Weather Observations and Forecasts
|
33
71
|
|
34
72
|
`ECWeather` provides current conditions and forecasts. It automatically determines which weather station to use based on latitude/longitude provided. It is also possible to specify a specific station code of the form `AB/s0000123` based on those listed in [this CSV file](https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv). For example:
|
@@ -39,7 +77,7 @@ import asyncio
|
|
39
77
|
from env_canada import ECWeather
|
40
78
|
|
41
79
|
ec_en = ECWeather(coordinates=(50, -100))
|
42
|
-
ec_fr = ECWeather(station_id=
|
80
|
+
ec_fr = ECWeather(station_id="ON/s0000430", language="french")
|
43
81
|
|
44
82
|
asyncio.run(ec_en.update())
|
45
83
|
|
@@ -120,7 +158,7 @@ from env_canada import ECHistorical
|
|
120
158
|
from env_canada.ec_historical import get_historical_stations
|
121
159
|
|
122
160
|
# search for stations, response contains station_ids
|
123
|
-
coordinates = [53.916944, -122.749444]
|
161
|
+
coordinates = [53.916944, -122.749444] # [lat, long]
|
124
162
|
|
125
163
|
# coordinates: [lat, long]
|
126
164
|
# radius: km
|
@@ -136,8 +174,12 @@ ec_fr_csv = ECHistorical(station_id=31688, year=2020, language="french", format=
|
|
136
174
|
# timeframe argument can be passed to change the granularity
|
137
175
|
# timeframe=1 hourly (need to create of for every month in that case, use ECHistoricalRange to handle it automatically)
|
138
176
|
# timeframe=2 daily (default)
|
139
|
-
ec_en_xml = ECHistorical(
|
140
|
-
|
177
|
+
ec_en_xml = ECHistorical(
|
178
|
+
station_id=31688, year=2020, month=1, language="english", format="xml", timeframe=1
|
179
|
+
)
|
180
|
+
ec_en_csv = ECHistorical(
|
181
|
+
station_id=31688, year=2020, month=1, language="english", format="csv", timeframe=1
|
182
|
+
)
|
141
183
|
|
142
184
|
asyncio.run(ec_en_xml.update())
|
143
185
|
asyncio.run(ec_en_csv.update())
|
@@ -150,8 +192,8 @@ ec_en_xml.station_data
|
|
150
192
|
|
151
193
|
# csv-generated responses return csv-like station data
|
152
194
|
import pandas as pd
|
153
|
-
df = pd.read_csv(ec_en_csv.station_data)
|
154
195
|
|
196
|
+
df = pd.read_csv(ec_en_csv.station_data)
|
155
197
|
```
|
156
198
|
|
157
199
|
`ECHistoricalRange` provides historical weather data within a specific range and handles the update by itself.
|
@@ -170,21 +212,29 @@ from env_canada import ECHistoricalRange
|
|
170
212
|
from env_canada.ec_historical import get_historical_stations
|
171
213
|
from datetime import datetime
|
172
214
|
|
173
|
-
coordinates = [
|
215
|
+
coordinates = ["48.508333", "-68.467667"]
|
174
216
|
|
175
|
-
stations = pd.DataFrame(
|
176
|
-
|
217
|
+
stations = pd.DataFrame(
|
218
|
+
asyncio.run(
|
219
|
+
get_historical_stations(
|
220
|
+
coordinates, start_year=2022, end_year=2022, radius=200, limit=100
|
221
|
+
)
|
222
|
+
)
|
223
|
+
).T
|
177
224
|
|
178
|
-
ec = ECHistoricalRange(
|
179
|
-
|
225
|
+
ec = ECHistoricalRange(
|
226
|
+
station_id=int(stations.iloc[0, 2]),
|
227
|
+
timeframe="daily",
|
228
|
+
daterange=(datetime(2022, 7, 1, 12, 12), datetime(2022, 8, 1, 12, 12)),
|
229
|
+
)
|
180
230
|
|
181
231
|
ec.get_data()
|
182
232
|
|
183
|
-
#yield an XML formated str.
|
233
|
+
# yield an XML formated str.
|
184
234
|
# For more options, use ec.to_xml(*arg, **kwargs) with pandas options
|
185
235
|
ec.xml
|
186
236
|
|
187
|
-
#yield an CSV formated str.
|
237
|
+
# yield an CSV formated str.
|
188
238
|
# For more options, use ec.to_csv(*arg, **kwargs) with pandas options
|
189
239
|
ec.csv
|
190
240
|
```
|
@@ -0,0 +1,16 @@
|
|
1
|
+
env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
|
2
|
+
env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
|
3
|
+
env_canada/__init__.py,sha256=O1KVC2GAPTmxAP46FXo9jd8Tq3YMefViowGjuxJ5JJM,366
|
4
|
+
env_canada/constants.py,sha256=RHa-Hp2H6XroFE9Z5-H0MIUPWlFXUVKmWEidwuWzakE,32
|
5
|
+
env_canada/ec_aqhi.py,sha256=-N4XLLgApaWZvtepJvS2lvqDSNjwV2X_fkmbtKQv8rA,7741
|
6
|
+
env_canada/ec_cache.py,sha256=zb3n79ul7hUTE0IohDfZbRBLY-siOHPjYzWldMbuPVk,798
|
7
|
+
env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
|
8
|
+
env_canada/ec_historical.py,sha256=qMr4RE6vfNiNa_zFolQ0PQGraok8bQtIVjs_o6sJKD4,16276
|
9
|
+
env_canada/ec_hydro.py,sha256=JoBe-QVV8GEeZXCNFscIs2R_spgkbxCZpLt7tL6-NUI,4889
|
10
|
+
env_canada/ec_radar.py,sha256=0SKusJWDTFODdn3D9yrhlkOS-Bv9hhBJM9EBh8TNRlk,12965
|
11
|
+
env_canada/ec_weather.py,sha256=l-gyXePkHBLW9vidFgiPEm81xevbZCeBKzKDLiLr7Y8,19200
|
12
|
+
env_canada-0.9.0.dist-info/licenses/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
|
13
|
+
env_canada-0.9.0.dist-info/METADATA,sha256=divVpZpbuU3Ch7_l1hno2So0e3ePrPoDr9nLOg9_8To,12709
|
14
|
+
env_canada-0.9.0.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
|
15
|
+
env_canada-0.9.0.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
|
16
|
+
env_canada-0.9.0.dist-info/RECORD,,
|
@@ -16,4 +16,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
16
16
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
17
17
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
18
18
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
19
|
-
SOFTWARE
|
19
|
+
SOFTWARE
|
@@ -1,16 +0,0 @@
|
|
1
|
-
env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
|
2
|
-
env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
|
3
|
-
env_canada/__init__.py,sha256=wEx1BCwVUH__GoosSlhNMHuUKCKNZAvv5uuSa5ZWq_g,187
|
4
|
-
env_canada/constants.py,sha256=DlW7o9XEpZNQry99jZ1vF0wX7Xm7ZRcK41n0YAwzp9U,32
|
5
|
-
env_canada/ec_aqhi.py,sha256=zEEt2U8gCxaLlePexl23r9zCfQYgmfhsP0ur2ZiupZc,7793
|
6
|
-
env_canada/ec_cache.py,sha256=xPlXBRLyrD6dTJWLRBy12J8kzBxMUC-20-xRuc56Hts,722
|
7
|
-
env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
|
8
|
-
env_canada/ec_historical.py,sha256=slHaFwsoyW16uCVtE3_-IF3_BFhFD4IuWl7rpIRsCm4,15901
|
9
|
-
env_canada/ec_hydro.py,sha256=LBsWreTlaTKec6ObjI0ih8-zOKBNjD02oiXKTyUa1EQ,4898
|
10
|
-
env_canada/ec_radar.py,sha256=zh0tbazBbvLpuxrY0yfRm9EIaXNkM6HXPe1us99h4xM,12982
|
11
|
-
env_canada/ec_weather.py,sha256=cLWMTuvASrxYeGnXvGwsNZxd_SfD1sdfInBU51QLFCo,17026
|
12
|
-
env_canada-0.7.2.dist-info/LICENSE,sha256=c037dTHQWAgRgDqZNN-5d-CZvcteSYN37u39SNklO0I,1072
|
13
|
-
env_canada-0.7.2.dist-info/METADATA,sha256=M-fb4_i9Je1a2e6T5M7RIYDW33nknvWN6GHSKg60Sds,10707
|
14
|
-
env_canada-0.7.2.dist-info/WHEEL,sha256=Z4pYXqR_rTB7OWNDYFOm1qRk0RX6GFP2o8LgvP453Hk,91
|
15
|
-
env_canada-0.7.2.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
|
16
|
-
env_canada-0.7.2.dist-info/RECORD,,
|
File without changes
|