Find not just that a drain network is contaminated β but exactly which node it came from.
AquaTrace is split into three independently deployable services. This repo is just the map β π head into each one for its own setup, deployment, and code-level docs.
| # | Repository | What it is | Stack |
|---|---|---|---|
| π°οΈ | contamination-node | The edge fleet β a simulated network of physical drain-monitoring devices, each running real classification, alerting & OTA logic | Python Β· FastAPI Β· scikit-learn Β· asyncio-mqtt |
| π§ | contamination-central-system | The brain β MQTT ingestion, Postgres storage, and the mass-balance tracing engine that pinpoints contamination sources | Python Β· FastAPI Β· SQLAlchemy (async) Β· Postgres Β· Alembic |
| π₯οΈ | contamination-dashboard | The eyes β a live operator UI: network map, incident/plume playback, sensor health, model registry | React Β· Vite Β· react-leaflet Β· recharts |
None of them share code. node and central-system only ever agree over the wire (a shared
MQTT protocol spec, implemented independently on each side); dashboard only ever talks to
central-system's REST API. Exactly like three real, separately-owned systems would.
In a drain / industrial-effluent network, contamination β an illegal chemical discharge, a sanitation failure, or a sensor fault mimicking one β can enter at any node. By the time it's visible downstream, it's diluted, delayed by travel time, and mixed with everything else in the flow. A single sensor crossing a threshold tells you something is wrong upstream β it doesn't tell you where.
AquaTrace models the drain network as a directed acyclic graph (DAG) of monitoring nodes and edges (drain reaches), and solves localization with mass-balance reconciliation: at every node, does the contaminant mass actually observed match what its parents' mass β shifted by travel time and decayed appropriately β would predict? Wherever it doesn't, that's unexplained mass, and the tracing engine flags it as a contamination-source candidate.
MQTT (register Β· summary Β· alert Β· status Β· model-update/ack)
βββββββββββββββββββββββββ ββββββββββββββββββββββββββββββββββββββββββββββββββββΆ ββββββββββββββββββββββββββββ
β π°οΈ contamination- β β π§ contamination- β
β node β ββββββββββββββββββββββββββββββββββββββββββββββββββββ β central-system β
β β OTA model download (plain HTTPS GET) β β
β fleet of simulated β β MQTT ingestion β DB β
β edge devices, one β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β tracing engine β API β
β per drain node β simulation-control proxy (demo only, HTTP) β β
βββββββββββββββββββββββββ ββββββββββββββ¬βββββββββββββββ
β REST API
βΌ
ββββββββββββββββββββββββββββ
β π₯οΈ contamination- β
β dashboard β
β map Β· incidents Β· β
β sensor health Β· models β
ββββββββββββββββββββββββββββ
Golden rule of this architecture: dashboard never talks to node directly β even the
"press start" simulation controls are forwarded through a narrow, isolated proxy inside
central-system (app/api/sim_proxy.py), which does nothing but forward the HTTP call
verbatim. It touches no database table, no MQTT client, and no tracing code β the entire real
ingestion/tracing pipeline has zero awareness a simulation even exists.
Each Virtual Node stands in for one physical device sitting at a point in the drain network. On every tick, it:
- π Derives hydraulics β flow rate (Q), flux, and mass per contaminant, from live level/speed via Manning's equation.
- π§ͺ Classifies locally, two ways at once:
- Rule-based β against registry-defined safe/warn/red thresholds.
- ML-based β a per-contaminant scikit-learn anomaly model, trained offline and shipped on-device, hot-swappable via OTA with a shadow-mode evaluation window before a new model is trusted.
- π©Ί Gates on sensor health β a generic onboard-diagnostic check (range, stuck-value, and
implausible-jump heuristics) marks a node/contaminant
OK/SUSPECT/FAILED; unreliable readings are excluded downstream, both locally and by the central tracing engine. - π¨ Alerts intelligently, not noisily β three trigger types working together:
fast_pathβ a single reading extreme enough not to wait for debounce.debouncedβ classic k-of-n debounce (3-of-5) with hysteresis to clear.cusumβ a CUSUM detector on the residual against a rolling baseline, catching sustained loads that never individually cross into WARN/RED.
- π‘ Publishes registration, periodic summaries, and alerts over MQTT β exactly as an independently-deployed physical device would.
Contaminants tracked: pH Β· temperature Β· conductivity Β· TSS Β· dissolved oxygen Β· BOD Β· COD Β·
ammoniacal nitrogen Β· fecal coliform Β· chromium(VI) Β· urea β a mix of physical, biological, and
industrial-discharge indicators, each with its own decay behaviour (conservative, reactive,
biologically-reactive, or physically-settling) defined in a shared registry.json.
The backend of record, and home of the tracing engine. It:
-
π₯ Ingests MQTT idempotently β QoS-1 can redeliver, so dedup is enforced at the database layer (a unique constraint on
node_id + seq), not just in application code. -
ποΈ Persists readings, topology, and incidents to Postgres via async SQLAlchemy + Alembic migrations.
-
π Runs the tracing engine on a schedule, walking the node DAG in topological order:
for each node, parents-first: M_observed = mass actually measured at this node M_expected = Ξ£ over parents of (parent's mass, shifted by travel time Ο, decayed by e^(-kΟ)) U = M_observed β M_expected # unexplained mass if |U| > 2 Γ Ο_U (propagated uncertainty): β flag as a contamination-source candidate β classify cause: local injection Β· dilution loss Β· pass-through β record an Incident with a confidence scoreTravel time (Ο) and decay (k) per edge come from real hydraulics β reach geometry is first estimated from node geolocation + invert level at registration time, then continuously refined from live flow data. Headwater nodes (no parents) fall back to rolling-baseline anomaly detection instead, since there's nothing upstream to reconcile against.
-
π Serves the REST API the dashboard consumes: nodes, edges, incidents (+ time-series playback data for the plume view), sensor health, and the model registry.
-
π Proxies simulation control to
node's orchestrator through the isolated router described above.
A single-page React/Vite app with five views:
| View | What it shows |
|---|---|
| πΊοΈ Network Map | Live node/edge health on an OpenStreetMap base layer, polled continuously |
| β±οΈ Incident List & Detail | A time-slider plume playback β node colour and edge flux animate directly from the tracing engine's own mass/flux numbers, with the top candidate source highlighted throughout |
| π©Ί Sensor Health / Replacement Queue | Per-node, per-contaminant reliability table |
| π Model Registry | OTA model versions & shadow-mode status, with a "Push Update" action |
| Start/stop the demo network, inject a discharge event or a sensor fault, and watch detection + tracing happen live |
- π‘ A node measures (or, in the demo, simulates) hydraulics and contaminant concentrations, and classifies the reading locally.
- π€ It publishes a summary β and any alert β over MQTT.
- π₯
central-systemingests and persists it, then periodically runs the tracing engine across the current topology. - π© Wherever observed mass isn't explained by upstream contributions, an Incident is recorded with a source candidate and confidence score.
- π₯οΈ
dashboardqueries the REST API and renders the live map, the incident's plume playback, and sensor/model health for an operator to act on.
node and central-system are deliberately not sharing a package β both sides implement
the same protocol spec independently (protocol/schemas.py / app/schemas.py, byte-for-byte
duplicated, not imported), the way two real, separately-owned systems would.
Topics:
| Topic | Direction | Purpose |
|---|---|---|
nodes/{id}/register |
node β CC | A device announcing itself + its claimed geometry/parents |
cc/{id}/register/ack |
CC β node | Assigned parents, baseline model refs, baselining period |
nodes/{id}/summary |
node β CC | Periodic hydraulics + per-contaminant readings |
nodes/{id}/alert/{contaminant} |
node β CC | A WARN/RED crossing, with its trigger type |
nodes/{id}/status |
node β CC | Battery, RSSI, buffer depth |
cc/{id}/model/update |
CC β node | Push a new model version for a contaminant |
nodes/{id}/model/ack |
node β CC | Confirms load / shadow-mode result |
Each repo has its own detailed setup guide β here's the short version, in order:
# 1οΈβ£ central-system β Postgres + Mosquitto, migrations, then the API
git clone https://github.com/devraj-saini-02/contamination-central-system
cd contamination-central-system
docker-compose up -d && alembic upgrade head
uvicorn app.main:app --reload --port 8000
# 2οΈβ£ node β the simulated device fleet + its orchestrator
git clone https://github.com/devraj-saini-02/contamination-node
cd contamination-node
uvicorn orchestrator:app --reload --port 8100
# 3οΈβ£ dashboard β the operator UI
git clone https://github.com/devraj-saini-02/contamination-dashboard
cd contamination-dashboard
npm install && npm run dev # β http://localhost:5173For cloud deployment (Render + Supabase for central-system, Render for node,
Vercel/Netlify for dashboard, HiveMQ Cloud as the shared MQTT broker), see the Deploying
section in each repo's own README. βοΈ
There's no real sensor hardware deployed for this build, so contamination-node stands in a
physics-based World Simulator β hydraulics, advection-dispersion-reaction transport, diurnal
contamination baselines, and injectable discharge/fault events β for what an actual field device
would measure. Everything downstream of that fake input is real, production-shaped code:
classification, alerting, health gating, the MQTT protocol, OTA updates, and the entire tracing
engine in central-system would run unchanged against genuine sensors. The dashboard's
Simulation Control Panel just lets an operator start the fleet and inject events for demo
purposes β it has no bearing on how detection or tracing actually works. π§ͺ