2  Pollution Stripes

Pollution stripes are inspired from climate stripes that sequentially places colored stripes of increasing temperature to show global warming. GLOBE—1850-2024-MO.png

In Pollution stripes, we use a air quality variable instead of temperature. In this tutorial we will use AQI values of a city in a year. This data is obtained from CPCB’s AQI Bulletins. UrbanEmissions cleaned and made these bulletins data from 2015-25 accessible: AQI Bulletins Visualisation.

import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection
from matplotlib.colors import ListedColormap
import pandas as pd
import numpy as np
import matplotlib as mpl
df = pd.read_csv('data/AllIndiaBulletinsMaster2025_openrefined.csv')
df.head()
date city no_stations aqi_category aqi prominent_pollutant
0 2015-05-01 Varanasi 0 Moderate 157 PM10
1 2015-05-01 Kanpur 0 Moderate 175 PM10
2 2015-05-01 Faridabad 0 Moderate 173 PM10
3 2015-05-01 Ahmedabad 0 Moderate 168 PM2.5
4 2015-05-01 Hyderabad 0 Moderate 189 PM2.5

We’ll produce pollution stripes of city Kanpur for the year 2025.

city = 'Noida'
year = '2025'

df_noida = df[df.city == city]
df_noida['date'] = pd.to_datetime(df_noida['date'])
df_noida.head()
/tmp/ipykernel_4868/701861396.py:5: SettingWithCopyWarning: 
A value is trying to be set on a copy of a slice from a DataFrame.
Try using .loc[row_indexer,col_indexer] = value instead

See the caveats in the documentation: https://pandas.pydata.org/pandas-docs/stable/user_guide/indexing.html#returning-a-view-versus-a-copy
  df_kanpur['date'] = pd.to_datetime(df_kanpur['date'])
date city no_stations aqi_category aqi prominent_pollutant
13981 2017-05-04 Noida 1 Poor 255 PM10
14026 2017-05-05 Noida 1 Very Poor 339 PM10
14054 2017-05-06 Noida 1 Poor 281 PM10
14071 2017-05-07 Noida 1 Very Poor 338 PM10
14125 2017-05-08 Noida 1 Very Poor 312 PM10
df_noida.set_index('date')['2025':]['aqi_category'].value_counts()
aqi_category
Moderate        171
Satisfactory     81
Very Poor        50
Poor             41
Severe           14
Good              7
Name: count, dtype: int64

AQI data could be missing on a few days. This is because atleast 16 hours of data is required for a station to calculate AQI. You can read more about it in our Calculating AQI tutorial.

To create a calendar heatmap, we’d need some value (including null) for every day of a calendar year. We thus create a template dataframe that has all dates of a calendar year. For days on which there is no reported AQI, we will take a null value.

# Create a dataframe with a single column - dates of 2023 -- We are producing pollution stripes for one year
daily_dates = pd.date_range(start=year+'-01-01', end=year+'-12-31', freq='D')
template = pd.DataFrame({'date': daily_dates})
template.head()
date
0 2025-01-01
1 2025-01-02
2 2025-01-03
3 2025-01-04
4 2025-01-05
df_noida = template.merge(df_noida, on='date', how='left') #Remove this code if you dont want dates without data in calendar
df_noida = df_noida.fillna(-1)
df_noida.head()
date city no_stations aqi_category aqi prominent_pollutant
0 2025-01-01 -1 -1.0 -1 -1.0 -1
1 2025-01-02 Noida 4.0 Poor 211.0 PM10, PM2.5
2 2025-01-03 Noida 4.0 Very Poor 332.0 PM2.5
3 2025-01-04 Noida 3.0 Very Poor 365.0 PM2.5
4 2025-01-05 Noida 4.0 Poor 206.0 PM2.5
# Define the conditions for each category
conditions = [
    (df_noida['aqi'] < 0), # Null values are replaced with -1 - this category is for that - remove it if null calendary years are not needed
    (df_noida['aqi'] <= 50),
    (df_noida['aqi'] > 50) & (df_noida['aqi'] <= 100),
    (df_noida['aqi'] > 100) & (df_noida['aqi'] <= 200),
    (df_noida['aqi'] > 200) & (df_noida['aqi'] <= 300),
    (df_noida['aqi'] > 300) & (df_noida['aqi'] <= 400),
    (df_noida['aqi'] > 400)
]
categories = ['1', '2', '3', '4', '5', '6', '7'] #Should be 6 - +1 for the null value category.
df_noida['AQI_cat'] = np.select(conditions, categories, default='outlier')
df_noida['AQI_cat'] = df_noida['AQI_cat'].astype(int)
df_noida.head()
date city no_stations aqi_category aqi prominent_pollutant AQI_cat
0 2025-01-01 -1 -1.0 -1 -1.0 -1 1
1 2025-01-02 Noida 4.0 Poor 211.0 PM10, PM2.5 5
2 2025-01-03 Noida 4.0 Very Poor 332.0 PM2.5 6
3 2025-01-04 Noida 3.0 Very Poor 365.0 PM2.5 6
4 2025-01-05 Noida 4.0 Poor 206.0 PM2.5 5

We will use the official AQI color codings in the pollution stripes. For which, we map each day’s AQI value to the required color.

aqi_colors = ['#eeeeeeff', # Null values are replaced with -1 - this color is for that - remove it if nulls are not needed
              '#274e13ff', '#93c47dff', '#f2f542', '#f59042', '#ff0000', '#753b3b']


aqi_colors = [aqi_colors[i-1] for i in sorted(df_noida['AQI_cat'].unique())]
# Create a custom discrete colormap for AQI
cmap = ListedColormap(aqi_colors)
aqi_ranges = [0, 50, 100, 200, 300, 400, 500]
norm = mpl.colors.BoundaryNorm(aqi_ranges, 6)
fig, ax = plt.subplots(figsize=(14, 4))

# Create rectangles — one per day (0 to 364 = 365 days, or 0 to 365 = 366 days)
n_days = len(df_noida)
rectangles = [Rectangle((y, 0), 1, 1) for y in range(n_days)]

col = PatchCollection(rectangles, zorder=1)
col.set_array(df_noida['aqi'].values)  # <-- use .values to ensure numpy array
col.set_cmap(cmap)
col.set_norm(norm)

ax.add_collection(col)

ax.set_xlim(0, n_days)   # <-- must cover all rectangles
ax.set_ylim(0, 1)

ax.set_axis_off()         # <-- set AFTER adding collection

ax.set_title(city + ', ' + year, fontsize=20, loc='left', y=1.03)

plt.figtext(0.856, 0.087, '© UrbanEmissions', fontsize=7)
plt.figtext(0.907, 0.15, 'Data source: CPCB AQI Bulletins', rotation=270, fontsize=7)

plt.show()

2.1 References

  1. Stripes with trend
  2. Warming Stripes - Matplotlib blog