q04·intermediate

Did a wildfire impact downwind air quality, and how far did the smoke reach?

atmospherefireair-qualitypublic-health Datasets: 6 20–60 min

Smoke doesn't stay put. Wind carries it hundreds, sometimes thousands of kilometres downwind, dirtying the air in cities far from the flames. Satellites can spot the fires themselves (each hot pixel is a flame burning right then) and track the chemical haze the smoke leaves behind as it drifts. Put the two together and you can watch a plume travel from where it started to where people are breathing it.

Did a wildfire impact downwind air quality?

Smoke doesn’t stay put. Wind carries it hundreds, sometimes thousands of kilometres downwind, dirtying the air in cities far from the flames. Satellites can spot the fires themselves (each hot pixel is a flame burning right then) and track the chemical haze the smoke leaves behind as it drifts. Put the two together and you can watch a plume travel from where it started to where people are breathing it.

Which data to use, and how

Use this dataset: TEMPO HCHO (short name TEMPO_HCHO_L2). HCHO is formaldehyde, a gas that smoke pumps into the air, so a spike in HCHO downwind of a fire is a fingerprint that the smoke arrived. TEMPO watches North America every daylight hour. That’s what you want for something that moves as fast as a smoke plume. (Outside North America, swap in the global daily OMI products instead. See the gotchas below.)

Then do four steps (the runnable code further down does all of this; this is just the idea):

  1. Find the fire. Pull the hot-pixel detections so you know where and when the fire was burning, and pick a study window (a few days during and after the event).
  2. Pick a downwind box. Choose a latitude/longitude box somewhere downwind of the fire. That’s where you’ll check whether the smoke arrived.
  3. Watch the chemistry over time. Average TEMPO’s HCHO over your downwind box, hour by hour, and plot it before, during, and after the fire. A clear bump means the smoke reached you.
  4. Add wind to trace the path. Overlay wind direction (which way the air was moving) on top of the fire location and the HCHO haze, so you can see the plume’s actual route from source to city.

That’s the whole method. Below, “How a scientist answers this” names the exact tools, and the Run it cell does it live on synthetic data so you can watch each step work.

What you can find out

  • Where the fire was burning, within hours of it being detected (the near-real-time fire pixels).
  • Which way the smoke went and how far it travelled, by following the haze (aerosol index, true-color imagery, HCHO) across the map.
  • A downwind chemical spike: a jump in HCHO or NO₂ over a city as the plume arrives (TEMPO for North America, OMI/TROPOMI for the rest of the world).
  • The burn scar afterward: how much land actually burned, and how badly, once the fire is out (MCD64A1 monthly burned area, or HLS burn-severity imagery).
  • A rough headcount of who was exposed, if you overlay a gridded population map on the plume’s path.

What it can’t tell you

  • What the air at street level actually measured. The satellite sees gases and haze in the whole column of air above a place, not the fine soot (PM₂.₅) you’d read off a ground monitor. For real surface numbers you need ground stations (e.g. EPA AirNow) or an air-chemistry model.
  • The health impact. “The plume reached the city” is not “X people got sick.” Turning exposure into health outcomes needs epidemiology and exposure modelling, well beyond what the imagery alone shows.
  • Exactly what the smoke is made of. Satellites tell you something is in the air, not the precise mix of particles. Composition needs samples taken on the ground or from aircraft.

Gotchas to watch for

  • The fast fire data is a heads-up, not a final answer. The near-real-time fire pixels (FIRMS) are produced within hours so responders can act, but they’re not the cleaned-up record. For the authoritative “how much burned,” use the monthly burned-area product (MCD64A1) once it’s available.
  • HCHO is a stand-in for smoke, not smoke itself. TEMPO HCHO tracks a gas the fire emits. It’s a handy proxy, but it isn’t the smoke particles. For the actual haze, pair it with a particle measure (MODIS aerosol optical depth, or the OMI Aerosol Index).
  • How high the air mixes changes everything. On days when the lower atmosphere stays shallow (a low “boundary layer”), smoke piles up near the ground; when it’s deep, the plume thins out fast. Check the boundary-layer height from MERRA-2 for your dates so you don’t misread a thinning plume as a vanishing one.
  • Smoke can fool the satellite into seeing “cloud.” Thick smoke looks a lot like cloud in some retrievals and may get thrown out, leaving a hole right where you wanted data. Cross-check the cloud flags (MOD06) to tell smoke from real cloud.
  • TEMPO only watches North America. For fires elsewhere (Amazon, Siberia, Indonesia) TEMPO sees nothing. Use the global OMI/TROPOMI products instead.

The real-data code

The Run it cell above runs the method on synthetic data (no login). Below is the whole recipe against the real archive: find the fire, open TEMPO HCHO over a downwind box, build the hourly before/during/after series, add wind, and plot it. It needs a free Earthdata Login (the fast fire feed, FIRMS, uses its own free API key instead).

import earthaccess, requests, io
import numpy as np, pandas as pd, xarray as xr
import matplotlib.pyplot as plt

earthaccess.login(strategy="netrc")

# Example: California wildfire complex, July 2024
fire_window = ("2024-07-15", "2024-08-15")
aoi = (-125, 35, -115, 42)                 # CA + western NV (W, S, E, N)
downwind = (-121.0, 38.0, -119.5, 39.0)    # box NE / downwind of the source

# 1. Find the fire: FIRMS near-real-time VIIRS hot pixels (free key, REST, no EDL).
firms_key = "YOUR_KEY"
firms_url = (f"https://firms.modaps.eosdis.nasa.gov/api/area/csv/{firms_key}/"
             f"VIIRS_SNPP_NRT/{aoi[0]},{aoi[1]},{aoi[2]},{aoi[3]}/10/2024-07-15")
fires = pd.read_csv(io.StringIO(requests.get(firms_url, timeout=60).text))
fire_lon, fire_lat = fires["longitude"].mean(), fires["latitude"].mean()
print(f"{len(fires)} fire detections; centroid {fire_lat:.2f}N {fire_lon:.2f}W")

# 2. Watch the chemistry: open each TEMPO HCHO granule, keep good pixels in the
#    downwind box, area-average the HCHO column, and tag it with the scan time.
grans = earthaccess.search_data(short_name="TEMPO_HCHO_L2",
                                bounding_box=aoi, temporal=fire_window)
times, hcho = [], []
for fh in earthaccess.open(grans):
    geo = xr.open_dataset(fh, group="geolocation")
    prod = xr.open_dataset(fh, group="product")
    lon, lat = geo["longitude"], geo["latitude"]
    col = prod["vertical_column"]                     # HCHO slant->vertical column
    keep = ((prod["main_data_quality_flag"] == 0)
            & (lon >= downwind[0]) & (lon <= downwind[2])
            & (lat >= downwind[1]) & (lat <= downwind[3]))
    v = float(col.where(keep).mean())
    if np.isfinite(v):
        times.append(pd.to_datetime(str(geo["time"].values.ravel()[0])))
        hcho.append(v)
ts = pd.Series(hcho, index=pd.DatetimeIndex(times)).sort_index().resample("1D").mean()

# 3. Verdict: compare downwind HCHO during the fire vs the quiet days before it.
during = ts["2024-07-18":"2024-08-05"].mean()
before = ts[:"2024-07-17"].mean()
ratio = during / before
print(f"Downwind HCHO during fire = {during:.2e}, before = {before:.2e} "
      f"-> {ratio:.1f}x  ->", "smoke reached the box" if ratio > 1.3 else "no clear plume")

# 4. Add wind: MERRA-2 3-hourly U/V over the fire confirms the plume blew downwind.
wgran = earthaccess.search_data(short_name="M2I3NPASM", bounding_box=aoi,
                                temporal=fire_window)
wds = xr.open_mfdataset(earthaccess.open(wgran[:8]))
u = wds["U"].sel(lev=850, lon=fire_lon, lat=fire_lat, method="nearest").mean().item()
v = wds["V"].sel(lev=850, lon=fire_lon, lat=fire_lat, method="nearest").mean().item()
print(f"Mean 850-hPa wind at the fire: U={u:+.1f} V={v:+.1f} m/s "
      f"(blows toward {'E' if u>0 else 'W'}/{'N' if v>0 else 'S'})")

# 5. Plot the downwind HCHO time series and mark the fire onset.
ts.plot(marker="o")
plt.axvline(pd.Timestamp("2024-07-17"), ls="--", c="r", label="fire onset")
plt.ylabel("HCHO column (downwind box)"); plt.xlabel("date")
plt.legend(); plt.tight_layout(); plt.show()

# Before you trust it: HCHO is a gas proxy, not the smoke particles — cross-check with
# an aerosol product (OMI Aerosol Index) and a ground PM2.5 monitor (EPA AirNow).

Where the data comes from

Everything but the fire feed comes through one free Earthdata Login, even though the pieces live in different NASA archives: TEMPO from the ASDC archive, OMI and MERRA-2 from GES DISC, and the burned-area product (MCD64A1) from the LP DAAC. The one exception is the fast near-real-time fire feed (FIRMS), which uses its own separate (also free) API key.

Sources

How a scientist answers this
Parameters
FIRMS near-real-time fire pixels (MODIS+VIIRS, with fire radiative power) to locate the source; TEMPO L2 tropospheric NO2 and HCHO columns (molecules/cm2, hourly over North America) or OMI HCHO + UV Aerosol Index globally as the smoke-chemistry signal; MERRA-2 850 hPa wind and PBL height for advection; MOD06 cloud properties to separate smoke from cloud. MCD64A1 gives post-event burned-area extent.
Method
Build the smoke transport vector from MERRA-2 winds, then compute downwind HCHO/NO2 and Aerosol-Index anomalies as the departure of the event-period column from a pre-fire (or same-season climatological) baseline along the plume axis; transport distance is the along-wind extent where the Aerosol Index exceeds its background threshold. Use the aerosol index plus MOD06 to mask cloud so elevated smoke is not mistaken for cloud.
Validation
Cross-check satellite-inferred surface impact against ground monitors (EPA AirNow/OpenAQ) since columns are not surface PM2.5; confirm plume attribution by aligning FIRMS detections with wind direction; note optical retrievals fail under thick cloud and that the aerosol index responds to absorbing aerosol height, not concentration.
In plain EnglishFind where the fire is burning, use the winds to see which way the smoke blew, and measure how far downwind the haze and smoke chemistry rose above normal levels. Satellites show the smoke overhead, but ground sensors are still needed to know what people are actually breathing.

Make it yours → Set the fire bounding box, the event date window, and the baseline period in the notebook, and swap TEMPO for OMI/TROPOMI outside North America.

🧪 Virtual Mock Lab stand-in data · runs in your browser · no login

A safe place to practise the method on TEMPO_HCHO_L2. The code is the real earthaccess workflow, but we feed it stand-in data shaped like the real product — so you can run it, change the numbers, and break it without an account. When you want the real data, use the recipe under “The real-data code” above, or download the notebook below.

editable · runs in your browser

Datasets used

Recipe ready · not yet run The recipe and the Virtual Mock Lab above are ready to use. We haven't yet run this question end-to-end on real data, so it has no verified answer yet — only the questions we've actually computed carry one. That's the honest line between a method and a result.