AWS Builder Center
A Child Can't Fix Delhi's Air. An Early Warning Can Protect Them.

A Child Can't Fix Delhi's Air. An Early Warning Can Protect Them.

Children, older people and asthma patients can't fix the air, but they can be warned. How we built a smoke alert for North India on AWS Lambda, and checked it against NOAA.

Every October and November, the air over Delhi, Punjab and Haryana gets bad enough that schools cancel outdoor activities and parents check AQI apps every morning.
A lot of environmental apps are about what you can do to improve the air: carpool, plant trees, report someone burning waste. That matters. But think about a seven-year-old, an eighty-year-old, or someone with asthma. What can they contribute to cleaner air? Honestly, very little. And they're the ones the smoke hurts most.
So we didn't build an app that asks them to fix the air. We built one that warns them: when to keep the windows shut, when to skip the morning walk, when PE should move indoors.
That's what UrbanWise is for. Most AQI apps tell you a number. They don't tell you where the smoke came from, whether more is on the way, or what to actually do about it. We built UrbanWise for the Environmental Hacks 2026 (Track 1: Air) hackathon to answer those questions for the people who need the warning most.
This post covers how it works, how we run it on AWS, and the part I found most useful: comparing our model with NOAA's HYSPLIT and finding out where it was wrong.

What it does

  • Smoke Trail. Starting at your city, we step the wind backwards one hour at a time for 48 hours, then match that path against NASA FIRMS satellite fire detections. The result reads like "the air over New Delhi likely passed 8 fires near Palwal and Faridabad".
  • Incoming Smoke Alert. The same engine run forwards: take the last 36 hours of fires, group them into clusters, carry each one forward on forecast winds, and see if any path reaches your city in the next 48 hours.
  • School Mode. Checks the hourly forecast at assembly, PE and dismissal time and gives go / limit / cancel guidance based on India's CPCB AQI categories. It can draft a notice for parents in English, Hindi or Punjabi.
  • Ask UrbanWise. Questions by voice or text in three languages, answered from that city's data.
The Incoming Smoke Alert on 9 October: smoke may reach Jaipur, but the second weather model disagrees, so the app says to treat it as uncertain.
School Mode: go / limit / cancel for each school activity, from the hourly forecast.
One rule we stuck to: every number comes from our own code. Gemini only writes the words. The assistant gets the city's data as its only facts, and it has to say "estimate" and "may".
The data is all free: PM2.5 and PM10 from the CAMS model through Open-Meteo, winds at about 750 m (925 hPa) from Open-Meteo, and NASA FIRMS VIIRS fire detections.

The AWS setup

Everything is in one CloudFormation template and a deploy script.
  • AWS Lambda runs the Flask API through the Lambda Web Adapter, so the same app runs locally and on Lambda with no changes.
  • EventBridge Scheduler runs a second Lambda every hour. It fetches wind grids for eight main cities from two weather models, and works out each city's air, Smoke Trail and smoke alert.
  • Amazon S3 stores those grids and results, plus an hourly history of every alert and every NASA fire detection.
  • CloudWatch has the logs, and alarms if the pipeline fails, stops running, or the API starts throwing errors.
  • SSM Parameter Store holds the Gemini key, encrypted.
The pipeline exists mainly because of rate limits. A wind grid is 49 points, and Open-Meteo counts every point as a request. Two weather models for eight cities is 784 requests, which goes over their per-minute limit if every visitor triggers it. Now the pipeline refreshes the grids when they're more than 3 hours old, spaced out, and every API instance reads them from S3. Visitors to the main cities never hit Open-Meteo for winds at all.
The history in S3 turned out to be a bonus. Every alert is stored with a timestamp, so we can check later whether "smoke may arrive Sunday 7 pm" actually happened.

Checking our model against NOAA HYSPLIT

Our trajectory model is deliberately simple: one height, hourly steps, nearest point on a 2° grid. HYSPLIT is the trajectory model air quality researchers actually use. We ran HYSPLIT back-trajectories for Delhi, Ludhiana and Chandigarh, ran ours for the same start time, and measured how far apart the paths were.
On the same GFS winds as HYSPLIT, Delhi was 4 km off at 6 hours and 170 km at 24 hours. Ludhiana was fine for 6 hours, then went the wrong way (797 km off at 24 hours). Chandigarh was off from the start.
Delhi, 48 hours back from 10 October 12:00 UTC: our path (solid) vs NOAA HYSPLIT (dashed), both on GFS winds.
The cause was our grid. Taking the wind from the nearest point 200 km away is too coarse, and near the hills it's worse: Chandigarh's nearest grid point is up in Himachal, where the 925 hPa level is underground. In a quick test with a 1° grid, the 24-hour error dropped to 98 km for Delhi, 6 km for Ludhiana and 125 km for Chandigarh.
The bigger surprise was the weather model. With the app's default winds, our Delhi path went south-east while GFS sent the air west. Two good forecasts disagreed by almost 180°. So we ran the live Delhi alert on three weather models:
  • The smoke alert held up: all three said smoke from the same fires would arrive, within 11 hours of each other.
  • The Smoke Trail didn't: which fires it named changed completely between models (1 fire, 9 fires or 31 fires).
That changed the product. Every smoke alert now runs on a second weather model, and the app shows "2 models agree" or "models disagree, treat it as uncertain". The Smoke Trail says it's based on one model and that its district names are a rough guide. The full write-up, with the NOAA job numbers so anyone can repeat it, is in docs/VALIDATION.md.

Things that broke on Lambda

A few of these cost us time, so they might save you some:
  1. gunicorn and frozen instances. Lambda freezes an instance between requests. gunicorn's 2-second keep-alive closed the adapter's connection while the instance was frozen, and the next request failed with "connection reset". Its worker watchdog also counted the frozen time and killed the worker. The fix was --keep-alive 86400 --timeout 0; Lambda has its own timeout anyway.
  2. X-Forwarded-For on a function URL. It arrives exactly as the client sent it, so it can be faked. Our rate limiter was keyed on it. The real address is in the request context the Web Adapter passes along, and the adapter overwrites any fake one.
  3. S3 "access denied" for missing objects. Without s3:ListBucket, S3 answers "access denied" instead of "not found", which made a normal cache miss look like an error.
  4. CloudFront needs account verification. A new account can't create a distribution until AWS Support verifies it. We added a switch to the template so the Lambda serves the site itself until then.
  5. A bug only live data showed. The alert picked the 8 strongest fire clusters in all of South Asia before checking which could reach the city. On 9 October, fires in Odisha, Jharkhand and Myanmar took five of the eight places and pushed out the Punjab fires. We now rank only fires inside each city's wind grid. That change turned up a real alert for Jaipur that the old code had missed.
The main lesson for me: a simple model is fine, as long as you check it against a good one and are honest in the app about what you found. Saying "the models disagree" is more useful to a parent than a confident wrong answer.
A child, a grandparent or an asthma patient can't do much to make the air cleaner. But they can be warned in time, in their own language, and stay safe. That's what UrbanWise does.
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article