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 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"
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 PIL import Image, ImageDraw, ImageFont
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
- if self.show_legend:
160
- self.legend_layer = None
161
- self.legend_image = None
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
- if self.show_timestamp:
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
- async with ClientSession(raise_for_status=True) as session:
225
- response = await session.get(url=geomet_url, params=legend_params)
226
- legend_bytes = await response.read()
227
- self.legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB")
228
- legend_width = self.legend_image.size[0]
229
- self.legend_position = (self.width - legend_width, 0)
230
- self.legend_layer = self.layer_key
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
- radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
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
- # Add timestamp
262
+ # All the synchronous PIL stuff here
263
+ def _create_image():
264
+ radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
285
265
 
286
- if self.show_timestamp:
287
- timestamp = (
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
- return frame_bytes
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
- gif_frames = [imageio.imread(f, mode="RGBA") for f in frames]
365
- gif_bytes = imageio.mimwrite(
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: env-canada
3
- Version: 0.6.1
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
  [![PyPI version](https://badge.fury.io/py/env-canada.svg)](https://badge.fury.io/py/env-canada)
28
- [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](https://snyk.io/vuln/pip:env-canada@0.6.1?utm_source=badge)
28
+ [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](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, get_historical_stations
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, get_historical_stations
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=wQpbGVmwROrIR06RWinatD-qX7fbJvxBFOd6U0xoek4,126
4
- env_canada/constants.py,sha256=IsaGrjrg-YGadCHWyT0ZkBjbTDaF14W1gBPGQylncUY,32
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=WaJHtNnuQa3XoidvymOjJxNHqRLORTcy6_mczjIwd_A,12217
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.1.dist-info/LICENSE,sha256=c037dTHQWAgRgDqZNN-5d-CZvcteSYN37u39SNklO0I,1072
13
- env_canada-0.6.1.dist-info/METADATA,sha256=yy2_dg0TKwYxaIwyc1UfBHiuIQpt3hyETSkzvnRPGU0,10580
14
- env_canada-0.6.1.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
15
- env_canada-0.6.1.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
- env_canada-0.6.1.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: setuptools (70.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5