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 mpl2 Pollution Stripes
Pollution stripes are inspired from climate stripes that sequentially places colored stripes of increasing temperature to show global warming. 
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.
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()