ClearHour: telling every Delhi school its cleanest hour, on AWS
How I built a serverless PM2.5 forecast that sends each Delhi school principal one WhatsApp message before school, naming the cleanest hour for assembly and PE. EventBridge Scheduler, Step Functions, Lambda, DynamoDB and AWS End User Messaging Social, plus a walk-forward backtest and the gotchas I hit.
TL;DR
- On Delhi's winter mornings, PM2.5 is near its daily peak just as schools hold assembly and morning PE. On school days in November and December 2025, PM2.5 at 1 PM averaged 31% less than at 8 AM across 48 Delhi NCR monitors.
- ClearHour forecasts PM2.5 hour by hour for 1,110 Delhi schools at 5:30 IST and sends each subscribed principal one WhatsApp message: the cleanest school hour, whether to hold assembly indoors, or that there is no safe window today.
- It is fully serverless in ap-south-1 (Mumbai), deployed from one AWS SAM template. In a walk-forward backtest, outdoor time at the chosen hour averaged 25% less PM2.5 than 8 AM assembly.
- Demo video: https://youtu.be/SEHrDsA\_eLM · Live dashboard: https://rshczjirhol3xg3tzbuwhv4txi0ooeac.lambda-url.ap-south-1.on.aws/ · Code: https://github.com/Shreyash-Sachit-Sahu/ClearHour
The problem: assembly happens at the worst hour
Delhi's winter air is at its worst overnight and in the early morning, when a shallow, cold boundary layer traps pollution near the ground. As the sun warms the ground, the layer deepens and PM2.5 falls through the afternoon. School timetables run the other way: assembly and morning PE happen right at the start of the day.
When the air turns severe, the main lever today is a GRAP order that moves classes online or to hybrid. That is a big, city-wide switch. I wanted the step in between: on any school morning, tell each principal which hour is cleanest and what to keep indoors, specific to their school and their nearest monitors.
What a principal gets
Every school morning, before 6:30, the principal gets one WhatsApp message in Hindi or English:
ClearHour air update for Kendriya Vidyalaya, RK Puram Sector 2 on Thu 13 Nov (replay): Hold assembly indoors. Keep outdoor time short. Cleanest hour for outdoor activity: 1:00–2:00 PM. Reply 1 once you have moved outdoor activities.
Replying 1 marks the school as acted on, and a public dashboard shows every school's call and which demo schools have acted.
In the demo recording I replayed the smoggy morning of 13 November 2025 through the real pipeline. The alert reached my phone 14 seconds after the Send step called the API, and the dashboard showed Acted 14 seconds after my reply.
Architecture
Everything runs in ap-south-1 (Mumbai) and deploys from one AWS SAM template.
Service | What it does in ClearHour |
|---|---|
Amazon EventBridge Scheduler | Starts the daily run at 05:30 IST and the hourly ingest |
AWS Step Functions | Runs ingest → forecast → decide → one send per school, with retries |
AWS Lambda (Python 3.13) | Six functions; the forecast is a container image carrying LightGBM; the web function serves the dashboard from a function URL |
Amazon DynamoDB | Hourly readings, school profiles and each alert's status, in one table with TTL |
Amazon S3 | Models, the PM2.5 archive, each run's forecast and decisions, the dashboard's data |
AWS End User Messaging Social | Sends the WhatsApp template and receives the reply |
Amazon SNS | Carries reply events to the Inbound function |
Amazon Location Service | The dashboard's Monochrome basemap |
AWS SAM / CloudFormation, Amazon ECR | The whole stack from one template.yaml, and the forecast image |
Here are the design decisions that mattered most.
1. A schedule that thinks in IST
India is UTC+5:30, so a UTC cron for "5:30 every morning" is an easy bug. EventBridge Scheduler takes a timezone, and SAM exposes it directly on the state machine's event:
YAML
Events:Morning:Type: ScheduleV2Properties:ScheduleExpression: cron(30 5 * * ? *)ScheduleExpressionTimezone: Asia/KolkataInput: '{"source": "live"}'
The same half-hour offset bit me in the data too (more on that below).
2. Step Functions runs the morning
The daily run is a Standard workflow:
- A Choice state sends live runs through Ingest and lets archive replays skip it. That is how I replay any past morning for testing and for the demo.
- Ingest pulls the newest readings. Its failure is caught, so a flaky upstream API never stops the morning's alerts.
- Forecast retries any error, 60 s and then 120 s apart, because an upstream weather API occasionally returns a 503.
- Decide applies the rule for all 1,110 schools and returns the list of alerts to send.
- A Map state sends one WhatsApp message per subscribed school:
JSON
"SendAlerts": {"Type": "Map","ItemsPath": "$.alerts","MaxConcurrency": 5,"ItemProcessor": { "ProcessorConfig": { "Mode": "INLINE" }, "StartAt": "Send", "States": { "...": "..." } },"ResultPath": "$.sent","End": true}
The execution graph was the best debugging tool I had, and it is also how the demo video shows AWS doing the work live.
3. LightGBM in a Lambda container image
LightGBM needs the OpenMP runtime, so the forecast runs as a container image on the official Lambda base image (2 GB memory, 300 s timeout):
dockerfile
FROM public.ecr.aws/lambda/python:3.13# LightGBM needs the OpenMP runtime; the base image is Amazon Linux 2023 minimalRUN dnf install -y libgomp && dnf clean allCOPY functions/forecast/requirements.txt ${LAMBDA_TASK_ROOT}/RUN pip install --no-cache-dir -r ${LAMBDA_TASK_ROOT}/requirements.txtCOPY src/clearhour ${LAMBDA_TASK_ROOT}/clearhourCMD ["clearhour.handlers.forecast.handler"]
sam build builds the image and sam deploy pushes it to ECR. No layer-size juggling, and the same Python 3.13 locally and in Lambda. Replaying a morning took about 6 s end to end with a cold forecast container and about 2.5 s warm.4. Sending exactly once with DynamoDB conditional writes
A principal should never get the same alert twice, even if Step Functions retries a Send or I re-run the morning. Each school's alert for the day is a DynamoDB item that moves
pending → sending → sent , and every move is a conditional write:Python
def move_alert(school_id, day, from_status, to_status, **attrs) -> bool:"""Atomically moves an alert between statuses; False if it wasn't in from_status."""try:table().update_item(Key={"pk": f"SCHOOL#{school_id}", "sk": f"ALERT#{day}"},UpdateExpression="SET #s = :to" + "".join(f", #{k} = :{k}" for k in attrs),ConditionExpression="#s = :from",ExpressionAttributeNames={"#s": "status", **{f"#{k}": k for k in attrs}},ExpressionAttributeValues={":from": from_status, ":to": to_status, **{f":{k}": v for k, v in attrs.items()}},)return Trueexcept ClientError as e:if e.response["Error"]["Code"] == "ConditionalCheckFailedException":return Falseraise
The Send function claims the alert (
pending → sending ) before calling WhatsApp. If the call fails, it moves the alert back to pending and re-raises, so Step Functions retries it; a duplicate invocation finds the alert already claimed and skips. No queue, no locks.5. WhatsApp through AWS End User Messaging Social
End User Messaging Social puts WhatsApp inside AWS: IAM permissions instead of a third-party provider's tokens, and
SendWhatsAppMessage takes Meta's message JSON as is. The alert is an approved template with four variables (school, day, advice, Clear Hour), in English and Hindi.Replies arrive as events on an SNS topic. A small Inbound Lambda parses them; a "1" (or "१", or "done") marks the alert as acted on, refreshes the dashboard data and sends a thank-you.
One gotcha cost me a debugging session. The API wants the recipient in E.164 with a leading
+ , while the numbers in WhatsApp's own events come without it:Python
# EUM rejects bare digits ("Invalid destination phone number"); WhatsApp sends, and we store, no "+"to = str(payload.get("to", ""))payload = {**payload, "to": to if to.startswith("+") else "+" + to}resp = boto3.client("socialmessaging").send_whatsapp_message(originationPhoneNumberId=os.environ["WA_PHONE_NUMBER_ID"],message=json.dumps(payload).encode(),metaApiVersion=version,)
Template approval takes time, so the Send function has a fallback that sends the template's exact words as plain text. WhatsApp allows that only within 24 hours of the recipient's last message, which was enough to test end to end while the templates were in review.
6. A dashboard with no web servers
The dashboard is a single static page (MapLibre, Hindi and English), served together with its JSON by a Lambda function URL. The S3 bucket stays fully private, with no CloudFront and no extra hosting setup outside the template. The basemap is Amazon Location Service's Monochrome style through an API key restricted to map tiles and to the dashboard's own address, so it is safe to ship in a public page.
The data and the model
History comes from the Registry of Open Data on AWS. OpenAQ publishes its archive in a public S3 bucket, one gzipped CSV per location per day, and boto3 reads it anonymously:
Python
_s3 = boto3.client("s3",region_name="us-east-1",config=Config(signature_version=UNSIGNED, retries={"max_attempts": 10, "mode": "adaptive"}),)def list_month_keys(location_id: int, year: int, month: int) -> list[str]:"""Every daily file for one location-month. Lists the prefix rather than guessing file names."""keys, kwargs = [], {"Bucket": "openaq-data-archive","Prefix": f"records/csv.gz/locationid={location_id}/year={year}/month={month:02d}/"}while True:resp = _s3.list_objects_v2(**kwargs)keys += [o["Key"] for o in resp.get("Contents", []) if o["Key"].endswith(".csv.gz")]if not resp.get("IsTruncated"):return keyskwargs["ContinuationToken"] = resp["NextContinuationToken"]
Three data checks mattered more than any model tuning:
- Which end of the hour is the timestamp? I checked each provider against the live API instead of assuming. CPCB readings are stamped at the end of their period (84 of 84 rows matched); the US Embassy monitor stamps the start. Mixing them would shift one source by an hour.
- Bin on the IST clock. The archive is 15-minute data. Flooring to UTC hours put 08:30–09:30 IST into the "8 AM" bin, because IST is UTC+5:30.
- Use the sensor that is still reporting. When a location lists several PM2.5 sensors, taking the first one silently pointed 18 of 46 stations at retired sensors.
The model is one LightGBM regressor across all monitors, with the station as a categorical feature. It predicts each school hour (8 AM to 1 PM) from the latest readings, the same hour yesterday, the CAMS global forecast for that hour, weather including boundary-layer height, and the lead time. Each school blends its nearest monitors (up to three within 10 km, weighted by distance).
OpenAQ often receives Delhi's readings late, in batches. So there are two models: a fresh one for mornings that have the night's readings, and a late-data one trained with 6 to 36 hours of readings hidden. On late mornings the Clear Hour defaults to 1 PM, which beat the late model's own pick at every delay I tested.
The rule follows India's National AQI bands for PM2.5. If every school hour is at or below 90 µg/m³, the air is fine. If every hour is above 250, there is no safe window. Otherwise the cleanest hour is the Clear Hour, assembly moves indoors when the latest reading is above 120, and the alert adds "keep outdoor time short" if even the Clear Hour is above 120. Those bands are defined for 24-hour averages, so ClearHour applies them hourly as a guide.
Does it work?
I tested out of sample with a walk-forward backtest over winter 2025–26 (10 Nov to 1 Feb): each week was predicted by a model trained only on earlier days, across 46 monitors and 1,956 monitor-days.
Where outdoor time goes | Average PM2.5 (µg/m³) | Less than 8 AM, on average |
|---|---|---|
8 AM assembly, today's habit | 202 | – |
The hour CAMS forecasts as cleanest | 156 | 22.8% |
Always 1 PM | 155 | 22.9% |
ClearHour's Clear Hour | 151 | 25.0% |
The cleanest hour in hindsight | 133 | 33.9% |
The forecast's mean absolute error was 49.1 µg/m³, against 59.5 for carrying the last reading forward and 94.0 for the raw CAMS forecast.
Caveats:
- Most of the gain comes from the afternoon itself. Always choosing 1 PM gets 22.9%. ClearHour leads on the average, which weights the smoggiest mornings most, and it is dirtier than 8 AM on fewer days. On the median day, though, a fixed 1 PM cuts more (20.9% against 17.6%).
- Assembly is called from the latest reading, not the forecast. That reading got "hold assembly indoors" right on 88.7% of mornings, against 86.2% for the 8 AM forecast.
- These are differences in concentration, not health outcomes.
Lessons learned
- Check every timestamp convention against a second source. The hour-ending vs hour-starting question, and IST's half-hour offset, would each have quietly broken the "8 AM" number the whole product rests on.
- Late data is the real enemy, not model error. The late-data model handles mornings when every monitor is late. When only some are late, the run currently covers only the schools near on-time monitors; judging staleness per monitor is the next fix.
- Idempotency is cheap in DynamoDB. A status attribute and conditional writes made the whole send path safe to retry or re-run.
-
sam deploykeeps a parameter's previous value if you leave it out of--parameter-overrides. Clearing a value in my config didn't clear it in the stack, so I now set every value explicitly.
- Replays make demos honest. Because the state machine can replay any past morning through the real pipeline, the demo shows a real alert, a real reply and a real Step Functions run, not a mock-up.
What's next
- Judge staleness per monitor, so a partly late morning still covers every school.
- A direct CPCB feed to remove OpenAQ's delay, and the UDISE+ school register for complete coverage.
- A pilot with real schools: today one demo school is set up, and its alerts go to my own phone.
Credits
Air-quality data from CPCB and DPCC monitors via OpenAQ . Weather and CAMS forecasts from Open-Meteo (contains modified Copernicus Atmosphere Monitoring Service information). Schools © OpenStreetMap contributors (ODbL). Basemap by Amazon Location Service.
I built ClearHour solo for Environmental Hacks (Bharat Builds Tour, WeMakeDevs × AWS), Air track. I used Claude for planning and design briefs, Claude Code for implementation, and Amazon Polly's generative voice for the video's narration. Every decision, including the ones that went against me, is logged in the repo's DECISIONS.md .
Tags: aws, serverless, step-functions, lambda, dynamodb, whatsapp, air-quality, machine-learning, hackathon
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article