diff --git a/act/discovery/airnow.py b/act/discovery/airnow.py index e6be3b449e..88fbb2bb7d 100644 --- a/act/discovery/airnow.py +++ b/act/discovery/airnow.py @@ -3,28 +3,29 @@ import xarray as xr -def get_airnow_forecast(token, date, zipcode=None, latlon=None, distance=25): +def get_airnow_forecast(token, date=None, zipcode=None, latlon=None, distance=25): """ - This tool will get current or historical AQI values and categories for a + This tool will get current AQI forecast values and categories for a reporting area by either Zip code or Lat/Lon coordinate. https://docs.airnowapi.org/ Parameters ---------- token : str - The access token for accesing the AirNowAPI web server + The access token for accessing the AirNowAPI web server date : str - The date of the data to be acquired. Format is YYYY-MM-DD + The date of the forecast to be acquired. Format is YYYY-MM-DD. + Default is None. zipcode : str The zipcode of the location for the data request. If zipcode is not defined then a latlon coordinate must be defined. latlon : array - The latlon coordinate of the loaction for the data request. + The latlon coordinate of the location for the data request. If latlon is not defined then a zipcode must be defined. distance : int - If no reporting are is associated with the specified zipcode or latlon, - return a forcast from a nearby reporting area with this distance (in miles). - Default is 25 miles + If no reporting area is associated with the specified zipcode or latlon, + return a forecast from a nearby reporting area within this distance + (in miles). Default is 25 miles Returns ------- @@ -33,79 +34,70 @@ def get_airnow_forecast(token, date, zipcode=None, latlon=None, distance=25): Example ------- - act.discovery.get_AirNow_forecast(token='XXXXXX', zipcode='60440', date='2012-05-31') + act.discovery.get_airnow_forecast(token='XXXXXX', zipcode='60440') """ - # default beginning of the query url - query_url = 'https://www.airnowapi.org/aq/forecast/' + # The previous forecast/zipCode and forecast/latLong services are retired + # September 30, 2026. Both are replaced by forecast/current. + query_url = 'https://www.airnowapi.org/aq/forecast/current/?' - # checking is either a zipcode or latlon coordinate is defined - # if neither is defined then error is raised + # Check whether either a zipcode or latlon coordinate is defined. if (zipcode is None) and (latlon is None): raise NameError("Zipcode or latlon must be defined") if zipcode: - url = query_url + ( - 'zipCode/?' - + 'format=text/csv' - + '&zipCode=' - + str(zipcode) - + '&date=' - + str(date) - + '&distance=' - + str(distance) - + '&API_KEY=' - + str(token) - ) - + url = query_url + ('format=text/csv' + '&zipCode=' + str(zipcode)) if latlon: url = query_url + ( - 'latLong/?' - + 'format=text/csv' - + '&latitude=' - + str(latlon[0]) - + '&longitude=' - + str(latlon[1]) - + '&date=' - + str(date) - + '&distance=' - + str(distance) - + '&API_KEY=' - + str(token) + 'format=text/csv' + '&latitude=' + str(latlon[0]) + '&longitude=' + str(latlon[1]) ) + # Retain the date argument for backwards compatibility. The new current + # forecast service can be queried without a date, so only append it when + # explicitly provided. + if date is not None: + url += '&date=' + str(date) + + url += '&distance=' + str(distance) + '&API_KEY=' + str(token) + df = pd.read_csv(url) - # converting to xarray dataset object + if 'Aqi' in df.columns: + df = df.rename(columns={'Aqi': 'AQI'}) + + # Convert to xarray dataset object. ds = df.to_xarray() return ds -def get_airnow_obs(token, date=None, zipcode=None, latlon=None, distance=25): +def get_airnow_obs(token, date=None, zipcode=None, latlon=None, state=None, distance=25): """ - This tool will get current or historical observed AQI values and categories for a - reporting area by either Zip code or Lat/Lon coordinate. + This tool will get current or historical observed AQI values and categories. + Current observations can be requested by Zip code or Lat/Lon coordinate. + Historical daily observations are requested by state. https://docs.airnowapi.org/ Parameters ---------- token : str - The access token for accesing the AirNowAPI web server + The access token for accessing the AirNowAPI web server date : str - The date of the data to be acquired. Format is YYYY-MM-DD - Default is None which will pull most recent observations + The date of the data to be acquired. Format is YYYY-MM-DD. + Default is None, which will pull the most recent observations. zipcode : str - The zipcode of the location for the data request. + The zipcode of the location for a current data request. If zipcode is not defined then a latlon coordinate must be defined. latlon : array - The latlon coordinate of the loaction for the data request. + The latlon coordinate of the location for a current data request. If latlon is not defined then a zipcode must be defined. + state : str + Two-letter state code used for historical daily observations. + Required when date is provided. distance : int - If no reporting are is associated with the specified zipcode or latlon, - return a forcast from a nearby reporting area with this distance (in miles). - Default is 25 miles + For current observations, search for data within this distance + (in miles). Default is 25 miles. Returns ------- @@ -114,28 +106,26 @@ def get_airnow_obs(token, date=None, zipcode=None, latlon=None, distance=25): Example ------- - act.discovery.get_AirNow_obs(token='XXXXXX', date='2021-12-01', zipcode='60440') - act.discovery.get_AirNow_obs(token='XXXXXX', latlon=[45,-87]) + act.discovery.get_airnow_obs(token='XXXXXX', zipcode='60440') + act.discovery.get_airnow_obs(token='XXXXXX', latlon=[45, -87]) + act.discovery.get_airnow_obs(token='XXXXXX', date='2025-05-01', state='IL') """ - # default beginning of the query url query_url = 'https://www.airnowapi.org/aq/observation/' - # checking is either a zipcode or latlon coordinate is defined - # if neither is defined then error is raised - if (zipcode is None) and (latlon is None): - raise NameError("Zipcode or latlon must be defined") - - # setting the observation type to either current or historical based on the date if date is None: - obs_type = 'current' + # The previous observation/zipCode/current and + # observation/latLong/current services are retired September 30, 2026. + # Both are replaced by observation/current/ziplatlong. + if (zipcode is None) and (latlon is None): + raise NameError("Zipcode or latlon must be defined") + + query_url += 'current/ziplatlong/?' + if zipcode: url = query_url + ( - 'zipCode/' - + str(obs_type) - + '/?' - + 'format=text/csv' + 'format=text/csv' + '&zipCode=' + str(zipcode) + '&distance=' @@ -143,12 +133,10 @@ def get_airnow_obs(token, date=None, zipcode=None, latlon=None, distance=25): + '&API_KEY=' + str(token) ) + if latlon: url = query_url + ( - 'latLong/' - + str(obs_type) - + '/?' - + 'format=text/csv' + 'format=text/csv' + '&latitude=' + str(latlon[0]) + '&longitude=' @@ -158,44 +146,33 @@ def get_airnow_obs(token, date=None, zipcode=None, latlon=None, distance=25): + '&API_KEY=' + str(token) ) - else: - obs_type = 'historical' - if zipcode: - url = query_url + ( - 'zipCode/' - + str(obs_type) - + '/?' - + 'format=text/csv' - + '&zipCode=' - + str(zipcode) - + '&date=' - + str(date) - + 'T00-0000&distance=' - + str(distance) - + '&API_KEY=' - + str(token) - ) - if latlon: - url = query_url + ( - 'latLong/' - + str(obs_type) - + '/?' - + 'format=text/csv' - + '&latitude=' - + str(latlon[0]) - + '&longitude=' - + str(latlon[1]) - + '&date=' - + str(date) - + 'T00-0000&distance=' - + str(distance) - + '&API_KEY=' - + str(token) - ) + else: + # Historical ZIP and Lat/Lon observation services are retired + # September 30, 2026. The replacement returns historical daily + # observations for Reporting Areas within a selected state. + if state is None: + raise NameError("State must be defined for historical observations") + + url = ( + query_url + + 'historical/state/?' + + 'format=text/csv' + + '&stateCode=' + + str(state) + + '&startDate=' + + str(date) + + '&endDate=' + + str(date) + + '&API_KEY=' + + str(token) + ) df = pd.read_csv(url) - # converting to xarray + if '' in df.columns: + df = df.rename(columns={'Aqi': 'AQI'}) + + # Convert to xarray. ds = df.to_xarray() return ds @@ -206,13 +183,13 @@ def get_airnow_bounded_obs( ): """ Get AQI values or data concentrations for a specific date and time range and set of - parameters within a geographic area of intrest + parameters within a geographic area of interest. https://docs.airnowapi.org/ Parameters ---------- token : str - The access token for accesing the AirNowAPI web server + The access token for accessing the AirNowAPI web server start_date : str The start date and hour (in UTC) of the data request. Format is YYYY-MM-DDTHH @@ -220,7 +197,7 @@ def get_airnow_bounded_obs( The end date and hour (in UTC) of the data request. Format is YYYY-MM-DDTHH latlon_bnds : str - Lat/Lon bounding box of the area of intrest. + Lat/Lon bounding box of the area of interest. Format is 'minX,minY,maxX,maxY' parameters : str Parameters to return data for. Options are: @@ -228,7 +205,7 @@ def get_airnow_bounded_obs( Format is 'PM25,PM10' mon_type : int The type of monitor to be returned. Default is 0 - 0-Permanent, 1-Mobile onlt, 2-Permanent & Mobile + 0-Permanent, 1-Mobile only, 2-Permanent & Mobile data_type : char The type of data to be returned. A-AQI, C-Concentrations, B-AQI & Concentrations @@ -240,9 +217,9 @@ def get_airnow_bounded_obs( """ + # The hourly aq/data service is not being retired in the 2026 API update. verbose = 1 inc_raw_con = 1 - url = ( 'https://www.airnowapi.org/aq/data/?startDate=' + str(start_date) @@ -264,7 +241,6 @@ def get_airnow_bounded_obs( + '&API_KEY=' + str(token) ) - # Set Column names names = [ 'latitude', @@ -285,6 +261,9 @@ def get_airnow_bounded_obs( # Read data into CSV df = pd.read_csv(url, names=names) + if 'Aqi' in df.columns: + df = df.rename(columns={'Aqi': 'AQI'}) + # Each line is a different time or site or variable so need to parse out sites = df['site_name'].unique() times = df['time'].unique() @@ -292,7 +271,6 @@ def get_airnow_bounded_obs( latitude = [list(df['latitude'].loc[df['site_name'] == s])[0] for s in sites] longitude = [list(df['longitude'].loc[df['site_name'] == s])[0] for s in sites] aqs_id = [list(df['aqs_id'].loc[df['site_name'] == s])[0] for s in sites] - # Set up the dataset ahead of time ds = xr.Dataset( data_vars={ @@ -303,10 +281,9 @@ def get_airnow_bounded_obs( coords={'time': (['time'], times), 'sites': (['sites'], sites)}, ) - # Set up emtpy data with nans + # Set up empty data with nans data = np.empty((len(variables), len(times), len(sites))) data[:] = np.nan - # For each variable, pull out the data from specific sites and times for v in range(len(variables)): for t in range(len(times)): @@ -325,7 +302,6 @@ def get_airnow_bounded_obs( if len(result['concentration']) > 0: data[v, t, s] = list(result['concentration'])[0] atts = {'units': list(result['unit'])[0]} - # Add variables to the dataset ds[variables[v]] = xr.DataArray(data=data[v, :, :], dims=['time', 'sites'], attrs=atts) diff --git a/examples/discovery/plot_airnow.py b/examples/discovery/plot_airnow.py index 8bacae79b5..47c277c94a 100644 --- a/examples/discovery/plot_airnow.py +++ b/examples/discovery/plot_airnow.py @@ -1,55 +1,80 @@ """ -Airnow Data +AirNow Data ----------- -This example shows the different ways to pull -air quality information from EPA's AirNow API for -a station near to SGP +This example shows the different ways to pull air quality information +from EPA's AirNow API for an area near the ARM Southern Great Plains +(SGP) atmospheric observatory. """ import os +from datetime import datetime import matplotlib.pyplot as plt import act -# You need an account and token from https://docs.airnowapi.org/ first +# You need an account and token from https://docs.airnowapi.org/ first. token = os.getenv('AIRNOW_API') if token is not None and len(token) > 0: - # This first example will get the forcasted values for the date passed - # at stations within 100 miles of the Zipcode. Can also use latlon instead as - # results = act.discovery.get_airnow_forecast(token, '2022-05-01', distance=100, - # latlon=[41.958, -88.12]) - # If the username and token are not set, use the existing sample file - results = act.discovery.get_airnow_forecast(token, '2022-05-01', zipcode=74630, distance=100) - - # The results show a dataset with air quality information from Oklahoma City - # The data is not indexed by time and just a rudimentary xarray object from - # converted from a pandas DataFrame. Note that the AirNow API labels the data - # returned as AQI. + # Get current forecast values for reporting areas within 100 miles of + # the ZIP code. A latitude/longitude location can also be used instead: + # + # results = act.discovery.get_airnow_forecast( + # token, date, distance=100, latlon=[41.958, -88.12] + # ) + # + # The updated AirNow forecast service provides current forecast + # information, so use today's date. + date = datetime.now().strftime('%Y-%m-%d') + results = act.discovery.get_airnow_forecast(token, date, zipcode=74630, distance=100) + + # The results are returned as a simple xarray Dataset converted from + # the AirNow tabular response. ACT normalizes the forecast AQI field + # to "AQI". + print(results) + + # Historical daily observations are now requested by state rather than + # by ZIP code or latitude/longitude. This returns daily AQI values for + # reporting areas across the selected state. + results = act.discovery.get_airnow_obs(token, date='2025-05-01', state='OK') + + # Historical observations include fields such as DailyAQI and + # DailyAQICategoryName. print(results) - # This call gives the daily average for Ozone, PM2.5 and PM10 - results = act.discovery.get_airnow_obs(token, date='2022-05-01', zipcode=74630, distance=100) + # Current observations can still be requested using either a ZIP code + # or latitude/longitude. The updated service returns nearby monitoring + # sites and includes fields such as NowcastAQI and AqiCategoryName. + results = act.discovery.get_airnow_obs(token, zipcode=74630, distance=100) + print(results) - # This call will get all the station data for a time period within - # the bounding box provided. This will return the object with time - # as a coordinate and can be used with ACT Plotting to plot after - # squeezing the dimensions. It can be a 2D time series + # This call gets station data for a time period within the provided + # bounding box. The existing /aq/data/ endpoint is not being retired. + # The returned object has time as a coordinate and can be used with + # ACT plotting after reducing the site dimension. lat_lon = '-98.172,35.879,-96.76,37.069' results = act.discovery.get_airnow_bounded_obs( - token, '2022-05-01T00', '2022-05-01T12', lat_lon, 'OZONE,PM25', data_type='B' + token, + '2022-05-01T00', + '2022-05-01T12', + lat_lon, + 'OZONE,PM25', + data_type='B', ) - # Reduce to 1D timeseries + + # Reduce to a 1D time series for this example. results = results.squeeze(dim='sites', drop=False) + print(results) - # Plot out data but note that Ozone was not return in the results + # Plot the available PM2.5 concentration and AQI data. display = act.plotting.TimeSeriesDisplay(results) display.plot('PM2.5', label='PM2.5') display.plot('AQI', label='AQI') + plt.legend() plt.show() diff --git a/tests/discovery/test_airnow.py b/tests/discovery/test_airnow.py index e4715561d9..e11ad5b3a3 100644 --- a/tests/discovery/test_airnow.py +++ b/tests/discovery/test_airnow.py @@ -1,4 +1,5 @@ import os +from datetime import datetime import numpy as np import pytest @@ -11,40 +12,62 @@ def test_get_airnow(): if token is not None: if len(token) == 0: return - results = act.discovery.get_airnow_forecast(token, '2022-05-01', zipcode=60108, distance=50) - assert results['CategoryName'].values[0] == 'Good' - assert results['AQI'].values[2] == -1 - assert results['ReportingArea'].values[3] == 'South and West Suburbs (Chicago)' + + # The new forecast/current service replaces the separate ZIP code and + # Lat/Lon forecast endpoints. Use today's date since this service + # provides current forecast information rather than archived forecasts. + date = datetime.now().strftime('%Y-%m-%d') + + results = act.discovery.get_airnow_forecast(token, date, zipcode=60108, distance=50) + assert 'CategoryName' in results + assert 'AQI' in results + assert 'ReportingArea' in results results = act.discovery.get_airnow_forecast( - token, '2022-05-01', distance=50, latlon=[41.958, -88.12] + token, date, distance=50, latlon=[41.958, -88.12] ) - assert results['CategoryName'].values[3] == 'Moderate' - assert results['AQI'].values[2] == -1 - assert results['ReportingArea'].values[3] == 'North and West Suburbs (Chicago)' + assert 'CategoryName' in results + assert 'AQI' in results + assert 'ReportingArea' in results - # results = act.discovery.get_airnow_obs(token, date='2025-05-01', zipcode=60108, distance=50) - # assert results['AQI'].values[1] == 39 - # assert results['ParameterName'].values[1] == 'PM2.5' - # assert results['CategoryName'].values[1] == 'Good' + # Current observations now use the combined ZIP/LatLon endpoint. + results = act.discovery.get_airnow_obs(token, zipcode=60108, distance=50) + + assert 'ReportingAreaName' in results + assert 'SiteID' in results + assert 'SiteName' in results + assert 'NowcastAQI' in results + assert 'AqiCategoryName' in results - # results = act.discovery.get_airnow_obs(token, zipcode=60108, distance=50) - # assert results['ReportingArea'].values[0] == 'South and West Suburbs (Chicago)' results = act.discovery.get_airnow_obs(token, latlon=[41.958, -88.12], distance=50) - assert results['StateCode'].values[0] == 'IL' + + assert 'ReportingAreaName' in results + assert 'SiteID' in results + assert 'SiteName' in results + assert 'NowcastAQI' in results + assert 'AqiCategoryName' in results with pytest.raises(NameError): results = act.discovery.get_airnow_obs(token) + with pytest.raises(NameError): - results = act.discovery.get_airnow_forecast(token, '2022-05-01') + results = act.discovery.get_airnow_forecast(token, date) - results = act.discovery.get_airnow_obs( - token, date='2025-05-01', distance=50, latlon=[41.958, -88.12] - ) - assert results['AQI'].values[1] == 39 - assert results['ParameterName'].values[1] == 'PM2.5' - assert results['CategoryName'].values[1] == 'Good' + # Historical observations can no longer be requested by ZIP code or + # Lat/Lon. AirNow's replacement service is queried by state. + results = act.discovery.get_airnow_obs(token, date='2025-05-01', state='IL') + assert 'DailyAQI' in results + assert 'ParameterName' in results + assert 'DailyAQICategoryName' in results + assert 'StateCode' in results + assert np.all(results['StateCode'].values == 'IL') + + with pytest.raises(NameError): + results = act.discovery.get_airnow_obs( + token, date='2025-05-01', latlon=[41.958, -88.12] + ) + # The hourly monitoring-site bounding-box service is not being retired. lat_lon = '-88.245401,41.871346,-87.685099,42.234359' results = act.discovery.get_airnow_bounded_obs( token, '2022-05-01T00', '2022-05-01T12', lat_lon, 'OZONE,PM25', data_type='B'