env-canada 0.6.3__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.6.3/env_canada.egg-info → env_canada-0.7.1}/PKG-INFO +2 -2
- {env_canada-0.6.3 → env_canada-0.7.1}/README.md +1 -1
- env_canada-0.7.1/env_canada/constants.py +1 -0
- {env_canada-0.6.3 → 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.6.3 → env_canada-0.7.1}/env_canada/ec_weather.py +7 -22
- {env_canada-0.6.3 → env_canada-0.7.1/env_canada.egg-info}/PKG-INFO +2 -2
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada.egg-info/SOURCES.txt +0 -1
- {env_canada-0.6.3 → env_canada-0.7.1}/setup.py +1 -1
- {env_canada-0.6.3 → env_canada-0.7.1}/tests/test_ec_radar.py +3 -3
- env_canada-0.6.3/env_canada/constants.py +0 -1
- env_canada-0.6.3/env_canada/ec_cache.py +0 -38
- env_canada-0.6.3/env_canada/ec_data.py +0 -501
- env_canada-0.6.3/env_canada/ec_radar.py +0 -391
- {env_canada-0.6.3 → env_canada-0.7.1}/LICENSE +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/MANIFEST.in +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/10x20.pbm +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/10x20.pil +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/__init__.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/ec_exc.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/ec_historical.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada/ec_hydro.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada.egg-info/dependency_links.txt +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada.egg-info/requires.txt +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/env_canada.egg-info/top_level.txt +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/setup.cfg +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/tests/test_ec_aqhi.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/tests/test_ec_historical.py +0 -0
- {env_canada-0.6.3 → env_canada-0.7.1}/tests/test_ec_hydro.py +0 -0
- {env_canada-0.6.3 → 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.
|
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.
|
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.
|
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)
|
@@ -137,24 +137,6 @@ conditions_meta = {
|
|
137
137
|
"english": "Icon Code",
|
138
138
|
"french": "Code icône",
|
139
139
|
},
|
140
|
-
"high_temp_yesterday": {
|
141
|
-
"xpath": './yesterdayConditions/temperature[@class="high"]',
|
142
|
-
"type": "float",
|
143
|
-
"english": "High Temperature Yesterday",
|
144
|
-
"french": "Haute température d'hier",
|
145
|
-
},
|
146
|
-
"low_temp_yesterday": {
|
147
|
-
"xpath": './yesterdayConditions/temperature[@class="low"]',
|
148
|
-
"type": "float",
|
149
|
-
"english": "Low Temperature Yesterday",
|
150
|
-
"french": "Basse température d'hier",
|
151
|
-
},
|
152
|
-
"precip_yesterday": {
|
153
|
-
"xpath": "./yesterdayConditions/precip",
|
154
|
-
"type": "float",
|
155
|
-
"english": "Precipitation Yesterday",
|
156
|
-
"french": "Précipitation d'hier",
|
157
|
-
},
|
158
140
|
"normal_high": {
|
159
141
|
"xpath": './forecastGroup/regionalNormals/temperature[@class="high"]',
|
160
142
|
"type": "int",
|
@@ -183,7 +165,7 @@ conditions_meta = {
|
|
183
165
|
"xpath": "./currentConditions/dateTime/timeStamp",
|
184
166
|
"type": "timestamp",
|
185
167
|
"english": "Observation Time",
|
186
|
-
"french": "Temps d'observation"
|
168
|
+
"french": "Temps d'observation",
|
187
169
|
},
|
188
170
|
}
|
189
171
|
|
@@ -278,7 +260,6 @@ def closest_site(site_list, lat, lon):
|
|
278
260
|
|
279
261
|
|
280
262
|
class ECWeather(object):
|
281
|
-
|
282
263
|
"""Get weather data from Environment Canada."""
|
283
264
|
|
284
265
|
def __init__(self, **kwargs):
|
@@ -432,7 +413,9 @@ class ECWeather(object):
|
|
432
413
|
self.conditions[c].update(get_condition(meta))
|
433
414
|
|
434
415
|
# Update station metadata
|
435
|
-
self.metadata["station"] = weather_tree.find(
|
416
|
+
self.metadata["station"] = weather_tree.find(
|
417
|
+
"./currentConditions/station"
|
418
|
+
).text
|
436
419
|
|
437
420
|
# Update text summary
|
438
421
|
period = get_condition(summary_meta["forecast_period"])["value"]
|
@@ -494,7 +477,9 @@ class ECWeather(object):
|
|
494
477
|
"temperature": int(f.findtext("./temperature") or 0),
|
495
478
|
"icon_code": f.findtext("./iconCode"),
|
496
479
|
"precip_probability": int(f.findtext("./lop") or "0"),
|
497
|
-
"wind_speed": int(
|
480
|
+
"wind_speed": int(
|
481
|
+
wind_speed_text if wind_speed_text.isnumeric() else 0
|
482
|
+
),
|
498
483
|
"wind_direction": f.findtext("./wind/direction"),
|
499
484
|
}
|
500
485
|
)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: env_canada
|
3
|
-
Version: 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
|
[](https://badge.fury.io/py/env-canada)
|
28
|
-
[](https://snyk.io/vuln/pip:env-canada@0.
|
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.
|
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.6.3"
|
@@ -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]
|