
Paani Kahan: building a street-level flood forecast for Indian cities in a weekend
Every monsoon the same streets flood, but nobody can tell you which ones will flood today. How I built an app that forecasts water depth on every street six hours ahead, warns you before your ride home goes under, and hands you a dry route in Google Maps.
Paani Kahan: everyone knows which roads flood, nobody knows which ones flood today
A street-level monsoon flood forecast, built solo over one weekend for Environmental Hacks 2026 (Track 02, Heat and Water).
Back in 2024, when Chennai flooded, my friend, his dad and I went out to pick up another friend of ours. His grandma came out to meet us holding her things above her head, to keep them out of the water. That's the picture I still have in my head when I think about floods here.
I'm from Chennai. If you've lived here through a monsoon, you already know the list: that one subway, that one stretch of main road, that lane that turns into a canal by evening. Everyone knows which roads flood.
What nobody can tell you is which of them will flood today, how deep, and whether it'll still be passable at six when you're actually on the road.
In July, heavy rain flooded Surat and authorities evacuated more than 3,800 people from low-lying areas ( The Pioneer, 9 July 2026 ). Every year it's some city, and mostly the same roads.
So I built Paani Kahan. In Hindi it means "where's the water?" It forecasts flood depth on every street around you, every 10 minutes, six hours ahead. If your route home is about to go under, it tells you before you leave and hands you a dry route in Google Maps.
This post is the whole weekend, more or less in order: what I built, what broke, and what I'd do differently.

Part one: the physics is the easy bit
The flood model, in one line: rain that the drains can't take runs downhill and fills the low spots.

That's most of it. Rain comes from Open-Meteo's forecast. Elevation comes from AWS Terrain Tiles, the open elevation dataset on the AWS Registry of Open Data. Streets come from OpenStreetMap. Water that the drains can't carry flows over the terrain, collects in depressions, and when a depression is full it spills into the next one. Each street segment gets a depth for every 10-minute step.
The physics wasn't the hard part. The hard part was "how much can a drain carry?", because nobody publishes that for Chennai's storm drains. I ended up sizing them the usual engineering way, for a 2-year storm, using 35 years of ERA5 rainfall history. It's an assumption (the app says so), but it means a light shower doesn't paint the whole map blue and a proper downpour does.
By about 3:30 on Saturday the first version was running: the flood engine, flood-aware routing, an OpenSearch store, Cedar policies, a Strands agent, a SAM Lambda posting alerts through LocalStack, and the whole thing running under Finch. It ran on a synthetic city I'd generated, because I wanted the engine to work before I made it depend on anyone's servers.
That helped a lot later. It also meant I hadn't seen any of the problems real data was going to cause.
Part two: nobody is switching map apps
My first thought was to build a full map app. Then I thought about how I actually get home: I open Google Maps. Nobody is switching navigation apps because of a hackathon project.
So Paani Kahan just does the flood part. You see the water, you get a route that stays under your limit, and one tap opens Google Maps with waypoints that keep you on the dry route. The limits are on the cautious side:
You're on | Turn back above | Why |
|---|---|---|
Two-wheeler | 30 cm | Engines stall, and open drains are invisible under water |
Car | 45 cm | Water in the intake, doors you can't open |
Foot | 25 cm | Open drains and live wires |
One detail I'm happy with: a route isn't checked at the time you leave. Each street is checked at the time you'd reach it, because a road that's fine at 6:00 might not be at 6:12.
By 5 PM it was an installable app with an offline cache, background refresh and notifications. If the power or the network goes during a storm (it often does), the next six hours are already on your phone.
Part three: Chennai is not a synthetic city
Around 6:40 PM I pointed it at Chennai, and a lot of my assumptions didn't survive.
Wet ground matters: the same rain does a lot more damage after a few days of drizzle. Near the coast, high tide backs water up into the drains. Some roads flood for reasons no terrain model will ever see, so I added documented closures and live blockades on top. I also added storm replays, so you can run the model on a real past event like Cyclone Michaung in 2023 and compare it with what actually happened.
None of this is exotic. It's just stuff the synthetic city never made me think about.
Part four: "it works on my laptop" isn't a demo
A flood app that only runs on a laptop isn't much use, so at 9 PM I got it onto my phone: an HTTPS tunnel, a little PowerShell script for the same Wi-Fi network, and a QR code in the app. Once it was on my phone, the obvious question came up, and it cost me the rest of the night: what if the person using this isn't in Chennai?
Part five: the rewrite at 9:30 PM
Everything so far assumed one city, loaded once. That doesn't work if someone opens the app in Patna, or in Guwahati, or 60 km outside Chennai on a highway. So I rebuilt the data layer so it loads the area around whoever opens it, in the background, without making them wait.

The map is cut into tiles at two levels of detail:
- Street level: every street within about 10 km, on a 20 m grid.
- Main roads: motorways, trunk and primary roads out to 100 km, on a 150 m grid. That's enough to cover a longer trip without downloading half the state.
A priority scheduler decides what loads next: the 3 × 3 tiles around you first, then what's on screen, then your route, then the rest of your ring, and only then the 100 km region. Downloads, flood maths and the slow region fetches each get their own pool of workers, so a big region download doesn't hold up the streets right around you.
I also rewrote the flood model to work on whole layers of terrain at once with NumPy, instead of cell by cell. Same answers, about 2.5× faster, which adds up when there are hundreds of tiles.
I redesigned the front end at the same time: black, off-white, blue for water and red for anything over your limit. I kept the colours to that on purpose.
Part six: the map with no map
At 11:30 PM I opened the app and got a mostly black screen with some blue lines on it. No streets, no labels.
Technically nothing was broken. The problem was that the streets were drawn from the same tiles as the forecast, so until a tile arrived there was nothing to look at. I'd been so focused on the data that I hadn't looked at it the way someone opening it for the first time would.
The fix was a normal raster base map underneath everything, there from the first frame. The first one I picked put a big "API KEY REQUIRED" watermark across the screen, which I only noticed after midnight. I switched to keyless OpenStreetMap tiles, rendered in black and white so the water stays the loudest thing on screen.
Then it was slow. Really slow. The 100 km region queries to OpenStreetMap's Overpass API were 12 MB each, and the servers (fairly) started rate-limiting me with 429s. The background loading was getting in the way of the streets around the user.
What fixed it:
- Region tiles now wait until your own streets have settled, and they get one lane, not five.
- Overpass has several public mirrors, so I hit them in parallel, least busy first. Each one sits behind its own circuit breaker.
- A bug that took me a while to see: the circuit breaker counted every failure. So when the rain archive returned a perfectly normal 404 for a date it didn't have, the breaker decided the whole upstream was down and stopped asking. Nothing crashed, data just stopped arriving. Now a 4xx counts as the request's fault, not the server's, and doesn't trip the breaker.
Part seven: the storm that wouldn't leave
For the demo I needed a storm, and you can't book one for a Sunday afternoon. So there's a demo storm: scripted rain over your real streets, real terrain and real drains. The app labels it on screen so nobody mistakes it for a real forecast.
At 4:10 PM, Priya's 6 PM ride to the hospital is fine. At 4:25 PM a new nowcast "lands", and her usual route crosses 55 cm on GST Road. The model re-runs, the alert fires, and she gets a dry route that's 11 minutes longer with nothing above 30 cm.
Two things went wrong here. First, once I switched the demo storm on, I couldn't switch it off: an environment variable kept forcing it back on every restart. That earned the app a "Back to live rain" button. Second, the demo was preparing tiles in the wrong order. For a demo the streets around you matter most, so those now jump the queue, nearest first.
What it runs on

Here's what each AWS tool actually does:
- Finch runs the whole stack on my laptop: the app, OpenSearch and LocalStack, with one
finch compose up.
- OpenSearch holds the forecast as one document per wet street, with fields
d0tod36for depth at each 10-minute step. "Streets over 15 cm within 1.2 km at 6 PM" becomes a geo-distance filter plus a range query ond11. It also powers place search (text match plus distance decay) and full-text search over past flood reports.
- AWS SAM builds the Lambda that polls for new nowcasts every 15 minutes and re-runs the forecast.
- SNS and SQS on LocalStack carry the alert: the Lambda publishes to an SNS topic, which fans out to an SQS queue that the app long-polls.
- Strands Agents SDK writes the advice. It has three tools:
check_route,flood_historyandbest_departure.
- Cedar checks every request.
- AWS Terrain Tiles provide the elevation everything flows over.

I liked working with Cedar because the policies read almost like plain English. A saved commute reveals where someone lives and works, so only its owner can see it:
cedar
// A saved commute reveals where someone lives and works. Only its owner can read or change it.@id("own-commute-only")permit (principal,action in [Action::"viewCommute", Action::"editCommute"],resource is Commute)when { resource.owner == principal };
A ward officer sees pump and crew priorities, but only for their own locality. The control room sees every locality. And which hospitals and schools are about to be cut off is restricted to emergency control, so it isn't used to target or panic people. Every decision is logged and shown on the app's System tab.
Part eight: getting a local model to talk to my app
I wanted the agent to run locally, so nobody needs an API key to try it. On Sunday I sat down with the models I already had in Ollama (llama3, phi3 and llava) and found out none of them support tool calling in Ollama. Pass them tools and Ollama just returns an error.
So the app checks what the model can do. If it can call tools, the agent picks its own tools. If it can't, the app runs the same three tools in the order the agent's instructions set, and the agent writes the notification from their results. The UI says which one happened.
Small models also like to make up numbers, so there's a check. Every depth in the advice has to appear in a tool result, or the advice is thrown out and a plain template is used instead:
Python
def _numbers_grounded(text: str, trace) -> bool:"""Every 'NN cm' in the model's text must appear in a tool result."""blob = json.dumps([t["result"] for t in trace])have = set(re.findall(r"\d+", blob))return all(n in have for n in re.findall(r"(\d+)\s*cm", text))
Then the networking. On Windows, Finch runs containers inside a WSL VM, so "localhost" inside the app container isn't my laptop.
host.docker.internal resolved to 10.255.255.254 , WSL's own internal address, and Ollama said "connection refused". There's no curl in my slim image either, so even checking needed a one-line Python script. In the end I made Ollama listen on all interfaces and pointed the app at my laptop's Wi-Fi IP.
Part nine: making the video
I recorded the demo in one take in OBS, with three tracks in sync: the screen, my webcam and my voice. I'd planned to edit it in After Effects, which I don't actually know how to use.
So the edit is code. Every cut is a line in an edit list, keyed to my words: the narration was transcribed and aligned word by word, and cuts snap to the quiet gaps between words so nothing clicks. A small Python compositor draws every frame: the app in a rounded window, captions that light up as I say each word (AWS services in blue, depths over the limit in red), and cards that appear exactly when I mention them. In the "how it works" part, the architecture diagram builds itself piece by piece as I name each service.
One edit I made against myself: my opening line in the recording gave the Surat floods' date and numbers from memory, and the news reports said something different. I cut it and opened on the sourced number instead.
Three things I'd tell myself on Saturday afternoon
- Open it like someone who's never seen it. I put a lot into the data loading and still ended up with a black screen at 11:30 PM.
- Get to real data early. The synthetic city let me move fast, but wet ground, tides, rate limits and that 404 only showed up once I used real data.
- Say what's real in the app itself. The demo storm is labelled, the advice says whether the agent or a template wrote it, and made-up numbers get rejected. If people are going to decide whether to ride through water based on this, it has to be upfront.
What's next
- Calibrate drain capacity against real flood reports, street by street, instead of one city-wide assumption.
- Let residents report water from the app, and feed those reports straight back into the model.
- Give ward offices a proper view for deciding where to send pumps before the water arrives, not after.
- Run it for a full monsoon in Chennai and publish how often it was right.
Every monsoon the same streets flood, and somehow it still catches people off guard. I'd like this to be one less reason for that.
Built for Environmental Hacks 2026 (Track 02, Heat and Water) by WeMakeDevs and AWS.
Code: [GitHub repo link] · Demo video: [YouTube link]
Map data © OpenStreetMap contributors. Rain: Open-Meteo and ERA5. Elevation: AWS Terrain Tiles from the AWS Registry of Open Data.
The opinions in this post are my own and do not necessarily reflect those 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