env-canada 0.7.0__tar.gz → 0.7.1__tar.gz

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.
Files changed (30) hide show
  1. {env_canada-0.7.0/env_canada.egg-info → env_canada-0.7.1}/PKG-INFO +2 -2
  2. {env_canada-0.7.0 → env_canada-0.7.1}/README.md +1 -1
  3. env_canada-0.7.1/env_canada/constants.py +1 -0
  4. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_aqhi.py +5 -6
  5. env_canada-0.7.1/env_canada/ec_cache.py +25 -0
  6. env_canada-0.7.1/env_canada/ec_radar.py +377 -0
  7. {env_canada-0.7.0 → env_canada-0.7.1/env_canada.egg-info}/PKG-INFO +2 -2
  8. {env_canada-0.7.0 → env_canada-0.7.1}/setup.py +1 -1
  9. {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_radar.py +3 -3
  10. env_canada-0.7.0/env_canada/constants.py +0 -1
  11. env_canada-0.7.0/env_canada/ec_cache.py +0 -38
  12. env_canada-0.7.0/env_canada/ec_radar.py +0 -391
  13. {env_canada-0.7.0 → env_canada-0.7.1}/LICENSE +0 -0
  14. {env_canada-0.7.0 → env_canada-0.7.1}/MANIFEST.in +0 -0
  15. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/10x20.pbm +0 -0
  16. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/10x20.pil +0 -0
  17. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/__init__.py +0 -0
  18. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_exc.py +0 -0
  19. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_historical.py +0 -0
  20. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_hydro.py +0 -0
  21. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_weather.py +0 -0
  22. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/SOURCES.txt +0 -0
  23. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/dependency_links.txt +0 -0
  24. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/requires.txt +0 -0
  25. {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/top_level.txt +0 -0
  26. {env_canada-0.7.0 → env_canada-0.7.1}/setup.cfg +0 -0
  27. {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_aqhi.py +0 -0
  28. {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_historical.py +0 -0
  29. {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_hydro.py +0 -0
  30. {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_weather.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: env_canada
3
- Version: 0.7.0
3
+ Version: 0.7.1
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.7.0?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.7.1?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
 
@@ -1,7 +1,7 @@
1
1
  # Environment Canada (env_canada)
2
2
 
3
3
  [![PyPI version](https://badge.fury.io/py/env-canada.svg)](https://badge.fury.io/py/env-canada)
4
- [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](https://snyk.io/vuln/pip:env-canada@0.7.0?utm_source=badge)
4
+ [![Snyk rating](https://snyk-widget.herokuapp.com/badge/pip/env-canada/badge.svg)](https://snyk.io/vuln/pip:env-canada@0.7.1?utm_source=badge)
5
5
 
6
6
  This package provides access to various data sources published by [Environment and Climate Change Canada](https://www.canada.ca/en/environment-climate-change.html).
7
7
 
@@ -0,0 +1 @@
1
+ USER_AGENT = "env_canada/0.7.1"
@@ -84,7 +84,6 @@ async def find_closest_region(language, lat, lon):
84
84
 
85
85
 
86
86
  class ECAirQuality(object):
87
-
88
87
  """Get air quality data from Environment Canada."""
89
88
 
90
89
  def __init__(self, **kwargs):
@@ -172,7 +171,7 @@ class ECAirQuality(object):
172
171
  # Fetch current measurement
173
172
  aqhi_current = await self.get_aqhi_data(url=AQHI_OBSERVATION_URL)
174
173
 
175
- if aqhi_current:
174
+ if aqhi_current is not None:
176
175
  # Update region name
177
176
  element = aqhi_current.find("region")
178
177
  self.region_name = element.attrib[
@@ -202,7 +201,7 @@ class ECAirQuality(object):
202
201
  # Update AQHI forecasts
203
202
  aqhi_forecast = await self.get_aqhi_data(url=AQHI_FORECAST_URL)
204
203
 
205
- if aqhi_forecast:
204
+ if aqhi_forecast is not None:
206
205
  # Update AQHI daily forecasts
207
206
  for f in aqhi_forecast.findall("./forecastGroup/forecast"):
208
207
  for p in f.findall("./period"):
@@ -214,6 +213,6 @@ class ECAirQuality(object):
214
213
 
215
214
  # Update AQHI hourly forecasts
216
215
  for f in aqhi_forecast.findall("./hourlyForecastGroup/hourlyForecast"):
217
- self.forecasts["hourly"][
218
- timestamp_to_datetime(f.attrib["UTCTime"])
219
- ] = int(f.text or 0)
216
+ self.forecasts["hourly"][timestamp_to_datetime(f.attrib["UTCTime"])] = (
217
+ int(f.text or 0)
218
+ )
@@ -0,0 +1,25 @@
1
+ from datetime import datetime
2
+
3
+
4
+ class Cache:
5
+ _cache = {}
6
+
7
+ @classmethod
8
+ def add(cls, cache_key, item, cache_time):
9
+ """Add an entry to the cache."""
10
+
11
+ cls._cache[cache_key] = (datetime.now() + cache_time, item)
12
+ return item # Returning item useful for chaining calls
13
+
14
+ @classmethod
15
+ def get(cls, cache_key):
16
+ """Get an entry from the cache."""
17
+
18
+ # Delete expired entries at start so we don't use expired entries
19
+ now = datetime.now()
20
+ expired = [key for key, value in cls._cache.items() if value[0] < now]
21
+ for key in expired:
22
+ del cls._cache[key]
23
+
24
+ result = cls._cache.get(cache_key)
25
+ return result[1] if result else None
@@ -0,0 +1,377 @@
1
+ import asyncio
2
+ from datetime import date, timedelta
3
+ import logging
4
+ import math
5
+ import os
6
+ from io import BytesIO
7
+ from typing import cast
8
+
9
+ import dateutil.parser
10
+ import defusedxml.ElementTree as et
11
+ import voluptuous as vol
12
+ from aiohttp import ClientSession
13
+ from aiohttp.client_exceptions import ClientConnectorError
14
+ from PIL import Image, ImageDraw, ImageFont
15
+
16
+ from .constants import USER_AGENT
17
+ from .ec_cache import Cache
18
+
19
+ ATTRIBUTION = {
20
+ "english": "Data provided by Environment Canada",
21
+ "french": "Données fournies par Environnement Canada",
22
+ }
23
+
24
+ __all__ = ["ECRadar"]
25
+
26
+ # Natural Resources Canada
27
+
28
+ basemap_url = "https://maps.geogratis.gc.ca/wms/CBMT"
29
+ basemap_params = {
30
+ "service": "wms",
31
+ "version": "1.3.0",
32
+ "request": "GetMap",
33
+ "layers": "CBMT",
34
+ "styles": "",
35
+ "CRS": "epsg:4326",
36
+ "format": "image/png",
37
+ }
38
+
39
+ # Mapbox Proxy
40
+
41
+ backup_map_url = (
42
+ "https://0wmiyoko9f.execute-api.ca-central-1.amazonaws.com/mapbox-proxy"
43
+ )
44
+
45
+ # Environment Canada
46
+
47
+ precip_layers = {"rain": "RADAR_1KM_RRAI", "snow": "RADAR_1KM_RSNO"}
48
+
49
+ legend_style = {"rain": "RADARURPPRECIPR", "snow": "RADARURPPRECIPS14"}
50
+
51
+ geomet_url = "https://geo.weather.gc.ca/geomet"
52
+ capabilities_params = {
53
+ "lang": "en",
54
+ "service": "WMS",
55
+ "version": "1.3.0",
56
+ "request": "GetCapabilities",
57
+ }
58
+ wms_namespace = {"wms": "http://www.opengis.net/wms"}
59
+ dimension_xpath = './/wms:Layer[wms:Name="{layer}"]/wms:Dimension'
60
+ radar_params = {
61
+ "service": "WMS",
62
+ "version": "1.3.0",
63
+ "request": "GetMap",
64
+ "crs": "EPSG:4326",
65
+ "format": "image/png",
66
+ }
67
+ legend_params = {
68
+ "service": "WMS",
69
+ "version": "1.3.0",
70
+ "request": "GetLegendGraphic",
71
+ "sld_version": "1.1.0",
72
+ "format": "image/png",
73
+ }
74
+ radar_interval = timedelta(minutes=6)
75
+
76
+ timestamp_label = {
77
+ "rain": {"english": "Rain", "french": "Pluie"},
78
+ "snow": {"english": "Snow", "french": "Neige"},
79
+ }
80
+
81
+
82
+ def _compute_bounding_box(distance, latittude, longitude):
83
+ """
84
+ Modified from https://gist.github.com/alexcpn/f95ae83a7ee0293a5225
85
+ """
86
+ latittude = math.radians(latittude)
87
+ longitude = math.radians(longitude)
88
+
89
+ distance_from_point_km = distance
90
+ angular_distance = distance_from_point_km / 6371.01
91
+
92
+ lat_min = latittude - angular_distance
93
+ lat_max = latittude + angular_distance
94
+
95
+ delta_longitude = math.asin(math.sin(angular_distance) / math.cos(latittude))
96
+
97
+ lon_min = longitude - delta_longitude
98
+ lon_max = longitude + delta_longitude
99
+ lon_min = round(math.degrees(lon_min), 5)
100
+ lat_max = round(math.degrees(lat_max), 5)
101
+ lon_max = round(math.degrees(lon_max), 5)
102
+ lat_min = round(math.degrees(lat_min), 5)
103
+
104
+ return lat_min, lon_min, lat_max, lon_max
105
+
106
+
107
+ async def _get_resource(url, params, bytes=True):
108
+ async with ClientSession(raise_for_status=True) as session:
109
+ response = await session.get(
110
+ url=url, params=params, headers={"User-Agent": USER_AGENT}
111
+ )
112
+ if bytes:
113
+ return await response.read()
114
+ return await response.text()
115
+
116
+
117
+ class ECRadar(object):
118
+ def __init__(self, **kwargs):
119
+ """Initialize the radar object."""
120
+
121
+ init_schema = vol.Schema(
122
+ {
123
+ vol.Required("coordinates"): (
124
+ vol.All(vol.Or(int, float), vol.Range(-90, 90)),
125
+ vol.All(vol.Or(int, float), vol.Range(-180, 180)),
126
+ ),
127
+ vol.Required("radius", default=200): vol.All(int, vol.Range(min=10)),
128
+ vol.Required("width", default=800): vol.All(int, vol.Range(min=10)),
129
+ vol.Required("height", default=800): vol.All(int, vol.Range(min=10)),
130
+ vol.Required("legend", default=True): bool,
131
+ vol.Required("timestamp", default=True): bool,
132
+ vol.Required("radar_opacity", default=65): vol.All(
133
+ int, vol.Range(0, 100)
134
+ ),
135
+ vol.Optional("precip_type"): vol.Any(
136
+ None, vol.In(["rain", "snow", "auto"])
137
+ ),
138
+ vol.Optional("language", default="english"): vol.In(
139
+ ["english", "french"]
140
+ ),
141
+ }
142
+ )
143
+
144
+ kwargs = init_schema(kwargs)
145
+ self.language = kwargs["language"]
146
+ self.metadata = {"attribution": ATTRIBUTION[self.language]}
147
+
148
+ self._precip_type_setting = kwargs.get("precip_type")
149
+ self._precip_type_actual = self.precip_type[1]
150
+
151
+ # Get map parameters
152
+ self.image = None
153
+ self.width = kwargs["width"]
154
+ self.height = kwargs["height"]
155
+ self.bbox = _compute_bounding_box(kwargs["radius"], *kwargs["coordinates"])
156
+ self.map_params = {
157
+ "bbox": ",".join([str(coord) for coord in self.bbox]),
158
+ "width": self.width,
159
+ "height": self.height,
160
+ }
161
+ self.radar_opacity = kwargs["radar_opacity"]
162
+
163
+ # Get overlay parameters
164
+ self.show_legend = kwargs["legend"]
165
+ self.show_timestamp = kwargs["timestamp"]
166
+
167
+ self._font = None
168
+
169
+ @property
170
+ def precip_type(self):
171
+ # NOTE: this is a breaking change for this lib; HA doesn't use this so not breaking for that
172
+ if self._precip_type_setting in ["rain", "snow"]:
173
+ return (self._precip_type_setting, self._precip_type_setting)
174
+ self._precip_type_actual = (
175
+ "rain" if date.today().month in range(4, 11) else "snow"
176
+ )
177
+ return ("auto", self._precip_type_actual)
178
+
179
+ @precip_type.setter
180
+ def precip_type(self, user_input):
181
+ if user_input not in ["rain", "snow", "auto"]:
182
+ raise ValueError("precip_type must be 'rain', 'snow', or 'auto'")
183
+ self._precip_type_setting = user_input
184
+ self._precip_type_actual = self.precip_type[1]
185
+
186
+ async def _get_basemap(self):
187
+ """Fetch the background map image."""
188
+ if base_bytes := Cache.get("basemap"):
189
+ return base_bytes
190
+
191
+ basemap_params.update(self.map_params)
192
+ for map_url in [basemap_url, backup_map_url]:
193
+ try:
194
+ base_bytes = await _get_resource(map_url, basemap_params)
195
+ return Cache.add("basemap", base_bytes, timedelta(days=7))
196
+
197
+ except ClientConnectorError as e:
198
+ logging.warning("Map from %s could not be retrieved: %s" % map_url, e)
199
+
200
+ async def _get_legend(self):
201
+ """Fetch legend image."""
202
+
203
+ legend_cache_key = f"legend-{self._precip_type_actual}"
204
+ if legend := Cache.get(legend_cache_key):
205
+ return legend
206
+
207
+ legend_params.update(
208
+ dict(
209
+ layer=precip_layers[self._precip_type_actual],
210
+ style=legend_style[self._precip_type_actual],
211
+ )
212
+ )
213
+ try:
214
+ legend = await _get_resource(geomet_url, legend_params)
215
+ return Cache.add(legend_cache_key, legend, timedelta(days=7))
216
+
217
+ except ClientConnectorError:
218
+ logging.warning("Legend could not be retrieved")
219
+ return None
220
+
221
+ async def _get_dimensions(self):
222
+ """Get time range of available radar images."""
223
+
224
+ capabilities_cache_key = f"capabilities-{self._precip_type_actual}"
225
+
226
+ if not (capabilities_xml := Cache.get(capabilities_cache_key)):
227
+ capabilities_params["layer"] = precip_layers[self._precip_type_actual]
228
+ capabilities_xml = await _get_resource(
229
+ geomet_url, capabilities_params, bytes=False
230
+ )
231
+ Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
232
+
233
+ dimension_string = et.fromstring(capabilities_xml).find(
234
+ dimension_xpath.format(layer=precip_layers[self._precip_type_actual]),
235
+ namespaces=wms_namespace,
236
+ )
237
+ if dimension_string is not None:
238
+ if dimension_string := dimension_string.text:
239
+ start, end = [
240
+ dateutil.parser.isoparse(t) for t in dimension_string.split("/")[:2]
241
+ ]
242
+ self.timestamp = end.isoformat()
243
+ return (start, end)
244
+ return None
245
+
246
+ async def _get_radar_image(self, frame_time):
247
+ def _create_image():
248
+ """Contains all the PIL calls; run in another thread."""
249
+
250
+ radar_image = Image.open(BytesIO(cast(bytes, radar_bytes))).convert("RGBA")
251
+
252
+ map_image = None
253
+ if base_bytes:
254
+ map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
255
+
256
+ if legend_bytes:
257
+ legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB")
258
+ legend_position = (self.width - legend_image.size[0], 0)
259
+ else:
260
+ legend_image = None
261
+ legend_position = None
262
+
263
+ # Add transparency to radar
264
+ if self.radar_opacity < 100:
265
+ alpha = round((self.radar_opacity / 100) * 255)
266
+ radar_copy = radar_image.copy()
267
+ radar_copy.putalpha(alpha)
268
+ radar_image.paste(radar_copy, radar_image)
269
+
270
+ # Overlay radar on basemap
271
+ if map_image:
272
+ frame = Image.alpha_composite(map_image, radar_image)
273
+ else:
274
+ frame = radar_image
275
+
276
+ # Add legend
277
+ if legend_image:
278
+ frame.paste(legend_image, legend_position)
279
+
280
+ # Add timestamp
281
+ if self.show_timestamp:
282
+ if not self._font:
283
+ self._font = ImageFont.load(
284
+ os.path.join(os.path.dirname(__file__), "10x20.pil")
285
+ )
286
+
287
+ if self._font:
288
+ label = timestamp_label[self._precip_type_actual][self.language]
289
+ timestamp = f"{label} @ {frame_time.astimezone().strftime('%H:%M')}"
290
+ text_box = Image.new(
291
+ "RGBA", self._font.getbbox(timestamp)[2:], "white"
292
+ )
293
+ box_draw = ImageDraw.Draw(text_box)
294
+ box_draw.text(
295
+ xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self._font
296
+ )
297
+ double_box = text_box.resize(
298
+ (text_box.width * 2, text_box.height * 2)
299
+ )
300
+ frame.paste(double_box)
301
+ frame = frame.quantize()
302
+
303
+ # Convert frame to PNG for return
304
+ img_byte_arr = BytesIO()
305
+ frame.save(img_byte_arr, format="PNG")
306
+
307
+ # Time is tuned for 3h radar image
308
+ return Cache.add(
309
+ f"radar-{time}", img_byte_arr.getvalue(), timedelta(minutes=200)
310
+ )
311
+
312
+ time = frame_time.strftime("%Y-%m-%dT%H:%M:00Z")
313
+
314
+ if img := Cache.get(f"radar-{time}"):
315
+ return img
316
+
317
+ base_bytes = await self._get_basemap()
318
+ legend_bytes = await self._get_legend() if self.show_legend else None
319
+
320
+ params = dict(
321
+ **radar_params,
322
+ **self.map_params,
323
+ layers=precip_layers[self._precip_type_actual],
324
+ time=time,
325
+ )
326
+ radar_bytes = await _get_resource(geomet_url, params)
327
+ return await asyncio.get_event_loop().run_in_executor(None, _create_image)
328
+
329
+ async def get_latest_frame(self):
330
+ """Get the latest image from Environment Canada."""
331
+ dimensions = await self._get_dimensions()
332
+ if not dimensions:
333
+ return None
334
+ return await self._get_radar_image(frame_time=dimensions[1])
335
+
336
+ async def update(self):
337
+ self.image = await self.get_loop()
338
+
339
+ async def get_loop(self, fps=5):
340
+ """Build an animated GIF of recent radar images."""
341
+
342
+ def create_gif():
343
+ """Assemble animated GIF."""
344
+ duration = 1000 / fps
345
+ imgs = [Image.open(BytesIO(img)).convert("RGBA") for img in radar_layers]
346
+ gif = BytesIO()
347
+ imgs[0].save(
348
+ gif,
349
+ format="GIF",
350
+ save_all=True,
351
+ append_images=imgs[1:],
352
+ duration=duration,
353
+ loop=0,
354
+ )
355
+ return gif.getvalue()
356
+
357
+ # Without this cache priming the tasks below each compete to load map/legend
358
+ # at the same time, resulting in them getting retrieved for each radar image.
359
+ await self._get_basemap()
360
+ await self._get_legend() if self.show_legend else None
361
+
362
+ timespan = await self._get_dimensions()
363
+ if not timespan:
364
+ logging.error("Cannot retrieve radar times.")
365
+ return None
366
+
367
+ tasks = []
368
+ curr = timespan[0]
369
+ while curr <= timespan[1]:
370
+ tasks.append(self._get_radar_image(frame_time=curr))
371
+ curr = curr + radar_interval
372
+ radar_layers = await asyncio.gather(*tasks)
373
+
374
+ for _ in range(3):
375
+ radar_layers.append(radar_layers[-1])
376
+
377
+ return await asyncio.get_running_loop().run_in_executor(None, create_gif)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: env_canada
3
- Version: 0.7.0
3
+ Version: 0.7.1
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.7.0?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.7.1?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
 
@@ -5,7 +5,7 @@ with open("README.md", "r") as fh:
5
5
 
6
6
  setuptools.setup(
7
7
  name="env_canada",
8
- version="0.7.0",
8
+ version="0.7.1",
9
9
  author="Michael Davie",
10
10
  author_email="michael.davie@gmail.com",
11
11
  description="A package to access meteorological data from Environment Canada",
@@ -48,9 +48,9 @@ def test_get_loop(test_radar):
48
48
 
49
49
  def test_set_precip_type(test_radar):
50
50
  test_radar.precip_type = "auto"
51
- assert test_radar.precip_type == "auto"
51
+ assert test_radar.precip_type[0] == "auto"
52
52
 
53
53
  if date.today().month in range(4, 11):
54
- assert test_radar.layer_key == "rain"
54
+ assert test_radar.precip_type[1] == "rain"
55
55
  else:
56
- assert test_radar.layer_key == "snow"
56
+ assert test_radar.precip_type[1] == "snow"
@@ -1 +0,0 @@
1
- USER_AGENT = "env_canada/0.7.0"
@@ -1,38 +0,0 @@
1
- from aiohttp import ClientSession
2
- from datetime import datetime, timedelta
3
-
4
- from .constants import USER_AGENT
5
-
6
- CACHE_EXPIRE_TIME = timedelta(minutes=200) # Time is tuned for 3h radar image
7
-
8
-
9
- class CacheClientSession(ClientSession):
10
- """Shim to cache ClientSession requests."""
11
-
12
- _cache = {}
13
-
14
- def _flush_cache(self):
15
- """Flush expired cache entries."""
16
-
17
- now = datetime.now()
18
- expired = [key for key, value in self._cache.items() if value[0] < now]
19
- for key in expired:
20
- del self._cache[key]
21
-
22
- async def get(self, url, params, cache_time=CACHE_EXPIRE_TIME):
23
- """Thin wrapper around ClientSession.get to cache responses."""
24
-
25
- self._flush_cache() # Flush at start so we don't use expired entries
26
-
27
- cache_key = (url, tuple(sorted(params.items())))
28
- result = self._cache.get(cache_key)
29
- if not result:
30
- result = (
31
- datetime.now() + cache_time,
32
- await super().get(
33
- url=url, params=params, headers={"User-Agent": USER_AGENT}
34
- ),
35
- )
36
- self._cache[cache_key] = result
37
-
38
- return result[1]
@@ -1,391 +0,0 @@
1
- import asyncio
2
- import datetime
3
- import logging
4
- import math
5
- import os
6
- from io import BytesIO
7
-
8
- import dateutil.parser
9
- import defusedxml.ElementTree as et
10
- import imageio.v2 as imageio
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
16
-
17
- ATTRIBUTION = {
18
- "english": "Data provided by Environment Canada",
19
- "french": "Données fournies par Environnement Canada",
20
- }
21
-
22
- __all__ = ["ECRadar"]
23
-
24
- # Natural Resources Canada
25
-
26
- basemap_url = "https://maps.geogratis.gc.ca/wms/CBMT"
27
- basemap_params = {
28
- "service": "wms",
29
- "version": "1.3.0",
30
- "request": "GetMap",
31
- "layers": "CBMT",
32
- "styles": "",
33
- "CRS": "epsg:4326",
34
- "format": "image/png",
35
- }
36
-
37
- # Mapbox Proxy
38
-
39
- backup_map_url = (
40
- "https://0wmiyoko9f.execute-api.ca-central-1.amazonaws.com/mapbox-proxy"
41
- )
42
-
43
- # Environment Canada
44
-
45
- precip_layers = {"rain": "RADAR_1KM_RRAI", "snow": "RADAR_1KM_RSNO"}
46
-
47
- legend_style = {"rain": "RADARURPPRECIPR", "snow": "RADARURPPRECIPS14"}
48
-
49
- geomet_url = "https://geo.weather.gc.ca/geomet"
50
- capabilities_params = {
51
- "lang": "en",
52
- "service": "WMS",
53
- "version": "1.3.0",
54
- "request": "GetCapabilities",
55
- }
56
- wms_namespace = {"wms": "http://www.opengis.net/wms"}
57
- dimension_xpath = './/wms:Layer[wms:Name="{layer}"]/wms:Dimension'
58
- radar_params = {
59
- "service": "WMS",
60
- "version": "1.3.0",
61
- "request": "GetMap",
62
- "crs": "EPSG:4326",
63
- "format": "image/png",
64
- }
65
- legend_params = {
66
- "service": "WMS",
67
- "version": "1.3.0",
68
- "request": "GetLegendGraphic",
69
- "sld_version": "1.1.0",
70
- "format": "image/png",
71
- }
72
- radar_interval = 6
73
-
74
- timestamp_label = {
75
- "rain": {"english": "Rain", "french": "Pluie"},
76
- "snow": {"english": "Snow", "french": "Neige"},
77
- }
78
-
79
-
80
- def compute_bounding_box(distance, latittude, longitude):
81
- """
82
- Modified from https://gist.github.com/alexcpn/f95ae83a7ee0293a5225
83
- """
84
- latittude = math.radians(latittude)
85
- longitude = math.radians(longitude)
86
-
87
- distance_from_point_km = distance
88
- angular_distance = distance_from_point_km / 6371.01
89
-
90
- lat_min = latittude - angular_distance
91
- lat_max = latittude + angular_distance
92
-
93
- delta_longitude = math.asin(math.sin(angular_distance) / math.cos(latittude))
94
-
95
- lon_min = longitude - delta_longitude
96
- lon_max = longitude + delta_longitude
97
- lon_min = round(math.degrees(lon_min), 5)
98
- lat_max = round(math.degrees(lat_max), 5)
99
- lon_max = round(math.degrees(lon_max), 5)
100
- lat_min = round(math.degrees(lat_min), 5)
101
-
102
- return lat_min, lon_min, lat_max, lon_max
103
-
104
-
105
- class ECRadar(object):
106
- def __init__(self, **kwargs):
107
- """Initialize the radar object."""
108
-
109
- init_schema = vol.Schema(
110
- {
111
- vol.Required("coordinates"): (
112
- vol.All(vol.Or(int, float), vol.Range(-90, 90)),
113
- vol.All(vol.Or(int, float), vol.Range(-180, 180)),
114
- ),
115
- vol.Required("radius", default=200): vol.All(int, vol.Range(min=10)),
116
- vol.Required("width", default=800): vol.All(int, vol.Range(min=10)),
117
- vol.Required("height", default=800): vol.All(int, vol.Range(min=10)),
118
- vol.Required("legend", default=True): bool,
119
- vol.Required("timestamp", default=True): bool,
120
- vol.Required("radar_opacity", default=65): vol.All(
121
- int, vol.Range(0, 100)
122
- ),
123
- vol.Optional("precip_type"): vol.Any(
124
- None, vol.In(["rain", "snow", "auto"])
125
- ),
126
- vol.Optional("language", default="english"): vol.In(
127
- ["english", "french"]
128
- ),
129
- }
130
- )
131
-
132
- kwargs = init_schema(kwargs)
133
- self.language = kwargs["language"]
134
- self.metadata = {"attribution": ATTRIBUTION[self.language]}
135
-
136
- # Set precipitation type
137
-
138
- if "precip_type" in kwargs and kwargs["precip_type"] is not None:
139
- self.precip_type = kwargs["precip_type"]
140
- else:
141
- self.precip_type = "auto"
142
-
143
- # Get map parameters
144
-
145
- self.image = None
146
- self.width = kwargs["width"]
147
- self.height = kwargs["height"]
148
- self.bbox = compute_bounding_box(kwargs["radius"], *kwargs["coordinates"])
149
- self.map_params = {
150
- "bbox": ",".join([str(coord) for coord in self.bbox]),
151
- "width": self.width,
152
- "height": self.height,
153
- }
154
- self.map_image = None
155
- self.radar_opacity = kwargs["radar_opacity"]
156
-
157
- # Get overlay parameters
158
-
159
- self.show_legend = kwargs["legend"]
160
- self.legend_layer = None
161
- self.legend_image = None
162
- self.legend_position = None
163
-
164
- self.show_timestamp = kwargs["timestamp"]
165
- self.font = None
166
-
167
- @property
168
- def precip_type(self):
169
- return self._precip_setting
170
-
171
- @precip_type.setter
172
- def precip_type(self, user_input):
173
- if user_input not in ["rain", "snow", "auto"]:
174
- raise ValueError("precip_type must be 'rain', 'snow', or 'auto'")
175
-
176
- self._precip_setting = user_input
177
-
178
- if self._precip_setting in ["rain", "snow"]:
179
- self.layer_key = self._precip_setting
180
- else:
181
- self._auto_precip_type()
182
-
183
- def _auto_precip_type(self):
184
- if datetime.date.today().month in range(4, 11):
185
- self.layer_key = "rain"
186
- else:
187
- self.layer_key = "snow"
188
-
189
- async def _get_basemap(self):
190
- """Fetch the background map image."""
191
- basemap_params.update(self.map_params)
192
-
193
- try:
194
- async with ClientSession(raise_for_status=True) as session:
195
- response = await session.get(url=basemap_url, params=basemap_params)
196
- base_bytes = await response.read()
197
-
198
- except ClientConnectorError as e:
199
- logging.warning("NRCan base map could not be retrieved: %s" % e)
200
- try:
201
- async with ClientSession(raise_for_status=True) as session:
202
- response = await session.get(
203
- url=backup_map_url, params=basemap_params
204
- )
205
- base_bytes = await response.read()
206
- except ClientConnectorError:
207
- logging.warning("Mapbox base map could not be retrieved")
208
- return None
209
-
210
- return base_bytes
211
-
212
- async def _get_legend(self):
213
- """Fetch legend image."""
214
- legend_params.update(
215
- dict(
216
- layer=precip_layers[self.layer_key], style=legend_style[self.layer_key]
217
- )
218
- )
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
226
-
227
- async def _get_dimensions(self):
228
- """Get time range of available data."""
229
- capabilities_params["layer"] = precip_layers[self.layer_key]
230
-
231
- async with ClientSession(raise_for_status=True) as session:
232
- response = await session.get(
233
- url=geomet_url,
234
- params=capabilities_params,
235
- cache_time=datetime.timedelta(minutes=5),
236
- )
237
- capabilities_xml = await response.text()
238
-
239
- capabilities_tree = et.fromstring(capabilities_xml)
240
- dimension_string = capabilities_tree.find(
241
- dimension_xpath.format(layer=precip_layers[self.layer_key]),
242
- namespaces=wms_namespace,
243
- ).text
244
- start, end = [
245
- dateutil.parser.isoparse(t) for t in dimension_string.split("/")[:2]
246
- ]
247
- self.timestamp = end.isoformat()
248
- return start, end
249
-
250
- async def _combine_layers(self, radar_bytes, frame_time):
251
- """Add radar overlay to base layer and add timestamp."""
252
-
253
- base_bytes = None
254
- if not self.map_image:
255
- base_bytes = await self._get_basemap()
256
-
257
- legend_bytes = None
258
- if self.show_legend:
259
- if not self.legend_image or self.legend_layer != self.layer_key:
260
- legend_bytes = await self._get_legend()
261
-
262
- # All the synchronous PIL stuff here
263
- def _create_image():
264
- radar = Image.open(BytesIO(radar_bytes)).convert("RGBA")
265
-
266
- if base_bytes:
267
- self.map_image = Image.open(BytesIO(base_bytes)).convert("RGBA")
268
-
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)
320
-
321
- async def _get_radar_image(self, session, frame_time):
322
- params = dict(
323
- **radar_params,
324
- **self.map_params,
325
- layers=precip_layers[self.layer_key],
326
- time=frame_time.strftime("%Y-%m-%dT%H:%M:00Z")
327
- )
328
- response = await session.get(url=geomet_url, params=params)
329
- return await response.read()
330
-
331
- async def get_latest_frame(self):
332
- """Get the latest image from Environment Canada."""
333
- dimensions = await self._get_dimensions()
334
- latest = dimensions[1]
335
- async with ClientSession(raise_for_status=True) as session:
336
- frame = await self._get_radar_image(session=session, frame_time=latest)
337
- return await self._combine_layers(frame, latest)
338
-
339
- async def update(self):
340
- if self.precip_type == "auto":
341
- self._auto_precip_type()
342
-
343
- self.image = await self.get_loop()
344
-
345
- async def get_loop(self, fps=5):
346
- """Build an animated GIF of recent radar images."""
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
-
359
- """Build list of frame timestamps."""
360
- start, end = await self._get_dimensions()
361
- frame_times = [start]
362
-
363
- while True:
364
- next_frame = frame_times[-1] + datetime.timedelta(minutes=radar_interval)
365
- if next_frame > end:
366
- break
367
- else:
368
- frame_times.append(next_frame)
369
-
370
- """Fetch frames."""
371
-
372
- tasks = []
373
- async with ClientSession(raise_for_status=True) as session:
374
- for t in frame_times:
375
- tasks.append(self._get_radar_image(session=session, frame_time=t))
376
- radar_layers = await asyncio.gather(*tasks)
377
-
378
- frames = []
379
-
380
- for i, f in enumerate(radar_layers):
381
- frames.append(await self._combine_layers(f, frame_times[i]))
382
-
383
- for f in range(3):
384
- frames.append(frames[-1])
385
-
386
- """Assemble animated GIF."""
387
- duration = 1000 / fps
388
-
389
- loop = asyncio.get_running_loop()
390
- gif_bytes = await loop.run_in_executor(None, build_image)
391
- return gif_bytes
File without changes
File without changes
File without changes