
A smoke forecast that waits for a teacher's tap
A smoke forecast for Punjab schools with no air monitor: Step Functions pauses for a teacher's tap, and the backtest is published, misses included.
Every October and November, farmers on both sides of the Punjab border burn rice stubble to clear their fields before the wheat goes in. The fires are small, but there are hundreds of them on any afternoon, and the smoke drifts over villages that have no way to measure it. Punjab has a handful of continuous air monitors, all in cities. A government school in Tarn Taran district is 38 km from the nearest one.
So the school finds out the air is bad the way everyone does: after the children have done assembly outside, played games at break, and sat with the windows open all morning.
For Environmental Hacks 2026 we built Smoke ETA, which tries to move that moment to the evening before. This post covers what it does, the one AWS pattern that made it a product rather than a dashboard, and the result we didn't want to publish but did.

The decision, not the map
The obvious build is a map of fires with a smoke plume on it. We built that too, and it's the least important part. The person who has to act is the school's in-charge, a teacher running the school, often without a head teacher. They don't need a map at 7 pm. They need to know what to change tomorrow.
So every evening, for every school, Smoke ETA produces one decision:
- a school-hours PM2.5 band, using the national CPCB breakpoints on the forecast mean between 08:30 and 14:30, shown beside the official 24-hour band and labelled "not AQI", because it isn't one;
- the day hour by hour, so you can see whether the worst air falls during school or after it;
- five actions: assembly, games, break, windows, and children with asthma. Each comes from published guidance (CPCB, the 2025 national school advisory, US EPA), not from a model.
Then a teacher has to agree.

The pause: Step Functions waitForTaskToken
Nothing goes to a parent until the in-charge approves. That rule shaped the whole architecture, and AWS Step Functions made it almost free.
Plain text
EventBridge Scheduler (19:00 IST)→ Step FunctionsForecast Lambda: fires + weather + drift + decisions → S3, DynamoDBNeedsApproval evening run? (the 06:00 re-score never asks)RequestApproval Lambda + .waitForTaskToken, timeout 12 hNotify Lambda: fill the template, send via SES
The approval state is a Lambda invocation with the
.waitForTaskToken suffix. Step Functions hands the Lambda a task token and then simply waits:JSON
"RequestApproval": {"Type": "Task","Resource": "arn:aws:states:::lambda:invoke.waitForTaskToken","Parameters": {"FunctionName": "${RequestApprovalFnArn}","Payload": { "taskToken.$": "$$.Task.Token", "decision_id.$": "$.forecast.demo_decision_id" }},"TimeoutSeconds": 43200,"Catch": [{ "ErrorEquals": ["States.Timeout"], "Next": "NoDecisionByMorning" }],"Next": "Notify"}
The Lambda stores the token in DynamoDB under a short random ID (with a TTL, so stale tokens clean themselves up) and emails the in-charge plain links: approve, or override to a different band. Each link hits an API Gateway HTTP API route, and a second Lambda resumes the workflow:
Python
tok = tokens.get_item(Key={"id": short_id}).get("Item")decisions.update_item(...) # status, approved_at, approved_by, final_band: the audit trailsfn.send_task_success(taskToken=tok["task_token"],output=json.dumps({"decision": decision, "final_band": band}))tokens.delete_item(Key={"id": short_id}) # one tap, once

That's the whole human-in-the-loop. No polling, no queue, no server waiting overnight. If nobody taps by morning, the state times out into a
Succeed state that sends nothing, which is exactly the behaviour we want. In the console you can open the execution and see it sitting on RequestApproval , which made it the easiest part of the system to explain.The rest is ordinary serverless. EventBridge Scheduler fires three runs a day. A 3 GB Lambda pulls NASA FIRMS fire detections from three satellites (both sides of the border), ECMWF weather and the CAMS PM2.5 background, and runs a puff-dispersion model on a 0.1° grid. Every run is written to S3 by run ID, so any past evening can be replayed exactly as the teacher saw it. SES sends the email and the parent notice. The web app is a static page on Amplify Hosting with an Amazon Location map, and everything is one SAM template.

Nothing a parent reads is model-written
We planned to draft the parent notice with a model on Amazon Bedrock. Our new account couldn't call Bedrock at all. Every request returned "Operation not allowed", and it took a support case to learn why.
So we wrote templates instead: one per band, in English, Hindi and Punjabi, filled with the school's name, the date and the actions. The Punjabi is labelled on the page as an unreviewed draft. By the end we thought this was the better design anyway. A notice that tells parents to keep a child with asthma indoors should be reviewable, predictable and identical for every school with the same band.
The result we almost didn't publish
A forecast that never checks itself is a guess with a nice interface. So Smoke ETA scores itself every evening against the monitors, next to three baselines: CAMS alone, persistence (tomorrow equals today), and simply saying "Moderate" every day.
Before trusting any of it, we ran a backtest on November 2025: three stations, 84 station-days, fitted on the first twenty days and tested on the last ten.
Method | Hit rate, all 84 days | Hold-out, 30 days |
|---|---|---|
Smoke ETA | 0.55 | 0.63 |
CAMS alone | 0.52 | 0.63 |
Persistence | 0.51 | 0.77 |
Always "Moderate" | 0.63 | 0.73 |
The fitted weight on our smoke-drift layer came out at zero. On this data, local transport added no verified skill over the CAMS background, which already accounts for fire emissions. And on the hold-out, a forecaster who always says "Moderate" beats us.

We changed the product rather than the story. The band is now CAMS plus a small per-station bias, the smoke map is labelled "experimental, not a concentration", and the page shows the table above in the scorecard section. A judge, a teacher or a health officer can see exactly how good it is.
What still holds is the part that doesn't depend on beating the baseline: a school with no monitor gets a concrete plan the evening before, a person decides, and every decision is logged and scored.
What we'd tell another team
- Start from who acts. The map is the obvious screen; the decision card is the one that changes what happens at school tomorrow.
-
waitForTaskTokenis the human-in-the-loop primitive. If a person has to approve something before your workflow continues, model it as a paused state, not as a database flag you poll.
- Store every run. S3 snapshots by run ID made replay, debugging and the backtest the same code path.
- Bundle your scientific Python. The AWS SDK for pandas managed layer crashed the runtime on datetime operations with our versions; bundling numpy and pandas in the package fixed it.
- Check what your account can actually do on day one. Bedrock, SES production access and Lambda function URLs were all restricted on our new account. We found out one at a time.
- Publish the baseline. If a constant beats you, say so on the page. It's the most credible thing in our submission.
Smoke ETA has run on schedule every day since October 9 for 87 schools and six monitoring stations.
Built for Environmental Hacks 2026 (WeMakeDevs × AWS), Air track.
Enjoyed reading this content? Let the author know!
Your likes, comments, shares, and saves help creators reach more builders.
Loading recommendations
Loading article