Real-time urban mobility intelligence. The system observes live conditions, compares them against what each movement normally does at that hour on that kind of day, detects meaningful change, and surfaces it for investigation.
OBSERVE → UNDERSTAND → COMPARE → DETECT → CONNECT → EXPLAIN → INVESTIGATE
Analytics discovers. AI explains. Humans decide. No language model computes a traffic figure anywhere in this system, and the copilot has no path to the observations. Every number in an answer came out of a deterministic tool, and the tool trace is returned with the answer.
OBSERVATION PROVIDER
ReplayProvider | GoogleRoutesProvider
│
▼
INTELLIGENCE LOOP (5-minute ticks)
│
┌─────────────────┼──────────────────┐
▼ ▼ ▼
CITY STATE BASELINE LAYER DETECTORS
deviation 2019 pace deterioration
status percentiles persistence
persistence confidence variability
velocity spatial cluster
└─────────────────┼──────────────────┘
▼
CONSOLIDATION
clusters absorb their members
same-signal same-zone collapse to one
│
┌───────────┴────────────┐
▼ ▼
INTELLIGENCE FEED COPILOT (tools only)
└───────────┬────────────┘
▼
DYNAMIC CONSOLE
map canvas · feed · adaptive view directives
Google's Maps Service Specific Terms permit caching latitude and longitude only. Travel times from the Routes API may not be retained to build a persistent dataset. A real-time product that stores every duration it fetches is exactly what that prohibits.
So the collector is an interface with two implementations:
| Provider | Live | Retains durations | Role |
|---|---|---|---|
ReplayProvider |
no | yes — CC BY 4.0 data | demonstration, and the default |
GoogleRoutesProvider |
yes | no — 6-hour memory window | deployment |
The engine cannot tell them apart. The persistent analytical history comes from the 2019 open dataset, which may be retained. If live history is needed, the sanctioned route is a product licensed for it (Roads Management Insights, under the Analytics Service Specific Terms) or first-party probe data — not a longer cache on this one.
The 2019 dataset — 101,418 valid primary-route observations, 143 days — is not the product. It is the calibration layer that makes live comparison mean anything.
- Zones are derived, not drawn. Endpoints are clustered by weighted k-means; the zone count is the largest at which every zone still holds enough evidence and has a landmark within 1.5 km to be honestly named after. That gives five: Siliguri Central, Siliguri Junction, Salugara, NJP Station, Champasari.
- Baselines are built on pace (seconds per kilometre), not journey time. Inside one movement-hour bin, trip distances span 5–10× and distance correlates 0.80 with travel time, so a baseline on seconds measures how far someone went. On seconds, 22.1% of observations scored as anomalies; on pace, 2.9%.
- Nothing is published below 30 observations per movement-hour-daytype bin. Where the system is silent it is uninformed, and it says so rather than estimating into the gap.
There are two front ends. apps/command-web (port 3050) is the duty
officer board — the deployed one, and the thing this repository is mainly
about. apps/web (port 3040) is the earlier narrative console kept for the
baseline write-up; it is not what the control room uses.
uv sync
# The baseline layer, from the 2019 extract. Optional: the board runs without
# it, and the tests that need it skip rather than fail.
PYTHONPATH=. .venv/bin/python scripts/build_analytics.py
PYTHONPATH=. .venv/bin/python scripts/discover.py
# The API. ROUTES_API_KEY is required — there is no data without it.
ROUTES_API_KEY=... \
CORS_ORIGINS=http://localhost:3050 \
AUTH_MODE=open \
PYTHONPATH=. .venv/bin/uvicorn apps.api.main:app --port 8099
# The board. apps/command-web/.env.local should point at the API above;
# pointing it at the deployed API fails, because production CORS correctly
# allows only the deployed origin.
npm --prefix apps/command-web run devscripts/replay_day.py [YYYYMMDD] drives a whole day through the loop and
prints what it found — the fastest way to see the engine work.
| variable | required | what it does |
|---|---|---|
ROUTES_API_KEY |
yes | Google Routes API key. Startup refuses without it. |
CORS_ORIGINS |
yes in production | Comma-separated allowed origins. Fails closed — an empty value permits nothing rather than everything. |
WRITE_TOKEN |
yes in production | The credential an officer needs to record an action. |
AUTH_MODE |
no | token (default) or open. See below. |
INCIDENT_STORE |
no | memory (default) or firestore. Only Firestore survives a restart. |
POLL_SECONDS |
no | Base cadence, 180 in production. Also drives the board's stale threshold, at 1.5 cycles. |
CONFIRM_MINUTES |
no | How long a condition must hold before it becomes an incident. 8 in production. |
LAPSE_MINUTES |
no | How long before an unattended incident is recorded as having cleared itself. |
TZ |
yes on Cloud Run | Asia/Kolkata. Without it the shift clock reads UTC, which is invisible on a machine already in IST. |
Reads are open: the board's figures are aggregates, and /api/roster returns
units without officer names or duty state. Recording is not, because every
action is a police record — and the endpoint was reachable unauthenticated
until the gate went in, on a public URL, with a durable store behind it.
Writes require Authorization: Bearer $WRITE_TOKEN. The gate fails closed:
with no token configured the API refuses every write with a 503 saying so,
rather than falling back to accepting anyone. /health reports the posture, so
an accidentally open deployment is visible from outside:
"writes": "gated" | "disabled — no WRITE_TOKEN configured" | "OPEN — anyone can record an action"
AUTH_MODE=open removes the gate and is for local development only. It is
never set on the deployed service, and the deploy smoke test fails if the live
API reports anything other than gated or accepts an anonymous write.
Set the token as the WRITE_TOKEN repository secret; the deploy passes it into
the service. The control room enters it once per console, from Recording
locked in the command bar. To rotate, change the secret and redeploy — every
console then needs the new value.
Per-officer tokens. WRITE_TOKEN alone is one secret shared by the room: it
proves the person at the console belongs there, but the server cannot tell one
officer from another, so it has to believe the by the console sends and the
record names a seat. Set OFFICER_TOKENS instead — a JSON object of token to
officer id — and the actor is taken from the credential, so a console cannot
record an action in another officer's name:
{"<random-1>": "DO-1", "<random-2>": "TG-2"}/health reports which of the two is in force as attribution: per-officer
or attribution: shared, so the strength of the audit trail is stated rather
than assumed. Officer ids come from data/curated/roster.json.
Once OFFICER_TOKENS is set the shared WRITE_TOKEN stops being accepted,
deliberately: a credential that authorises a write but produces no attribution
would be a bypass of the guarantee /health is then advertising. Rotating to
per-officer tokens therefore locks out any console still holding the old room
token, which is the intended effect.
240 reads and 30 writes per minute per caller, and 10 rejected credentials per
minute so the token cannot be guessed at the write rate. /api/stream is
exempt: one long-lived connection per console is not traffic. Override with
RATE_READS_PER_MIN, RATE_WRITES_PER_MIN and RATE_FAILED_AUTH_PER_MIN.
Callers are told apart by the head of X-Forwarded-For, because behind Cloud
Run request.client is the load balancer and every caller would otherwise
share one bucket. The counters are in-process, which is correct only while the
service runs at --max-instances=1 — see the comment on that flag for why it
should stay that way.
is_live is False in replay and the mode is on every API response that could
be mistaken for current conditions. Zone pairs are not roads. The system can
establish that something changed and by how much; it cannot establish why, and
no detector or prompt is permitted to imply otherwise.