env-canada 0.10.1__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 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.8.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 asyncio
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 dateutil.parser
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
- 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
- )
12
+ # Extract ECRadar-specific parameters
13
+ precip_type = kwargs.pop("precip_type", None)
14
+ radar_opacity = kwargs.pop("radar_opacity", 65)
143
15
 
144
- kwargs = init_schema(kwargs)
145
- self.language = kwargs["language"]
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
- self._precip_type_setting = kwargs.get("precip_type")
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
- # 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"]
23
+ # Map the actual precipitation type to the layer
24
+ kwargs["layer"] = self._precip_type_actual
162
25
 
163
- # Get overlay parameters
164
- self.show_legend = kwargs["legend"]
165
- self.show_timestamp = kwargs["timestamp"]
26
+ # Create the underlying ECMap instance
27
+ self._map = ECMap(**kwargs)
166
28
 
167
- self._font = None
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
- # NOTE: this is a breaking change for this lib; HA doesn't use this so not breaking for that
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
- 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=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
- 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)
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
- dimensions = await self._get_dimensions()
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
- self.image = await self.get_loop()
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
- 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)
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
- for _ in range(3):
375
- radar_layers.append(radar_layers[-1])
89
+ async def _get_basemap(self):
90
+ """Fetch the background map image."""
91
+ return await self._map._get_basemap()
376
92
 
377
- return await asyncio.get_running_loop().run_in_executor(None, create_gif)
93
+ async def _get_legend(self):
94
+ """Fetch legend image."""
95
+ return await self._map._get_legend()
env_canada/ec_weather.py CHANGED
@@ -1,8 +1,11 @@
1
+ import copy
1
2
  import csv
2
3
  import logging
3
4
  import re
4
5
  from dataclasses import dataclass
5
6
  from datetime import datetime, timedelta, timezone
7
+ from urllib.parse import urljoin
8
+
6
9
  import voluptuous as vol
7
10
  from aiohttp import (
8
11
  ClientConnectorDNSError,
@@ -20,7 +23,7 @@ from .constants import USER_AGENT
20
23
 
21
24
  SITE_LIST_URL = "https://dd.weather.gc.ca/citypage_weather/docs/site_list_en.csv"
22
25
 
23
- WEATHER_URL = "https://dd.weather.gc.ca/citypage_weather/xml/{}_{}.xml"
26
+ WEATHER_BASE_URL = "https://dd.weather.gc.ca/citypage_weather/{province}/{hour}/"
24
27
 
25
28
  CLIENT_TIMEOUT = ClientTimeout(10)
26
29
 
@@ -228,9 +231,16 @@ def validate_station(station):
228
231
  """Check that the station ID is well-formed."""
229
232
  if station is None:
230
233
  return
231
- if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station):
232
- raise vol.Invalid('Station ID must be of the form "XX/s0000###"')
233
- return station
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:]
234
244
 
235
245
 
236
246
  def _parse_timestamp(time_str: str | None) -> datetime | None:
@@ -267,8 +277,16 @@ async def get_ec_sites():
267
277
  return sites
268
278
 
269
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
+
270
288
  def closest_site(site_list, lat, lon):
271
- """Return the province/site_code of the closest station to our lat/lon."""
289
+ """Return the (province_code, station_number) tuple of the closest station to our lat/lon."""
272
290
 
273
291
  def site_distance(site):
274
292
  """Calculate distance to a site."""
@@ -276,7 +294,55 @@ def closest_site(site_list, lat, lon):
276
294
 
277
295
  closest = min(site_list, key=site_distance)
278
296
 
279
- return "{}/{}".format(closest["Province Codes"], closest["Codes"])
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
+ )
280
346
 
281
347
 
282
348
  class ECWeather:
@@ -321,7 +387,8 @@ class ECWeather:
321
387
  self.site_list = []
322
388
 
323
389
  if "station_id" in kwargs and kwargs["station_id"] is not None:
324
- self.station_id = kwargs["station_id"]
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
325
392
  self.lat = None
326
393
  self.lon = None
327
394
  else:
@@ -356,13 +423,27 @@ class ECWeather:
356
423
  if not self.site_list:
357
424
  self.site_list = await get_ec_sites()
358
425
  if self.station_id:
359
- stn = self.station_id.split("/")
360
- if len(stn) == 2:
361
- for site in self.site_list:
362
- if stn[1] == site["Codes"] and stn[0] == site["Province Codes"]:
363
- self.lat = site["Latitude"]
364
- self.lon = site["Longitude"]
365
- break
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
366
447
  if not self.lat:
367
448
  raise ec_exc.UnknownStationId
368
449
  else:
@@ -377,8 +458,14 @@ class ECWeather:
377
458
  # Get weather data
378
459
  try:
379
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
+
380
467
  response = await session.get(
381
- WEATHER_URL.format(self.station_id, self.language[0]),
468
+ weather_url,
382
469
  headers={"User-Agent": USER_AGENT},
383
470
  timeout=CLIENT_TIMEOUT,
384
471
  )
@@ -390,6 +477,8 @@ class ECWeather:
390
477
  err,
391
478
  f"Unable to retrieve weather '{err.request_info.url}': {err.message} ({err.status})",
392
479
  )
480
+ except ec_exc.UnknownStationId as err:
481
+ return self.handle_error(err, f"Unable to discover weather file: {err}")
393
482
 
394
483
  try:
395
484
  weather_tree = et.fromstring(bytes(weather_xml, encoding="utf-8"))
@@ -436,7 +525,7 @@ class ECWeather:
436
525
  try:
437
526
  condition["value"] = int(float(element.text))
438
527
  except ValueError:
439
- condition["value"] = int(0)
528
+ condition["value"] = 0
440
529
  elif meta["type"] == "float":
441
530
  try:
442
531
  condition["value"] = float(element.text)
@@ -466,7 +555,7 @@ class ECWeather:
466
555
  }
467
556
 
468
557
  # Update alerts
469
- self.alerts = ALERTS_INIT[self.language].copy()
558
+ self.alerts = copy.deepcopy(ALERTS_INIT[self.language])
470
559
  alert_elements = weather_tree.findall("./warnings/event")
471
560
  for alert in alert_elements:
472
561
  title = alert.attrib.get("description")
@@ -1,36 +1,16 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: env_canada
3
- Version: 0.10.1
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>
7
- License: Copyright (c) 2018 The Python Packaging Authority
8
-
9
- Permission is hereby granted, free of charge, to any person obtaining a copy
10
- of this software and associated documentation files (the "Software"), to deal
11
- in the Software without restriction, including without limitation the rights
12
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13
- copies of the Software, and to permit persons to whom the Software is
14
- furnished to do so, subject to the following conditions:
15
-
16
- The above copyright notice and this permission notice shall be included in all
17
- copies or substantial portions of the Software.
18
-
19
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
25
- SOFTWARE
26
-
7
+ License-Expression: MIT
27
8
  Project-URL: Homepage, https://github.com/michaeldavie/env_canada
28
9
  Project-URL: Documentation, https://github.com/michaeldavie/env_canada
29
10
  Project-URL: Repository, https://github.com/michaeldavie/env_canada
30
11
  Project-URL: Issues, https://github.com/michaeldavie/env_canada/issues
31
12
  Project-URL: Changelog, https://github.com/michaeldavie/env_canada/blob/master/CHANGELOG.md
32
13
  Classifier: Programming Language :: Python :: 3
33
- Classifier: License :: OSI Approved :: MIT License
34
14
  Classifier: Operating System :: OS Independent
35
15
  Requires-Python: >=3.11
36
16
  Description-Content-Type: text/markdown
@@ -69,31 +49,45 @@ This package provides access to various data sources published by [Environment a
69
49
 
70
50
  ## Weather Observations and Forecasts
71
51
 
72
- `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 specific station code of the form `AB/s0000123` based on those listed in [this CSV file](https://dd.weather.gc.ca/citypage_weather/docs/site_list_towns_en.csv). For example:
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:
73
59
 
74
60
  ```python
75
61
  import asyncio
76
62
 
77
63
  from env_canada import ECWeather
78
64
 
79
- ec_en = ECWeather(coordinates=(50, -100))
80
- ec_fr = ECWeather(station_id="ON/s0000430", language="french")
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
81
72
 
82
- asyncio.run(ec_en.update())
73
+ asyncio.run(ec_coords.update())
83
74
 
84
75
  # current conditions
85
- ec_en.conditions
76
+ ec_coords.conditions
86
77
 
87
78
  # daily forecasts
88
- ec_en.daily_forecasts
79
+ ec_coords.daily_forecasts
89
80
 
90
81
  # hourly forecasts
91
- ec_en.hourly_forecasts
82
+ ec_coords.hourly_forecasts
92
83
 
93
84
  # alerts
94
- ec_en.alerts
85
+ ec_coords.alerts
95
86
  ```
96
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
+
97
91
  ## Weather Radar
98
92
 
99
93
  `ECRadar` provides Environment Canada meteorological [radar imagery](https://weather.gc.ca/radar/index_e.html).
@@ -110,6 +104,55 @@ animated_gif = asyncio.run(radar_coords.get_loop())
110
104
  latest_png = asyncio.run(radar_coords.get_latest_frame())
111
105
  ```
112
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
+
113
156
  ## Air Quality Health Index (AQHI)
114
157
 
115
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (78.1.0)
2
+ Generator: setuptools (80.9.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5
 
@@ -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=RHa-Hp2H6XroFE9Z5-H0MIUPWlFXUVKmWEidwuWzakE,32
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=l-gyXePkHBLW9vidFgiPEm81xevbZCeBKzKDLiLr7Y8,19200
12
- env_canada-0.10.1.dist-info/licenses/LICENSE,sha256=BkgGIGgy9sv-OsI7mRi9dIQ3Su0m4IbjpZlfxv8oBbM,1073
13
- env_canada-0.10.1.dist-info/METADATA,sha256=Cq1R2bbOithdmTqgbehba3Kqm6G1f-GbKxjKQWLs6XY,12710
14
- env_canada-0.10.1.dist-info/WHEEL,sha256=CmyFI0kx5cdEMTLiONQRbGQwjIoR1aIYB7eCAQ4KPJ0,91
15
- env_canada-0.10.1.dist-info/top_level.txt,sha256=fw7Pcl9ULBXYvqnAdyBdmwPXW8GSRFmhO0sLZWVfOCc,11
16
- env_canada-0.10.1.dist-info/RECORD,,