Baltimore Air Quality Analysis

Investigating the impact of Wheelabrator Baltimore (WIN Waste) on local air quality compared to the I-95 corridor. Data from EPA NEI, AQS, and Iowa Environmental Mesonet (2019-2024).

Open the Narrative Presentation

A 13-slide directional analysis of Wheelabrator vs the I-95 corridor, with stress-tested conclusions. The dashboard below is the technical build log; the presentation is the story that sits on top of it.
Launch Presentation →

0. Data Pipeline & Technical Architecture

Every number and chart on this dashboard is produced by the same ordered pipeline. This section documents what runs, in what order, from which source, and where each artifact lands on disk.

Processing Order

1
Hourly wind: Iowa Environmental Mesonet ASOS feed

Fetched by src/fetch_wind.py from the IEM asos.py endpoint for BWI (station=BWI, 2019-2024, hourly METAR reports). Columns kept: timestamp, wind_dir (degrees, meteorological convention: direction the wind comes from), wind_speed_kt, wind_gust_kt. Speed is converted to m/s on load. Cached to data/wind_bwi.csv.

2
Facility emissions: EPA NEI 2014 & 2017 plus GHG Reporting Program

Compiled in src/fetch_emissions.py from the EPA National Emissions Inventory (major & trace pollutants in lbs/yr) and the EPA Greenhouse Gas Reporting Program (facility ID 1004094, CO2e metric tons). Values live inline in the module because NEI downloads are annual snapshots. Converted to tons/yr at load. Written to data/wheelabrator_emissions.csv and data/wheelabrator_ghg.csv.

3
AQS monitor data: EPA Air Quality System API

Fetched by src/fetch_aqs.py with an AQS API key (AQS_EMAIL, AQS_KEY in .env). Pulls daily summary data for PM2.5 (parameter 88101), plus PM10, SO2, NO2, Ozone, CO for Maryland FIPS 24, Baltimore City county 510, and Baltimore County county 005. Deduplicated to one daily value per monitor-date before downstream merges. Cached to data/aqs_*.csv.

4
Directional classification: per monitor, per hour, per day

For each AQS monitor, src/analyze.py:classify_wind_for_monitor computes the bearing from the monitor to Wheelabrator and flags each hour as "downwind of WB" when wind comes from that bearing ± 45°. classify_wind_for_i95 treats the highway as a line source: it finds the closest point on the I-95 polyline (I95_WAYPOINTS in src/config.py) using perpendicular projection onto each segment, then applies the same ± 45° rule. Hourly flags are aggregated to daily shares by merge_aqi_wind, and a day is tagged WB-only, I-95-only, Both, or Neither when its shares cross 50%.

5
Aggregation & comparison

directional_analysis and seasonal_directional_analysis compute mean, median, and 90th-percentile PM2.5 by category for each monitor and season. src/presentation.py then rolls those into the deck-level summary, the confounder stress-test (bearing overlap and SW-quadrant share of WB-only days), and the static PNG figures in presentation_assets/.

6
Visualization & serving

src/visualize.py emits the interactive per-monitor HTML (pollution roses, directional bar charts, time series, seasonal plots) and the static wind-rose PNGs into output/. app.py serves the dashboard, the presentation, the study map (a Folium render), and the cached artifacts. build.py captures the rendered Flask routes into docs/ for static hosting.

Module Map

Reproducing the Pipeline

  1. Register for an EPA AQS API key at https://aqs.epa.gov/data/api/signup and put AQS_EMAIL/AQS_KEY in .env.
  2. Install dependencies from requirements.txt.
  3. Run python run_all.py to pull wind + AQS, recompute the directional tables, and regenerate every chart in output/.
  4. Run python app.py to serve the dashboard and presentation locally, or python build.py to freeze them into docs/.

Re-running is idempotent: cached CSVs in data/ are used when available, and the get_presentation_context cache is keyed per-process so the presentation rebuilds its figures from the current CSVs on first render.

1. Study Area Map

Where the study neighborhoods are located, color-coded by exposure group. Red = near both Wheelabrator and I-95. Amber = I-95 corridor only. Gray = control areas.

Study Locations

16 Neighborhoods

Exposure Classification

Study Groups
NeighborhoodGroup
Westport Near Both
Cherry Hill Near Both
Brooklyn Near Both
Curtis Bay Near Both
South Baltimore Near Both
Federal Hill Near Both
Elkridge I-95 Only
Savage I-95 Only
Laurel I-95 Only
Rossville I-95 Only
White Marsh I-95 Only
Aberdeen I-95 Only
Canton Control
Fells Point Control
Dundalk Control
Hampden Control

2. Facility Emissions

What Wheelabrator Baltimore reports emitting, from EPA National Emissions Inventory data (2014 and 2017).

How this section is built

The NEI values live inline in src/fetch_emissions.py as a {year: {pollutant: lbs_per_year}} dictionary, then flattened into a long-form DataFrame by get_emissions_df(). Tons/yr is just lbs_per_year / 2000. The GHG number comes from the EPA Greenhouse Gas Reporting Program (facility ID 1004094) via get_ghg_df(). The year-over-year deltas in the table above are computed client-side in the Jinja template from the same frame; the Plotly charts below are written by src/visualize.py:plot_facility_emissions into output/facility_emissions.html and output/facility_emissions_trace.html.

NOx Emissions
1,101 tons/yr
+2.4% from 2014
SO2 Emissions
308 tons/yr
-0.9% from 2014
PM2.5 Emissions
27.3 tons/yr
+18% from 2014
CO2 Equivalent
762,683 metric tons
Total Emissions
3,248,587 lbs/yr
+2.8% from 2014

Wheelabrator Baltimore is the city's single largest stationary source of air pollution. Its SO2 emissions account for roughly half of all SO2 emitted in Baltimore City. The facility produces more mercury, lead, and greenhouse gases per hour than each of Maryland's four largest coal plants.

Major Pollutants

EPA NEI
Pollutant 2014 (tons/yr) 2017 (tons/yr) Change
Nitrogen Oxides (NOx) 1,075.8 1,101.2 +2.4%
Sulfur Dioxide (SO2) 310.9 308.1 -0.9%
Hydrochloric Acid (HCl) 73.7 78.4 +6.4%
Carbon Monoxide (CO) 66.0 74.9 +13.5%
Particulate Matter (PM) 24.9 29.0 +16.5%
Fine Particulate Matter (PM2.5) 23.1 27.3 +18.2%
Formaldehyde (CH2O) 2.0 2.0 0.0%
Volatile Organic Compounds (VOC) 3.3 2.7 -18.2%

Trace Pollutants

Heavy Metals & Toxics
Pollutant 2014 (lbs/yr) 2017 (lbs/yr) Change
Hydrogen Fluoride (HF) 482 1,019 +111.4%
Lead (Pb) 294 247 -16.0%
Mercury (Hg) 53 29 -45.3%
Nickel (Ni) 17 92 +441.2%
Hexavalent Chromium (Cr VI) 4 2 -50.0%

Source: EPA National Emissions Inventory, 2014 & 2017

Major Emissions Chart

Interactive

Trace Pollutants Chart

Interactive

3. Wind Patterns

Wind direction and speed distributions from BWI airport (2019-2024). These show where emissions are likely carried. Click any rose to view full size.

How this section is built

Hourly observations are pulled by src/fetch_wind.py:fetch_wind_data from https://mesonet.agron.iastate.edu/cgi-bin/request/asos.py for station BWI, report type 3 (routine METAR), for each year between DEFAULT_START_YEAR and DEFAULT_END_YEAR. The response is parsed into columns timestamp, wind_dir (degrees, direction wind comes from), and wind_speed_kt; speed is converted to m/s on load. The 16-sector wind rose PNGs are rendered by src/visualize.py:plot_wind_rose and plot_seasonal_wind_roses using matplotlib with polar projection; the per-sector shares are also exposed to the presentation via wind_summary.sector_share in src/presentation.py.

Wind Roses

Hourly Observations

4. Pollution Roses

Mean measured pollutant concentration by wind direction at each EPA monitor. If pollution is higher from one direction, these roses will show it.

How this section is built

For each AQS monitor discovered in data/aqs_monitors.csv, src/visualize.py:plot_pollution_rose bins the merged monitor-day records (from merge_aqi_wind) by the daily mean wind direction and plots mean PM2.5 per 22.5° sector as a Plotly polar bar chart. One HTML is emitted per monitor at output/pollution_rose_<lat>_<lon>.html and iframed in the cards below.

Monitor 39.30, -76.60

PM2.5

Monitor 39.31, -76.47

PM2.5

Monitor 39.34, -76.59

PM2.5

Monitor 39.46, -76.63

PM2.5

5. Directional Comparison

Side-by-side comparison of pollutant levels when monitors are downwind of Wheelabrator, downwind of I-95, downwind of both, or downwind of neither.

How this section is built

Each monitor-day is tagged using the pct_downwind_wheelabrator and pct_downwind_i95 shares from merge_aqi_wind: a day is WB-only when the WB share > 0.5 and the I-95 share ≤ 0.5, and symmetrically for the other three categories. src/analyze.py:directional_analysis aggregates mean/median/p90 PM2.5 in each bucket, and src/visualize.py:plot_directional_comparison renders the four-bar Plotly chart per monitor. The presentation-level version of this comparison, plus the confounder stress-test (bearing overlap + SW-quadrant share) lives in the presentation deck.

Monitor 39.30, -76.60

WB vs I-95

Monitor 39.31, -76.47

WB vs I-95

Monitor 39.34, -76.59

WB vs I-95

Monitor 39.46, -76.63

WB vs I-95

6. Time Series

Daily pollutant readings over time, colored by whether the monitor was downwind of Wheelabrator, I-95, both, or neither that day. Includes 30-day rolling averages.

How this section is built

src/visualize.py:plot_time_series takes the merged monitor-day frame, applies the same four-way categorization used in the directional charts, and plots daily PM2.5 as a scatter with category-colored markers plus a 30-day rolling mean as an overlay. Gaps correspond to monitor-days dropped during AQS deduplication or to dates missing from the BWI wind record.

Monitor 39.30, -76.60

Daily Values

Monitor 39.31, -76.47

Daily Values

Monitor 39.34, -76.59

Daily Values

Monitor 39.46, -76.63

Daily Values

7. Seasonal Breakdown

The same directional comparison broken down by season, to see if the Wheelabrator vs I-95 pattern varies with weather and wind shifts.

How this section is built

src/analyze.py:seasonal_directional_analysis tags each monitor-day with a season (DJF / MAM / JJA / SON) and re-runs directional_analysis inside each season. src/visualize.py:plot_seasonal_comparison renders the resulting grouped bar chart per monitor. The presentation-level seasonal line chart on Slide 9 is the same data aggregated across monitors.

Monitor 39.30, -76.60

By Season

Monitor 39.31, -76.47

By Season

Monitor 39.34, -76.59

By Season

Monitor 39.46, -76.63

By Season

Data: EPA National Emissions Inventory, EPA AQS, Iowa Environmental Mesonet — Wheelabrator Baltimore / WIN Waste, 1801 Annapolis Rd, Baltimore MD 21230