env-canada 0.6.1__py3-none-any.whl → 0.6.3__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 +5 -5
- env_canada/constants.py +1 -1
- env_canada/ec_data.py +501 -0
- env_canada/ec_radar.py +91 -72
- {env_canada-0.6.1.dist-info → env_canada-0.6.3.dist-info}/METADATA +8 -6
- {env_canada-0.6.1.dist-info → env_canada-0.6.3.dist-info}/RECORD +9 -8
- {env_canada-0.6.1.dist-info → env_canada-0.6.3.dist-info}/WHEEL +1 -1
- {env_canada-0.6.1.dist-info → env_canada-0.6.3.dist-info}/LICENSE +0 -0
- {env_canada-0.6.1.dist-info → env_canada-0.6.3.dist-info}/top_level.txt +0 -0
env_canada/__init__.py
CHANGED
@@ -1,5 +1,5 @@
|
|
1
|
-
from .ec_aqhi import
|
2
|
-
from .ec_historical import
|
3
|
-
from .ec_hydro import
|
4
|
-
from .ec_radar import
|
5
|
-
from .ec_weather import
|
1
|
+
from .ec_aqhi import ECAirQuality
|
2
|
+
from .ec_historical import ECHistorical, ECHistoricalRange
|
3
|
+
from .ec_hydro import ECHydro
|
4
|
+
from .ec_radar import ECRadar
|
5
|
+
from .ec_weather import ECWeather
|
env_canada/constants.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
USER_AGENT = "env_canada/0.6.
|
1
|
+
USER_AGENT = "env_canada/0.6.3"
|
env_canada/ec_data.py
ADDED
@@ -0,0 +1,501 @@
|
|
1
|
+
from datetime import datetime, timezone
|
2
|
+
import logging
|
3
|
+
import re
|
4
|
+
import xml.etree.ElementTree as et
|
5
|
+
|
6
|
+
from geopy import distance
|
7
|
+
from ratelimit import limits, RateLimitException
|
8
|
+
import requests
|
9
|
+
|
10
|
+
SITE_LIST_URL = "https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv"
|
11
|
+
AQHI_SITE_LIST_URL = "https://dd.weather.gc.ca/air_quality/doc/AQHI_XML_File_List.xml"
|
12
|
+
|
13
|
+
WEATHER_URL = "https://hpfx.collab.science.gc.ca/{date}/WXO-DD/citypage_weather/xml/{site}_{language}.xml"
|
14
|
+
AQHI_OBSERVATION_URL = "https://dd.weather.gc.ca/air_quality/aqhi/{}/observation/realtime/xml/AQ_OBS_{}_CURRENT.xml"
|
15
|
+
AQHI_FORECAST_URL = "https://dd.weather.gc.ca/air_quality/aqhi/{}/forecast/realtime/xml/AQ_FCST_{}_CURRENT.xml"
|
16
|
+
|
17
|
+
LOG = logging.getLogger(__name__)
|
18
|
+
|
19
|
+
conditions_meta = {
|
20
|
+
"temperature": {
|
21
|
+
"xpath": "./currentConditions/temperature",
|
22
|
+
"english": "Temperature",
|
23
|
+
"french": "Température",
|
24
|
+
},
|
25
|
+
"dewpoint": {
|
26
|
+
"xpath": "./currentConditions/dewpoint",
|
27
|
+
"english": "Dew Point",
|
28
|
+
"french": "Point de rosée",
|
29
|
+
},
|
30
|
+
"wind_chill": {
|
31
|
+
"xpath": "./currentConditions/windChill",
|
32
|
+
"english": "Wind Chill",
|
33
|
+
"french": "Refroidissement éolien",
|
34
|
+
},
|
35
|
+
"humidex": {
|
36
|
+
"xpath": "./currentConditions/humidex",
|
37
|
+
"english": "Humidex",
|
38
|
+
"french": "Humidex",
|
39
|
+
},
|
40
|
+
"pressure": {
|
41
|
+
"xpath": "./currentConditions/pressure",
|
42
|
+
"english": "Pressure",
|
43
|
+
"french": "Pression",
|
44
|
+
},
|
45
|
+
"tendency": {
|
46
|
+
"xpath": "./currentConditions/pressure",
|
47
|
+
"attribute": "tendency",
|
48
|
+
"english": "Tendency",
|
49
|
+
"french": "Tendance",
|
50
|
+
},
|
51
|
+
"humidity": {
|
52
|
+
"xpath": "./currentConditions/relativeHumidity",
|
53
|
+
"english": "Humidity",
|
54
|
+
"french": "Humidité",
|
55
|
+
},
|
56
|
+
"visibility": {
|
57
|
+
"xpath": "./currentConditions/visibility",
|
58
|
+
"english": "Visibility",
|
59
|
+
"french": "Visibilité",
|
60
|
+
},
|
61
|
+
"condition": {
|
62
|
+
"xpath": "./currentConditions/condition",
|
63
|
+
"english": "Condition",
|
64
|
+
"french": "Condition",
|
65
|
+
},
|
66
|
+
"wind_speed": {
|
67
|
+
"xpath": "./currentConditions/wind/speed",
|
68
|
+
"english": "Wind Speed",
|
69
|
+
"french": "Vitesse de vent",
|
70
|
+
},
|
71
|
+
"wind_gust": {
|
72
|
+
"xpath": "./currentConditions/wind/gust",
|
73
|
+
"english": "Wind Gust",
|
74
|
+
"french": "Rafale de vent",
|
75
|
+
},
|
76
|
+
"wind_dir": {
|
77
|
+
"xpath": "./currentConditions/wind/direction",
|
78
|
+
"english": "Wind Direction",
|
79
|
+
"french": "Direction de vent",
|
80
|
+
},
|
81
|
+
"wind_bearing": {
|
82
|
+
"xpath": "./currentConditions/wind/bearing",
|
83
|
+
"english": "Wind Bearing",
|
84
|
+
"french": "Palier de vent",
|
85
|
+
},
|
86
|
+
"high_temp": {
|
87
|
+
"xpath": './forecastGroup/forecast/temperatures/temperature[@class="high"]',
|
88
|
+
"english": "High Temperature",
|
89
|
+
"french": "Haute température",
|
90
|
+
},
|
91
|
+
"low_temp": {
|
92
|
+
"xpath": './forecastGroup/forecast/temperatures/temperature[@class="low"]',
|
93
|
+
"english": "Low Temperature",
|
94
|
+
"french": "Basse température",
|
95
|
+
},
|
96
|
+
"uv_index": {
|
97
|
+
"xpath": "./forecastGroup/forecast/uv/index",
|
98
|
+
"english": "UV Index",
|
99
|
+
"french": "Indice UV",
|
100
|
+
},
|
101
|
+
"pop": {
|
102
|
+
"xpath": "./forecastGroup/forecast/abbreviatedForecast/pop",
|
103
|
+
"english": "Chance of Precip.",
|
104
|
+
"french": "Probabilité d'averses",
|
105
|
+
},
|
106
|
+
"icon_code": {
|
107
|
+
"xpath": "./currentConditions/iconCode",
|
108
|
+
"english": "Icon Code",
|
109
|
+
"french": "Code icône",
|
110
|
+
},
|
111
|
+
"precip_yesterday": {
|
112
|
+
"xpath": "./yesterdayConditions/precip",
|
113
|
+
"english": "Precipitation Yesterday",
|
114
|
+
"french": "Précipitation d'hier",
|
115
|
+
},
|
116
|
+
}
|
117
|
+
|
118
|
+
aqhi_meta = {
|
119
|
+
"label": {"english": "Air Quality Health Index", "french": "Cote air santé"}
|
120
|
+
}
|
121
|
+
|
122
|
+
summary_meta = {
|
123
|
+
"forecast_period": {
|
124
|
+
"xpath": "./forecastGroup/forecast/period",
|
125
|
+
"attribute": "textForecastName",
|
126
|
+
},
|
127
|
+
"text_summary": {
|
128
|
+
"xpath": "./forecastGroup/forecast/textSummary",
|
129
|
+
},
|
130
|
+
"label": {"english": "Forecast", "french": "Prévision"},
|
131
|
+
}
|
132
|
+
|
133
|
+
alerts_meta = {
|
134
|
+
"warnings": {
|
135
|
+
"english": {"label": "Warnings", "pattern": ".*WARNING((?!ENDED).)*$"},
|
136
|
+
"french": {
|
137
|
+
"label": "Alertes",
|
138
|
+
"pattern": ".*(ALERTE|AVERTISSEMENT)((?!TERMINÉ).)*$",
|
139
|
+
},
|
140
|
+
},
|
141
|
+
"watches": {
|
142
|
+
"english": {"label": "Watches", "pattern": ".*WATCH((?!ENDED).)*$"},
|
143
|
+
"french": {"label": "Veilles", "pattern": ".*VEILLE((?!TERMINÉ).)*$"},
|
144
|
+
},
|
145
|
+
"advisories": {
|
146
|
+
"english": {"label": "Advisories", "pattern": ".*ADVISORY((?!ENDED).)*$"},
|
147
|
+
"french": {"label": "Avis", "pattern": ".*AVIS((?!TERMINÉ).)*$"},
|
148
|
+
},
|
149
|
+
"statements": {
|
150
|
+
"english": {"label": "Statements", "pattern": ".*STATEMENT((?!ENDED).)*$"},
|
151
|
+
"french": {"label": "Bulletins", "pattern": ".*BULLETIN((?!TERMINÉ).)*$"},
|
152
|
+
},
|
153
|
+
"endings": {
|
154
|
+
"english": {"label": "Endings", "pattern": ".*ENDED"},
|
155
|
+
"french": {"label": "Terminaisons", "pattern": ".*TERMINÉE?"},
|
156
|
+
},
|
157
|
+
}
|
158
|
+
|
159
|
+
metadata_meta = {
|
160
|
+
"timestamp": {
|
161
|
+
"xpath": "./currentConditions/dateTime/timeStamp",
|
162
|
+
},
|
163
|
+
"location": {
|
164
|
+
"xpath": "./location/name",
|
165
|
+
},
|
166
|
+
"station": {
|
167
|
+
"xpath": "./currentConditions/station",
|
168
|
+
},
|
169
|
+
}
|
170
|
+
|
171
|
+
|
172
|
+
def ignore_ratelimit_error(fun):
|
173
|
+
def res(*args, **kwargs):
|
174
|
+
try:
|
175
|
+
return fun(*args, **kwargs)
|
176
|
+
except RateLimitException:
|
177
|
+
return None
|
178
|
+
|
179
|
+
return res
|
180
|
+
|
181
|
+
|
182
|
+
class ECData(object):
|
183
|
+
|
184
|
+
"""Get weather data from Environment Canada."""
|
185
|
+
|
186
|
+
def __init__(self, station_id=None, coordinates=(0, 0), language="english"):
|
187
|
+
"""Initialize the data object."""
|
188
|
+
self.language = language
|
189
|
+
self.language_abr = language[:2].upper()
|
190
|
+
self.zone_name_tag = "name_%s_CA" % self.language_abr.lower()
|
191
|
+
self.region_name_tag = "name%s" % self.language_abr.title()
|
192
|
+
|
193
|
+
self.metadata = {}
|
194
|
+
self.conditions = {}
|
195
|
+
self.alerts = {}
|
196
|
+
self.daily_forecasts = []
|
197
|
+
self.hourly_forecasts = []
|
198
|
+
self.aqhi = {}
|
199
|
+
self.forecast_time = ""
|
200
|
+
self.aqhi_id = None
|
201
|
+
self.lat = 0
|
202
|
+
self.lon = 0
|
203
|
+
|
204
|
+
site_list = self.get_ec_sites()
|
205
|
+
if station_id:
|
206
|
+
self.station_id = station_id
|
207
|
+
stn = station_id.split("/")
|
208
|
+
if len(stn) == 2:
|
209
|
+
for site in site_list:
|
210
|
+
if stn[1] == site["Codes"] and stn[0] == site["Province Codes"]:
|
211
|
+
self.lat = site["Latitude"]
|
212
|
+
self.lon = site["Longitude"]
|
213
|
+
break
|
214
|
+
else:
|
215
|
+
self.station_id = self.closest_site(
|
216
|
+
site_list, coordinates[0], coordinates[1]
|
217
|
+
)
|
218
|
+
self.lat = coordinates[0]
|
219
|
+
self.lon = coordinates[1]
|
220
|
+
|
221
|
+
self.update()
|
222
|
+
|
223
|
+
@ignore_ratelimit_error
|
224
|
+
@limits(calls=5, period=120)
|
225
|
+
def update(self):
|
226
|
+
"""Get the latest data from Environment Canada."""
|
227
|
+
url = WEATHER_URL.format(
|
228
|
+
date=datetime.now(tz=timezone.utc).strftime("%Y%m%d"),
|
229
|
+
site=self.station_id,
|
230
|
+
language=self.language[0],
|
231
|
+
)
|
232
|
+
try:
|
233
|
+
weather_result = requests.get(url)
|
234
|
+
|
235
|
+
except requests.exceptions.RequestException as e:
|
236
|
+
LOG.warning("Unable to retrieve weather forecast: %s", e)
|
237
|
+
return
|
238
|
+
|
239
|
+
if weather_result.status_code != 200:
|
240
|
+
LOG.warning(
|
241
|
+
"Unable to retrieve weather forecast, status code: %d, url: %s",
|
242
|
+
weather_result.status_code,
|
243
|
+
url,
|
244
|
+
)
|
245
|
+
return
|
246
|
+
|
247
|
+
weather_xml = weather_result.content.decode("iso-8859-1")
|
248
|
+
try:
|
249
|
+
weather_tree = et.fromstring(weather_xml)
|
250
|
+
except Exception as e:
|
251
|
+
LOG.warning("Unable to parse XML returned")
|
252
|
+
return
|
253
|
+
|
254
|
+
# Update metadata
|
255
|
+
for m, meta in metadata_meta.items():
|
256
|
+
element = weather_tree.find(meta["xpath"])
|
257
|
+
if element is not None:
|
258
|
+
self.metadata[m] = weather_tree.find(meta["xpath"]).text
|
259
|
+
else:
|
260
|
+
self.metadata[m] = None
|
261
|
+
|
262
|
+
# Update current conditions
|
263
|
+
def get_condition(meta):
|
264
|
+
condition = {}
|
265
|
+
|
266
|
+
element = weather_tree.find(meta["xpath"])
|
267
|
+
|
268
|
+
if element is not None:
|
269
|
+
if meta.get("attribute"):
|
270
|
+
condition["value"] = element.attrib.get(meta["attribute"])
|
271
|
+
else:
|
272
|
+
condition["value"] = element.text
|
273
|
+
if element.attrib.get("units"):
|
274
|
+
condition["unit"] = element.attrib.get("units")
|
275
|
+
return condition
|
276
|
+
|
277
|
+
for c, meta in conditions_meta.items():
|
278
|
+
self.conditions[c] = {"label": meta[self.language]}
|
279
|
+
self.conditions[c].update(get_condition(meta))
|
280
|
+
|
281
|
+
# Update text summary
|
282
|
+
period = get_condition(summary_meta["forecast_period"])["value"]
|
283
|
+
summary = get_condition(summary_meta["text_summary"])["value"]
|
284
|
+
|
285
|
+
self.conditions["text_summary"] = {
|
286
|
+
"label": summary_meta["label"][self.language],
|
287
|
+
"value": ". ".join([period, summary]),
|
288
|
+
}
|
289
|
+
|
290
|
+
# Update alerts
|
291
|
+
for category, meta in alerts_meta.items():
|
292
|
+
self.alerts[category] = {"value": [], "label": meta[self.language]["label"]}
|
293
|
+
|
294
|
+
alert_elements = weather_tree.findall("./warnings/event")
|
295
|
+
|
296
|
+
for a in alert_elements:
|
297
|
+
title = a.attrib.get("description").strip()
|
298
|
+
for category, meta in alerts_meta.items():
|
299
|
+
category_match = re.search(meta[self.language]["pattern"], title)
|
300
|
+
if category_match:
|
301
|
+
alert = {
|
302
|
+
"title": title.title(),
|
303
|
+
"date": a.find("./dateTime[last()]/textSummary").text,
|
304
|
+
}
|
305
|
+
self.alerts[category]["value"].append(alert)
|
306
|
+
|
307
|
+
# Update daily forecasts
|
308
|
+
self.forecast_time = weather_tree.findtext("./forecastGroup/dateTime/timeStamp")
|
309
|
+
self.daily_forecasts = []
|
310
|
+
self.hourly_forecasts = []
|
311
|
+
|
312
|
+
for f in weather_tree.findall("./forecastGroup/forecast"):
|
313
|
+
self.daily_forecasts.append(
|
314
|
+
{
|
315
|
+
"period": f.findtext("period"),
|
316
|
+
"text_summary": f.findtext("textSummary"),
|
317
|
+
"icon_code": f.findtext("./abbreviatedForecast/iconCode"),
|
318
|
+
"temperature": f.findtext("./temperatures/temperature"),
|
319
|
+
"temperature_class": f.find(
|
320
|
+
"./temperatures/temperature"
|
321
|
+
).attrib.get("class"),
|
322
|
+
"precip_probability": f.findtext("./abbreviatedForecast/pop")
|
323
|
+
or "0",
|
324
|
+
}
|
325
|
+
)
|
326
|
+
|
327
|
+
# Update hourly forecasts
|
328
|
+
for f in weather_tree.findall("./hourlyForecastGroup/hourlyForecast"):
|
329
|
+
self.hourly_forecasts.append(
|
330
|
+
{
|
331
|
+
"period": f.attrib.get("dateTimeUTC"),
|
332
|
+
"condition": f.findtext("./condition"),
|
333
|
+
"temperature": f.findtext("./temperature"),
|
334
|
+
"icon_code": f.findtext("./iconCode"),
|
335
|
+
"precip_probability": f.findtext("./lop") or "0",
|
336
|
+
}
|
337
|
+
)
|
338
|
+
|
339
|
+
# Update AQHI current condition
|
340
|
+
|
341
|
+
if self.aqhi_id is None:
|
342
|
+
lat = weather_tree.find("./location/name").attrib.get("lat")[:-1]
|
343
|
+
lon = weather_tree.find("./location/name").attrib.get("lon")[:-1]
|
344
|
+
aqhi_coordinates = (float(lat), float(lon) * -1)
|
345
|
+
self.aqhi_id = self.closest_aqhi(aqhi_coordinates[0], aqhi_coordinates[1])
|
346
|
+
|
347
|
+
success = True
|
348
|
+
try:
|
349
|
+
aqhi_result = requests.get(
|
350
|
+
AQHI_OBSERVATION_URL.format(self.aqhi_id[0], self.aqhi_id[1]),
|
351
|
+
timeout=10,
|
352
|
+
)
|
353
|
+
except requests.exceptions.RequestException as e:
|
354
|
+
LOG.warning("Unable to retrieve current AQHI observation: %s", e)
|
355
|
+
success = False
|
356
|
+
|
357
|
+
if not success or aqhi_result.status_code == 404:
|
358
|
+
self.aqhi["current"] = None
|
359
|
+
else:
|
360
|
+
aqhi_xml = aqhi_result.content.decode("utf-8")
|
361
|
+
aqhi_tree = et.fromstring(aqhi_xml)
|
362
|
+
|
363
|
+
element = aqhi_tree.find("airQualityHealthIndex")
|
364
|
+
if element is not None:
|
365
|
+
self.aqhi["current"] = element.text
|
366
|
+
else:
|
367
|
+
self.aqhi["current"] = None
|
368
|
+
|
369
|
+
self.conditions["air_quality"] = {
|
370
|
+
"label": aqhi_meta["label"][self.language],
|
371
|
+
"value": self.aqhi["current"],
|
372
|
+
}
|
373
|
+
|
374
|
+
element = aqhi_tree.find("./dateStamp/UTCStamp")
|
375
|
+
if element is not None:
|
376
|
+
self.aqhi["utc_time"] = element.text
|
377
|
+
else:
|
378
|
+
self.aqhi["utc_time"] = None
|
379
|
+
|
380
|
+
# Update AQHI forecasts
|
381
|
+
success = True
|
382
|
+
try:
|
383
|
+
aqhi_result = requests.get(
|
384
|
+
AQHI_FORECAST_URL.format(self.aqhi_id[0], self.aqhi_id[1]), timeout=10
|
385
|
+
)
|
386
|
+
except requests.exceptions.RequestException as e:
|
387
|
+
LOG.warning("Unable to retrieve forecast AQHI observation: %s", e)
|
388
|
+
success = False
|
389
|
+
|
390
|
+
if not success or aqhi_result.status_code == 404:
|
391
|
+
self.aqhi["forecasts"] = None
|
392
|
+
else:
|
393
|
+
aqhi_xml = aqhi_result.content.decode("ISO-8859-1")
|
394
|
+
aqhi_tree = et.fromstring(aqhi_xml)
|
395
|
+
|
396
|
+
self.aqhi["forecasts"] = {"daily": [], "hourly": []}
|
397
|
+
|
398
|
+
# Update AQHI daily forecasts
|
399
|
+
period = None
|
400
|
+
for f in aqhi_tree.findall("./forecastGroup/forecast"):
|
401
|
+
for p in f.findall("./period"):
|
402
|
+
if self.language_abr == p.attrib["lang"]:
|
403
|
+
period = p.attrib["forecastName"]
|
404
|
+
self.aqhi["forecasts"]["daily"].append(
|
405
|
+
{
|
406
|
+
"period": period,
|
407
|
+
"aqhi": f.findtext("./airQualityHealthIndex"),
|
408
|
+
}
|
409
|
+
)
|
410
|
+
|
411
|
+
# Update AQHI hourly forecasts
|
412
|
+
for f in aqhi_tree.findall("./hourlyForecastGroup/hourlyForecast"):
|
413
|
+
self.aqhi["forecasts"]["hourly"].append(
|
414
|
+
{"period": f.attrib["UTCTime"], "aqhi": f.text}
|
415
|
+
)
|
416
|
+
|
417
|
+
def get_ec_sites(self):
|
418
|
+
"""Get list of all sites from Environment Canada, for auto-config."""
|
419
|
+
import csv
|
420
|
+
import io
|
421
|
+
|
422
|
+
sites = []
|
423
|
+
|
424
|
+
try:
|
425
|
+
sites_result = requests.get(SITE_LIST_URL, timeout=10)
|
426
|
+
sites_csv_string = sites_result.text
|
427
|
+
except requests.exceptions.RequestException as e:
|
428
|
+
LOG.warning("Unable to retrieve site list csv: %s", e)
|
429
|
+
return sites
|
430
|
+
|
431
|
+
sites_csv_stream = io.StringIO(sites_csv_string)
|
432
|
+
|
433
|
+
sites_csv_stream.seek(0)
|
434
|
+
next(sites_csv_stream)
|
435
|
+
|
436
|
+
sites_reader = csv.DictReader(sites_csv_stream)
|
437
|
+
|
438
|
+
for site in sites_reader:
|
439
|
+
if site["Province Codes"] != "HEF":
|
440
|
+
site["Latitude"] = float(site["Latitude"].replace("N", ""))
|
441
|
+
site["Longitude"] = -1 * float(site["Longitude"].replace("W", ""))
|
442
|
+
sites.append(site)
|
443
|
+
|
444
|
+
return sites
|
445
|
+
|
446
|
+
def closest_site(self, site_list, lat, lon):
|
447
|
+
"""Return the province/site_code of the closest station to our lat/lon."""
|
448
|
+
|
449
|
+
def site_distance(site):
|
450
|
+
"""Calculate distance to a site."""
|
451
|
+
return distance.distance((lat, lon), (site["Latitude"], site["Longitude"]))
|
452
|
+
|
453
|
+
closest = min(site_list, key=site_distance)
|
454
|
+
|
455
|
+
return "{}/{}".format(closest["Province Codes"], closest["Codes"])
|
456
|
+
|
457
|
+
def get_aqhi_regions(self):
|
458
|
+
"""Get list of all AQHI regions from Environment Canada, for auto-config."""
|
459
|
+
regions = []
|
460
|
+
try:
|
461
|
+
result = requests.get(AQHI_SITE_LIST_URL, timeout=10)
|
462
|
+
except requests.exceptions.RequestException as e:
|
463
|
+
LOG.warning("Unable to retrieve AQHI regions: %s", e)
|
464
|
+
return regions
|
465
|
+
|
466
|
+
site_xml = result.content.decode("utf-8")
|
467
|
+
xml_object = et.fromstring(site_xml)
|
468
|
+
|
469
|
+
for zone in xml_object.findall("./EC_administrativeZone"):
|
470
|
+
_zone_attribs = zone.attrib
|
471
|
+
_zone_attrib = {
|
472
|
+
"abbreviation": _zone_attribs["abreviation"],
|
473
|
+
"zone_name": _zone_attribs[self.zone_name_tag],
|
474
|
+
}
|
475
|
+
for region in zone.findall("./regionList/region"):
|
476
|
+
_region_attribs = region.attrib
|
477
|
+
|
478
|
+
_region_attrib = {
|
479
|
+
"region_name": _region_attribs[self.region_name_tag],
|
480
|
+
"cgndb": _region_attribs["cgndb"],
|
481
|
+
"latitude": float(_region_attribs["latitude"]),
|
482
|
+
"longitude": float(_region_attribs["longitude"]),
|
483
|
+
}
|
484
|
+
_children = list(region)
|
485
|
+
for child in _children:
|
486
|
+
_region_attrib[child.tag] = child.text
|
487
|
+
_region_attrib.update(_zone_attrib)
|
488
|
+
regions.append(_region_attrib)
|
489
|
+
return regions
|
490
|
+
|
491
|
+
def closest_aqhi(self, lat, lon):
|
492
|
+
"""Return the AQHI region and site ID of the closest site."""
|
493
|
+
region_list = self.get_aqhi_regions()
|
494
|
+
|
495
|
+
def site_distance(site):
|
496
|
+
"""Calculate distance to a region."""
|
497
|
+
return distance.distance((lat, lon), (site["latitude"], site["longitude"]))
|
498
|
+
|
499
|
+
closest = min(region_list, key=site_distance)
|
500
|
+
|
501
|
+
return closest["abbreviation"], closest["cgndb"]
|
env_canada/ec_radar.py
CHANGED
@@ -1,17 +1,18 @@
|
|
1
|
-
from aiohttp.client_exceptions import ClientConnectorError
|
2
1
|
import asyncio
|
3
2
|
import datetime
|
4
|
-
from io import BytesIO
|
5
3
|
import logging
|
6
4
|
import math
|
7
5
|
import os
|
8
|
-
from
|
6
|
+
from io import BytesIO
|
9
7
|
|
10
|
-
from .ec_cache import CacheClientSession as ClientSession
|
11
8
|
import dateutil.parser
|
12
9
|
import defusedxml.ElementTree as et
|
13
10
|
import imageio.v2 as imageio
|
14
11
|
import voluptuous as vol
|
12
|
+
from aiohttp.client_exceptions import ClientConnectorError
|
13
|
+
from PIL import Image, ImageDraw, ImageFont
|
14
|
+
|
15
|
+
from .ec_cache import CacheClientSession as ClientSession
|
15
16
|
|
16
17
|
ATTRIBUTION = {
|
17
18
|
"english": "Data provided by Environment Canada",
|
@@ -156,16 +157,12 @@ class ECRadar(object):
|
|
156
157
|
# Get overlay parameters
|
157
158
|
|
158
159
|
self.show_legend = kwargs["legend"]
|
159
|
-
|
160
|
-
|
161
|
-
|
162
|
-
self.legend_position = None
|
160
|
+
self.legend_layer = None
|
161
|
+
self.legend_image = None
|
162
|
+
self.legend_position = None
|
163
163
|
|
164
164
|
self.show_timestamp = kwargs["timestamp"]
|
165
|
-
|
166
|
-
self.font = ImageFont.load(
|
167
|
-
os.path.join(os.path.dirname(__file__), "10x20.pil")
|
168
|
-
)
|
165
|
+
self.font = None
|
169
166
|
|
170
167
|
@property
|
171
168
|
def precip_type(self):
|
@@ -197,22 +194,20 @@ class ECRadar(object):
|
|
197
194
|
async with ClientSession(raise_for_status=True) as session:
|
198
195
|
response = await session.get(url=basemap_url, params=basemap_params)
|
199
196
|
base_bytes = await response.read()
|
200
|
-
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
201
197
|
|
202
198
|
except ClientConnectorError as e:
|
203
199
|
logging.warning("NRCan base map could not be retrieved: %s" % e)
|
204
|
-
|
205
200
|
try:
|
206
201
|
async with ClientSession(raise_for_status=True) as session:
|
207
202
|
response = await session.get(
|
208
203
|
url=backup_map_url, params=basemap_params
|
209
204
|
)
|
210
205
|
base_bytes = await response.read()
|
211
|
-
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
212
206
|
except ClientConnectorError:
|
213
207
|
logging.warning("Mapbox base map could not be retrieved")
|
208
|
+
return None
|
214
209
|
|
215
|
-
return
|
210
|
+
return base_bytes
|
216
211
|
|
217
212
|
async def _get_legend(self):
|
218
213
|
"""Fetch legend image."""
|
@@ -221,13 +216,13 @@ class ECRadar(object):
|
|
221
216
|
layer=precip_layers[self.layer_key], style=legend_style[self.layer_key]
|
222
217
|
)
|
223
218
|
)
|
224
|
-
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
219
|
+
try:
|
220
|
+
async with ClientSession(raise_for_status=True) as session:
|
221
|
+
response = await session.get(url=geomet_url, params=legend_params)
|
222
|
+
return await response.read()
|
223
|
+
except ClientConnectorError:
|
224
|
+
logging.warning("Legend could not be retrieved")
|
225
|
+
return None
|
231
226
|
|
232
227
|
async def _get_dimensions(self):
|
233
228
|
"""Get time range of available data."""
|
@@ -255,54 +250,73 @@ class ECRadar(object):
|
|
255
250
|
async def _combine_layers(self, radar_bytes, frame_time):
|
256
251
|
"""Add radar overlay to base layer and add timestamp."""
|
257
252
|
|
258
|
-
|
259
|
-
|
260
|
-
# Add transparency to radar
|
261
|
-
|
262
|
-
if self.radar_opacity < 100:
|
263
|
-
alpha = round((self.radar_opacity / 100) * 255)
|
264
|
-
radar_copy = radar.copy()
|
265
|
-
radar_copy.putalpha(alpha)
|
266
|
-
radar.paste(radar_copy, radar)
|
267
|
-
|
268
|
-
# Overlay radar on basemap
|
269
|
-
|
253
|
+
base_bytes = None
|
270
254
|
if not self.map_image:
|
271
|
-
await self._get_basemap()
|
272
|
-
if self.map_image:
|
273
|
-
frame = Image.alpha_composite(self.map_image, radar)
|
274
|
-
else:
|
275
|
-
frame = radar
|
276
|
-
|
277
|
-
# Add legend
|
255
|
+
base_bytes = await self._get_basemap()
|
278
256
|
|
257
|
+
legend_bytes = None
|
279
258
|
if self.show_legend:
|
280
259
|
if not self.legend_image or self.legend_layer != self.layer_key:
|
281
|
-
await self._get_legend()
|
282
|
-
frame.paste(self.legend_image, self.legend_position)
|
260
|
+
legend_bytes = await self._get_legend()
|
283
261
|
|
284
|
-
#
|
262
|
+
# All the synchronous PIL stuff here
|
263
|
+
def _create_image():
|
264
|
+
radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
|
285
265
|
|
286
|
-
|
287
|
-
|
288
|
-
timestamp_label[self.layer_key][self.language]
|
289
|
-
+ " @ "
|
290
|
-
+ frame_time.astimezone().strftime("%H:%M")
|
291
|
-
)
|
292
|
-
text_box = Image.new("RGBA", self.font.getbbox(timestamp)[2:], "white")
|
293
|
-
box_draw = ImageDraw.Draw(text_box)
|
294
|
-
box_draw.text(xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self.font)
|
295
|
-
double_box = text_box.resize((text_box.width * 2, text_box.height * 2))
|
296
|
-
frame.paste(double_box)
|
297
|
-
frame = frame.quantize()
|
298
|
-
|
299
|
-
# Return frame as PNG bytes
|
300
|
-
|
301
|
-
img_byte_arr = BytesIO()
|
302
|
-
frame.save(img_byte_arr, format="PNG")
|
303
|
-
frame_bytes = img_byte_arr.getvalue()
|
266
|
+
if base_bytes:
|
267
|
+
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
304
268
|
|
305
|
-
|
269
|
+
if legend_bytes:
|
270
|
+
self.legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB")
|
271
|
+
legend_width = self.legend_image.size[0]
|
272
|
+
self.legend_position = (self.width - legend_width, 0)
|
273
|
+
self.legend_layer = self.layer_key
|
274
|
+
|
275
|
+
# Add transparency to radar
|
276
|
+
if self.radar_opacity < 100:
|
277
|
+
alpha = round((self.radar_opacity / 100) * 255)
|
278
|
+
radar_copy = radar.copy()
|
279
|
+
radar_copy.putalpha(alpha)
|
280
|
+
radar.paste(radar_copy, radar)
|
281
|
+
|
282
|
+
if self.show_timestamp and not self.font:
|
283
|
+
self.font = ImageFont.load(
|
284
|
+
os.path.join(os.path.dirname(__file__), "10x20.pil")
|
285
|
+
)
|
286
|
+
|
287
|
+
# Overlay radar on basemap
|
288
|
+
if self.map_image:
|
289
|
+
frame = Image.alpha_composite(self.map_image, radar)
|
290
|
+
else:
|
291
|
+
frame = radar
|
292
|
+
|
293
|
+
# Add legend
|
294
|
+
if self.show_legend and self.legend_image:
|
295
|
+
frame.paste(self.legend_image, self.legend_position)
|
296
|
+
|
297
|
+
# Add timestamp
|
298
|
+
if self.show_timestamp and self.font:
|
299
|
+
timestamp = (
|
300
|
+
timestamp_label[self.layer_key][self.language]
|
301
|
+
+ " @ "
|
302
|
+
+ frame_time.astimezone().strftime("%H:%M")
|
303
|
+
)
|
304
|
+
text_box = Image.new("RGBA", self.font.getbbox(timestamp)[2:], "white")
|
305
|
+
box_draw = ImageDraw.Draw(text_box)
|
306
|
+
box_draw.text(xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self.font)
|
307
|
+
double_box = text_box.resize((text_box.width * 2, text_box.height * 2))
|
308
|
+
frame.paste(double_box)
|
309
|
+
frame = frame.quantize()
|
310
|
+
|
311
|
+
# Return frame as PNG bytes
|
312
|
+
img_byte_arr = BytesIO()
|
313
|
+
frame.save(img_byte_arr, format="PNG")
|
314
|
+
frame_bytes = img_byte_arr.getvalue()
|
315
|
+
|
316
|
+
return frame_bytes
|
317
|
+
|
318
|
+
# Since PIL is synchronous, run it all in another thread
|
319
|
+
return await asyncio.get_event_loop().run_in_executor(None, _create_image)
|
306
320
|
|
307
321
|
async def _get_radar_image(self, session, frame_time):
|
308
322
|
params = dict(
|
@@ -331,6 +345,17 @@ class ECRadar(object):
|
|
331
345
|
async def get_loop(self, fps=5):
|
332
346
|
"""Build an animated GIF of recent radar images."""
|
333
347
|
|
348
|
+
def build_image():
|
349
|
+
gif_frames = [imageio.imread(f, mode="RGBA") for f in frames]
|
350
|
+
gif_bytes = imageio.mimwrite(
|
351
|
+
imageio.RETURN_BYTES,
|
352
|
+
gif_frames,
|
353
|
+
format="GIF",
|
354
|
+
duration=duration,
|
355
|
+
subrectangles=True,
|
356
|
+
)
|
357
|
+
return gif_bytes
|
358
|
+
|
334
359
|
"""Build list of frame timestamps."""
|
335
360
|
start, end = await self._get_dimensions()
|
336
361
|
frame_times = [start]
|
@@ -361,12 +386,6 @@ class ECRadar(object):
|
|
361
386
|
"""Assemble animated GIF."""
|
362
387
|
duration = 1000 / fps
|
363
388
|
|
364
|
-
|
365
|
-
gif_bytes =
|
366
|
-
imageio.RETURN_BYTES,
|
367
|
-
gif_frames,
|
368
|
-
format="GIF",
|
369
|
-
duration=duration,
|
370
|
-
subrectangles=True,
|
371
|
-
)
|
389
|
+
loop = asyncio.get_running_loop()
|
390
|
+
gif_bytes = await loop.run_in_executor(None, build_image)
|
372
391
|
return gif_bytes
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
|
-
Name:
|
3
|
-
Version: 0.6.
|
2
|
+
Name: env_canada
|
3
|
+
Version: 0.6.3
|
4
4
|
Summary: A package to access meteorological data from Environment Canada
|
5
5
|
Home-page: https://github.com/michaeldavie/env_canada
|
6
6
|
Author: Michael Davie
|
@@ -25,7 +25,7 @@ Requires-Dist: voluptuous
|
|
25
25
|
# Environment Canada (env_canada)
|
26
26
|
|
27
27
|
[](https://badge.fury.io/py/env-canada)
|
28
|
-
[](https://snyk.io/vuln/pip:env-canada@0.6.
|
28
|
+
[](https://snyk.io/vuln/pip:env-canada@0.6.3?utm_source=badge)
|
29
29
|
|
30
30
|
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
31
|
|
@@ -116,7 +116,8 @@ Once updated asynchronously, historical weather data is contained with the `stat
|
|
116
116
|
```python
|
117
117
|
import asyncio
|
118
118
|
|
119
|
-
from env_canada import ECHistorical
|
119
|
+
from env_canada import ECHistorical
|
120
|
+
from env_canada.ec_historical import get_historical_stations
|
120
121
|
|
121
122
|
# search for stations, response contains station_ids
|
122
123
|
coordinates = [53.916944, -122.749444] # [lat, long]
|
@@ -165,7 +166,8 @@ For example :
|
|
165
166
|
```python
|
166
167
|
import pandas as pd
|
167
168
|
import asyncio
|
168
|
-
from env_canada import ECHistoricalRange
|
169
|
+
from env_canada import ECHistoricalRange
|
170
|
+
from env_canada.ec_historical import get_historical_stations
|
169
171
|
from datetime import datetime
|
170
172
|
|
171
173
|
coordinates = ['48.508333', '-68.467667']
|
@@ -205,4 +207,4 @@ of `datetime(2022, 7, 1, 12, 12)`
|
|
205
207
|
|
206
208
|
# License
|
207
209
|
|
208
|
-
The code is available under terms of [MIT License](LICENSE.md)
|
210
|
+
The code is available under terms of [MIT License](https://github.com/michaeldavie/env_canada/tree/master/LICENSE.md)
|
@@ -1,16 +1,17 @@
|
|
1
1
|
env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
|
2
2
|
env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
|
3
|
-
env_canada/__init__.py,sha256=
|
4
|
-
env_canada/constants.py,sha256=
|
3
|
+
env_canada/__init__.py,sha256=wEx1BCwVUH__GoosSlhNMHuUKCKNZAvv5uuSa5ZWq_g,187
|
4
|
+
env_canada/constants.py,sha256=P8tdLi9F5nSq1VHsA5avWwtf2mXSANWRgf7-qjSB7pM,32
|
5
5
|
env_canada/ec_aqhi.py,sha256=kJQ8xEgFnujGMYdxRXpoEK17B5e-ya-Y7rK0vLo_-w0,7768
|
6
6
|
env_canada/ec_cache.py,sha256=qoFxmO-kOBT8jhgPeNWtVBRmguXcARIIOI54OaDh-20,1171
|
7
|
+
env_canada/ec_data.py,sha256=DacCeZSDeMMVdN-Mx5WVa2ObooVm4SfEOK3J0kAV6H8,17597
|
7
8
|
env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
|
8
9
|
env_canada/ec_historical.py,sha256=slHaFwsoyW16uCVtE3_-IF3_BFhFD4IuWl7rpIRsCm4,15901
|
9
10
|
env_canada/ec_hydro.py,sha256=LBsWreTlaTKec6ObjI0ih8-zOKBNjD02oiXKTyUa1EQ,4898
|
10
|
-
env_canada/ec_radar.py,sha256=
|
11
|
+
env_canada/ec_radar.py,sha256=gcLa2z5T_CkrY-NLEJRqaLDHODJRcO5unW5MGxjKxF8,13115
|
11
12
|
env_canada/ec_weather.py,sha256=uBY6qd0-hVyZDhqPcpipfMDImXpJGiNIzMOjIzqNBfo,17358
|
12
|
-
env_canada-0.6.
|
13
|
-
env_canada-0.6.
|
14
|
-
env_canada-0.6.
|
15
|
-
env_canada-0.6.
|
16
|
-
env_canada-0.6.
|
13
|
+
env_canada-0.6.3.dist-info/LICENSE,sha256=c037dTHQWAgRgDqZNN-5d-CZvcteSYN37u39SNklO0I,1072
|
14
|
+
env_canada-0.6.3.dist-info/METADATA,sha256=00ezEXuLV3vc0BkMlGRi4jPCqFjqFkXSI-M_KgxYBPo,10707
|
15
|
+
env_canada-0.6.3.dist-info/WHEEL,sha256=cpQTJ5IWu9CdaPViMhC9YzF8gZuS5-vlfoFihTBC86A,91
|
16
|
+
env_canada-0.6.3.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
|
17
|
+
env_canada-0.6.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|