Which pollutant drives NCR's AQI in each season? A bulletin project
In this article (6 sections)
Related programme: the Data Analytics with Generative AI course (3–4 months; ₹90,000 (inclusive of GST) · EMI from ₹4,000/month).
Winter smog gets the headlines, but the pollutant that sets the day's AQI in Delhi, Noida, Gurugram, Ghaziabad and Faridabad is not always the same. This project counts, for each NCR city and season, how often CPCB's daily bulletin names PM2.5, PM10, ozone or another pollutant as prominent. It then asks whether the cities differ. It is a counting project with one genuinely tricky step: days on which the bulletin lists two pollutants at once.
Starting point: a parsed bulletin table
This builds on a table of daily bulletin rows (city, date, AQI, category, prominent pollutant, stations). If you have not built one, the bulletin parsing project walks through it. The source PDFs are on CPCB's AQI Bulletin page. The "Prominent Pollutant" column holds one or more pollutant codes, comma separated.
"Prominent" has a specific meaning. It is the pollutant whose sub-index is highest, so it determines the AQI. It does not mean the pollutant present in the largest quantity. Say that in your README, because readers routinely confuse the two.
Decisions you have to make, and a defensible default for each
How do you define seasons? Use the India Meteorological Department's four seasons: winter (January and February), pre-monsoon (March to May), monsoon (June to September) and post-monsoon (October to December). Some NCR analysts prefer to move December into winter because of its smog. That is a legitimate choice, but whichever you pick, fix it before looking at results and name it in the README.
What do you do with a day that lists two pollutants? There are three reasonable options:
- 1Count each listed pollutant as a full day (totals then exceed the number of days).
- 2Split the day equally, so "PM2.5, PM10" gives 0.5 to each.
- 3Report shared days as their own category.
Option 2 keeps shares summing to 100%, and it is the default here. Show option 1 in an appendix so a reader can see the choice does not drive the story.
Do you include days when few stations reported? Delhi's figure averages dozens of stations while smaller cities have a handful. Report the station fraction beside every count, and repeat the analysis restricted to days with at least half the city's stations reporting.
What do you do with absent days? Leave them out of the denominator, and report how many there were per city and season.
The core calculation on synthetic rows
The rows below are synthetic. They are shaped like parsed bulletin output, but none of the values are real CPCB results.
import pandas as pd
import numpy as np
rng = np.random.default_rng(3)
cities = ["Delhi", "Noida", "Ghaziabad", "Gurugram", "Faridabad"]
dates = pd.date_range("2025-01-01", "2025-12-31", freq="D")
choices = ["PM2.5", "PM10", "O3", "CO", "PM2.5, PM10", "PM10, O3"]
records = []
for city in cities:
for d in dates:
winter = d.month in (12, 1, 2, 10, 11)
p = [0.55, 0.2, 0.05, 0.05, 0.15, 0.0] if winter else [0.2, 0.4, 0.2, 0.05, 0.05, 0.1]
records.append({"city": city, "date": d, "pollutants": rng.choice(choices, p=p)})
df = pd.DataFrame(records)
season_of = {12: "Post-monsoon", 1: "Winter", 2: "Winter", 3: "Pre-monsoon", 4: "Pre-monsoon",
5: "Pre-monsoon", 6: "Monsoon", 7: "Monsoon", 8: "Monsoon", 9: "Monsoon",
10: "Post-monsoon", 11: "Post-monsoon"}
df["season"] = df["date"].dt.month.map(season_of)
# Split multi-pollutant days into fractional weights
df["pollutant"] = df["pollutants"].str.split(",")
df["weight"] = 1 / df["pollutant"].str.len()
long = df.explode("pollutant")
long["pollutant"] = long["pollutant"].str.strip()
share = (long.groupby(["city", "season", "pollutant"])["weight"].sum()
.div(df.groupby(["city", "season"]).size())
.mul(100).round(1)
.unstack("pollutant", fill_value=0))
print(share.loc["Noida"])
print("rows sum to 100:", bool(np.allclose(share.sum(axis=1), 100, atol=0.3)))
shared_days = df[df["pollutant"].str.len() > 1].groupby("city").size()
print(shared_days)The sum to 100 check is how you prove the fractional weighting worked. If it fails, a pollutant string had stray spacing or an empty element.
Comparing cities without overclaiming
Once you have shares, the tempting sentence is "Gurugram has an ozone problem". Test before you write it:
- Build a contingency table for one season: cities as rows, the count of days led by each pollutant as columns. Use whole days only, dropping shared days, so a chi-square test is valid.
- A chi-square test of independence tells you whether the mix differs across cities at all. It does not tell you which city differs, or why.
- Look at standardised residuals to see which cells drive the difference.
- Remember that the counts are not independent across days. Consecutive days are correlated, so p-values will look more confident than they should. Say so.
What to hand in
- Stacked bar chart: one bar per city per season, segments for each pollutant's share. Keep a consistent colour for each pollutant across all charts.
- Small multiples: one line per pollutant showing monthly share, one panel per city.
- Table: season × city with the leading pollutant, its share and the number of valid days.
- README sections:
- what "prominent" means
- season definition
- rule for shared days
- station-coverage restriction
- chi-square result with its caveat
- what the bulletin cannot tell you (pollutant sources, and anything below city level)
Likely interview questions
- 1Why is "prominent pollutant" not the same as "most abundant pollutant"?
- 2Your fractional weighting assumes both pollutants matter equally on a shared day. Defend it or offer an alternative.
- 3Why might ozone appear more often in some months?
- 4What would change if you used station-level data instead of city bulletins?
- 5How would you present this to a municipal officer with two minutes to spare?
Grouping, reshaping and honest categorical comparison are the daily work in our Data Analyst programme. If you are unsure whether you are ready for it, the skill check takes a few minutes.
Pankit Kumar has 10 years in Data Science & AI, building and shipping production systems in regulated pharma and clinical environments. He is a freelance trainer at Boston Institute of Analytics, AnalytixLabs and Scaler, and has taught this material to thousands of working professionals.
This article is part of our Data Analytics with Generative AI programme — 3–4 months. The full analyst stack — Excel, SQL, Power BI and Python pipelines — then a generative-AI layer you can prove is right.
Explore Data Analytics with Generative AI