.. DO NOT EDIT. .. THIS FILE WAS AUTOMATICALLY GENERATED BY SPHINX-GALLERY. .. TO MAKE CHANGES, EDIT THE SOURCE PYTHON FILE: .. "auto_examples/workflows/plot_wildfire_smoke.py" .. LINE NUMBERS ARE GIVEN BELOW. .. only:: html .. note:: :class: sphx-glr-download-link-note :ref:`Go to the end ` to download the full example code. .. rst-class:: sphx-glr-example-title .. _sphx_glr_auto_examples_workflows_plot_wildfire_smoke.py: Tracking wildfire smoke with AirNow ----------------------------------- Canadian wildfire smoke routinely degrades air quality across the US Midwest and Great Lakes. This example uses ACT's AirNow discovery function ``act.discovery.get_airnow_bounded_obs`` as the single data source to build a picture of one such event, and plots it entirely with ACT's own displays combined into one four-panel figure: * a regional map of the latest hourly surface PM2.5 at every AirNow station (``GeographicPlotDisplay``) on top, and * 24-hour PM2.5 time series for one site each in Duluth MN, Toronto ON, and Chicago IL (``TimeSeriesDisplay``) on a shared y-axis below, with EPA AQI category bands shaded behind them. Because the US EPA AirNow feed also re-serves Canadian provincial monitors (for example the Ontario Ministry of the Environment network), a single API path covers stations on both sides of the border. The date window below is fixed to a July 2026 smoke episode so the example is reproducible; AirNow serves this range from its rolling archive. Set your free AirNow token (https://docs.airnowapi.org/) in the ``AIRNOW_API`` environment variable before running. Author: Scott Collis Author: Claude (Anthropic) .. GENERATED FROM PYTHON SOURCE LINES 29-258 .. image-sg:: /auto_examples/workflows/images/sphx_glr_plot_wildfire_smoke_001.png :alt: US + Canada surface PM2.5 via AirNow (data pulled with ACT), Duluth, MN — West Duluth, Toronto, ON — Toronto Downtown, Chicago, IL — CHI_COM :srcset: /auto_examples/workflows/images/sphx_glr_plot_wildfire_smoke_001.png :class: sphx-glr-single-img .. code-block:: Python import os import cartopy.crs as ccrs import matplotlib.pyplot as plt import numpy as np import xarray as xr from matplotlib.patches import Patch import act # --------------------------------------------------------------------------- # Fixed date window (UTC). AirNow expects 'YYYY-MM-DDTHH'. Pinning the dates # (rather than using the current time) keeps the example reproducible. # --------------------------------------------------------------------------- START_DATE = '2026-07-14T15' END_DATE = '2026-07-15T16' token = os.getenv('AIRNOW_API') # Map colorbar ceiling (ug/m^3). PM25_MAX = 250.0 # EPA PM2.5 AQI category bands: (low, high, color, label), ug/m^3. EPA_BANDS = [ (0.0, 12.0, '#2c7fb8', 'Good'), (12.0, 35.4, '#fdae61', 'Moderate'), (35.4, 55.4, '#d7301f', 'Unhealthy (SG)'), (55.4, 150.4, '#7a0177', 'Unhealthy'), (150.4, 600.0, '#4d004b', 'Hazardous'), ] if token is not None and len(token) > 0: # ----------------------------------------------------------------------- # 1. Pull every station in the map region through ACT. data_type='B' # returns both AQI and concentrations; mon_type=2 includes permanent # and mobile monitors. The result is a (time, sites) xarray.Dataset. # ----------------------------------------------------------------------- map_bounds = '-104,40,-74,50' ds_map = act.discovery.get_airnow_bounded_obs( token, START_DATE, END_DATE, map_bounds, 'PM25', mon_type=2, data_type='B', ) # Reduce to the latest valid PM2.5 per site. AirNow flags missing as -999. pm = ds_map['PM2.5'].values.copy() pm[pm < 0] = np.nan latest = np.array( [col[np.where(~np.isnan(col))[0][-1]] if np.any(~np.isnan(col)) else np.nan for col in pm.T] ) ds_latest = xr.Dataset( {'PM2.5': ('sites', latest)}, coords={ 'latitude': ('sites', ds_map['latitude'].values), 'longitude': ('sites', ds_map['longitude'].values), }, ) ds_latest['PM2.5'].attrs = { 'long_name': 'PM2.5', 'units': 'ug/m^3', } # ----------------------------------------------------------------------- # 2. Pull a tight bounding box around one site in each of three cities. # ----------------------------------------------------------------------- cities = { 'Duluth, MN': ('-92.3,46.6,-91.9,47.0', 'West Duluth'), 'Toronto, ON': ('-79.65,43.58,-79.25,43.85', 'Toronto Downtown'), 'Chicago, IL': ('-87.9,41.7,-87.5,42.05', 'CHI_COM'), } ds_cities = {} for city, (bounds, site) in cities.items(): ds = act.discovery.get_airnow_bounded_obs( token, START_DATE, END_DATE, bounds, 'PM25', mon_type=2, data_type='B', ) ds_cities[city] = ds.sel(sites=site) # ----------------------------------------------------------------------- # 3. Create the map using GeographicPlotDisplay. # # geoplot() creates the Cartopy GeoAxes required by the map and # returns that axis. Use the returned axis and its figure directly # rather than creating and subsequently closing another figure. # ----------------------------------------------------------------------- geo = act.plotting.GeographicPlotDisplay(ds_latest) map_ax = geo.geoplot( 'PM2.5', lat_field='latitude', lon_field='longitude', projection=ccrs.PlateCarree(), cartopy_feature=['LAND', 'OCEAN', 'LAKES', 'STATES', 'BORDERS'], title='US + Canada surface PM2.5 via AirNow (data pulled with ACT)', cmap='turbo', vmin=0, vmax=PM25_MAX, marker='o', s=40, edgecolor='white', linewidth=0.3, ) # Use the figure created by GeographicPlotDisplay as the common figure. fig = map_ax.figure fig.set_size_inches(13, 10) map_ax.set_extent( [-104, -74, 39.5, 50.5], crs=ccrs.PlateCarree(), ) # Position the map in the upper portion of the combined figure. map_ax.set_position([0.06, 0.48, 0.84, 0.44]) # GeographicPlotDisplay creates the colorbar on the same figure. # Move it alongside the resized map. cbar_axes = [ax for ax in fig.axes if ax is not map_ax] if cbar_axes: cbar_axes[-1].set_position([0.92, 0.50, 0.018, 0.40]) # ----------------------------------------------------------------------- # 4. Create the three time-series axes directly on the same figure. # ----------------------------------------------------------------------- gs = fig.add_gridspec( 1, 3, left=0.06, right=0.97, bottom=0.14, top=0.36, wspace=0.12, ) bottom_axes = [] for idx in range(3): if idx == 0: ax = fig.add_subplot(gs[0, idx]) else: ax = fig.add_subplot( gs[0, idx], sharey=bottom_axes[0], ) bottom_axes.append(ax) # ----------------------------------------------------------------------- # 5. Assign an ACT TimeSeriesDisplay to each existing axis. # # This avoids creating a TimeSeriesDisplay figure, closing it, and # manually replacing tsd.fig and tsd.axes. # ----------------------------------------------------------------------- for idx, (city, (_, site)) in enumerate(cities.items()): ax = bottom_axes[idx] # Add the EPA category bands before the data so they remain in # the background. for lo, hi, color, _ in EPA_BANDS: ax.axhspan( lo, hi, color=color, alpha=0.13, zorder=0, linewidth=0, ) tsd = act.plotting.TimeSeriesDisplay( ds_cities[city], subplot_shape=None, ) tsd.assign_to_figure_axis(fig, ax) tsd.plot( 'PM2.5', y_rng=(0, 600), force_line_plot=True, marker='o', set_title=f'{city} — {site}', ) if idx > 0: ax.set_ylabel('') ax.tick_params(labelleft=False) # ----------------------------------------------------------------------- # 6. Add the EPA category legend. # ----------------------------------------------------------------------- handles = [ Patch( facecolor=color, alpha=0.5, label=label, ) for _, _, color, label in EPA_BANDS ] fig.legend( handles=handles, loc='lower center', ncol=5, frameon=False, fontsize=9, bbox_to_anchor=(0.5, 0.035), title='EPA PM2.5 AQI category (bands)', ) plt.show() .. rst-class:: sphx-glr-timing **Total running time of the script:** (0 minutes 43.185 seconds) .. _sphx_glr_download_auto_examples_workflows_plot_wildfire_smoke.py: .. only:: html .. container:: sphx-glr-footer sphx-glr-footer-example .. container:: sphx-glr-download sphx-glr-download-jupyter :download:`Download Jupyter notebook: plot_wildfire_smoke.ipynb ` .. container:: sphx-glr-download sphx-glr-download-python :download:`Download Python source code: plot_wildfire_smoke.py ` .. container:: sphx-glr-download sphx-glr-download-zip :download:`Download zipped: plot_wildfire_smoke.zip ` .. only:: html .. rst-class:: sphx-glr-signature `Gallery generated by Sphinx-Gallery `_