
SaansSetu: One Alert Before School
A city’s AQI badge is a 24-hour average; a child’s walk to school is half an hour. SaansSetu forecasts the morning’s air and sends a family one message, which road to take and when to leave, built on AWS Lambda, EventBridge, DynamoDB and Amplify.
We set out to put a better air-quality number in front of parents. We ended up shipping one sentence.
On 7 December 2024, the 24-hour PM2.5 average at Asansol's monitoring station was 83 µg/m³. That is Moderate, and a 24-hour average is what an AQI badge shows. A parent glancing at a phone would have seen nothing to act on.
Between 5 and 6 that morning, the same station read 113.6, which is Poor. In the 7, 8 and 9 AM hours it read 127, 130 and 124. Our estimate for the main road to school peaked at 150, Very Poor, at 7:45.
Same monitor, same day. The badge averages 24 hours. A walk to school takes half an hour.
The short version. SaansSetu ("breath bridge") forecasts the next four hours at a town's monitoring station, estimates what that means along each walking route, and sends a family at most one message a morning: which road to take and when to leave. It runs on AWS Lambda, EventBridge, DynamoDB and Amplify. The deployed version replays real mornings from December 2024, and says so on every screen. We are team Axiom: Anirban on the model and backend, Manideep on the dashboard. We built it for Environmental Hacks.
A note before the story. The organisers allowed data work and model training before the event, so parts two, three, four and six happened in the week before it. The routes, the alerts, the dashboard and everything on AWS were built in the four days. The README lists which is which.
Part one: a number is not a decision
The first idea was the obvious one: street-level AQI. Give parents a better number than the city badge.
One question ended that plan. What does a parent do with a number at 6:30 in the morning? The child still has to get to school. A family can change two things: the road, and the time they leave.
So the output became a sentence. This is the one the system produced for that December morning:
text
SB gorai road will likely turn Very Poor around 7:45. Take Dharmapally Main Rd, and leave by 7:00.SB gorai road पर लगभग 7:45 बजे हवा बहुत खराब होने की संभावना है। Dharmapally Main Rd से जाएँ, और 7:00 तक निकलें।
The wording is a fixed Hindi and English template. No model writes it, so there is nothing in it that we did not put there.
It goes out at most once a morning, and only when two things are true. The better option must cut the estimated dose (concentration × minutes along the walk) by at least 20%. And the chance of Poor air during school hours must be at least 60%. On that morning the figures were 30% and 99%. On a morning when the advice would change nothing, the system sends nothing.
The school gets its own line: move PT and assembly indoors, or carry on as usual.
Part two: the town we wanted has no monitor
We wanted Jamshedpur. The archive we used has no continuous station for it.
That archive is the India Air Quality Database from XKDR Forum: hourly readings from CPCB's continuous monitoring stations. We scouted other industrial towns in it, and set one rule before training anything. A town ships only if its model beats "repeat the last reading" for a 6 AM alert covering 7 to 9 AM. That baseline sounds lazy. One or two hours ahead, it is hard to beat.
Bhilai failed. The first model was 9% worse than the baseline there. And at its station, not one hour between 7 and 9 AM reached Poor in the winter we held back for testing, so there was nothing to alert on anyway. Durgapur, with under 3,000 usable training hours, did not make it either.
Four towns passed: Asansol, Rourkela, Visakhapatnam and Angul. Asansol got the mapped routes. The other three get the forecast and the school flag.
Part three: the model that could not imagine a worse morning
Bhilai's failure taught us more than the four passes did. At other times of day, its station recorded 95 Poor hours in that test winter. The model predicted none of them.
The reason is simple once you see it. We were predicting the PM2.5 value itself with a tree model, and a tree model can never output a number higher than the ones it was trained on. Bhilai's training years were cleaner than its test winter. The worst hours sat above the model's ceiling.
That is a problem for every town, not only Bhilai. The worst morning of a new winter is the one an alert exists for.
The fix was a change of target: predict the change from the latest reading, not the level. A change has no ceiling.
The models are LightGBM. For each town and each hour ahead there is a "likely" model (the median), a "bad case" model, and chance models for the Moderate, Poor and Very Poor bands.
The bad case had its own surprise. We first trained it as a 90th percentile. On Asansol's test winter, the real value stayed under that line only 82% of the time. We moved it to the 95th percentile. In the case we deploy, it holds 85% of the time.
So nowhere do we call it a 95% bound. It is the bad case, and we report how often it held.
Part four: the timestamp that flattered us
The day before the event, we went looking for a live feed for our four stations. OpenAQ carries all of them, but its newest readings were five days old. We did not get a feed. We got a bug report on our own scoring.
Comparing that feed with our archive hour by hour, we saw that a reading stamped 06:00 is the average from 06:00 to 07:00. It does not exist until 07:00.
Our backtest had been scoring a 6 AM alert as if it already had that reading. It cannot. An alert sent at 6 AM can only use the reading for 5 to 6 AM.
Rescoring with that rule cost us accuracy. At Asansol, the improvement over the baseline fell from 23.0% to 20.2%. We quote the lower number, because it describes the alert a family can receive in time.
Here is that case for every town: error for 7 to 9 AM, from a 6 AM alert that uses the 5 to 6 AM reading and no weather forecast, on a winter the models never saw.
Town (station) | Training hours | Error against "repeat the last reading" |
|---|---|---|
Asansol (Court Area) | 39,017 | 20.2% lower |
Rourkela (Raghunathpali) | 15,032 | 24.9% lower |
Visakhapatnam (GVM Corporation) | 40,187 | 17.6% lower |
Angul (Hakimapada) | 7,763 | 29.8% lower |
How to read the table:
- For Asansol, 20.2% means an average miss of 13.3 µg/m³ against 16.7 for the baseline, across 275 scored hours. The 95% range on it is roughly 11% to 29%.
- Angul has under a year of training data. Treat its number as provisional.
- These are without a weather forecast, which is what we deploy. With a perfect forecast for the target hour, each figure would be up to 3 points better.
Part five: the street is an estimate, and the map changed our story
The station forecast is the only trained model in the system. Getting from one station to a street is a formula, and its settings sit in each town's file where anyone can read them.
The formula takes the station forecast and adds two bumps. One is for rush-hour traffic, bigger on main roads. The other is for being downwind of the industrial area, fading with distance. Each route is cut into segments of about 100 m. For every route and every leaving time, we add up concentration × minutes along the walk and pick the lowest total.
Nobody measured those streets. The map legend says so in one line: "Street values are estimates from the station forecast, not measurements."
During the event we swapped our placeholder routes for real walking paths from OpenStreetMap, between a housing complex and a school we picked off the map. Neither has any part in this project.
Real geometry changed what the result means. We had pictured a plume from the industrial area drifting across the walk. On the map, that area is a little over 3 km from the home we chose, and our downwind term fades within about a kilometre. For this family it adds almost nothing.
The 30% lower dose in the alert comes from plainer things: a shorter, quieter road (27% on its own) and leaving 20 minutes earlier. By the estimate, both roads reach Very Poor that morning. One peaks at 150 and the other at 130. The message does not promise clean air. It picks the less bad of the options a family has.
Two cautions. Band names like Poor and Very Poor are officially defined on 24-hour averages, and we apply them to single hours as a guide. And we make no health claims: the tool compares estimated exposure between options, and does not say what that exposure does to a child.
Part six: the evidence that would not name a culprit
Moving a child around bad air does nothing about the air. So we added a count. Over every winter morning in the archive, how often did each wind direction come with one of the dirtiest hours?
It is plain counting, with no model. "Dirtiest" is judged against the same month and the same hour, so a dirty month's usual wind does not pass for a source. Hours of nearly still air get no direction at all. And a direction only "stands out" if it stays on top when the days are resampled 400 times.
The plan was a clear arrow for each town. This is what came back:
Town | What the count says |
|---|---|
Visakhapatnam | Wind from the west to north-west came with 13% of the dirtiest hours but blows only 8% of the time. That is 1.75 times as often (range 1.34 to 2.16), and the same sector came out on top in every resample. |
Asansol | No direction stands out. 25% of the dirtiest hours came in nearly still air. |
Rourkela | No direction stands out. 62% of the dirtiest hours came in nearly still air. |
Angul | No direction stands out. 58% of the dirtiest hours came in nearly still air. |
In three towns out of four there is no arrow. At Rourkela and Angul, most of the dirtiest hours came when the air was barely moving and pollution stayed where it was made. That is a duller finding than the one we hoped to show, and it is the one the data supports.
Even where a direction stands out, the wording is "a direction, not proof". The wind is area wind from a weather reanalysis, not an instrument at the station. No company is named anywhere, and the map pin reads "Industrial area".
Two things here are unfinished. The API serves this table, but the dashboard does not draw it yet. And the agent we had planned, to draft a brief for a school from it, was never built. The verdict is a fixed sentence per case.
Part seven: the afternoon AWS said "up to two days"
Our rule from the last hackathon was to deploy to the live URL on day one. We deployed on the last afternoon.
By then the backend had 126 passing tests and had never touched a real AWS account. The account was new, opened for this event. Here is what it said back, in order.
- Docker was not installed on the laptop, so the runner image could not be built there.
- The deploy script stopped on
ecr:DescribeRepositories: access denied.
- Attaching a wider policy to the user failed too:
LimitExceeded: Cannot exceed quota for PoliciesPerUser: 10. An inline policy got us past it.
- CodeBuild would not start, and CloudShell answered: "Your account verification is in progress. This may take up to two days for new accounts."
The event ended that night.
What worked was already in front of us. The deploy script had put the build files in S3. A short GitHub Actions workflow fetches them and runs the same build script: build the image, do one real run for every town inside it, and push to ECR only if every town passes.
YAML
steps:- name: Fetch the build files deploy_aws.py uploadedrun: aws s3 cp s3://<build-bucket>/runner-context/context.zip ctx.zip- name: Build, test one tick inside the image, pushrun: |unzip -q ctx.zip -d ctxbash ctx/build.sh
The workflow finished in 1 minute 21 seconds. The deploy script picked up the image and created the runner, and the first run on AWS came back with all four towns ok. EventBridge took over from there. The log shows scheduled runs at 18:29, 18:44 and 18:59 IST.
In between, at 18:45, we started one run by hand with the clock set to 6 AM. The alert from part one arrived on a phone, sent by the Lambda and not by a laptop. We submitted at 9:57 that night.
What it runs on

The left column is how data and models become a runnable image. The dashed box is what runs on AWS every 15 minutes.
Service | What it does here |
|---|---|
EventBridge | Calls the runner every 15 minutes. |
Lambda (container image) | The runner: one run for every town. Code, models, town files and replay data are in the image. |
DynamoDB | One table for everything: readings, runs, each morning's decision, alerts sent, subscribers, accuracy scores. |
Lambda (zip) with a Function URL | The API the dashboard reads. It answers from the table and computes nothing. |
Amplify Hosting | Serves the dashboard: React, a Leaflet map and a time slider. |
ECR and S3 | The runner image, and the build files it is made from. |
Three choices we would make again.
Two Lambdas, not one. The runner carries LightGBM, pandas and the models, so it is a container image. The API only reads the table, so it is a small zip. A parent opening the dashboard never waits for a model to load.
One table. Six kinds of record share one DynamoDB table. The "one alert per morning" rule lives there too: the first run that sends claims the day, so two overlapping runs cannot both send.
A build that tests itself. Before the image is pushed, the pipeline runs once for every town inside it. If a model fails to load or a town fails, nothing is pushed.
Alerts go out on Telegram. SMS to Indian numbers needs TRAI DLT registration, which is the production path and not something a four-day event allows.
We are not quoting a cost. The account is days old and there is no real bill to point at.
Three things we would tell ourselves on day one
- Score the alert you can send. Our best number used a reading that did not exist at alert time. The number we can stand behind is about three points lower, and it is the only one we quote.
- Put the labels on the screen. REPLAY. "Estimates, not measurements." "Bad case." A caveat in the README does not help the person looking at the map.
- Meet your AWS account on day one. A new account can sit in verification for up to two days, and that closed CodeBuild and CloudShell to us. We found out with hours left. Have a second way to build your image before you need it.
The replay has about two weeks of data. After that, SaansSetu needs the one thing we could not find: a live feed for these stations. The adapter is waiting for it.
SaansSetu was built for Environmental Hacks, event 02 of the AWS × WeMakeDevs Bharat Builds Tour (8 to 11 October 2026), Air track, by team Axiom. The dashboard is live at main.dad6cxz1027iv.amplifyapp.com and the code is at github.com/anirban-3204/SaansSetu . Pollutant readings: CPCB CAAQM network, through the India Air Quality Database by XKDR Forum (CC BY 4.0). Weather: Open-Meteo (CC BY 4.0). Map and routes: © OpenStreetMap contributors.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article