Skip to content

Commit f68d6b7

Browse files
committed
Air pollution forecasts
1 parent ce7f702 commit f68d6b7

File tree

9 files changed

+151
-25
lines changed

9 files changed

+151
-25
lines changed

pyowm/airpollutionapi30/airpollution_client.py

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
#!/usr/bin/env python
22
# -*- coding: utf-8 -*-
33

4-
from pyowm.airpollutionapi30.uris import CO_INDEX_URL, OZONE_URL, NO2_INDEX_URL, SO2_INDEX_URL, AIR_POLLUTION_URL
4+
from pyowm.airpollutionapi30.uris import CO_INDEX_URL, OZONE_URL, NO2_INDEX_URL, SO2_INDEX_URL, AIR_POLLUTION_URL, \
5+
AIR_POLLUTION_FORECAST_URL
56
from pyowm.utils import formatting
67

78

@@ -164,6 +165,18 @@ def get_air_pollution(self, params_dict):
164165
_, json_data = self._client.get_json(AIR_POLLUTION_URL, params=params_dict)
165166
return json_data
166167

168+
def get_forecast_air_pollution(self, params_dict):
169+
"""
170+
Invokes the new AirPollution API forecast endpoint
171+
172+
:param params_dict: dict of parameters
173+
:returns: a string containing raw JSON data
174+
:raises: *ValueError*, *APIRequestError*
175+
176+
"""
177+
_, json_data = self._client.get_json(AIR_POLLUTION_FORECAST_URL, params=params_dict)
178+
return json_data
179+
167180
def __repr__(self):
168181
return "<%s.%s - httpclient=%s>" % \
169-
(__name__, self.__class__.__name__, str(self._client))
182+
(__name__, self.__class__.__name__, str(self._client))

pyowm/airpollutionapi30/airpollution_manager.py

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -197,7 +197,7 @@ def so2index_around_coords(self, lat, lon, start=None, interval=None):
197197

198198
def air_quality_at_coords(self, lat, lon):
199199
"""
200-
Queries the OWM AirPollution API for all available air quality indicators around the specified coordinates.
200+
Queries the OWM AirPollution API for available air quality indicators around the specified coordinates.
201201
202202
:param lat: the location's latitude, must be between -90.0 and 90.0
203203
:type lat: int/float
@@ -212,8 +212,32 @@ def air_quality_at_coords(self, lat, lon):
212212
geo.assert_is_lat(lat)
213213
params = {'lon': lon, 'lat': lat}
214214
json_data = self.new_ap_client.get_air_pollution(params)
215-
air_status = airstatus.AirStatus.from_dict(json_data)
216-
return air_status
215+
try:
216+
return airstatus.AirStatus.from_dict(json_data)
217+
except:
218+
return None
219+
220+
def air_quality_forecast_at_coords(self, lat, lon):
221+
"""
222+
Queries the OWM AirPollution API for available forecasted air quality indicators around the specified coordinates.
223+
224+
:param lat: the location's latitude, must be between -90.0 and 90.0
225+
:type lat: int/float
226+
:param lon: the location's longitude, must be between -180.0 and 180.0
227+
:type lon: int/float
228+
:return: a `list` of *AirStatus* instances or an empty `list` if data is not available
229+
:raises: *ParseResponseException* when OWM AirPollution API responses' data
230+
cannot be parsed, *APICallException* when OWM AirPollution API can not be
231+
reached, *ValueError* for wrong input values
232+
"""
233+
geo.assert_is_lon(lon)
234+
geo.assert_is_lat(lat)
235+
params = {'lon': lon, 'lat': lat}
236+
json_data = self.new_ap_client.get_forecast_air_pollution(params)
237+
try:
238+
return airstatus.AirStatus.from_dict(json_data)
239+
except:
240+
return []
217241

218242
def __repr__(self):
219243
return '<%s.%s>' % (__name__, self.__class__.__name__)

pyowm/airpollutionapi30/airstatus.py

Lines changed: 23 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -76,41 +76,48 @@ def reception_time(self, timeformat='unix'):
7676
@classmethod
7777
def from_dict(cls, the_dict):
7878
"""
79-
Parses a *AirStatus* instance out of a data dictionary.
79+
Parses an *AirStatus* instance or `list` of instances out of a data dictionary.
8080
8181
:param the_dict: the input dictionary
8282
:type the_dict: `dict`
83-
:returns: a *AirStatus* instance or ``None`` if no data is available
83+
:returns: a *AirStatus* instance or ``list` of such instances
8484
:raises: *ParseAPIResponseError* if it is impossible to find or parse the data needed to build the result
8585
8686
"""
8787
if the_dict is None:
8888
raise exceptions.ParseAPIResponseError('Data is None')
8989
try:
90-
91-
item = the_dict['list'][0]
92-
93-
# -- reference time (strip away Z and T on ISO8601 format)
94-
reference_time = item['dt']
95-
96-
# -- reception time (now)
97-
reception_time = timestamps.now('unix')
98-
9990
# -- location
10091
lon = float(the_dict['coord']['lat'])
10192
lat = float(the_dict['coord']['lon'])
10293
place = location.Location(None, lon, lat, None)
10394

104-
# -- air quality data
105-
data = item['components']
106-
data['aqi'] = item['main']['aqi']
95+
# -- reception time (now)
96+
rcp_time = timestamps.now('unix')
97+
98+
def build_air_status(item_dict, location, reception_time):
99+
# -- reference time (strip away Z and T on ISO8601 format)
100+
reference_time = item_dict['dt']
101+
102+
# -- air quality data
103+
data = item_dict['components']
104+
data['aqi'] = item_dict['main']['aqi']
105+
106+
return AirStatus(reference_time, location, data, reception_time)
107+
108+
items = the_dict['list']
109+
110+
# one datapoint
111+
if len(items) == 1:
112+
return build_air_status(items[0], place, rcp_time)
113+
# multiple datapoints
114+
else:
115+
return [build_air_status(item, place, rcp_time) for item in items]
107116

108117
except KeyError:
109118
raise exceptions.ParseAPIResponseError(
110119
''.join([__name__, ': impossible to parse AirStatus']))
111120

112-
return AirStatus(reference_time, place, data, reception_time)
113-
114121
def to_dict(self):
115122
"""Dumps object to a dictionary
116123

pyowm/airpollutionapi30/uris.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@
1212
# current API endpoint
1313
NEW_ROOT_POLLUTION_API_URL = 'openweathermap.org/data/2.5'
1414
AIR_POLLUTION_URL = 'air_pollution'
15+
AIR_POLLUTION_FORECAST_URL = 'air_pollution/forecast'

sphinx/v3/code-recipes.md

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -831,7 +831,7 @@ TBD
831831

832832
Instead of getting a `weather_manager`, get from the main OWM object a `airpollution_manager` and use it
833833

834-
### Getting air polluting concentrations and Air Quality Index on geographic coords
834+
### Getting air pollution concentrations and Air Quality Index on geographic coords
835835
Air polluting agents concentration can be queried in one shot:
836836

837837
```python
@@ -855,6 +855,28 @@ air_status.nh3
855855
air_status.aqi
856856
```
857857

858+
### Getting forecasts for air pollution on geographic coords
859+
We can get also get forecasts for air pollution agents concentration and air quality index:
860+
861+
```python
862+
from pyowm.owm import OWM
863+
owm = OWM('your-api-key')
864+
mgr = owm.airpollution_manager()
865+
866+
list_of_forecasts = mgr.air_quality_forecast_at_coords(51.507351, -0.127758) # London, GB
867+
868+
# Each item in the list_of_forecasts is an AirStatus object
869+
for air_status in list_of_forecasts:
870+
air_status.co
871+
air_status.no
872+
air_status.no2
873+
air_status.o3
874+
air_status.so2
875+
air_status.pm2_5
876+
air_status.pm10
877+
air_status.nh3
878+
air_status.aqi # air quality index
879+
```
858880

859881

860882
<div id="station_measurements"/>

tests/integration/pollutionapi30/test_integration_pollutionapi30.py renamed to tests/integration/airpollutionapi30/test_integration_pollutionapi30.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class IntegrationTestsPollutionAPI30(unittest.TestCase):
1212

1313
def test_air_quality_at_coords(self):
1414
"""
15-
Test feature: get all air quality data around geo-coordinates.
15+
Test feature: get all air quality data around geo-coordinates.
1616
"""
1717
airstatus = self.__owm.air_quality_at_coords(45, 9)
1818
self.assertIsNotNone(airstatus)
@@ -21,6 +21,18 @@ def test_air_quality_at_coords(self):
2121
self.assertIsNotNone(airstatus.reference_time())
2222
self.assertIsNotNone(airstatus.location)
2323

24+
def test_air_quality_forecast_at_coords(self):
25+
"""
26+
Test feature: get all forecasted air quality data around geo-coordinates.
27+
"""
28+
list_of_airstatuses = self.__owm.air_quality_forecast_at_coords(45, 9)
29+
self.assertTrue(list_of_airstatuses)
30+
for airstatus in list_of_airstatuses:
31+
self.assertIsNotNone(airstatus.air_quality_data)
32+
self.assertIsNotNone(airstatus.reception_time())
33+
self.assertIsNotNone(airstatus.reference_time())
34+
self.assertIsNotNone(airstatus.location)
35+
2436

2537
if __name__ == "__main__":
2638
unittest.main()

tests/unit/airpollutionapi30/test_airpollution_manager.py

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
from tests.unit.airpollutionapi30.test_coindex import COINDEX_JSON
1111
from tests.unit.airpollutionapi30.test_no2index import NO2INDEX_JSON
1212
from tests.unit.airpollutionapi30.test_so2index import SO2INDEX_JSON
13-
from tests.unit.airpollutionapi30.test_airstatus import AIRSTATUS_JSON
13+
from tests.unit.airpollutionapi30.test_airstatus import AIRSTATUS_JSON, AIRSTATUS_MULTIPLE_JSON
1414

1515

1616
class TestAirPollutionManager(unittest.TestCase):
@@ -29,6 +29,9 @@ def mock_get_no2_returning_no2index_around_coords(self, params_dict):
2929
def mock_get_air_pollution(self, params_dict):
3030
return json.loads(AIRSTATUS_JSON)
3131

32+
def mock_get_forecast_air_pollution(self, params_dict):
33+
return json.loads(AIRSTATUS_MULTIPLE_JSON)
34+
3235
def mock_get_so2_returning_so2index_around_coords(self, params_dict):
3336
return json.loads(SO2INDEX_JSON)
3437

@@ -199,5 +202,32 @@ def test_air_quality_at_coords_fails_with_wrong_parameters(self):
199202
self.assertRaises(ValueError, airpollution_manager.AirPollutionManager.air_quality_at_coords, \
200203
self.__test_instance, 200, 2.5)
201204

205+
def test_air_quality_forecast_at_coords(self):
206+
ref_to_original = airpollution_client.AirPollutionHttpClient.get_forecast_air_pollution
207+
airpollution_client.AirPollutionHttpClient.get_forecast_air_pollution = \
208+
self.mock_get_forecast_air_pollution
209+
result = self.__test_instance.air_quality_forecast_at_coords(45, 9)
210+
airpollution_client.AirPollutionHttpClient.get_forecast_air_pollution = ref_to_original
211+
self.assertTrue(isinstance(result, list))
212+
for item in result:
213+
self.assertIsInstance(item, airstatus.AirStatus)
214+
self.assertIsNotNone(item.reference_time)
215+
self.assertIsNotNone(item.reception_time())
216+
loc = item.location
217+
self.assertIsNotNone(loc)
218+
self.assertIsNotNone(loc.lat)
219+
self.assertIsNotNone(loc.lon)
220+
self.assertIsNotNone(item.air_quality_data)
221+
222+
def test_air_quality_forecast_at_coords_fails_with_wrong_parameters(self):
223+
self.assertRaises(ValueError, airpollution_manager.AirPollutionManager.air_quality_forecast_at_coords, \
224+
self.__test_instance, 43.7, -200.0)
225+
self.assertRaises(ValueError, airpollution_manager.AirPollutionManager.air_quality_forecast_at_coords, \
226+
self.__test_instance, 43.7, 200.0)
227+
self.assertRaises(ValueError, airpollution_manager.AirPollutionManager.air_quality_forecast_at_coords, \
228+
self.__test_instance, -200, 2.5)
229+
self.assertRaises(ValueError, airpollution_manager.AirPollutionManager.air_quality_forecast_at_coords, \
230+
self.__test_instance, 200, 2.5)
231+
202232
def test_repr(self):
203233
print(self.__test_instance)

tests/unit/airpollutionapi30/test_airstatus.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111

1212
AIRSTATUS_JSON = '{"coord":{"lon":-0.1278,"lat":51.5074},"list":[{"main":{"aqi":1},"components":{"co":250.34,"no":0.19,"no2":35.99,"o3":30.76,"so2":8.11,"pm2_5":3.15,"pm10":3.81,"nh3":0.74},"dt":1611597600}]}'
13+
AIRSTATUS_MULTIPLE_JSON = '{"coord":{"lon":50,"lat":50},"list":[{"main":{"aqi":1},"components":{"co":240.33,"no":0,"no2":1.07,"o3":79.39,"so2":0.97,"pm2_5":1.84,"pm10":1.9,"nh3":1.25},"dt":1613606400},{"main":{"aqi":1},"components":{"co":240.33,"no":0,"no2":0.98,"o3":79.39,"so2":0.69,"pm2_5":1.92,"pm10":1.97,"nh3":1.36},"dt":1613610000}]}'
1314
AIRSTATUS_MALFORMED_JSON = '{"time":"2016-10-01T13:07:01Z","xyz":[]}'
1415
AIRSTATUS_JSON_DUMP = '{"reference_time": 1234567, "location": {"name": "test", "coordinates": {"lon": 12.3, "lat": 43.7}, "ID": 987, "country": "UK"}, "air_quality_data": {"aqi": 1, "co": 250.34, "no": 0.19, "no2": 35.99, "o3": 30.76, "so2": 8.11, "pm2_5": 3.15, "pm10": 3.81, "nh3": 0.74}, "reception_time": 1475283600}'
1516

@@ -64,11 +65,11 @@ def test_returning_different_formats_for_reception_time(self):
6465
self.__test_date_reception_time)
6566

6667
def test_from_dict(self):
68+
# one item
6769
d = json.loads(AIRSTATUS_JSON)
6870
result = AirStatus.from_dict(d)
6971
self.assertIsNotNone(result)
7072
self.assertIsNotNone(result.reference_time())
71-
self.assertIsNotNone(result.reference_time())
7273
loc = result.location
7374
self.assertIsNotNone(loc)
7475
self.assertIsNone(loc.name)
@@ -78,6 +79,22 @@ def test_from_dict(self):
7879
for key in self.__test_air_quality_data:
7980
getattr(result, key)
8081

82+
# multiple items
83+
d = json.loads(AIRSTATUS_MULTIPLE_JSON)
84+
result = AirStatus.from_dict(d)
85+
self.assertIsInstance(result, list)
86+
for item in result:
87+
self.assertIsInstance(item, AirStatus)
88+
self.assertIsNotNone(item.reference_time())
89+
loc = item.location
90+
self.assertIsNotNone(loc)
91+
self.assertIsNone(loc.name)
92+
self.assertIsNone(loc.id)
93+
self.assertIsNotNone(loc.lon)
94+
self.assertIsNotNone(loc.lat)
95+
for key in self.__test_air_quality_data:
96+
getattr(item, key)
97+
8198
def test_from_dict_fails_when_JSON_data_is_None(self):
8299
self.assertRaises(pyowm.commons.exceptions.ParseAPIResponseError, AirStatus.from_dict, None)
83100

0 commit comments

Comments
 (0)