env-canada 0.6.2__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/constants.py +1 -1
- env_canada/ec_data.py +501 -0
- env_canada/ec_radar.py +73 -60
- {env_canada-0.6.2.dist-info → env_canada-0.6.3.dist-info}/METADATA +3 -3
- {env_canada-0.6.2.dist-info → env_canada-0.6.3.dist-info}/RECORD +8 -7
- {env_canada-0.6.2.dist-info → env_canada-0.6.3.dist-info}/WHEEL +1 -1
- {env_canada-0.6.2.dist-info → env_canada-0.6.3.dist-info}/LICENSE +0 -0
- {env_canada-0.6.2.dist-info → env_canada-0.6.3.dist-info}/top_level.txt +0 -0
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
@@ -157,16 +157,12 @@ class ECRadar(object):
|
|
157
157
|
# Get overlay parameters
|
158
158
|
|
159
159
|
self.show_legend = kwargs["legend"]
|
160
|
-
|
161
|
-
|
162
|
-
|
163
|
-
self.legend_position = None
|
160
|
+
self.legend_layer = None
|
161
|
+
self.legend_image = None
|
162
|
+
self.legend_position = None
|
164
163
|
|
165
164
|
self.show_timestamp = kwargs["timestamp"]
|
166
|
-
|
167
|
-
self.font = ImageFont.load(
|
168
|
-
os.path.join(os.path.dirname(__file__), "10x20.pil")
|
169
|
-
)
|
165
|
+
self.font = None
|
170
166
|
|
171
167
|
@property
|
172
168
|
def precip_type(self):
|
@@ -198,22 +194,20 @@ class ECRadar(object):
|
|
198
194
|
async with ClientSession(raise_for_status=True) as session:
|
199
195
|
response = await session.get(url=basemap_url, params=basemap_params)
|
200
196
|
base_bytes = await response.read()
|
201
|
-
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
202
197
|
|
203
198
|
except ClientConnectorError as e:
|
204
199
|
logging.warning("NRCan base map could not be retrieved: %s" % e)
|
205
|
-
|
206
200
|
try:
|
207
201
|
async with ClientSession(raise_for_status=True) as session:
|
208
202
|
response = await session.get(
|
209
203
|
url=backup_map_url, params=basemap_params
|
210
204
|
)
|
211
205
|
base_bytes = await response.read()
|
212
|
-
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
213
206
|
except ClientConnectorError:
|
214
207
|
logging.warning("Mapbox base map could not be retrieved")
|
208
|
+
return None
|
215
209
|
|
216
|
-
return
|
210
|
+
return base_bytes
|
217
211
|
|
218
212
|
async def _get_legend(self):
|
219
213
|
"""Fetch legend image."""
|
@@ -222,13 +216,13 @@ class ECRadar(object):
|
|
222
216
|
layer=precip_layers[self.layer_key], style=legend_style[self.layer_key]
|
223
217
|
)
|
224
218
|
)
|
225
|
-
|
226
|
-
|
227
|
-
|
228
|
-
|
229
|
-
|
230
|
-
|
231
|
-
|
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
|
232
226
|
|
233
227
|
async def _get_dimensions(self):
|
234
228
|
"""Get time range of available data."""
|
@@ -256,54 +250,73 @@ class ECRadar(object):
|
|
256
250
|
async def _combine_layers(self, radar_bytes, frame_time):
|
257
251
|
"""Add radar overlay to base layer and add timestamp."""
|
258
252
|
|
259
|
-
|
260
|
-
|
261
|
-
# Add transparency to radar
|
262
|
-
|
263
|
-
if self.radar_opacity < 100:
|
264
|
-
alpha = round((self.radar_opacity / 100) * 255)
|
265
|
-
radar_copy = radar.copy()
|
266
|
-
radar_copy.putalpha(alpha)
|
267
|
-
radar.paste(radar_copy, radar)
|
268
|
-
|
269
|
-
# Overlay radar on basemap
|
270
|
-
|
253
|
+
base_bytes = None
|
271
254
|
if not self.map_image:
|
272
|
-
await self._get_basemap()
|
273
|
-
if self.map_image:
|
274
|
-
frame = Image.alpha_composite(self.map_image, radar)
|
275
|
-
else:
|
276
|
-
frame = radar
|
277
|
-
|
278
|
-
# Add legend
|
255
|
+
base_bytes = await self._get_basemap()
|
279
256
|
|
257
|
+
legend_bytes = None
|
280
258
|
if self.show_legend:
|
281
259
|
if not self.legend_image or self.legend_layer != self.layer_key:
|
282
|
-
await self._get_legend()
|
283
|
-
frame.paste(self.legend_image, self.legend_position)
|
260
|
+
legend_bytes = await self._get_legend()
|
284
261
|
|
285
|
-
#
|
262
|
+
# All the synchronous PIL stuff here
|
263
|
+
def _create_image():
|
264
|
+
radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
|
286
265
|
|
287
|
-
|
288
|
-
|
289
|
-
timestamp_label[self.layer_key][self.language]
|
290
|
-
+ " @ "
|
291
|
-
+ frame_time.astimezone().strftime("%H:%M")
|
292
|
-
)
|
293
|
-
text_box = Image.new("RGBA", self.font.getbbox(timestamp)[2:], "white")
|
294
|
-
box_draw = ImageDraw.Draw(text_box)
|
295
|
-
box_draw.text(xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self.font)
|
296
|
-
double_box = text_box.resize((text_box.width * 2, text_box.height * 2))
|
297
|
-
frame.paste(double_box)
|
298
|
-
frame = frame.quantize()
|
299
|
-
|
300
|
-
# Return frame as PNG bytes
|
301
|
-
|
302
|
-
img_byte_arr = BytesIO()
|
303
|
-
frame.save(img_byte_arr, format="PNG")
|
304
|
-
frame_bytes = img_byte_arr.getvalue()
|
266
|
+
if base_bytes:
|
267
|
+
self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
305
268
|
|
306
|
-
|
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)
|
307
320
|
|
308
321
|
async def _get_radar_image(self, session, frame_time):
|
309
322
|
params = dict(
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: env_canada
|
3
|
-
Version: 0.6.
|
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
|
|
@@ -207,4 +207,4 @@ of `datetime(2022, 7, 1, 12, 12)`
|
|
207
207
|
|
208
208
|
# License
|
209
209
|
|
210
|
-
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
3
|
env_canada/__init__.py,sha256=wEx1BCwVUH__GoosSlhNMHuUKCKNZAvv5uuSa5ZWq_g,187
|
4
|
-
env_canada/constants.py,sha256=
|
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
|