env-canada 0.10.2__py3-none-any.whl → 0.11.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- env_canada/__init__.py +2 -0
- env_canada/constants.py +1 -1
- env_canada/ec_map.py +439 -0
- env_canada/ec_radar.py +52 -334
- env_canada/ec_weather.py +103 -16
- {env_canada-0.10.2.dist-info → env_canada-0.11.0.dist-info}/METADATA +72 -9
- env_canada-0.11.0.dist-info/RECORD +17 -0
- {env_canada-0.10.2.dist-info → env_canada-0.11.0.dist-info}/WHEEL +1 -1
- env_canada-0.10.2.dist-info/RECORD +0 -16
- {env_canada-0.10.2.dist-info → env_canada-0.11.0.dist-info}/licenses/LICENSE +0 -0
- {env_canada-0.10.2.dist-info → env_canada-0.11.0.dist-info}/top_level.txt +0 -0
env_canada/__init__.py
CHANGED
@@ -3,6 +3,7 @@ __all__ = [
|
|
3
3
|
"ECHistorical",
|
4
4
|
"ECHistoricalRange",
|
5
5
|
"ECHydro",
|
6
|
+
"ECMap",
|
6
7
|
"ECRadar",
|
7
8
|
"ECWeather",
|
8
9
|
"ECWeatherUpdateFailed",
|
@@ -12,4 +13,5 @@ from .ec_aqhi import ECAirQuality
|
|
12
13
|
from .ec_historical import ECHistorical, ECHistoricalRange
|
13
14
|
from .ec_hydro import ECHydro
|
14
15
|
from .ec_radar import ECRadar
|
16
|
+
from .ec_map import ECMap
|
15
17
|
from .ec_weather import ECWeather, ECWeatherUpdateFailed
|
env_canada/constants.py
CHANGED
@@ -1 +1 @@
|
|
1
|
-
USER_AGENT = "env_canada/0.
|
1
|
+
USER_AGENT = "env_canada/0.11.0"
|
env_canada/ec_map.py
ADDED
@@ -0,0 +1,439 @@
|
|
1
|
+
import asyncio
|
2
|
+
import logging
|
3
|
+
import math
|
4
|
+
import os
|
5
|
+
from datetime import timedelta
|
6
|
+
from io import BytesIO
|
7
|
+
|
8
|
+
import dateutil.parser
|
9
|
+
import voluptuous as vol
|
10
|
+
from aiohttp import ClientSession
|
11
|
+
from aiohttp.client_exceptions import ClientConnectorError
|
12
|
+
from lxml import etree as et
|
13
|
+
from PIL import Image, ImageDraw, ImageFont
|
14
|
+
|
15
|
+
from .constants import USER_AGENT
|
16
|
+
from .ec_cache import Cache
|
17
|
+
|
18
|
+
LOG = logging.getLogger(__name__)
|
19
|
+
|
20
|
+
ATTRIBUTION = {
|
21
|
+
"english": "Data provided by Environment Canada",
|
22
|
+
"french": "Données fournies par Environnement Canada",
|
23
|
+
}
|
24
|
+
|
25
|
+
__all__ = ["ECMap"]
|
26
|
+
|
27
|
+
# Natural Resources Canada
|
28
|
+
|
29
|
+
basemap_url = "https://maps.geogratis.gc.ca/wms/CBMT"
|
30
|
+
basemap_params = {
|
31
|
+
"service": "wms",
|
32
|
+
"version": "1.3.0",
|
33
|
+
"request": "GetMap",
|
34
|
+
"layers": "CBMT",
|
35
|
+
"styles": "",
|
36
|
+
"CRS": "epsg:4326",
|
37
|
+
"format": "image/png",
|
38
|
+
}
|
39
|
+
|
40
|
+
|
41
|
+
# Environment Canada
|
42
|
+
|
43
|
+
# Common WMS layers available from Environment Canada
|
44
|
+
|
45
|
+
wms_layers = {
|
46
|
+
"rain": "RADAR_1KM_RRAI",
|
47
|
+
"snow": "RADAR_1KM_RSNO",
|
48
|
+
"precip_type": "Radar_1km_SfcPrecipType",
|
49
|
+
}
|
50
|
+
|
51
|
+
|
52
|
+
geomet_url = "https://geo.weather.gc.ca/geomet"
|
53
|
+
capabilities_params = {
|
54
|
+
"lang": "en",
|
55
|
+
"service": "WMS",
|
56
|
+
"version": "1.3.0",
|
57
|
+
"request": "GetCapabilities",
|
58
|
+
}
|
59
|
+
wms_namespace = {"wms": "http://www.opengis.net/wms"}
|
60
|
+
dimension_xpath = './/wms:Layer[wms:Name="{layer}"]/wms:Dimension'
|
61
|
+
map_params = {
|
62
|
+
"service": "WMS",
|
63
|
+
"version": "1.3.0",
|
64
|
+
"request": "GetMap",
|
65
|
+
"crs": "EPSG:4326",
|
66
|
+
"format": "image/png",
|
67
|
+
"transparent": "true",
|
68
|
+
}
|
69
|
+
legend_params = {
|
70
|
+
"service": "WMS",
|
71
|
+
"version": "1.3.0",
|
72
|
+
"request": "GetLegendGraphic",
|
73
|
+
"sld_version": "1.1.0",
|
74
|
+
"format": "image/png",
|
75
|
+
}
|
76
|
+
image_interval = timedelta(minutes=6)
|
77
|
+
|
78
|
+
timestamp_label = {
|
79
|
+
"rain": {"english": "Rain", "french": "Pluie"},
|
80
|
+
"snow": {"english": "Snow", "french": "Neige"},
|
81
|
+
"precip_type": {"english": "Precipitation", "french": "Précipitation"},
|
82
|
+
}
|
83
|
+
|
84
|
+
|
85
|
+
def _compute_bounding_box(distance, latittude, longitude):
|
86
|
+
"""
|
87
|
+
Modified from https://gist.github.com/alexcpn/f95ae83a7ee0293a5225
|
88
|
+
"""
|
89
|
+
latittude = math.radians(latittude)
|
90
|
+
longitude = math.radians(longitude)
|
91
|
+
|
92
|
+
distance_from_point_km = distance
|
93
|
+
angular_distance = distance_from_point_km / 6371.01
|
94
|
+
|
95
|
+
lat_min = latittude - angular_distance
|
96
|
+
lat_max = latittude + angular_distance
|
97
|
+
|
98
|
+
delta_longitude = math.asin(math.sin(angular_distance) / math.cos(latittude))
|
99
|
+
|
100
|
+
lon_min = longitude - delta_longitude
|
101
|
+
lon_max = longitude + delta_longitude
|
102
|
+
lon_min = round(math.degrees(lon_min), 5)
|
103
|
+
lat_max = round(math.degrees(lat_max), 5)
|
104
|
+
lon_max = round(math.degrees(lon_max), 5)
|
105
|
+
lat_min = round(math.degrees(lat_min), 5)
|
106
|
+
|
107
|
+
return lat_min, lon_min, lat_max, lon_max
|
108
|
+
|
109
|
+
|
110
|
+
async def _get_resource(url, params, bytes=True):
|
111
|
+
async with ClientSession(raise_for_status=True) as session:
|
112
|
+
response = await session.get(
|
113
|
+
url=url, params=params, headers={"User-Agent": USER_AGENT}
|
114
|
+
)
|
115
|
+
if bytes:
|
116
|
+
return await response.read()
|
117
|
+
return await response.text()
|
118
|
+
|
119
|
+
|
120
|
+
class ECMap:
|
121
|
+
def __init__(self, **kwargs):
|
122
|
+
"""Initialize the map object."""
|
123
|
+
|
124
|
+
init_schema = vol.Schema(
|
125
|
+
{
|
126
|
+
vol.Required("coordinates"): (
|
127
|
+
vol.All(vol.Or(int, float), vol.Range(-90, 90)),
|
128
|
+
vol.All(vol.Or(int, float), vol.Range(-180, 180)),
|
129
|
+
),
|
130
|
+
vol.Required("radius", default=200): vol.All(int, vol.Range(min=10)),
|
131
|
+
vol.Required("width", default=800): vol.All(int, vol.Range(min=10)),
|
132
|
+
vol.Required("height", default=800): vol.All(int, vol.Range(min=10)),
|
133
|
+
vol.Required("legend", default=True): bool,
|
134
|
+
vol.Required("timestamp", default=True): bool,
|
135
|
+
vol.Required("layer_opacity", default=65): vol.All(
|
136
|
+
int, vol.Range(0, 100)
|
137
|
+
),
|
138
|
+
vol.Required("layer", default="rain"): vol.In(wms_layers.keys()),
|
139
|
+
vol.Optional("language", default="english"): vol.In(
|
140
|
+
["english", "french"]
|
141
|
+
),
|
142
|
+
}
|
143
|
+
)
|
144
|
+
|
145
|
+
kwargs = init_schema(kwargs)
|
146
|
+
self.language = kwargs["language"]
|
147
|
+
self.metadata = {"attribution": ATTRIBUTION[self.language]}
|
148
|
+
|
149
|
+
# Get layer
|
150
|
+
self.layer = kwargs["layer"]
|
151
|
+
|
152
|
+
# Get map parameters
|
153
|
+
self.image = None
|
154
|
+
self.width = kwargs["width"]
|
155
|
+
self.height = kwargs["height"]
|
156
|
+
self.bbox = _compute_bounding_box(kwargs["radius"], *kwargs["coordinates"])
|
157
|
+
self.map_params = {
|
158
|
+
"bbox": ",".join([str(coord) for coord in self.bbox]),
|
159
|
+
"width": self.width,
|
160
|
+
"height": self.height,
|
161
|
+
}
|
162
|
+
self.layer_opacity = kwargs["layer_opacity"]
|
163
|
+
|
164
|
+
# Get overlay parameters
|
165
|
+
self.show_legend = kwargs["legend"]
|
166
|
+
self.show_timestamp = kwargs["timestamp"]
|
167
|
+
|
168
|
+
self._font = None
|
169
|
+
self.timestamp = None
|
170
|
+
|
171
|
+
async def _get_basemap(self):
|
172
|
+
"""Fetch the background map image."""
|
173
|
+
if base_bytes := Cache.get("basemap"):
|
174
|
+
return base_bytes
|
175
|
+
|
176
|
+
basemap_params.update(self.map_params)
|
177
|
+
try:
|
178
|
+
base_bytes = await _get_resource(basemap_url, basemap_params)
|
179
|
+
return Cache.add("basemap", base_bytes, timedelta(days=7))
|
180
|
+
except ClientConnectorError as e:
|
181
|
+
LOG.warning("Map from %s could not be retrieved: %s", basemap_url, e)
|
182
|
+
return None
|
183
|
+
|
184
|
+
async def _get_style_for_layer(self):
|
185
|
+
"""Extract the appropriate style name from capabilities XML."""
|
186
|
+
capabilities_cache_key = f"capabilities-{self.layer}"
|
187
|
+
|
188
|
+
if not (capabilities_xml := Cache.get(capabilities_cache_key)):
|
189
|
+
capabilities_params["layer"] = wms_layers[self.layer]
|
190
|
+
capabilities_xml = await _get_resource(
|
191
|
+
geomet_url, capabilities_params, bytes=True
|
192
|
+
)
|
193
|
+
Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
|
194
|
+
|
195
|
+
# Parse for style information
|
196
|
+
root = et.fromstring(capabilities_xml)
|
197
|
+
layer_xpath = f'.//wms:Layer[wms:Name="{wms_layers[self.layer]}"]/wms:Style'
|
198
|
+
|
199
|
+
styles = root.findall(layer_xpath, namespaces=wms_namespace)
|
200
|
+
if styles:
|
201
|
+
# Choose style based on language preference
|
202
|
+
for style in styles:
|
203
|
+
style_name = style.find("wms:Name", namespaces=wms_namespace)
|
204
|
+
if style_name is not None:
|
205
|
+
name = style_name.text
|
206
|
+
# Prefer language-specific style if available
|
207
|
+
if self.language == "french" and name.endswith("_Fr"):
|
208
|
+
return name
|
209
|
+
elif self.language == "english" and not name.endswith("_Fr"):
|
210
|
+
return name
|
211
|
+
|
212
|
+
# Fallback to first available style
|
213
|
+
first_style = styles[0].find("wms:Name", namespaces=wms_namespace)
|
214
|
+
if first_style is not None:
|
215
|
+
return first_style.text
|
216
|
+
|
217
|
+
# If no styles found, raise an error
|
218
|
+
raise ValueError(f"No styles found for layer {self.layer}")
|
219
|
+
|
220
|
+
async def _get_legend(self):
|
221
|
+
"""Fetch legend image for the layer."""
|
222
|
+
|
223
|
+
legend_cache_key = f"legend-{self.layer}"
|
224
|
+
if legend := Cache.get(legend_cache_key):
|
225
|
+
return legend
|
226
|
+
|
227
|
+
# Dynamically determine style
|
228
|
+
style_name = await self._get_style_for_layer()
|
229
|
+
|
230
|
+
legend_params.update(
|
231
|
+
dict(
|
232
|
+
layer=wms_layers[self.layer],
|
233
|
+
style=style_name,
|
234
|
+
)
|
235
|
+
)
|
236
|
+
try:
|
237
|
+
legend = await _get_resource(geomet_url, legend_params)
|
238
|
+
return Cache.add(legend_cache_key, legend, timedelta(days=7))
|
239
|
+
|
240
|
+
except ClientConnectorError:
|
241
|
+
LOG.warning("Legend could not be retrieved")
|
242
|
+
return None
|
243
|
+
|
244
|
+
async def _get_dimensions(self):
|
245
|
+
"""Get time range of available images for the layer."""
|
246
|
+
|
247
|
+
capabilities_cache_key = f"capabilities-{self.layer}"
|
248
|
+
|
249
|
+
if not (capabilities_xml := Cache.get(capabilities_cache_key)):
|
250
|
+
capabilities_params["layer"] = wms_layers[self.layer]
|
251
|
+
capabilities_xml = await _get_resource(
|
252
|
+
geomet_url, capabilities_params, bytes=True
|
253
|
+
)
|
254
|
+
Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
|
255
|
+
|
256
|
+
dimension_string = et.fromstring(capabilities_xml).find(
|
257
|
+
dimension_xpath.format(layer=wms_layers[self.layer]),
|
258
|
+
namespaces=wms_namespace,
|
259
|
+
)
|
260
|
+
if dimension_string is not None:
|
261
|
+
if dimension_string := dimension_string.text:
|
262
|
+
start, end = (
|
263
|
+
dateutil.parser.isoparse(t) for t in dimension_string.split("/")[:2]
|
264
|
+
)
|
265
|
+
self.timestamp = end.isoformat()
|
266
|
+
return (start, end)
|
267
|
+
return None
|
268
|
+
|
269
|
+
async def _get_layer_image(self, frame_time):
|
270
|
+
"""Fetch image for the layer at a specific time."""
|
271
|
+
time = frame_time.strftime("%Y-%m-%dT%H:%M:00Z")
|
272
|
+
layer_cache_key = f"layer-{self.layer}-{time}"
|
273
|
+
|
274
|
+
if img := Cache.get(layer_cache_key):
|
275
|
+
return img
|
276
|
+
|
277
|
+
params = dict(
|
278
|
+
**map_params,
|
279
|
+
**self.map_params,
|
280
|
+
layers=wms_layers[self.layer],
|
281
|
+
time=time,
|
282
|
+
)
|
283
|
+
|
284
|
+
try:
|
285
|
+
layer_bytes = await _get_resource(geomet_url, params)
|
286
|
+
return Cache.add(layer_cache_key, layer_bytes, timedelta(minutes=200))
|
287
|
+
except ClientConnectorError:
|
288
|
+
LOG.warning("Layer could not be retrieved")
|
289
|
+
return None
|
290
|
+
|
291
|
+
async def _create_composite_image(self, frame_time):
|
292
|
+
"""Create a composite image from the layer."""
|
293
|
+
|
294
|
+
def _create_image():
|
295
|
+
"""Contains all the PIL calls; run in another thread."""
|
296
|
+
|
297
|
+
# Start with the basemap if available
|
298
|
+
if base_bytes:
|
299
|
+
composite = Image.open(BytesIO(base_bytes)).convert("RGBA")
|
300
|
+
else:
|
301
|
+
# Create a blank image if no basemap
|
302
|
+
composite = Image.new(
|
303
|
+
"RGBA", (self.width, self.height), (255, 255, 255, 255)
|
304
|
+
)
|
305
|
+
|
306
|
+
# Add the layer with transparency
|
307
|
+
if layer_bytes:
|
308
|
+
layer_image = Image.open(BytesIO(layer_bytes)).convert("RGBA")
|
309
|
+
|
310
|
+
# Add transparency to layer
|
311
|
+
if self.layer_opacity < 100:
|
312
|
+
alpha = round((self.layer_opacity / 100) * 255)
|
313
|
+
layer_copy = layer_image.copy()
|
314
|
+
layer_copy.putalpha(alpha)
|
315
|
+
layer_image.paste(layer_copy, layer_image)
|
316
|
+
|
317
|
+
# Composite the layer onto the image
|
318
|
+
composite = Image.alpha_composite(composite, layer_image)
|
319
|
+
|
320
|
+
# Add legend
|
321
|
+
if legend_bytes:
|
322
|
+
legend_image = Image.open(BytesIO(legend_bytes)).convert("RGB")
|
323
|
+
legend_position = (
|
324
|
+
self.width - legend_image.size[0],
|
325
|
+
0,
|
326
|
+
)
|
327
|
+
composite.paste(legend_image, legend_position)
|
328
|
+
|
329
|
+
# Add timestamp
|
330
|
+
if self.show_timestamp:
|
331
|
+
if not self._font:
|
332
|
+
self._font = ImageFont.load(
|
333
|
+
os.path.join(os.path.dirname(__file__), "10x20.pil")
|
334
|
+
)
|
335
|
+
|
336
|
+
if self._font:
|
337
|
+
# Create a timestamp with the layer
|
338
|
+
if self.layer in timestamp_label:
|
339
|
+
layer_text = timestamp_label[self.layer][self.language]
|
340
|
+
else:
|
341
|
+
layer_text = self.layer
|
342
|
+
|
343
|
+
timestamp = (
|
344
|
+
f"{layer_text} @ {frame_time.astimezone().strftime('%H:%M')}"
|
345
|
+
)
|
346
|
+
|
347
|
+
text_box = Image.new(
|
348
|
+
"RGBA", self._font.getbbox(timestamp)[2:], "white"
|
349
|
+
)
|
350
|
+
box_draw = ImageDraw.Draw(text_box)
|
351
|
+
box_draw.text(
|
352
|
+
xy=(0, 0), text=timestamp, fill=(0, 0, 0), font=self._font
|
353
|
+
)
|
354
|
+
double_box = text_box.resize(
|
355
|
+
(text_box.width * 2, text_box.height * 2)
|
356
|
+
)
|
357
|
+
composite.paste(double_box)
|
358
|
+
composite = composite.quantize()
|
359
|
+
|
360
|
+
# Convert frame to PNG for return
|
361
|
+
img_byte_arr = BytesIO()
|
362
|
+
composite.save(img_byte_arr, format="PNG")
|
363
|
+
|
364
|
+
return Cache.add(
|
365
|
+
f"composite-{time}", img_byte_arr.getvalue(), timedelta(minutes=200)
|
366
|
+
)
|
367
|
+
|
368
|
+
time = frame_time.strftime("%Y-%m-%dT%H:%M:00Z")
|
369
|
+
cache_key = f"composite-{time}"
|
370
|
+
|
371
|
+
if img := Cache.get(cache_key):
|
372
|
+
return img
|
373
|
+
|
374
|
+
# Get the basemap
|
375
|
+
base_bytes = await self._get_basemap()
|
376
|
+
|
377
|
+
# Get the layer image and legend
|
378
|
+
layer_bytes = await self._get_layer_image(frame_time)
|
379
|
+
legend_bytes = None
|
380
|
+
if self.show_legend:
|
381
|
+
legend_bytes = await self._get_legend()
|
382
|
+
|
383
|
+
return await asyncio.get_event_loop().run_in_executor(None, _create_image)
|
384
|
+
|
385
|
+
async def get_latest_frame(self):
|
386
|
+
"""Get the latest image with the specified layer."""
|
387
|
+
dimensions = await self._get_dimensions()
|
388
|
+
if not dimensions:
|
389
|
+
return None
|
390
|
+
|
391
|
+
return await self._create_composite_image(frame_time=dimensions[1])
|
392
|
+
|
393
|
+
async def update(self):
|
394
|
+
self.image = await self.get_loop()
|
395
|
+
|
396
|
+
async def get_loop(self, fps=5):
|
397
|
+
"""Build an animated GIF of recent images with the specified layer."""
|
398
|
+
|
399
|
+
def create_gif():
|
400
|
+
"""Assemble animated GIF."""
|
401
|
+
duration = 1000 / fps
|
402
|
+
imgs = [
|
403
|
+
Image.open(BytesIO(img)).convert("RGBA") for img in composite_frames
|
404
|
+
]
|
405
|
+
gif = BytesIO()
|
406
|
+
imgs[0].save(
|
407
|
+
gif,
|
408
|
+
format="GIF",
|
409
|
+
save_all=True,
|
410
|
+
append_images=imgs[1:],
|
411
|
+
duration=duration,
|
412
|
+
loop=0,
|
413
|
+
)
|
414
|
+
return gif.getvalue()
|
415
|
+
|
416
|
+
# Without this cache priming the tasks below each compete to load map/legend
|
417
|
+
# at the same time, resulting in them getting retrieved for each image.
|
418
|
+
await self._get_basemap()
|
419
|
+
if self.show_legend:
|
420
|
+
await self._get_legend()
|
421
|
+
|
422
|
+
# Use the layer to determine the time dimensions
|
423
|
+
timespan = await self._get_dimensions()
|
424
|
+
if not timespan:
|
425
|
+
LOG.error("Cannot retrieve image times.")
|
426
|
+
return None
|
427
|
+
|
428
|
+
tasks = []
|
429
|
+
curr = timespan[0]
|
430
|
+
while curr <= timespan[1]:
|
431
|
+
tasks.append(self._create_composite_image(frame_time=curr))
|
432
|
+
curr = curr + image_interval
|
433
|
+
composite_frames = await asyncio.gather(*tasks)
|
434
|
+
|
435
|
+
# Repeat the last frame 3 times to make it pause at the end
|
436
|
+
for _ in range(3):
|
437
|
+
composite_frames.append(composite_frames[-1])
|
438
|
+
|
439
|
+
return await asyncio.get_running_loop().run_in_executor(None, create_gif)
|
env_canada/ec_radar.py
CHANGED
@@ -1,174 +1,46 @@
|
|
1
|
-
import
|
2
|
-
import logging
|
3
|
-
import math
|
4
|
-
import os
|
5
|
-
from datetime import date, timedelta
|
6
|
-
from io import BytesIO
|
7
|
-
from typing import cast
|
1
|
+
from datetime import date
|
8
2
|
|
9
|
-
import
|
10
|
-
import voluptuous as vol
|
11
|
-
from aiohttp import ClientSession
|
12
|
-
from aiohttp.client_exceptions import ClientConnectorError
|
13
|
-
from lxml import etree as et
|
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
|
-
}
|
3
|
+
from .ec_map import ECMap
|
23
4
|
|
24
5
|
__all__ = ["ECRadar"]
|
25
6
|
|
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
7
|
|
117
8
|
class ECRadar:
|
118
9
|
def __init__(self, **kwargs):
|
119
10
|
"""Initialize the radar object."""
|
120
11
|
|
121
|
-
|
122
|
-
|
123
|
-
|
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
|
-
)
|
12
|
+
# Extract ECRadar-specific parameters
|
13
|
+
precip_type = kwargs.pop("precip_type", None)
|
14
|
+
radar_opacity = kwargs.pop("radar_opacity", 65)
|
143
15
|
|
144
|
-
|
145
|
-
|
146
|
-
self.metadata = {"attribution": ATTRIBUTION[self.language]}
|
16
|
+
# Rename radar_opacity to layer_opacity for ECMap
|
17
|
+
kwargs["layer_opacity"] = radar_opacity
|
147
18
|
|
148
|
-
|
19
|
+
# Set up precip type logic
|
20
|
+
self._precip_type_setting = precip_type
|
149
21
|
self._precip_type_actual = self.precip_type[1]
|
150
22
|
|
151
|
-
#
|
152
|
-
|
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"]
|
23
|
+
# Map the actual precipitation type to the layer
|
24
|
+
kwargs["layer"] = self._precip_type_actual
|
162
25
|
|
163
|
-
#
|
164
|
-
self.
|
165
|
-
self.show_timestamp = kwargs["timestamp"]
|
26
|
+
# Create the underlying ECMap instance
|
27
|
+
self._map = ECMap(**kwargs)
|
166
28
|
|
167
|
-
|
29
|
+
# Expose common properties for backward compatibility
|
30
|
+
self.language = self._map.language
|
31
|
+
self.metadata = self._map.metadata
|
32
|
+
self.image = self._map.image
|
33
|
+
self.width = self._map.width
|
34
|
+
self.height = self._map.height
|
35
|
+
self.bbox = self._map.bbox
|
36
|
+
self.map_params = self._map.map_params
|
37
|
+
self.show_legend = self._map.show_legend
|
38
|
+
self.show_timestamp = self._map.show_timestamp
|
39
|
+
self.timestamp = getattr(self._map, "timestamp", None)
|
168
40
|
|
169
41
|
@property
|
170
42
|
def precip_type(self):
|
171
|
-
|
43
|
+
"""Get precipitation type as (setting, actual) tuple for backward compatibility."""
|
172
44
|
if self._precip_type_setting in ["rain", "snow"]:
|
173
45
|
return (self._precip_type_setting, self._precip_type_setting)
|
174
46
|
self._precip_type_actual = (
|
@@ -178,200 +50,46 @@ class ECRadar:
|
|
178
50
|
|
179
51
|
@precip_type.setter
|
180
52
|
def precip_type(self, user_input):
|
53
|
+
"""Set precipitation type."""
|
181
54
|
if user_input not in ["rain", "snow", "auto"]:
|
182
55
|
raise ValueError("precip_type must be 'rain', 'snow', or 'auto'")
|
183
56
|
self._precip_type_setting = user_input
|
184
57
|
self._precip_type_actual = self.precip_type[1]
|
58
|
+
# Update the underlying map layer
|
59
|
+
self._map.layer = self._precip_type_actual
|
185
60
|
|
186
|
-
|
187
|
-
|
188
|
-
|
189
|
-
|
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=True
|
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
|
61
|
+
@property
|
62
|
+
def radar_opacity(self):
|
63
|
+
"""Get radar opacity for backward compatibility."""
|
64
|
+
return self._map.layer_opacity
|
319
65
|
|
320
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
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)
|
66
|
+
@radar_opacity.setter
|
67
|
+
def radar_opacity(self, value):
|
68
|
+
"""Set radar opacity for backward compatibility."""
|
69
|
+
self._map.layer_opacity = value
|
328
70
|
|
329
71
|
async def get_latest_frame(self):
|
330
|
-
"""Get the latest image from Environment Canada."""
|
331
|
-
|
332
|
-
if not dimensions:
|
333
|
-
return None
|
334
|
-
return await self._get_radar_image(frame_time=dimensions[1])
|
72
|
+
"""Get the latest radar image from Environment Canada."""
|
73
|
+
return await self._map.get_latest_frame()
|
335
74
|
|
336
75
|
async def update(self):
|
337
|
-
|
76
|
+
"""Update the radar image."""
|
77
|
+
await self._map.update()
|
78
|
+
self.image = self._map.image
|
338
79
|
|
339
80
|
async def get_loop(self, fps=5):
|
340
81
|
"""Build an animated GIF of recent radar images."""
|
82
|
+
return await self._map.get_loop(fps)
|
341
83
|
|
342
|
-
|
343
|
-
|
344
|
-
|
345
|
-
|
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)
|
84
|
+
# Expose internal methods for backward compatibility if needed
|
85
|
+
async def _get_dimensions(self):
|
86
|
+
"""Get time range of available radar images."""
|
87
|
+
return await self._map._get_dimensions()
|
373
88
|
|
374
|
-
|
375
|
-
|
89
|
+
async def _get_basemap(self):
|
90
|
+
"""Fetch the background map image."""
|
91
|
+
return await self._map._get_basemap()
|
376
92
|
|
377
|
-
|
93
|
+
async def _get_legend(self):
|
94
|
+
"""Fetch legend image."""
|
95
|
+
return await self._map._get_legend()
|
env_canada/ec_weather.py
CHANGED
@@ -4,6 +4,7 @@ import logging
|
|
4
4
|
import re
|
5
5
|
from dataclasses import dataclass
|
6
6
|
from datetime import datetime, timedelta, timezone
|
7
|
+
from urllib.parse import urljoin
|
7
8
|
|
8
9
|
import voluptuous as vol
|
9
10
|
from aiohttp import (
|
@@ -22,7 +23,7 @@ from .constants import USER_AGENT
|
|
22
23
|
|
23
24
|
SITE_LIST_URL = "https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv"
|
24
25
|
|
25
|
-
|
26
|
+
WEATHER_BASE_URL = "https://dd.weather.gc.ca/citypage_weather/{province}/{hour}/"
|
26
27
|
|
27
28
|
CLIENT_TIMEOUT = ClientTimeout(10)
|
28
29
|
|
@@ -230,9 +231,16 @@ def validate_station(station):
|
|
230
231
|
"""Check that the station ID is well-formed."""
|
231
232
|
if station is None:
|
232
233
|
return
|
233
|
-
|
234
|
-
|
235
|
-
|
234
|
+
# Accept either full format "XX/s0000###" or simplified 1-3 digit format
|
235
|
+
if not (
|
236
|
+
re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station)
|
237
|
+
or re.fullmatch(r"s0000\d{3}", station)
|
238
|
+
or re.fullmatch(r"\d{1,3}", station)
|
239
|
+
):
|
240
|
+
raise vol.Invalid(
|
241
|
+
'Station ID must be of the form "XX/s0000###", "s0000###" or 1-3 digits'
|
242
|
+
)
|
243
|
+
return station[-3:]
|
236
244
|
|
237
245
|
|
238
246
|
def _parse_timestamp(time_str: str | None) -> datetime | None:
|
@@ -269,8 +277,16 @@ async def get_ec_sites():
|
|
269
277
|
return sites
|
270
278
|
|
271
279
|
|
280
|
+
def find_province_for_station(site_list, station_number):
|
281
|
+
"""Find the province code for a given station number."""
|
282
|
+
for site in site_list:
|
283
|
+
if site["Codes"] == f"s0000{station_number.zfill(3)}":
|
284
|
+
return site["Province Codes"]
|
285
|
+
return None
|
286
|
+
|
287
|
+
|
272
288
|
def closest_site(site_list, lat, lon):
|
273
|
-
"""Return the
|
289
|
+
"""Return the (province_code, station_number) tuple of the closest station to our lat/lon."""
|
274
290
|
|
275
291
|
def site_distance(site):
|
276
292
|
"""Calculate distance to a site."""
|
@@ -278,7 +294,55 @@ def closest_site(site_list, lat, lon):
|
|
278
294
|
|
279
295
|
closest = min(site_list, key=site_distance)
|
280
296
|
|
281
|
-
|
297
|
+
# Extract station number from "s0000###" format
|
298
|
+
station_number = closest["Codes"][5:] # Remove "s0000" prefix
|
299
|
+
return (closest["Province Codes"], station_number)
|
300
|
+
|
301
|
+
|
302
|
+
async def discover_weather_file_url(session, province_code, station_number, language):
|
303
|
+
"""Discover the URL for the most recent weather file."""
|
304
|
+
# Convert language to the file suffix format
|
305
|
+
lang_suffix = "en" if language == "english" else "fr"
|
306
|
+
|
307
|
+
# Start with current UTC hour and work backwards
|
308
|
+
current_utc = datetime.now(timezone.utc)
|
309
|
+
|
310
|
+
for hours_back in range(3): # Check current hour and 2 hours back
|
311
|
+
check_time = current_utc - timedelta(hours=hours_back)
|
312
|
+
hour_str = f"{check_time.hour:02d}"
|
313
|
+
|
314
|
+
# Construct directory URL
|
315
|
+
directory_url = WEATHER_BASE_URL.format(province=province_code, hour=hour_str)
|
316
|
+
|
317
|
+
try:
|
318
|
+
LOG.debug("Checking directory: %s", directory_url)
|
319
|
+
response = await session.get(
|
320
|
+
directory_url,
|
321
|
+
headers={"User-Agent": USER_AGENT},
|
322
|
+
timeout=CLIENT_TIMEOUT,
|
323
|
+
)
|
324
|
+
html_content = await response.text()
|
325
|
+
|
326
|
+
# Parse HTML directory listing to find matching files
|
327
|
+
station_pattern = f"s0000{station_number.zfill(3)}"
|
328
|
+
file_pattern = rf'href="([^"]*MSC_CitypageWeather_{station_pattern}_{lang_suffix}\.xml)"'
|
329
|
+
|
330
|
+
matches = re.findall(file_pattern, html_content)
|
331
|
+
|
332
|
+
if matches:
|
333
|
+
# Sort by filename (which includes timestamp) and get the most recent
|
334
|
+
matches.sort(reverse=True)
|
335
|
+
latest_file = matches[0]
|
336
|
+
return urljoin(directory_url, latest_file)
|
337
|
+
|
338
|
+
except (ClientConnectorDNSError, TimeoutError, ClientResponseError) as err:
|
339
|
+
LOG.debug("Failed to check directory %s: %s", directory_url, err)
|
340
|
+
continue
|
341
|
+
|
342
|
+
# If no file found in recent hours, raise exception
|
343
|
+
raise ec_exc.UnknownStationId(
|
344
|
+
f"No recent weather data found for station {station_number} in province {province_code}"
|
345
|
+
)
|
282
346
|
|
283
347
|
|
284
348
|
class ECWeather:
|
@@ -323,7 +387,8 @@ class ECWeather:
|
|
323
387
|
self.site_list = []
|
324
388
|
|
325
389
|
if "station_id" in kwargs and kwargs["station_id"] is not None:
|
326
|
-
|
390
|
+
# Station ID will be converted to tuple (province_code, station_number) during update()
|
391
|
+
self.station_id = kwargs["station_id"] # Store raw input temporarily
|
327
392
|
self.lat = None
|
328
393
|
self.lon = None
|
329
394
|
else:
|
@@ -358,13 +423,27 @@ class ECWeather:
|
|
358
423
|
if not self.site_list:
|
359
424
|
self.site_list = await get_ec_sites()
|
360
425
|
if self.station_id:
|
361
|
-
|
362
|
-
if
|
363
|
-
|
364
|
-
|
365
|
-
|
366
|
-
|
367
|
-
|
426
|
+
# Convert raw station input to tuple (province_code, station_number)
|
427
|
+
if isinstance(self.station_id, str):
|
428
|
+
station_number = (
|
429
|
+
self.station_id
|
430
|
+
) # Already normalized by validate_station
|
431
|
+
province_code = find_province_for_station(
|
432
|
+
self.site_list, station_number
|
433
|
+
)
|
434
|
+
if province_code is None:
|
435
|
+
raise ec_exc.UnknownStationId
|
436
|
+
self.station_id = (province_code, station_number)
|
437
|
+
|
438
|
+
# Find lat/lon for the station
|
439
|
+
for site in self.site_list:
|
440
|
+
if (
|
441
|
+
site["Codes"] == f"s0000{self.station_id[1].zfill(3)}"
|
442
|
+
and site["Province Codes"] == self.station_id[0]
|
443
|
+
):
|
444
|
+
self.lat = site["Latitude"]
|
445
|
+
self.lon = site["Longitude"]
|
446
|
+
break
|
368
447
|
if not self.lat:
|
369
448
|
raise ec_exc.UnknownStationId
|
370
449
|
else:
|
@@ -379,8 +458,14 @@ class ECWeather:
|
|
379
458
|
# Get weather data
|
380
459
|
try:
|
381
460
|
async with ClientSession(raise_for_status=True) as session:
|
461
|
+
# Discover the URL for the most recent weather file
|
462
|
+
weather_url = await discover_weather_file_url(
|
463
|
+
session, self.station_id[0], self.station_id[1], self.language
|
464
|
+
)
|
465
|
+
LOG.debug("Using weather URL: %s", weather_url)
|
466
|
+
|
382
467
|
response = await session.get(
|
383
|
-
|
468
|
+
weather_url,
|
384
469
|
headers={"User-Agent": USER_AGENT},
|
385
470
|
timeout=CLIENT_TIMEOUT,
|
386
471
|
)
|
@@ -392,6 +477,8 @@ class ECWeather:
|
|
392
477
|
err,
|
393
478
|
f"Unable to retrieve weather '{err.request_info.url}': {err.message} ({err.status})",
|
394
479
|
)
|
480
|
+
except ec_exc.UnknownStationId as err:
|
481
|
+
return self.handle_error(err, f"Unable to discover weather file: {err}")
|
395
482
|
|
396
483
|
try:
|
397
484
|
weather_tree = et.fromstring(bytes(weather_xml, encoding="utf-8"))
|
@@ -438,7 +525,7 @@ class ECWeather:
|
|
438
525
|
try:
|
439
526
|
condition["value"] = int(float(element.text))
|
440
527
|
except ValueError:
|
441
|
-
condition["value"] =
|
528
|
+
condition["value"] = 0
|
442
529
|
elif meta["type"] == "float":
|
443
530
|
try:
|
444
531
|
condition["value"] = float(element.text)
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: env_canada
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.11.0
|
4
4
|
Summary: A package to access meteorological data from Environment Canada
|
5
5
|
Author-email: Michael Davie <michael.davie@gmail.com>
|
6
6
|
Maintainer-email: Michael Davie <michael.davie@gmail.com>
|
@@ -49,31 +49,45 @@ This package provides access to various data sources published by [Environment a
|
|
49
49
|
|
50
50
|
## Weather Observations and Forecasts
|
51
51
|
|
52
|
-
`ECWeather` provides current conditions and forecasts. It automatically determines which weather station to use based on latitude/longitude provided. It is also possible to specify a
|
52
|
+
`ECWeather` provides current conditions and forecasts. It automatically determines which weather station to use based on latitude/longitude provided. It is also possible to specify a station code in multiple flexible formats:
|
53
|
+
|
54
|
+
- **Full format**: `"AB/s0000123"` (province code and full station ID)
|
55
|
+
- **Station ID only**: `"s0000123"` (station ID without province - province is resolved automatically)
|
56
|
+
- **Numeric only**: `"123"` (just the station number - province is resolved automatically)
|
57
|
+
|
58
|
+
Station codes are based on those listed in [this CSV file](https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv). For example:
|
53
59
|
|
54
60
|
```python
|
55
61
|
import asyncio
|
56
62
|
|
57
63
|
from env_canada import ECWeather
|
58
64
|
|
59
|
-
|
60
|
-
|
65
|
+
# Using coordinates (automatic station selection)
|
66
|
+
ec_coords = ECWeather(coordinates=(50, -100))
|
67
|
+
|
68
|
+
# Using station ID - multiple formats supported:
|
69
|
+
ec_full = ECWeather(station_id="ON/s0000430", language="french") # Full format
|
70
|
+
ec_station = ECWeather(station_id="s0000430") # Station ID only
|
71
|
+
ec_numeric = ECWeather(station_id="430") # Numeric only
|
61
72
|
|
62
|
-
asyncio.run(
|
73
|
+
asyncio.run(ec_coords.update())
|
63
74
|
|
64
75
|
# current conditions
|
65
|
-
|
76
|
+
ec_coords.conditions
|
66
77
|
|
67
78
|
# daily forecasts
|
68
|
-
|
79
|
+
ec_coords.daily_forecasts
|
69
80
|
|
70
81
|
# hourly forecasts
|
71
|
-
|
82
|
+
ec_coords.hourly_forecasts
|
72
83
|
|
73
84
|
# alerts
|
74
|
-
|
85
|
+
ec_coords.alerts
|
75
86
|
```
|
76
87
|
|
88
|
+
> [!NOTE]
|
89
|
+
> As of version 0.11.0, `ECWeather` automatically handles Environment Canada's new timestamped weather file URL structure (effective June 2025). The library dynamically discovers the most recent weather files, ensuring continued functionality during Environment Canada's infrastructure changes.
|
90
|
+
|
77
91
|
## Weather Radar
|
78
92
|
|
79
93
|
`ECRadar` provides Environment Canada meteorological [radar imagery](https://weather.gc.ca/radar/index_e.html).
|
@@ -90,6 +104,55 @@ animated_gif = asyncio.run(radar_coords.get_loop())
|
|
90
104
|
latest_png = asyncio.run(radar_coords.get_latest_frame())
|
91
105
|
```
|
92
106
|
|
107
|
+
## Weather Maps
|
108
|
+
|
109
|
+
`ECMap` provides Environment Canada WMS weather map imagery with support for various meteorological layers.
|
110
|
+
|
111
|
+
```python
|
112
|
+
import asyncio
|
113
|
+
|
114
|
+
from env_canada import ECMap
|
115
|
+
|
116
|
+
# Create a map with rain radar layer
|
117
|
+
map_coords = ECMap(coordinates=(50, -100), layer="rain")
|
118
|
+
|
119
|
+
# Get the latest image with the specified layer
|
120
|
+
latest_png = asyncio.run(map_coords.get_latest_frame())
|
121
|
+
|
122
|
+
# Get an animated GIF with the specified layer
|
123
|
+
animated_gif = asyncio.run(map_coords.get_loop())
|
124
|
+
|
125
|
+
# Customize the map appearance
|
126
|
+
custom_map = ECMap(
|
127
|
+
coordinates=(50, -100),
|
128
|
+
layer="snow",
|
129
|
+
width=1200,
|
130
|
+
height=800,
|
131
|
+
radius=300,
|
132
|
+
layer_opacity=80,
|
133
|
+
legend=True,
|
134
|
+
timestamp=True,
|
135
|
+
language="french",
|
136
|
+
)
|
137
|
+
```
|
138
|
+
|
139
|
+
Available layers include:
|
140
|
+
|
141
|
+
- `rain`: Precipitation rain radar
|
142
|
+
- `snow`: Precipitation snow radar
|
143
|
+
- `precip_type`: Surface precipitation type
|
144
|
+
|
145
|
+
Additional configuration options:
|
146
|
+
|
147
|
+
- `width`/`height`: Image dimensions (default: 800x800)
|
148
|
+
- `radius`: Map radius in km around coordinates (default: 200km)
|
149
|
+
- `layer_opacity`: Layer transparency 0-100% (default: 65%)
|
150
|
+
- `legend`: Show legend (default: True)
|
151
|
+
- `timestamp`: Show timestamp (default: True)
|
152
|
+
- `language`: "english" or "french" (default: "english")
|
153
|
+
|
154
|
+
> **Note**: ECMap automatically discovers available legend styles from Environment Canada's WMS capabilities, ensuring compatibility with any future style changes.
|
155
|
+
|
93
156
|
## Air Quality Health Index (AQHI)
|
94
157
|
|
95
158
|
`ECAirQuality` provides Environment Canada [air quality](https://weather.gc.ca/airquality/pages/index_e.html) data.
|
@@ -0,0 +1,17 @@
|
|
1
|
+
env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
|
2
|
+
env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
|
3
|
+
env_canada/__init__.py,sha256=EXzGEHwon-usFzLzuJeISKHlfJdV3DBa0_rR9b_XfvE,405
|
4
|
+
env_canada/constants.py,sha256=YkWAwWu2M6izF61EYMWanH5S-3crC0PV2Wo5mgt5aOg,33
|
5
|
+
env_canada/ec_aqhi.py,sha256=oE52qfk-AKbHdhTSl5RP3vsWL-50eMRCCRVy9RW-pP4,8080
|
6
|
+
env_canada/ec_cache.py,sha256=zb3n79ul7hUTE0IohDfZbRBLY-siOHPjYzWldMbuPVk,798
|
7
|
+
env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
|
8
|
+
env_canada/ec_historical.py,sha256=qMr4RE6vfNiNa_zFolQ0PQGraok8bQtIVjs_o6sJKD4,16276
|
9
|
+
env_canada/ec_hydro.py,sha256=JoBe-QVV8GEeZXCNFscIs2R_spgkbxCZpLt7tL6-NUI,4889
|
10
|
+
env_canada/ec_map.py,sha256=936CN6G9ZAY9zQjuph0Xp4f9OwNnHb6qXMnk6M6bE68,15134
|
11
|
+
env_canada/ec_radar.py,sha256=dKZqWJyb66R2EJzAy4K7pii7vPK9FxDKmuW9vA1ADbw,3330
|
12
|
+
env_canada/ec_weather.py,sha256=ztMYx8jnmGqUQiUrqGFkiag7B3Xb0uhqo5g5j57bevI,22948
|
13
|
+
env_canada-0.11.0.dist-info/licenses/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
|
14
|
+
env_canada-0.11.0.dist-info/METADATA,sha256=KwVr43s8m3N3zOmCuzmThL1kiQUOPQfV-wZibXfJEZs,13678
|
15
|
+
env_canada-0.11.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
|
16
|
+
env_canada-0.11.0.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
|
17
|
+
env_canada-0.11.0.dist-info/RECORD,,
|
@@ -1,16 +0,0 @@
|
|
1
|
-
env_canada/10x20.pbm,sha256=ClKTs2WUmhUhTHAQzPuGwPTICGVBzCvos5l-vHRBE5M,2463
|
2
|
-
env_canada/10x20.pil,sha256=Oki6-TD7b0xFtfm6vxCKsmpEpsZ5Jaia_0v_aDz8bfE,5143
|
3
|
-
env_canada/__init__.py,sha256=O1KVC2GAPTmxAP46FXo9jd8Tq3YMefViowGjuxJ5JJM,366
|
4
|
-
env_canada/constants.py,sha256=k6Oe20AiFaK_CiM1q7jlYvABkMnNUsC4INTShGEuAoI,33
|
5
|
-
env_canada/ec_aqhi.py,sha256=oE52qfk-AKbHdhTSl5RP3vsWL-50eMRCCRVy9RW-pP4,8080
|
6
|
-
env_canada/ec_cache.py,sha256=zb3n79ul7hUTE0IohDfZbRBLY-siOHPjYzWldMbuPVk,798
|
7
|
-
env_canada/ec_exc.py,sha256=SBJwzmLf94lTx7KYVLfQYrMXYNYUoIxeVXc-BLkuXoE,67
|
8
|
-
env_canada/ec_historical.py,sha256=qMr4RE6vfNiNa_zFolQ0PQGraok8bQtIVjs_o6sJKD4,16276
|
9
|
-
env_canada/ec_hydro.py,sha256=JoBe-QVV8GEeZXCNFscIs2R_spgkbxCZpLt7tL6-NUI,4889
|
10
|
-
env_canada/ec_radar.py,sha256=0SKusJWDTFODdn3D9yrhlkOS-Bv9hhBJM9EBh8TNRlk,12965
|
11
|
-
env_canada/ec_weather.py,sha256=CoL9lr7C-0bXFc5CjPR6edK-lZS31OVcr6Yl0SdGED0,19221
|
12
|
-
env_canada-0.10.2.dist-info/licenses/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
|
13
|
-
env_canada-0.10.2.dist-info/METADATA,sha256=0bkHqTUq-1Ot79R6IwdWY5aMU4JW9MLy5AwRBVBqUVQ,11448
|
14
|
-
env_canada-0.10.2.dist-info/WHEEL,sha256=SmOxYU7pzNKBqASvQJ7DjX3XGUF92lrGhMb3R6_iiqI,91
|
15
|
-
env_canada-0.10.2.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
|
16
|
-
env_canada-0.10.2.dist-info/RECORD,,
|
File without changes
|
File without changes
|