Hyperlocal flood watch for New York City. It reads the city's street-level water sensors, pairs them to traffic cameras, and has a rat write down what the instruments report.
cp .env.example .env # DATABASE_URL is the only one you must fill in
python -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
python -m waterline.poll probe # what's reachable right now
python -m waterline.poll bootstrap # schema + sensors + cameras + pairing
python -m waterline.poll run # the 60s loop
uvicorn waterline.api:app --reload --port 8080FloodNet, NWS, NOAA and USGS need no API key. Roboflow needs one, and the vision layer is optional — the app runs without it.
The UI needs Node 20+ to build. Nothing runs Node in production. Until you build
it, / answers 503 with the command to run, and the API works normally.
cd web && npm ci
npm run prod:local # build + stage into waterline/web/, then hit :8080
npm run dev # or a hot-reloading dev server on :3000One command checks everything before a commit. Seven Python contracts, then the
151 vitest tests in web/.
./scripts/checkFloodNet is a public network of 425 street-level depth sensors across the five boroughs. Each reports depth in millimetres about once a minute, from a rangefinder mounted over the roadway. These readings are precise. They are also sparse: 425 instruments for 6,000 miles of street.
Both depth thresholds are borrowed. 10 mm is FloodNet's own flood-event definition. 150 mm is roughly NYC curb height — the point where water leaves the roadway and heads for sidewalks and basement stairwells.
NYC DOT runs 968 public traffic cameras. Many point at the underpasses and low corners that flood first. They are dense. They are also uncalibrated, and you cannot read a depth off one.
Where a camera and a sensor watch the same corner, the sensor labels the
camera's frames for free. poll bootstrap computes this and finds 27 gold
pairs within 100 m and 131 within 250 m. The gold tier is not a guess. At
those distances the two agencies independently name the same intersection:
8m DOT: South St @ Broad St FloodNet: M - Broad St/South St
12m DOT: 3 Ave @ Union St FloodNet: BK - 3rd Ave/Union St
20m DOT: Hylan Blvd @ Seaview FloodNet: SI - Seaview Ave/Hylan Blvd
Two naming conventions, one corner. That agreement is the evidence the pairing is sound.
A Roboflow segmentation model runs over each watched frame. Beside it, a reference-frame delta compares the frame to that camera's dry baseline.
NOAA's Battery tide gauge and four USGS stream gauges give a regional baseline. Each sits on its own datum. They are never compared to each other.
NYC's sewers are combined, so storm water and waste share the same pipes. When rain fills them, rats leave through the drains ahead of the water. The rat knows first because it is down there. It is a rat and it holds no city office.
The warning arrives as text, pushed over SSE into a panel that colours by alert level. There is no synthesized voice. Speech excludes deaf and hard-of-hearing users, needs a click before a browser will emit a sound, and is inaudible on a muted projector. Text has none of those failure modes.
A rehearsal fired through /api/rat/drill and badged DRILL. A warning that is
not real must never look like one.
Every sentence is a reviewed template in agent.py. No language model writes any
of it. Which sentence a reader gets is a hash of the episode, so the page, the
stored alert and every subscriber's email all carry the same words.
Character shrinks as severity rises:
watch — "Rat here. South St @ Broad St. The pipe's filling and I'm leaving it. Nothing on the street yet. Basement door, basement window — pick one and clear it."
emergency — "Water at Ave C @ 23 St is above the curb. About 7 inches. If you are below street level, get out and get up now. Call 911 if you cannot. Do not walk or drive into this water."
By EMERGENCY there is no character left. A rat still doing a bit at 150 mm is a rat that gets someone hurt.
One Cloud Run service, one container, no Node at runtime. The poll loop runs on a
background thread inside it, so vision.py keeps its dry-frame baselines warm
between ticks. The UI is a Next.js static export served by FastAPI from the same
origin, so there is no second service and no CORS.
┌─ Cloud Run service ──────────────────────────────┐
│ │
│ poll loop (60s) FloodNet GraphQL (mm) ─┐ │
│ DOT camera stills ─┤ │
│ NWS alerts ─┼─▶ escalation
│ NOAA + USGS gauges ─┘ state machine
│ ▼ │ │
│ Postgres (Neon) rat.emit()
│ │ │ │
│ watch machine ─▶ outbox ─▶ mail ─────────▶ SMTP
│ │ (the one
│ api: / · /api/status · /api/events (SSE) ◀──────┘ arrow that
└─────────────────────────┬────────────────────────┘ LEAVES)
│ SSE + 15s poll, same origin
▼
browser: Next.js + shadcn
That SMTP arrow is off by default. MAIL_TRANSPORT=log renders every message,
logs it in full and marks it skipped, so the whole email-watch path runs with no
provider and no credential.
| Module | Does |
|---|---|
floodnet.py |
The FloodNet GraphQL API — deployments, depth, flood events |
cameras.py |
DOT camera index and the camera↔sensor pairing |
vision.py |
Roboflow segmentation plus the dry-baseline reference delta |
escalation.py |
The alert state machine. Escalates fast, stands down slowly |
agent.py |
All warning copy. Templated, multilingual, never generated |
watch.py |
The sensor-only state machine, for email subscriptions |
mail.py |
Renders and delivers. Sends nothing by default |
poll.py · api.py |
The loop, and the API + UI entrypoint |
| Source | Use | Access |
|---|---|---|
| FloodNet | Depth ground truth, mm | Public GraphQL, no key |
| NYC DOT cameras | Frames | Public JSON index |
| NWS | Flash flood watches and warnings | Public, no key |
| NOAA CO-OPS | The Battery water level (8518750) | Public, no key |
| USGS NWIS | Four in-city stream gauges | Public, no key |
| NYC Open Data | Sensor metadata, rodent inspections, NTA crosswalk | Socrata |
| NY State DEC | Combined sewer outfall locations | Socrata |
| GeoSearch | Address → coordinate | Public, no key. |
| Roboflow | Water segmentation workflow | API key |
Three of those are fetched by hand and committed: the basemap, the rodent aggregate and the CSO outfalls. Nothing fetches them at build or run time. The map is drawn from ~1,400 committed coordinates, so it still draws when the venue wifi does not.
A typed address is geocoded in the browser and reaches no Flood Rat endpoint in any form — LIMITATIONS §16. There is no real-time sewer overflow feed and there cannot be one — §10.
gcloud auth login
gcloud config set project <id> # billing must be enabled on it
./deploy.sh setup # once: enable the three APIs
./deploy.sh # build + deploy, prints the live URL
curl $URL/api/healthz # NOT /healthz — Cloud Run reserves that pathIt ships as one service running the API, the UI and the poll loop together.
deploy.sh passes --no-cpu-throttling --min-instances=1. Never remove them.
- LIMITATIONS.md — what this instrument cannot see and what it refuses to do. Sixteen sections, and half the design.
- MEASUREMENTS.md — every number that came from running it, dated. Re-measured, never edited.
- CLAUDE.md — the map of the repo and the hard invariants.
MIT.


