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.
- {env_canada-0.7.0/env_canada.egg-info → env_canada-0.7.1}/PKG-INFO +2 -2
- {env_canada-0.7.0 → env_canada-0.7.1}/README.md +1 -1
- env_canada-0.7.1/env_canada/constants.py +1 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_aqhi.py +5 -6
- env_canada-0.7.1/env_canada/ec_cache.py +25 -0
- env_canada-0.7.1/env_canada/ec_radar.py +377 -0
- {env_canada-0.7.0 → env_canada-0.7.1/env_canada.egg-info}/PKG-INFO +2 -2
- {env_canada-0.7.0 → env_canada-0.7.1}/setup.py +1 -1
- {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_radar.py +3 -3
- env_canada-0.7.0/env_canada/constants.py +0 -1
- env_canada-0.7.0/env_canada/ec_cache.py +0 -38
- env_canada-0.7.0/env_canada/ec_radar.py +0 -391
- {env_canada-0.7.0 → env_canada-0.7.1}/LICENSE +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/MANIFEST.in +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/10x20.pbm +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/10x20.pil +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/__init__.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_exc.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_historical.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_hydro.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada/ec_weather.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/SOURCES.txt +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/dependency_links.txt +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/requires.txt +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/env_canada.egg-info/top_level.txt +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/setup.cfg +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_aqhi.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_historical.py +0 -0
- {env_canada-0.7.0 → env_canada-0.7.1}/tests/test_ec_hydro.py +0 -0
- {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.
|
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
|
[](https://badge.fury.io/py/env-canada)
|
28
|
-
[](https://snyk.io/vuln/pip:env-canada@0.7.
|
28
|
+
[](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
|
[](https://badge.fury.io/py/env-canada)
|
4
|
-
[](https://snyk.io/vuln/pip:env-canada@0.7.
|
4
|
+
[](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
|
-
|
219
|
-
|
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.
|
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
|
[](https://badge.fury.io/py/env-canada)
|
28
|
-
[](https://snyk.io/vuln/pip:env-canada@0.7.
|
28
|
+
[](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.
|
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.
|
54
|
+
assert test_radar.precip_type[1] == "rain"
|
55
55
|
else:
|
56
|
-
assert test_radar.
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|