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 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.10.2"
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
@@ -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
- WEATHER_URL = "https://dd.weather.gc.ca/citypage_weather/xml/{}_{}.xml"
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
- if not re.fullmatch(r"[A-Z]{2}/s0000\d{3}", station):
234
- raise vol.Invalid('Station ID must be of the form "XX/s0000###"')
235
- 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:]
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 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."""
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
- 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
+ )
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
- 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
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
- stn = self.station_id.split("/")
362
- if len(stn) == 2:
363
- for site in self.site_list:
364
- if stn[1] == site["Codes"] and stn[0] == site["Province Codes"]:
365
- self.lat = site["Latitude"]
366
- self.lon = site["Longitude"]
367
- 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
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
- WEATHER_URL.format(self.station_id, self.language[0]),
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"] = int(0)
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.10.2
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 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:
53
59
 
54
60
  ```python
55
61
  import asyncio
56
62
 
57
63
  from env_canada import ECWeather
58
64
 
59
- ec_en = ECWeather(coordinates=(50, -100))
60
- 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
61
72
 
62
- asyncio.run(ec_en.update())
73
+ asyncio.run(ec_coords.update())
63
74
 
64
75
  # current conditions
65
- ec_en.conditions
76
+ ec_coords.conditions
66
77
 
67
78
  # daily forecasts
68
- ec_en.daily_forecasts
79
+ ec_coords.daily_forecasts
69
80
 
70
81
  # hourly forecasts
71
- ec_en.hourly_forecasts
82
+ ec_coords.hourly_forecasts
72
83
 
73
84
  # alerts
74
- ec_en.alerts
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,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: setuptools (79.0.1)
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=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,,