env_canada 0.15.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Binary file
env_canada/__init__.py ADDED
@@ -0,0 +1,19 @@
1
+ __all__ = [
2
+ "ECAirQuality",
3
+ "ECAlerts",
4
+ "ECHistorical",
5
+ "ECHistoricalRange",
6
+ "ECHydro",
7
+ "ECMap",
8
+ "ECRadar",
9
+ "ECWeather",
10
+ "ECWeatherUpdateFailed",
11
+ ]
12
+
13
+ from .ec_aqhi import ECAirQuality
14
+ from .ec_alerts import ECAlerts
15
+ from .ec_historical import ECHistorical, ECHistoricalRange
16
+ from .ec_hydro import ECHydro
17
+ from .ec_radar import ECRadar
18
+ from .ec_map import ECMap
19
+ from .ec_weather import ECWeather, ECWeatherUpdateFailed
@@ -0,0 +1 @@
1
+ USER_AGENT = "env_canada/0.15.0"
@@ -0,0 +1,164 @@
1
+ import copy
2
+ import logging
3
+ import math
4
+ from dataclasses import dataclass
5
+ from datetime import datetime, timedelta, UTC
6
+
7
+ from aiohttp import ClientSession, ClientTimeout
8
+
9
+ from .constants import USER_AGENT
10
+ from .ec_cache import Cache
11
+
12
+
13
+ def _point_in_polygon(lon, lat, ring):
14
+ """Ray-casting point-in-polygon test for a single ring."""
15
+ inside = False
16
+ j = len(ring) - 1
17
+ for i, (xi, yi) in enumerate(ring):
18
+ xj, yj = ring[j]
19
+ if (yi > lat) != (yj > lat) and lon < (xj - xi) * (lat - yi) / (yj - yi) + xi:
20
+ inside = not inside
21
+ j = i
22
+ return inside
23
+
24
+
25
+ def _point_in_geometry(lon, lat, geometry):
26
+ """Return True if (lon, lat) falls within a GeoJSON Polygon or MultiPolygon."""
27
+ gtype = geometry.get("type")
28
+ coords = geometry.get("coordinates", [])
29
+ if gtype == "Polygon":
30
+ return _point_in_polygon(lon, lat, coords[0])
31
+ if gtype == "MultiPolygon":
32
+ return any(_point_in_polygon(lon, lat, poly[0]) for poly in coords)
33
+ return False
34
+
35
+
36
+ GEOMET_WFS_URL = "https://geo.weather.gc.ca/geomet"
37
+
38
+ ALERTS_WFS_PARAMS = {
39
+ "SERVICE": "WFS",
40
+ "VERSION": "2.0.0",
41
+ "REQUEST": "GetFeature",
42
+ "TYPENAMES": "Current-Alerts",
43
+ "outputFormat": "application/json",
44
+ }
45
+
46
+ CLIENT_TIMEOUT = ClientTimeout(10)
47
+
48
+ CACHE_TTL = timedelta(minutes=5)
49
+
50
+ ATTRIBUTION = {
51
+ "english": "Data provided by Environment Canada",
52
+ "french": "Données fournies par Environnement Canada",
53
+ }
54
+
55
+ LOG = logging.getLogger(__name__)
56
+
57
+ __all__ = ["ECAlerts"]
58
+
59
+
60
+ @dataclass
61
+ class MetaData:
62
+ attribution: str
63
+ timestamp: datetime = datetime(1970, 1, 1, 0, 0, tzinfo=UTC)
64
+
65
+
66
+ class ECAlerts:
67
+ """Get weather alerts from Environment Canada GeoMet WFS."""
68
+
69
+ def __init__(self, coordinates, language="english"):
70
+ """Initialize ECAlerts with coordinates and language."""
71
+ from .ec_weather import ALERTS_INIT # lazy import to avoid circular dependency
72
+
73
+ self.lat = coordinates[0]
74
+ self.lon = coordinates[1]
75
+ self.language = language
76
+ self.metadata = MetaData(ATTRIBUTION[language])
77
+ self.alerts = copy.deepcopy(ALERTS_INIT[language])
78
+ self.alert_features = []
79
+
80
+ async def update(self):
81
+ """Fetch current alerts from Environment Canada GeoMet WFS."""
82
+ from .ec_weather import ALERTS_INIT, ALERT_TYPE_TO_NAME # lazy import
83
+
84
+ cache_key = f"alerts-{self.language}-{self.lat:.4f}-{self.lon:.4f}"
85
+ cached = Cache.get(cache_key)
86
+ if cached is not None:
87
+ self.alerts, self.alert_features = cached
88
+ return
89
+
90
+ km = 50
91
+ lat_delta = km / 111.0
92
+ lon_delta = km / (111.0 * math.cos(math.radians(self.lat)))
93
+ bbox = (
94
+ f"{self.lat - lat_delta:.4f},{self.lon - lon_delta:.4f},"
95
+ f"{self.lat + lat_delta:.4f},{self.lon + lon_delta:.4f},EPSG:4326"
96
+ )
97
+ params = {**ALERTS_WFS_PARAMS, "BBOX": bbox}
98
+
99
+ async with ClientSession(raise_for_status=True) as session:
100
+ response = await session.get(
101
+ GEOMET_WFS_URL,
102
+ params=params,
103
+ headers={"User-Agent": USER_AGENT},
104
+ timeout=CLIENT_TIMEOUT,
105
+ )
106
+ data = await response.json()
107
+
108
+ if not isinstance(data, dict) or data.get("type") != "FeatureCollection":
109
+ raise ValueError(f"Unexpected WFS response type: {type(data)}")
110
+
111
+ lang_suffix = "en" if self.language == "english" else "fr"
112
+
113
+ self.alerts = copy.deepcopy(ALERTS_INIT[self.language])
114
+ self.alert_features = []
115
+
116
+ for feature in data.get("features", []):
117
+ props = feature.get("properties", {})
118
+ if not isinstance(props, dict):
119
+ continue
120
+
121
+ geometry = feature.get("geometry") or {}
122
+ if not _point_in_geometry(self.lon, self.lat, geometry):
123
+ continue
124
+
125
+ self.alert_features.append(props)
126
+
127
+ status_en = (props.get("status_en") or "").lower()
128
+ alert_type = (props.get("alert_type") or "").lower()
129
+
130
+ # Ended alerts go to endings category regardless of alert_type
131
+ if status_en == "ended":
132
+ category = "endings"
133
+ else:
134
+ category = ALERT_TYPE_TO_NAME.get(alert_type)
135
+ if category is None:
136
+ continue
137
+
138
+ title = props.get(f"alert_name_{lang_suffix}") or ""
139
+
140
+ alert_dict = {
141
+ "title": title.strip().title() if title else title,
142
+ "date": props.get("publication_datetime"),
143
+ "alertColourLevel": props.get(f"risk_colour_{lang_suffix}"),
144
+ "expiryTime": props.get("expiration_datetime"),
145
+ # Additive new fields
146
+ "text": props.get(f"alert_text_{lang_suffix}"),
147
+ "area": props.get(f"feature_name_{lang_suffix}"),
148
+ "status": props.get(f"status_{lang_suffix}"),
149
+ "confidence": props.get(f"confidence_{lang_suffix}"),
150
+ "impact": props.get(f"impact_{lang_suffix}"),
151
+ "alert_code": props.get("alert_code"),
152
+ }
153
+
154
+ self.alerts[category]["value"].append(alert_dict)
155
+
156
+ Cache.add(cache_key, (self.alerts, self.alert_features), CACHE_TTL)
157
+ self.metadata.timestamp = datetime.now(UTC)
158
+
159
+ LOG.debug(
160
+ "update(): fetched %d alert features for (%f, %f)",
161
+ len(self.alert_features),
162
+ self.lat,
163
+ self.lon,
164
+ )
env_canada/ec_aqhi.py ADDED
@@ -0,0 +1,233 @@
1
+ import logging
2
+ from dataclasses import dataclass
3
+ from datetime import datetime, UTC
4
+
5
+ import voluptuous as vol
6
+ from aiohttp import ClientSession, ClientTimeout
7
+ from geopy import distance
8
+ from lxml import etree as et
9
+
10
+ from .constants import USER_AGENT
11
+
12
+ AQHI_SITE_LIST_URL = (
13
+ "https://dd.weather.gc.ca/today/air_quality/doc/AQHI_XML_File_List.xml"
14
+ )
15
+ AQHI_OBSERVATION_URL = "https://dd.weather.gc.ca/today/air_quality/aqhi/{}/observation/realtime/xml/AQ_OBS_{}_CURRENT.xml"
16
+ AQHI_FORECAST_URL = "https://dd.weather.gc.ca/today/air_quality/aqhi/{}/forecast/realtime/xml/AQ_FCST_{}_CURRENT.xml"
17
+
18
+ CLIENT_TIMEOUT = ClientTimeout(10)
19
+
20
+ LOG = logging.getLogger(__name__)
21
+
22
+ ATTRIBUTION = {
23
+ "EN": "Data provided by Environment Canada",
24
+ "FR": "Données fournies par Environnement Canada",
25
+ }
26
+
27
+
28
+ @dataclass
29
+ class MetaData:
30
+ attribution: str
31
+ timestamp: datetime | None = None
32
+ location: str | None = None
33
+
34
+ # Not used; needed for compatability with ec_weather metadata
35
+ station: str | None = None
36
+
37
+
38
+ __all__ = ["ECAirQuality"]
39
+
40
+
41
+ def timestamp_to_datetime(timestamp):
42
+ dt = datetime.strptime(timestamp, "%Y%m%d%H%M%S")
43
+ dt = dt.replace(tzinfo=UTC)
44
+ return dt
45
+
46
+
47
+ async def get_aqhi_regions(language):
48
+ """Get list of all AQHI regions from Environment Canada, for auto-config."""
49
+ zone_name_tag = f"name_{language.lower()}_CA"
50
+ region_name_tag = f"name{language.title()}"
51
+
52
+ LOG.debug("get_aqhi_regions() started")
53
+
54
+ regions = []
55
+ async with ClientSession(raise_for_status=True) as session:
56
+ response = await session.get(
57
+ AQHI_SITE_LIST_URL,
58
+ headers={"User-Agent": USER_AGENT},
59
+ timeout=CLIENT_TIMEOUT,
60
+ )
61
+ result = await response.read()
62
+
63
+ site_xml = result
64
+ xml_object = et.fromstring(site_xml)
65
+
66
+ for zone in xml_object.findall("./EC_administrativeZone"):
67
+ _zone_attribs = zone.attrib
68
+ _zone_attrib = {
69
+ "abbreviation": _zone_attribs["abreviation"],
70
+ "zone_name": _zone_attribs[zone_name_tag],
71
+ }
72
+ for region in zone.findall("./regionList/region"):
73
+ _region_attribs = region.attrib
74
+
75
+ _region_attrib = {
76
+ "region_name": _region_attribs[region_name_tag],
77
+ "cgndb": _region_attribs["cgndb"],
78
+ "latitude": float(_region_attribs["latitude"]),
79
+ "longitude": float(_region_attribs["longitude"]),
80
+ }
81
+ _children = list(region)
82
+ for child in _children:
83
+ _region_attrib[child.tag] = child.text
84
+ _region_attrib.update(_zone_attrib)
85
+ regions.append(_region_attrib)
86
+
87
+ LOG.debug("get_aqhi_regions(): found %d regions", len(regions))
88
+
89
+ return regions
90
+
91
+
92
+ async def find_closest_region(language, lat, lon):
93
+ """Return the AQHI region and site ID of the closest site."""
94
+ region_list = await get_aqhi_regions(language)
95
+
96
+ def site_distance(site):
97
+ """Calculate distance to a region."""
98
+ return distance.distance((lat, lon), (site["latitude"], site["longitude"]))
99
+
100
+ return min(region_list, key=site_distance)
101
+
102
+
103
+ class ECAirQuality:
104
+ """Get air quality data from Environment Canada."""
105
+
106
+ def __init__(self, **kwargs):
107
+ """Initialize the data object."""
108
+
109
+ init_schema = vol.Schema(
110
+ vol.All(
111
+ vol.Any(
112
+ {
113
+ vol.Required("coordinates"): object,
114
+ vol.Optional("language"): object,
115
+ },
116
+ {
117
+ vol.Required("zone_id"): object,
118
+ vol.Required("region_id"): object,
119
+ vol.Optional("language"): object,
120
+ },
121
+ ),
122
+ {
123
+ vol.Optional("zone_id"): vol.In(
124
+ ["atl", "ont", "pnr", "pyr", "que"]
125
+ ),
126
+ vol.Optional("region_id"): vol.All(str, vol.Length(5)),
127
+ vol.Optional("coordinates"): (
128
+ vol.All(vol.Or(int, float), vol.Range(-90, 90)),
129
+ vol.All(vol.Or(int, float), vol.Range(-180, 180)),
130
+ ),
131
+ vol.Optional("language", default="EN"): vol.In(["EN", "FR"]),
132
+ },
133
+ )
134
+ )
135
+
136
+ kwargs = init_schema(kwargs)
137
+
138
+ self.language = kwargs["language"]
139
+
140
+ if (
141
+ "zone_id" in kwargs
142
+ and "region_id" in kwargs
143
+ and kwargs["zone_id"] is not None
144
+ and kwargs["region_id"] is not None
145
+ ):
146
+ self.zone_id = kwargs["zone_id"]
147
+ self.region_id = kwargs["region_id"].upper()
148
+ else:
149
+ self.zone_id = None
150
+ self.region_id = None
151
+ self.coordinates = kwargs["coordinates"]
152
+
153
+ self.metadata = MetaData(ATTRIBUTION[self.language])
154
+ self.region_name = None
155
+ self.current = None
156
+ self.current_timestamp = None
157
+ self.forecasts = dict(daily={}, hourly={})
158
+
159
+ async def get_aqhi_data(self, url):
160
+ async with ClientSession(raise_for_status=True) as session:
161
+ try:
162
+ response = await session.get(
163
+ url.format(self.zone_id, self.region_id),
164
+ headers={"User-Agent": USER_AGENT},
165
+ timeout=CLIENT_TIMEOUT,
166
+ )
167
+ except Exception:
168
+ LOG.debug("Retrieving AQHI failed", exc_info=True)
169
+ return None
170
+
171
+ result = await response.read()
172
+ aqhi_xml = result
173
+ return et.fromstring(aqhi_xml)
174
+
175
+ async def update(self):
176
+ # Find closest site if not identified
177
+
178
+ if not (self.zone_id and self.region_id):
179
+ closest = await find_closest_region(self.language, *self.coordinates)
180
+ self.zone_id = closest["abbreviation"]
181
+ self.region_id = closest["cgndb"]
182
+ LOG.debug(
183
+ "update() closest region returned: zone_id '%s' region_id '%s'",
184
+ self.zone_id,
185
+ self.region_id,
186
+ )
187
+
188
+ # Fetch current measurement
189
+ aqhi_current = await self.get_aqhi_data(url=AQHI_OBSERVATION_URL)
190
+
191
+ if aqhi_current is not None:
192
+ # Update region name
193
+ element = aqhi_current.find("region")
194
+ self.region_name = element.attrib[f"name{self.language.title()}"]
195
+ self.metadata.location = self.region_name
196
+
197
+ # Update AQHI current condition
198
+ element = aqhi_current.find("airQualityHealthIndex")
199
+ if element is not None:
200
+ self.current = float(element.text)
201
+ else:
202
+ self.current = None
203
+
204
+ element = aqhi_current.find("./dateStamp/UTCStamp")
205
+ if element is not None:
206
+ self.current_timestamp = timestamp_to_datetime(element.text)
207
+ else:
208
+ self.current_timestamp = None
209
+ self.metadata.timestamp = self.current_timestamp
210
+ LOG.debug(
211
+ "update(): aqhi_current %d timestamp %s",
212
+ self.current,
213
+ self.current_timestamp,
214
+ )
215
+
216
+ # Update AQHI forecasts
217
+ aqhi_forecast = await self.get_aqhi_data(url=AQHI_FORECAST_URL)
218
+
219
+ if aqhi_forecast is not None:
220
+ # Update AQHI daily forecasts
221
+ for f in aqhi_forecast.findall("./forecastGroup/forecast"):
222
+ for p in f.findall("./period"):
223
+ if self.language == p.attrib["lang"]:
224
+ period = p.attrib["forecastName"]
225
+ self.forecasts["daily"][period] = int(
226
+ f.findtext("./airQualityHealthIndex") or 0
227
+ )
228
+
229
+ # Update AQHI hourly forecasts
230
+ for f in aqhi_forecast.findall("./hourlyForecastGroup/hourlyForecast"):
231
+ self.forecasts["hourly"][timestamp_to_datetime(f.attrib["UTCTime"])] = (
232
+ int(f.text or 0)
233
+ )
env_canada/ec_cache.py ADDED
@@ -0,0 +1,47 @@
1
+ from datetime import datetime
2
+ from typing import Any, ClassVar
3
+
4
+
5
+ class Cache:
6
+ _cache: ClassVar[dict[str, tuple[datetime, Any]]] = {}
7
+
8
+ @classmethod
9
+ def add(cls, cache_key, item, cache_time):
10
+ """Add an entry to the cache."""
11
+
12
+ cls._cache[cache_key] = (datetime.now() + cache_time, item)
13
+ return item # Returning item useful for chaining calls
14
+
15
+ @classmethod
16
+ def get(cls, cache_key):
17
+ """Get an entry from the cache."""
18
+
19
+ # Delete expired entries at start so we don't use expired entries
20
+ now = datetime.now()
21
+ expired = [key for key, value in cls._cache.items() if value[0] < now]
22
+ for key in expired:
23
+ del cls._cache[key]
24
+
25
+ result = cls._cache.get(cache_key)
26
+ return result[1] if result else None
27
+
28
+ @classmethod
29
+ def clear(cls, prefix: str | None = None) -> int:
30
+ """Clear cache entries.
31
+
32
+ Args:
33
+ prefix: If provided, only clear entries whose keys start with this prefix.
34
+ If None, clear all entries.
35
+
36
+ Returns:
37
+ Number of entries cleared.
38
+ """
39
+ if prefix is None:
40
+ count = len(cls._cache)
41
+ cls._cache.clear()
42
+ return count
43
+
44
+ keys_to_delete = [key for key in cls._cache if key.startswith(prefix)]
45
+ for key in keys_to_delete:
46
+ del cls._cache[key]
47
+ return len(keys_to_delete)
env_canada/ec_exc.py ADDED
@@ -0,0 +1,2 @@
1
+ class UnknownStationId(Exception):
2
+ """Station ID is unknown"""