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 CHANGED
@@ -1 +1 @@
1
- USER_AGENT = "env_canada/0.6.2"
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
- if self.show_legend:
161
- self.legend_layer = None
162
- self.legend_image = None
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
- if self.show_timestamp:
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
- async with ClientSession(raise_for_status=True) as session:
226
- response = await session.get(url=geomet_url, params=legend_params)
227
- legend_bytes = await response.read()
228
- self.legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB")
229
- legend_width = self.legend_image.size[0]
230
- self.legend_position = (self.width - legend_width, 0)
231
- 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
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
- radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
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
- # Add timestamp
262
+ # All the synchronous PIL stuff here
263
+ def _create_image():
264
+ radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
286
265
 
287
- if self.show_timestamp:
288
- timestamp = (
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
- 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)
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.2
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.2?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
 
@@ -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=aVL9KzeCGoOZ2odaGXcKNqcxHZRamfD0CLXSFmyoZSM,32
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=TPzIlzwkgnsCSbN9Isdo_Q-1ORemNdYEBCcKfl_-ZcE,12415
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.2.dist-info/LICENSE,sha256=c037dTHQWAgRgDqZNN-5d-CZvcteSYN37u39SNklO0I,1072
13
- env_canada-0.6.2.dist-info/METADATA,sha256=0-49I2ZCrOoguMf9YnoCrmV3I0eR_Pn-l2_Rvvkpwxw,10652
14
- env_canada-0.6.2.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
15
- env_canada-0.6.2.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
- env_canada-0.6.2.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.43.0)
2
+ Generator: setuptools (70.1.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5