Computing AQI from pollutant data: an NCR portfolio project
In this article (6 sections)
Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).
India's National Air Quality Index converts each pollutant's concentration into a sub-index using fixed breakpoints published by the Central Pollution Control Board (CPCB). It then reports the worst sub-index as the AQI. You can reproduce the PM2.5 and PM10 part in about thirty lines of Python using linear interpolation between breakpoints. That makes it a good data science portfolio project for anyone in Delhi NCR: the formula is public, the data is local, and every winter gives your results a real-world check.
The breakpoint table you are implementing
The National AQI was launched on 17 September 2014. It covers eight pollutants; this project uses the two that drive most NCR winter readings. The 24-hour average concentrations (µg/m³) map to AQI bands like this:
| AQI category | AQI range | PM2.5 (24-hr) | PM10 (24-hr) |
|---|---|---|---|
| Good | 0–50 | 0–30 | 0–50 |
| Satisfactory | 51–100 | 31–60 | 51–100 |
| Moderate | 101–200 | 61–90 | 101–250 |
| Poor | 201–300 | 91–120 | 251–350 |
| Very Poor | 301–400 | 121–250 | 351–430 |
| Severe | 401–500 | above 250 | above 430 |
Check the current table on the CPCB air quality portal before you rely on it, and cite it in your README. Note that the PM ranges are not evenly spaced: PM10's Moderate band spans 150 µg/m³, while PM2.5's Poor band spans only 30. That unevenness is why the calculation is interesting, not just a lookup.
The interpolation formula
Within a band, the sub-index is a straight line between the band's endpoints:
I = (I_hi - I_lo) / (C_hi - C_lo) * (C - C_lo) + I_loHere C is the concentration, C_lo/C_hi are the band's concentration breakpoints, and I_lo/I_hi are the matching AQI values. Take PM2.5 at 45 µg/m³: it falls in 31–60, which maps to 51–100, so I = 49/29 × 14 + 51 = 74.7. That is Satisfactory.
The top band has no published upper concentration in the table above. Rather than inventing one, the code below reports any value above the last breakpoint as "Severe (401+)" and does not extrapolate a number. Say so in your write-up; it is the kind of decision an interviewer will ask about.
A runnable implementation with tests
The readings below are synthetic, chosen to exercise each band edge. They are not real station data.
# (C_lo, C_hi, I_lo, I_hi) for each band; concentrations in µg/m³, 24-hour averages.
BREAKPOINTS = {
"PM2.5": [(0, 30, 0, 50), (31, 60, 51, 100), (61, 90, 101, 200),
(91, 120, 201, 300), (121, 250, 301, 400)],
"PM10": [(0, 50, 0, 50), (51, 100, 51, 100), (101, 250, 101, 200),
(251, 350, 201, 300), (351, 430, 301, 400)],
}
CATEGORIES = [(50, "Good"), (100, "Satisfactory"), (200, "Moderate"),
(300, "Poor"), (400, "Very Poor"), (500, "Severe")]
def sub_index(pollutant, concentration):
"""Return (index or None, label). None means above the last breakpoint."""
c = round(concentration) # breakpoints are whole numbers
for c_lo, c_hi, i_lo, i_hi in BREAKPOINTS[pollutant]:
if c_lo <= c <= c_hi:
value = (i_hi - i_lo) / (c_hi - c_lo) * (c - c_lo) + i_lo
return round(value), category(round(value))
return None, "Severe (401+)"
def category(index):
for upper, label in CATEGORIES:
if index <= upper:
return label
return "Severe"
# Tests at known points: band edges must land exactly on AQI edges.
assert sub_index("PM2.5", 30) == (50, "Good")
assert sub_index("PM2.5", 31) == (51, "Satisfactory")
assert sub_index("PM2.5", 45) == (75, "Satisfactory")
assert sub_index("PM10", 250) == (200, "Moderate")
assert sub_index("PM10", 251) == (201, "Poor")
assert sub_index("PM2.5", 300) == (None, "Severe (401+)")
# Synthetic 24-hour averages for three imaginary monitoring stations.
readings = {
"Station X (synthetic)": {"PM2.5": 78, "PM10": 190},
"Station Y (synthetic)": {"PM2.5": 132, "PM10": 240},
"Station Z (synthetic)": {"PM2.5": 41, "PM10": 305},
}
for station, values in readings.items():
subs = {p: sub_index(p, c) for p, c in values.items()}
worst = max(subs, key=lambda p: subs[p][0] if subs[p][0] is not None else 501)
idx, label = subs[worst]
shown = idx if idx is not None else "401+"
print(f"{station:<24} PM2.5={subs['PM2.5'][0]:>4} PM10={subs['PM10'][0]:>4} "
f"-> AQI {shown} {label} (driven by {worst})")On this synthetic data, Station Y is driven by PM2.5 (sub-index 309, Very Poor), even though its PM10 reading looks larger in raw µg/m³. Station Z is the reverse: PM10 at 305 µg/m³ gives a Poor sub-index of 255, while its PM2.5 is only Satisfactory. The finding that "the bigger number is not the worse pollutant" is the most useful sentence in this project, because it is what people get wrong when they read raw readings.
Where your version differs from the official index
Your implementation is a teaching reconstruction, and the write-up should say how it differs from what CPCB publishes:
- Pollutant coverage. The official AQI considers eight pollutants and, per CPCB's method, is computed only when enough of them have data, including at least one of PM2.5 or PM10. Yours uses two.
- Averaging and completeness. PM sub-indices use 24-hour averages, and CPCB applies a minimum data-completeness rule before an average counts. A station with a gap-filled day should not be treated as an equal observation.
- Rounding. Rounding the input, the output, or both changes results by one point at band edges. State your choice.
- Source of truth. For anything a reader might act on, point them to CPCB's published value, not your recomputation.
How this connects to what NCR residents actually see
Delhi NCR's Graded Response Action Plan ties its stages to Delhi's AQI using the same category names: Stage I at Poor (201–300), Stage II at Very Poor (301–400), Stage III at Severe (401–450) and Stage IV at Severe+ (above 450), per the Commission for Air Quality Management's order of 21 November 2025. That link gives your project a natural second question: on how many days in a season would a given station's readings have fallen into each GRAP band? Our guide to GRAP stages explains what each stage changes for offices and schools.
Turning it into a portfolio entry
Structure the repository so a reviewer can follow it in order:
- 1
aqi.pywith the functions and tests above, and a comment citing the CPCB table. - 2A notebook that loads real station data you downloaded yourself, with the download date and source stated.
- 3One chart: daily PM2.5 sub-index for one winter at one station, with band colours.
- 4A README with the question, the method, the four "differs from official" notes, and one paragraph of findings.
This exercise covers what a first data science role asks for day to day: implement a published method exactly, test it at the edges, and explain the gap between your number and the official one. Across six months, the Data Science programme builds that implement-test-explain habit through live online classes and projects. If you would rather start with transport data, see the Delhi bus GTFS project.
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 Science programme — 6 months. From data foundations to machine learning, deep learning and deployment.
Explore Data Science