env-canada 0.10.1__tar.gz → 0.11.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (24) hide show
  1. {env_canada-0.10.1/env_canada.egg-info → env_canada-0.11.0}/PKG-INFO +73 -30
  2. {env_canada-0.10.1 → env_canada-0.11.0}/README.md +71 -8
  3. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/__init__.py +2 -0
  4. env_canada-0.11.0/env_canada/constants.py +1 -0
  5. env_canada-0.10.1/env_canada/ec_radar.py → env_canada-0.11.0/env_canada/ec_map.py +168 -106
  6. env_canada-0.11.0/env_canada/ec_radar.py +95 -0
  7. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_weather.py +106 -17
  8. {env_canada-0.10.1 → env_canada-0.11.0/env_canada.egg-info}/PKG-INFO +73 -30
  9. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/SOURCES.txt +1 -0
  10. {env_canada-0.10.1 → env_canada-0.11.0}/pyproject.toml +3 -4
  11. env_canada-0.10.1/env_canada/constants.py +0 -1
  12. {env_canada-0.10.1 → env_canada-0.11.0}/LICENSE +0 -0
  13. {env_canada-0.10.1 → env_canada-0.11.0}/MANIFEST.in +0 -0
  14. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/10x20.pbm +0 -0
  15. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/10x20.pil +0 -0
  16. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_aqhi.py +0 -0
  17. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_cache.py +0 -0
  18. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_exc.py +0 -0
  19. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_historical.py +0 -0
  20. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_hydro.py +0 -0
  21. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/dependency_links.txt +0 -0
  22. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/requires.txt +0 -0
  23. {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/top_level.txt +0 -0
  24. {env_canada-0.10.1 → env_canada-0.11.0}/setup.cfg +0 -0
@@ -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.
@@ -21,31 +21,45 @@ This package provides access to various data sources published by [Environment a
21
21
 
22
22
  ## Weather Observations and Forecasts
23
23
 
24
- `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:
24
+ `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:
25
+
26
+ - **Full format**: `"AB/s0000123"` (province code and full station ID)
27
+ - **Station ID only**: `"s0000123"` (station ID without province - province is resolved automatically)
28
+ - **Numeric only**: `"123"` (just the station number - province is resolved automatically)
29
+
30
+ 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:
25
31
 
26
32
  ```python
27
33
  import asyncio
28
34
 
29
35
  from env_canada import ECWeather
30
36
 
31
- ec_en = ECWeather(coordinates=(50, -100))
32
- ec_fr = ECWeather(station_id="ON/s0000430", language="french")
37
+ # Using coordinates (automatic station selection)
38
+ ec_coords = ECWeather(coordinates=(50, -100))
39
+
40
+ # Using station ID - multiple formats supported:
41
+ ec_full = ECWeather(station_id="ON/s0000430", language="french") # Full format
42
+ ec_station = ECWeather(station_id="s0000430") # Station ID only
43
+ ec_numeric = ECWeather(station_id="430") # Numeric only
33
44
 
34
- asyncio.run(ec_en.update())
45
+ asyncio.run(ec_coords.update())
35
46
 
36
47
  # current conditions
37
- ec_en.conditions
48
+ ec_coords.conditions
38
49
 
39
50
  # daily forecasts
40
- ec_en.daily_forecasts
51
+ ec_coords.daily_forecasts
41
52
 
42
53
  # hourly forecasts
43
- ec_en.hourly_forecasts
54
+ ec_coords.hourly_forecasts
44
55
 
45
56
  # alerts
46
- ec_en.alerts
57
+ ec_coords.alerts
47
58
  ```
48
59
 
60
+ > [!NOTE]
61
+ > 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.
62
+
49
63
  ## Weather Radar
50
64
 
51
65
  `ECRadar` provides Environment Canada meteorological [radar imagery](https://weather.gc.ca/radar/index_e.html).
@@ -62,6 +76,55 @@ animated_gif = asyncio.run(radar_coords.get_loop())
62
76
  latest_png = asyncio.run(radar_coords.get_latest_frame())
63
77
  ```
64
78
 
79
+ ## Weather Maps
80
+
81
+ `ECMap` provides Environment Canada WMS weather map imagery with support for various meteorological layers.
82
+
83
+ ```python
84
+ import asyncio
85
+
86
+ from env_canada import ECMap
87
+
88
+ # Create a map with rain radar layer
89
+ map_coords = ECMap(coordinates=(50, -100), layer="rain")
90
+
91
+ # Get the latest image with the specified layer
92
+ latest_png = asyncio.run(map_coords.get_latest_frame())
93
+
94
+ # Get an animated GIF with the specified layer
95
+ animated_gif = asyncio.run(map_coords.get_loop())
96
+
97
+ # Customize the map appearance
98
+ custom_map = ECMap(
99
+ coordinates=(50, -100),
100
+ layer="snow",
101
+ width=1200,
102
+ height=800,
103
+ radius=300,
104
+ layer_opacity=80,
105
+ legend=True,
106
+ timestamp=True,
107
+ language="french",
108
+ )
109
+ ```
110
+
111
+ Available layers include:
112
+
113
+ - `rain`: Precipitation rain radar
114
+ - `snow`: Precipitation snow radar
115
+ - `precip_type`: Surface precipitation type
116
+
117
+ Additional configuration options:
118
+
119
+ - `width`/`height`: Image dimensions (default: 800x800)
120
+ - `radius`: Map radius in km around coordinates (default: 200km)
121
+ - `layer_opacity`: Layer transparency 0-100% (default: 65%)
122
+ - `legend`: Show legend (default: True)
123
+ - `timestamp`: Show timestamp (default: True)
124
+ - `language`: "english" or "french" (default: "english")
125
+
126
+ > **Note**: ECMap automatically discovers available legend styles from Environment Canada's WMS capabilities, ensuring compatibility with any future style changes.
127
+
65
128
  ## Air Quality Health Index (AQHI)
66
129
 
67
130
  `ECAirQuality` provides Environment Canada [air quality](https://weather.gc.ca/airquality/pages/index_e.html) data.
@@ -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
@@ -0,0 +1 @@
1
+ USER_AGENT = "env_canada/0.11.0"
@@ -2,9 +2,8 @@ import asyncio
2
2
  import logging
3
3
  import math
4
4
  import os
5
- from datetime import date, timedelta
5
+ from datetime import timedelta
6
6
  from io import BytesIO
7
- from typing import cast
8
7
 
9
8
  import dateutil.parser
10
9
  import voluptuous as vol
@@ -16,12 +15,14 @@ from PIL import Image, ImageDraw, ImageFont
16
15
  from .constants import USER_AGENT
17
16
  from .ec_cache import Cache
18
17
 
18
+ LOG = logging.getLogger(__name__)
19
+
19
20
  ATTRIBUTION = {
20
21
  "english": "Data provided by Environment Canada",
21
22
  "french": "Données fournies par Environnement Canada",
22
23
  }
23
24
 
24
- __all__ = ["ECRadar"]
25
+ __all__ = ["ECMap"]
25
26
 
26
27
  # Natural Resources Canada
27
28
 
@@ -36,17 +37,17 @@ basemap_params = {
36
37
  "format": "image/png",
37
38
  }
38
39
 
39
- # Mapbox Proxy
40
-
41
- backup_map_url = (
42
- "https://0wmiyoko9f.execute-api.ca-central-1.amazonaws.com/mapbox-proxy"
43
- )
44
40
 
45
41
  # Environment Canada
46
42
 
47
- precip_layers = {"rain": "RADAR_1KM_RRAI", "snow": "RADAR_1KM_RSNO"}
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
+ }
48
50
 
49
- legend_style = {"rain": "RADARURPPRECIPR", "snow": "RADARURPPRECIPS14"}
50
51
 
51
52
  geomet_url = "https://geo.weather.gc.ca/geomet"
52
53
  capabilities_params = {
@@ -57,12 +58,13 @@ capabilities_params = {
57
58
  }
58
59
  wms_namespace = {"wms": "http://www.opengis.net/wms"}
59
60
  dimension_xpath = './/wms:Layer[wms:Name="{layer}"]/wms:Dimension'
60
- radar_params = {
61
+ map_params = {
61
62
  "service": "WMS",
62
63
  "version": "1.3.0",
63
64
  "request": "GetMap",
64
65
  "crs": "EPSG:4326",
65
66
  "format": "image/png",
67
+ "transparent": "true",
66
68
  }
67
69
  legend_params = {
68
70
  "service": "WMS",
@@ -71,11 +73,12 @@ legend_params = {
71
73
  "sld_version": "1.1.0",
72
74
  "format": "image/png",
73
75
  }
74
- radar_interval = timedelta(minutes=6)
76
+ image_interval = timedelta(minutes=6)
75
77
 
76
78
  timestamp_label = {
77
79
  "rain": {"english": "Rain", "french": "Pluie"},
78
80
  "snow": {"english": "Snow", "french": "Neige"},
81
+ "precip_type": {"english": "Precipitation", "french": "Précipitation"},
79
82
  }
80
83
 
81
84
 
@@ -114,9 +117,9 @@ async def _get_resource(url, params, bytes=True):
114
117
  return await response.text()
115
118
 
116
119
 
117
- class ECRadar:
120
+ class ECMap:
118
121
  def __init__(self, **kwargs):
119
- """Initialize the radar object."""
122
+ """Initialize the map object."""
120
123
 
121
124
  init_schema = vol.Schema(
122
125
  {
@@ -129,12 +132,10 @@ class ECRadar:
129
132
  vol.Required("height", default=800): vol.All(int, vol.Range(min=10)),
130
133
  vol.Required("legend", default=True): bool,
131
134
  vol.Required("timestamp", default=True): bool,
132
- vol.Required("radar_opacity", default=65): vol.All(
135
+ vol.Required("layer_opacity", default=65): vol.All(
133
136
  int, vol.Range(0, 100)
134
137
  ),
135
- vol.Optional("precip_type"): vol.Any(
136
- None, vol.In(["rain", "snow", "auto"])
137
- ),
138
+ vol.Required("layer", default="rain"): vol.In(wms_layers.keys()),
138
139
  vol.Optional("language", default="english"): vol.In(
139
140
  ["english", "french"]
140
141
  ),
@@ -145,8 +146,8 @@ class ECRadar:
145
146
  self.language = kwargs["language"]
146
147
  self.metadata = {"attribution": ATTRIBUTION[self.language]}
147
148
 
148
- self._precip_type_setting = kwargs.get("precip_type")
149
- self._precip_type_actual = self.precip_type[1]
149
+ # Get layer
150
+ self.layer = kwargs["layer"]
150
151
 
151
152
  # Get map parameters
152
153
  self.image = None
@@ -158,30 +159,14 @@ class ECRadar:
158
159
  "width": self.width,
159
160
  "height": self.height,
160
161
  }
161
- self.radar_opacity = kwargs["radar_opacity"]
162
+ self.layer_opacity = kwargs["layer_opacity"]
162
163
 
163
164
  # Get overlay parameters
164
165
  self.show_legend = kwargs["legend"]
165
166
  self.show_timestamp = kwargs["timestamp"]
166
167
 
167
168
  self._font = None
168
-
169
- @property
170
- def precip_type(self):
171
- # NOTE: this is a breaking change for this lib; HA doesn't use this so not breaking for that
172
- if self._precip_type_setting in ["rain", "snow"]:
173
- return (self._precip_type_setting, self._precip_type_setting)
174
- self._precip_type_actual = (
175
- "rain" if date.today().month in range(4, 11) else "snow"
176
- )
177
- return ("auto", self._precip_type_actual)
178
-
179
- @precip_type.setter
180
- def precip_type(self, user_input):
181
- if user_input not in ["rain", "snow", "auto"]:
182
- raise ValueError("precip_type must be 'rain', 'snow', or 'auto'")
183
- self._precip_type_setting = user_input
184
- self._precip_type_actual = self.precip_type[1]
169
+ self.timestamp = None
185
170
 
186
171
  async def _get_basemap(self):
187
172
  """Fetch the background map image."""
@@ -189,25 +174,63 @@ class ECRadar:
189
174
  return base_bytes
190
175
 
191
176
  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))
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))
196
194
 
197
- except ClientConnectorError as e:
198
- logging.warning("Map from %s could not be retrieved: %s", map_url, e)
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}")
199
219
 
200
220
  async def _get_legend(self):
201
- """Fetch legend image."""
221
+ """Fetch legend image for the layer."""
202
222
 
203
- legend_cache_key = f"legend-{self._precip_type_actual}"
223
+ legend_cache_key = f"legend-{self.layer}"
204
224
  if legend := Cache.get(legend_cache_key):
205
225
  return legend
206
226
 
227
+ # Dynamically determine style
228
+ style_name = await self._get_style_for_layer()
229
+
207
230
  legend_params.update(
208
231
  dict(
209
- layer=precip_layers[self._precip_type_actual],
210
- style=legend_style[self._precip_type_actual],
232
+ layer=wms_layers[self.layer],
233
+ style=style_name,
211
234
  )
212
235
  )
213
236
  try:
@@ -215,23 +238,23 @@ class ECRadar:
215
238
  return Cache.add(legend_cache_key, legend, timedelta(days=7))
216
239
 
217
240
  except ClientConnectorError:
218
- logging.warning("Legend could not be retrieved")
241
+ LOG.warning("Legend could not be retrieved")
219
242
  return None
220
243
 
221
244
  async def _get_dimensions(self):
222
- """Get time range of available radar images."""
245
+ """Get time range of available images for the layer."""
223
246
 
224
- capabilities_cache_key = f"capabilities-{self._precip_type_actual}"
247
+ capabilities_cache_key = f"capabilities-{self.layer}"
225
248
 
226
249
  if not (capabilities_xml := Cache.get(capabilities_cache_key)):
227
- capabilities_params["layer"] = precip_layers[self._precip_type_actual]
250
+ capabilities_params["layer"] = wms_layers[self.layer]
228
251
  capabilities_xml = await _get_resource(
229
252
  geomet_url, capabilities_params, bytes=True
230
253
  )
231
254
  Cache.add(capabilities_cache_key, capabilities_xml, timedelta(minutes=5))
232
255
 
233
256
  dimension_string = et.fromstring(capabilities_xml).find(
234
- dimension_xpath.format(layer=precip_layers[self._precip_type_actual]),
257
+ dimension_xpath.format(layer=wms_layers[self.layer]),
235
258
  namespaces=wms_namespace,
236
259
  )
237
260
  if dimension_string is not None:
@@ -243,39 +266,65 @@ class ECRadar:
243
266
  return (start, end)
244
267
  return None
245
268
 
246
- async def _get_radar_image(self, frame_time):
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
+
247
294
  def _create_image():
248
295
  """Contains all the PIL calls; run in another thread."""
249
296
 
250
- radar_image = Image.open(BytesIO(cast(bytes, radar_bytes))).convert("RGBA")
251
-
252
- map_image = None
297
+ # Start with the basemap if available
253
298
  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)
299
+ composite = Image.open(BytesIO(base_bytes)).convert("RGBA")
259
300
  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
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)
275
319
 
276
320
  # Add legend
277
- if legend_image:
278
- frame.paste(legend_image, legend_position)
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)
279
328
 
280
329
  # Add timestamp
281
330
  if self.show_timestamp:
@@ -285,8 +334,16 @@ class ECRadar:
285
334
  )
286
335
 
287
336
  if self._font:
288
- label = timestamp_label[self._precip_type_actual][self.language]
289
- timestamp = f"{label} @ {frame_time.astimezone().strftime('%H:%M')}"
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
+
290
347
  text_box = Image.new(
291
348
  "RGBA", self._font.getbbox(timestamp)[2:], "white"
292
349
  )
@@ -297,52 +354,54 @@ class ECRadar:
297
354
  double_box = text_box.resize(
298
355
  (text_box.width * 2, text_box.height * 2)
299
356
  )
300
- frame.paste(double_box)
301
- frame = frame.quantize()
357
+ composite.paste(double_box)
358
+ composite = composite.quantize()
302
359
 
303
360
  # Convert frame to PNG for return
304
361
  img_byte_arr = BytesIO()
305
- frame.save(img_byte_arr, format="PNG")
362
+ composite.save(img_byte_arr, format="PNG")
306
363
 
307
- # Time is tuned for 3h radar image
308
364
  return Cache.add(
309
- f"radar-{time}", img_byte_arr.getvalue(), timedelta(minutes=200)
365
+ f"composite-{time}", img_byte_arr.getvalue(), timedelta(minutes=200)
310
366
  )
311
367
 
312
368
  time = frame_time.strftime("%Y-%m-%dT%H:%M:00Z")
369
+ cache_key = f"composite-{time}"
313
370
 
314
- if img := Cache.get(f"radar-{time}"):
371
+ if img := Cache.get(cache_key):
315
372
  return img
316
373
 
374
+ # Get the basemap
317
375
  base_bytes = await self._get_basemap()
318
- legend_bytes = await self._get_legend() if self.show_legend else None
319
376
 
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)
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
+
327
383
  return await asyncio.get_event_loop().run_in_executor(None, _create_image)
328
384
 
329
385
  async def get_latest_frame(self):
330
- """Get the latest image from Environment Canada."""
386
+ """Get the latest image with the specified layer."""
331
387
  dimensions = await self._get_dimensions()
332
388
  if not dimensions:
333
389
  return None
334
- return await self._get_radar_image(frame_time=dimensions[1])
390
+
391
+ return await self._create_composite_image(frame_time=dimensions[1])
335
392
 
336
393
  async def update(self):
337
394
  self.image = await self.get_loop()
338
395
 
339
396
  async def get_loop(self, fps=5):
340
- """Build an animated GIF of recent radar images."""
397
+ """Build an animated GIF of recent images with the specified layer."""
341
398
 
342
399
  def create_gif():
343
400
  """Assemble animated GIF."""
344
401
  duration = 1000 / fps
345
- imgs = [Image.open(BytesIO(img)).convert("RGBA") for img in radar_layers]
402
+ imgs = [
403
+ Image.open(BytesIO(img)).convert("RGBA") for img in composite_frames
404
+ ]
346
405
  gif = BytesIO()
347
406
  imgs[0].save(
348
407
  gif,
@@ -355,23 +414,26 @@ class ECRadar:
355
414
  return gif.getvalue()
356
415
 
357
416
  # 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.
417
+ # at the same time, resulting in them getting retrieved for each image.
359
418
  await self._get_basemap()
360
- await self._get_legend() if self.show_legend else None
419
+ if self.show_legend:
420
+ await self._get_legend()
361
421
 
422
+ # Use the layer to determine the time dimensions
362
423
  timespan = await self._get_dimensions()
363
424
  if not timespan:
364
- logging.error("Cannot retrieve radar times.")
425
+ LOG.error("Cannot retrieve image times.")
365
426
  return None
366
427
 
367
428
  tasks = []
368
429
  curr = timespan[0]
369
430
  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)
431
+ tasks.append(self._create_composite_image(frame_time=curr))
432
+ curr = curr + image_interval
433
+ composite_frames = await asyncio.gather(*tasks)
373
434
 
435
+ # Repeat the last frame 3 times to make it pause at the end
374
436
  for _ in range(3):
375
- radar_layers.append(radar_layers[-1])
437
+ composite_frames.append(composite_frames[-1])
376
438
 
377
439
  return await asyncio.get_running_loop().run_in_executor(None, create_gif)
@@ -0,0 +1,95 @@
1
+ from datetime import date
2
+
3
+ from .ec_map import ECMap
4
+
5
+ __all__ = ["ECRadar"]
6
+
7
+
8
+ class ECRadar:
9
+ def __init__(self, **kwargs):
10
+ """Initialize the radar object."""
11
+
12
+ # Extract ECRadar-specific parameters
13
+ precip_type = kwargs.pop("precip_type", None)
14
+ radar_opacity = kwargs.pop("radar_opacity", 65)
15
+
16
+ # Rename radar_opacity to layer_opacity for ECMap
17
+ kwargs["layer_opacity"] = radar_opacity
18
+
19
+ # Set up precip type logic
20
+ self._precip_type_setting = precip_type
21
+ self._precip_type_actual = self.precip_type[1]
22
+
23
+ # Map the actual precipitation type to the layer
24
+ kwargs["layer"] = self._precip_type_actual
25
+
26
+ # Create the underlying ECMap instance
27
+ self._map = ECMap(**kwargs)
28
+
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)
40
+
41
+ @property
42
+ def precip_type(self):
43
+ """Get precipitation type as (setting, actual) tuple for backward compatibility."""
44
+ if self._precip_type_setting in ["rain", "snow"]:
45
+ return (self._precip_type_setting, self._precip_type_setting)
46
+ self._precip_type_actual = (
47
+ "rain" if date.today().month in range(4, 11) else "snow"
48
+ )
49
+ return ("auto", self._precip_type_actual)
50
+
51
+ @precip_type.setter
52
+ def precip_type(self, user_input):
53
+ """Set precipitation type."""
54
+ if user_input not in ["rain", "snow", "auto"]:
55
+ raise ValueError("precip_type must be 'rain', 'snow', or 'auto'")
56
+ self._precip_type_setting = user_input
57
+ self._precip_type_actual = self.precip_type[1]
58
+ # Update the underlying map layer
59
+ self._map.layer = self._precip_type_actual
60
+
61
+ @property
62
+ def radar_opacity(self):
63
+ """Get radar opacity for backward compatibility."""
64
+ return self._map.layer_opacity
65
+
66
+ @radar_opacity.setter
67
+ def radar_opacity(self, value):
68
+ """Set radar opacity for backward compatibility."""
69
+ self._map.layer_opacity = value
70
+
71
+ async def get_latest_frame(self):
72
+ """Get the latest radar image from Environment Canada."""
73
+ return await self._map.get_latest_frame()
74
+
75
+ async def update(self):
76
+ """Update the radar image."""
77
+ await self._map.update()
78
+ self.image = self._map.image
79
+
80
+ async def get_loop(self, fps=5):
81
+ """Build an animated GIF of recent radar images."""
82
+ return await self._map.get_loop(fps)
83
+
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()
88
+
89
+ async def _get_basemap(self):
90
+ """Fetch the background map image."""
91
+ return await self._map._get_basemap()
92
+
93
+ async def _get_legend(self):
94
+ """Fetch legend image."""
95
+ return await self._map._get_legend()
@@ -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.
@@ -11,6 +11,7 @@ env_canada/ec_cache.py
11
11
  env_canada/ec_exc.py
12
12
  env_canada/ec_historical.py
13
13
  env_canada/ec_hydro.py
14
+ env_canada/ec_map.py
14
15
  env_canada/ec_radar.py
15
16
  env_canada/ec_weather.py
16
17
  env_canada.egg-info/PKG-INFO
@@ -1,11 +1,11 @@
1
1
  [build-system]
2
- requires = ["setuptools>=64"]
2
+ requires = ["setuptools>=77.0"]
3
3
  build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "env_canada"
7
7
  description="A package to access meteorological data from Environment Canada"
8
- version="0.10.1"
8
+ version="0.11.0"
9
9
  authors = [
10
10
  {name = "Michael Davie", email = "michael.davie@gmail.com"},
11
11
  ]
@@ -13,11 +13,10 @@ maintainers = [
13
13
  {name = "Michael Davie", email = "michael.davie@gmail.com"},
14
14
  ]
15
15
  readme = "README.md"
16
- license = {file = "LICENSE"}
16
+ license = "MIT"
17
17
  requires-python = ">=3.11"
18
18
  classifiers = [
19
19
  "Programming Language :: Python :: 3",
20
- "License :: OSI Approved :: MIT License",
21
20
  "Operating System :: OS Independent",
22
21
  ]
23
22
  dependencies = [
@@ -1 +0,0 @@
1
- USER_AGENT = "env_canada/0.8.0"
File without changes
File without changes
File without changes