Skip to content

Repository files navigation

Flood Rat

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.


Quickstart

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 8080

FloodNet, 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 :3000

One command checks everything before a commit. Seven Python contracts, then the 151 vitest tests in web/.

./scripts/check

The map page

What it does

The sensors

FloodNet 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.

The cameras

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.

The pairing

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.

The vision

A Roboflow segmentation model runs over each watched frame. Beside it, a reference-frame delta compares the frame to that camera's dry baseline.

The gauges

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.

The rat

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.

The warning block

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.

The landing page

Architecture

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

Data sources

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. ⚠️ Called by the browser, never by the server
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.

Deploy to Cloud Run

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 path

It ships as one service running the API, the UI and the poll loop together.

⚠️ Outside an active request Cloud Run throttles CPU to near zero, and a background loop silently stops ticking. No error, no crash, just no new data. deploy.sh passes --no-cpu-throttling --min-instances=1. Never remove them.

Read this too

  • 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.

License

MIT.

About

Hyperlocal NYC flood watch — pairs FloodNet depth sensors to DOT traffic cameras, with an agent that speaks the warning. Deployed on Cloud Run.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages