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.
- {env_canada-0.10.1/env_canada.egg-info → env_canada-0.11.0}/PKG-INFO +73 -30
- {env_canada-0.10.1 → env_canada-0.11.0}/README.md +71 -8
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/__init__.py +2 -0
- env_canada-0.11.0/env_canada/constants.py +1 -0
- env_canada-0.10.1/env_canada/ec_radar.py → env_canada-0.11.0/env_canada/ec_map.py +168 -106
- env_canada-0.11.0/env_canada/ec_radar.py +95 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_weather.py +106 -17
- {env_canada-0.10.1 → env_canada-0.11.0/env_canada.egg-info}/PKG-INFO +73 -30
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/SOURCES.txt +1 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/pyproject.toml +3 -4
- env_canada-0.10.1/env_canada/constants.py +0 -1
- {env_canada-0.10.1 → env_canada-0.11.0}/LICENSE +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/MANIFEST.in +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/10x20.pbm +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/10x20.pil +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_aqhi.py +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_cache.py +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_exc.py +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_historical.py +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada/ec_hydro.py +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/dependency_links.txt +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/requires.txt +0 -0
- {env_canada-0.10.1 → env_canada-0.11.0}/env_canada.egg-info/top_level.txt +0 -0
- {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.
|
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:
|
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
|
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
|
-
|
80
|
-
|
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(
|
73
|
+
asyncio.run(ec_coords.update())
|
83
74
|
|
84
75
|
# current conditions
|
85
|
-
|
76
|
+
ec_coords.conditions
|
86
77
|
|
87
78
|
# daily forecasts
|
88
|
-
|
79
|
+
ec_coords.daily_forecasts
|
89
80
|
|
90
81
|
# hourly forecasts
|
91
|
-
|
82
|
+
ec_coords.hourly_forecasts
|
92
83
|
|
93
84
|
# alerts
|
94
|
-
|
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
|
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
|
-
|
32
|
-
|
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(
|
45
|
+
asyncio.run(ec_coords.update())
|
35
46
|
|
36
47
|
# current conditions
|
37
|
-
|
48
|
+
ec_coords.conditions
|
38
49
|
|
39
50
|
# daily forecasts
|
40
|
-
|
51
|
+
ec_coords.daily_forecasts
|
41
52
|
|
42
53
|
# hourly forecasts
|
43
|
-
|
54
|
+
ec_coords.hourly_forecasts
|
44
55
|
|
45
56
|
# alerts
|
46
|
-
|
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
|
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__ = ["
|
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
|
-
|
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
|
-
|
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
|
-
|
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
|
120
|
+
class ECMap:
|
118
121
|
def __init__(self, **kwargs):
|
119
|
-
"""Initialize the
|
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("
|
135
|
+
vol.Required("layer_opacity", default=65): vol.All(
|
133
136
|
int, vol.Range(0, 100)
|
134
137
|
),
|
135
|
-
vol.
|
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
|
-
|
149
|
-
self.
|
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.
|
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
|
-
|
193
|
-
|
194
|
-
|
195
|
-
|
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
|
-
|
198
|
-
|
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.
|
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=
|
210
|
-
style=
|
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
|
-
|
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
|
245
|
+
"""Get time range of available images for the layer."""
|
223
246
|
|
224
|
-
capabilities_cache_key = f"capabilities-{self.
|
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"] =
|
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=
|
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
|
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
|
-
|
251
|
-
|
252
|
-
map_image = None
|
297
|
+
# Start with the basemap if available
|
253
298
|
if base_bytes:
|
254
|
-
|
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
|
-
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
266
|
-
|
267
|
-
|
268
|
-
|
269
|
-
|
270
|
-
|
271
|
-
|
272
|
-
|
273
|
-
|
274
|
-
|
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
|
278
|
-
|
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
|
-
|
289
|
-
|
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
|
-
|
301
|
-
|
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
|
-
|
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"
|
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(
|
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
|
-
|
321
|
-
|
322
|
-
|
323
|
-
|
324
|
-
|
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
|
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
|
-
|
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
|
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 = [
|
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
|
417
|
+
# at the same time, resulting in them getting retrieved for each image.
|
359
418
|
await self._get_basemap()
|
360
|
-
|
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
|
-
|
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.
|
371
|
-
curr = curr +
|
372
|
-
|
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
|
-
|
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
|
-
|
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
|
-
|
232
|
-
|
233
|
-
|
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
|
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
|
-
|
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
|
-
|
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
|
-
|
360
|
-
if
|
361
|
-
|
362
|
-
|
363
|
-
|
364
|
-
|
365
|
-
|
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
|
-
|
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"] =
|
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]
|
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.
|
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:
|
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
|
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
|
-
|
80
|
-
|
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(
|
73
|
+
asyncio.run(ec_coords.update())
|
83
74
|
|
84
75
|
# current conditions
|
85
|
-
|
76
|
+
ec_coords.conditions
|
86
77
|
|
87
78
|
# daily forecasts
|
88
|
-
|
79
|
+
ec_coords.daily_forecasts
|
89
80
|
|
90
81
|
# hourly forecasts
|
91
|
-
|
82
|
+
ec_coords.hourly_forecasts
|
92
83
|
|
93
84
|
# alerts
|
94
|
-
|
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.
|
@@ -1,11 +1,11 @@
|
|
1
1
|
[build-system]
|
2
|
-
requires = ["setuptools>=
|
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.
|
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 =
|
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
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|