Data ScienceWorking and learning in Delhi NCR

Computing AQI from pollutant data: an NCR portfolio project

PK
Pankit Kumar
Sr. Data Scientist at Parexel (a Goldman Sachs–backed company) · 27 September 2026 · 5 min read
In this article (6 sections)

Related programme: the Data Science course (6 months; ₹1,30,000 (inclusive of GST) · EMI from ₹5,000/month).

The short answer

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 categoryAQI rangePM2.5 (24-hr)PM10 (24-hr)
Good0–500–300–50
Satisfactory51–10031–6051–100
Moderate101–20061–90101–250
Poor201–30091–120251–350
Very Poor301–400121–250351–430
Severe401–500above 250above 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:

text
I = (I_hi - I_lo) / (C_hi - C_lo) * (C - C_lo) + I_lo

Here 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.

python
# (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. 1aqi.py with the functions and tests above, and a comment citing the CPCB table.
  2. 2A notebook that loads real station data you downloaded yourself, with the download date and source stated.
  3. 3One chart: daily PM2.5 sub-index for one winter at one station, with band colours.
  4. 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.

PK
Pankit Kumar
Lead Instructor, NeuraPath Academy

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
Counselling is free · no obligation

Not sure which programme fits?

Tell us your background and we will map it to the right entry point — including saying so when a cheaper programme is the better fit. A counsellor replies within one working day.