Multi-station NO2 — the traffic diurnal cycle¶
Fetch hourly-rollup NO2 for several stations in one metro area, then group by hour-of-day split into weekday vs. weekend to reveal the traffic-driven diurnal pattern. Shows multi-station handling and using the lat/lon columns to tell stations apart.
Needs a free
OPENAQ_API_KEY. The live cell is guarded so the notebook stays--nbval-lax-safe. Hourly rollups over a month are a few hundred rows per sensor — keep the window modest.
Setup¶
Imports up front: os reads the API key from the environment, matplotlib draws the diurnal plot at the end, and earthlens provides the unified EarthLens entry point.
import os
import matplotlib.pyplot as plt
from earthlens import EarthLens
The request¶
Pick a bounding box over the Los Angeles basin and a one-month window. These define the area of interest and date range for the OpenAQ query.
bbox_lat = [34.0, 34.3] # Los Angeles basin
bbox_lon = [-118.5, -118.1]
start, end = "2024-03-01", "2024-03-31"
Fetch the hourly NO2 rollups¶
Build the EarthLens request first, then call download() on its own line — the server-side hourly rollup keeps each sensor to a few hundred rows. The whole fetch is guarded behind OPENAQ_API_KEY so the notebook stays safe to run without credentials.
df = None
if os.environ.get("OPENAQ_API_KEY"):
openaq = EarthLens(
data_source="openaq",
variables=["no2"],
start=start,
end=end,
aoi=[bbox_lon[0], bbox_lat[0], bbox_lon[1], bbox_lat[1]],
temporal_resolution="hourly", # server-side hourly rollup
max_locations=8,
path="out/openaq",
)
df = openaq.download(progress_bar=False)
print(df.shape, "stations:", df['station_id'].nunique() if df is not None else 0)
else:
print("set OPENAQ_API_KEY to run the live cell")
Group into a weekday/weekend diurnal cycle¶
Bucket each reading by hour-of-day and a weekend flag, then average so each hour gets one weekday and one weekend mean. The result is a 24-row table with two columns ready to plot.
diurnal = None
if df is not None and not df.empty:
local = df.copy()
local["hour"] = local["datetime_utc"].dt.hour
local["is_weekend"] = local["datetime_utc"].dt.dayofweek >= 5
diurnal = (
local.groupby(["is_weekend", "hour"])["value"].mean().unstack("is_weekend")
)
diurnal.columns = ["weekday" if not c else "weekend" for c in diurnal.columns]
display(diurnal.head())
Plot the diurnal pattern¶
Plot both curves over the 24-hour axis: the weekday line should show the morning and evening rush-hour NO2 peaks that the weekend line flattens out.
if diurnal is not None and not diurnal.empty:
fig, ax = plt.subplots(figsize=(9, 4))
diurnal.plot(ax=ax, marker="o")
ax.set_xlabel("hour of day (UTC)")
ax.set_ylabel("NO2 (µg/m³)")
ax.set_title("NO2 diurnal cycle — weekday vs weekend (metro mean)")
ax.set_xticks(range(0, 24, 3))
plt.show()