rittika03 commited on
Commit
c8a8b27
·
0 Parent(s):

DRISHTI deploy

Browse files
.gitattributes ADDED
@@ -0,0 +1 @@
 
 
1
+ *.parquet filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # raw data is 110MB — never commit it; the app only needs data/processed/*
2
+ data/raw/
3
+
4
+ __pycache__/
5
+ *.pyc
6
+ .ipynb_checkpoints/
7
+ .venv/
8
+ venv/
9
+ .DS_Store
10
+ .streamlit/secrets.toml
11
+
12
+ # never commit real credentials
13
+ auth_config.yaml
.streamlit/config.toml ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Define BOTH a light and dark purple theme natively. Streamlit themes every
2
+ # widget (including tables) consistently, and the user switches via ⋮ → Settings.
3
+ [theme]
4
+ base = "dark"
5
+ primaryColor = "#8B5CF6"
6
+ font = "sans serif"
7
+
8
+ [theme.dark]
9
+ backgroundColor = "#150C2E"
10
+ secondaryBackgroundColor = "#1E1640"
11
+ textColor = "#F4F1FF"
12
+
13
+ [theme.dark.sidebar]
14
+ backgroundColor = "#190F33"
15
+ secondaryBackgroundColor = "#241748"
16
+
17
+ [theme.light]
18
+ backgroundColor = "#F7F4FF"
19
+ secondaryBackgroundColor = "#FFFFFF"
20
+ textColor = "#1E1338"
21
+
22
+ [theme.light.sidebar]
23
+ backgroundColor = "#EFEAFB"
24
+ secondaryBackgroundColor = "#FFFFFF"
25
+
26
+ [server]
27
+ headless = true
28
+ enableXsrfProtection = true
29
+ # --- optional: enable HTTPS locally (see notes). Uncomment after creating certs ---
30
+ # sslCertFile = "cert.pem"
31
+ # sslKeyFile = "key.pem"
Dockerfile ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ # LightGBM needs the OpenMP runtime (libgomp1); the slim image doesn't ship it.
4
+ RUN apt-get update && apt-get install -y --no-install-recommends libgomp1 \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ # install Python deps first (better layer caching)
10
+ COPY requirements.txt .
11
+ RUN pip install --no-cache-dir -r requirements.txt
12
+
13
+ # copy the app (data/raw and auth_config.yaml are gitignored, so never shipped)
14
+ COPY . .
15
+
16
+ # Hugging Face Spaces runs containers as a non-root user (UID 1000)
17
+ RUN useradd -m -u 1000 user && chown -R user:user /app
18
+ USER user
19
+
20
+ EXPOSE 8501
21
+
22
+ CMD ["streamlit", "run", "app.py", \
23
+ "--server.port=8501", "--server.address=0.0.0.0", "--server.headless=true"]
README.md ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: ParkSight
3
+ emoji: 🅿️
4
+ colorFrom: purple
5
+ colorTo: indigo
6
+ sdk: docker
7
+ app_port: 8501
8
+ pinned: false
9
+ short_description: Parking-induced congestion intelligence for Bengaluru
10
+ ---
11
+
12
+ # ParkSight — Parking-Induced Congestion Intelligence (Bengaluru)
13
+
14
+ AI-driven parking-violation intelligence that **detects illegal-parking
15
+ hotspots and quantifies their impact on traffic flow**, then **forecasts where
16
+ hotspots will flare up next** — turning reactive, patrol-based enforcement into
17
+ data-driven, targeted deployment.
18
+
19
+ Built for *Flipkart Gridlock Hackathon 2.0 — Theme 1*.
20
+
21
+ - **Detect** chronic hotspots over a city-wide H3 hex grid.
22
+ - **Quantify** flow impact with a transparent **Congestion Impact Index (CII)**.
23
+ - **Forecast** next-day intensity per zone with LightGBM.
24
+ - **Act** — a ranked, downloadable enforcement-priority list + interactive map.
25
+
26
+ > Results on the provided data: **298,445** parking violations → **2,534**
27
+ > impact zones; forecast MAE **0.71**, ~**34% better** than a same-weekday
28
+ > baseline.
29
+
30
+ ---
31
+
32
+ ## Architecture in one line
33
+ The heavy offline pipeline (`src/`) crunches the 110 MB CSV and writes a few
34
+ small artifacts (`data/processed/*`). The Streamlit app (`app.py`) reads only
35
+ those, so it loads in seconds and runs inside free-tier memory.
36
+
37
+ ```
38
+ parking-impact/
39
+ ├── app.py # Streamlit dashboard (serving layer)
40
+ ├── requirements.txt
41
+ ├── .streamlit/config.toml # dark theme
42
+ ├── data/
43
+ │ ├── raw/violations.csv # the provided CSV (gitignored)
44
+ │ └── processed/ # hotspots.parquet · forecast.parquet · meta.json
45
+ ├── src/ # offline pipeline
46
+ │ ├── config.py # ALL tunable knobs (weights, H3 res, lags ...)
47
+ │ ├── data_prep.py # load · parse · tz-fix · filter to parking
48
+ │ ├── features.py # H3 indexing
49
+ │ ├── hotspots.py # per-cell stats
50
+ │ ├── impact_index.py # the CII
51
+ │ ├── model.py # LightGBM next-day forecast
52
+ │ └── build_artifacts.py # runs the whole pipeline
53
+ ├── models/lgbm_intensity.txt
54
+ └── docs/methodology.md # CII rationale (cite this in the deck)
55
+ ```
56
+
57
+ ---
58
+
59
+ ## Step-by-step: run it locally
60
+
61
+ **1. Python 3.10–3.12 and a virtual env**
62
+ ```bash
63
+ python -m venv .venv
64
+ source .venv/bin/activate # Windows: .venv\Scripts\activate
65
+ ```
66
+
67
+ **2. Install dependencies**
68
+ ```bash
69
+ pip install -r requirements.txt
70
+ ```
71
+
72
+ **3. Put the dataset in place**
73
+ Copy the provided CSV to `data/raw/violations.csv`.
74
+
75
+ **4. Build the artifacts (offline, run once)**
76
+ ```bash
77
+ python -m src.build_artifacts
78
+ ```
79
+ This writes `data/processed/hotspots.parquet`, `forecast.parquet`, `meta.json`
80
+ and `models/lgbm_intensity.txt`. Takes ~1–2 minutes.
81
+
82
+ **5. Launch the dashboard**
83
+ ```bash
84
+ streamlit run app.py
85
+ ```
86
+ Opens at `http://localhost:8501`.
87
+
88
+ ---
89
+
90
+ ## Deploy to a public URL (free)
91
+
92
+ The repo is deploy-ready for **either** platform. Commit the small
93
+ `data/processed/*` artifacts (they're tiny); the raw CSV stays gitignored.
94
+
95
+ ### Option A — Hugging Face Spaces (recommended, 16 GB free RAM)
96
+ 1. Create a Space → SDK = **Streamlit**.
97
+ 2. Push this repo to the Space (entry file must be `app.py` at root — it is).
98
+ 3. Commit `data/processed/*` so the app has data. Done — you get a public URL.
99
+
100
+ ### Option B — Streamlit Community Cloud (simplest)
101
+ 1. Push to GitHub (public).
102
+ 2. share.streamlit.io → New app → pick the repo, main file `app.py`.
103
+ 3. Set a custom subdomain. Every `git push` auto-redeploys.
104
+
105
+ ---
106
+
107
+ ## Tuning (for your 2 optimization days)
108
+ Everything is in `src/config.py`:
109
+ - `PARKING_SEVERITY` — re-weight violation types.
110
+ - `CII_WEIGHTS`, `CII_JUNCTION_ALPHA` — re-balance the index.
111
+ - `H3_RES` — 8 (coarser) ↔ 10 (finer) zones.
112
+ - `LAGS`, `ROLL_WINDOWS`, `MIN_CELL_VIOLATIONS`, `VALID_DAYS` — the model.
113
+ Re-run `python -m src.build_artifacts` after any change.
114
+
115
+ ---
116
+
117
+ ## Multi-language, theme, history & login
118
+
119
+ **Languages (English / ಕನ್ನಡ / हिन्दी)** — sidebar selector translates the UI and
120
+ powers **voice + text search in Kannada and Hindi**. Native-script place names
121
+ are resolved to the data's English localities via transliteration + fuzzy match
122
+ (e.g. ಶಿವಾಜಿನಗರ → Shivaji Nagar). Voice input uses the matching locale
123
+ (`kn-IN` / `hi-IN`). *Spoken read-back* uses the selected locale but depends on
124
+ the OS/browser having that voice installed — it may fall back to English; demo
125
+ in Chrome.
126
+
127
+ **Theme** — Streamlit already ships a reliable light/dark toggle in
128
+ ⋮ → Settings → Theme. The sidebar toggle additionally flips the map basemap
129
+ (reliable) and nudges the chrome via CSS (partial — native per-user runtime
130
+ theming isn't supported yet, so the Settings menu is the robust path).
131
+
132
+ **Session history** — the sidebar logs your searches this session (downloadable,
133
+ clearable). Cross-session, per-user history needs the login add-on **plus** a
134
+ database — free-tier disks are wiped on restart, so a server-side history file
135
+ will not survive. For real persistence, write to a free Postgres (Neon/Supabase)
136
+ via `st.secrets`.
137
+
138
+ **Login add-on (optional, off by default)** — `src/auth.py` +
139
+ `auth_config.yaml` give bcrypt-hashed, role-based login (admin / officer) via
140
+ `streamlit-authenticator`. Enable by adding to the top of `app.py`:
141
+ ```python
142
+ from src.auth import require_login
143
+ name, username, roles = require_login()
144
+ ```
145
+ Run `python hash.py` to hash passwords and replace the plain-text values in
146
+ `auth_config.yaml` (which is gitignored — never commit real credentials). The
147
+ login `login()` signature varies by library version, so pin it and test locally.
148
+
149
+
150
+ | Form field | What to submit |
151
+ |---|---|
152
+ | Title | ParkSight — Parking-Induced Congestion Intelligence |
153
+ | Description | Problem, CII method, forecast, impact (see deck) |
154
+ | Theme | Poor Visibility on Parking-Induced Congestion |
155
+ | Snapshots | Screenshots of the map / priorities / forecast tabs |
156
+ | Video URL | 3-min screen-recorded demo (Loom / unlisted YouTube) |
157
+ | Presentation | Your pitch deck (PDF) |
158
+ | Demo Link | The public Spaces / Streamlit URL |
159
+ | Repository URL | Your GitHub repo |
160
+ | Source Code | A zip of this repo |
161
+ | Instructions to Run | The "run it locally" section above |
app.py ADDED
@@ -0,0 +1,745 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ParkSight — Parking-Induced Congestion Intelligence for Bengaluru.
2
+
3
+ Serving layer: reads only the small precomputed artifacts in data/processed/.
4
+ Run locally: streamlit run app.py
5
+ """
6
+ import io
7
+ import re
8
+ import json
9
+ import hashlib
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+
13
+ import pandas as pd
14
+ import pydeck as pdk
15
+ import plotly.graph_objects as go
16
+ import streamlit as st
17
+ import streamlit.components.v1 as components
18
+
19
+ try:
20
+ from streamlit_option_menu import option_menu
21
+ HAS_MENU = True
22
+ except Exception:
23
+ HAS_MENU = False
24
+ try:
25
+ from streamlit_mic_recorder import speech_to_text
26
+ HAS_MIC = True
27
+ except Exception:
28
+ HAS_MIC = False
29
+ try:
30
+ from streamlit_autorefresh import st_autorefresh
31
+ HAS_AUTOREFRESH = True
32
+ except Exception:
33
+ HAS_AUTOREFRESH = False
34
+
35
+ from src.i18n import LANGS, SPEECH_LANG, t, build_area_vocab, resolve_area
36
+
37
+ ROOT = Path(__file__).resolve().parent
38
+ PROC = ROOT / "data" / "processed"
39
+ VIOLET = "#8B5CF6"
40
+ GOLD = "#D4AF37"
41
+ DONUT_COLORS = ["#8B5CF6", "#22D3EE", "#EC4899", "#6366F1", "#0EA5E9",
42
+ "#F472B6", "#A855F7", "#34D399", "#FB7185", "#818CF8"]
43
+
44
+ # ---- demo-grade login (self-contained, no fragile dependency) ----
45
+ # NOTE: demonstration auth — credentials are shown on the login screen so judges
46
+ # can always get in. Production would use a real identity provider. To DISABLE
47
+ # the login entirely, comment out the `require_login()` call below.
48
+ _USERS = {"admin": "Traffic Admin (BTP)", "officer": "Patrol Officer"}
49
+ _PW = {"admin": hashlib.sha256(b"admin123").hexdigest(),
50
+ "officer": hashlib.sha256(b"officer123").hexdigest()}
51
+
52
+
53
+ def require_login():
54
+ if st.session_state.get("authed"):
55
+ return
56
+ # keep the session logged in across theme-switch page reloads (demo only)
57
+ if st.query_params.get("auth") == "1":
58
+ st.session_state.authed = True
59
+ st.session_state.user = "Traffic Admin (BTP)"
60
+ return
61
+ st.markdown(
62
+ "<div style='background:linear-gradient(135deg,#7C3AED,#EC4899);"
63
+ "border-radius:18px;padding:26px 30px;color:#fff;margin-bottom:20px;'>"
64
+ "<div style='font-size:2rem;font-weight:800;'>दृष्टि — DRISHTI</div>"
65
+ "<div style='opacity:0.92;margin-top:4px;'>Digital Real-time Intelligence for "
66
+ "Smart Hotspot &amp; Traffic Insights · हर सड़क पर नज़र, हर सफ़र आसान</div></div>",
67
+ unsafe_allow_html=True)
68
+ st.subheader("🔐 Secure sign-in")
69
+ with st.form("login_form"):
70
+ u = st.text_input("Username")
71
+ p = st.text_input("Password", type="password")
72
+ ok = st.form_submit_button("Sign in")
73
+ if ok:
74
+ if u in _PW and _PW[u] == hashlib.sha256(p.encode()).hexdigest():
75
+ st.session_state.authed = True
76
+ st.session_state.user = _USERS[u]
77
+ st.query_params["auth"] = "1"
78
+ st.rerun()
79
+ st.error("Invalid username or password.")
80
+ st.info("**Demo login** → `admin` / `admin123` · `officer` / `officer123`")
81
+ st.stop()
82
+
83
+ # sidebar starts open so the nav is always visible
84
+ st.set_page_config(page_title="DRISHTI · Bengaluru", page_icon="🚦",
85
+ layout="wide", initial_sidebar_state="expanded")
86
+
87
+
88
+ # ---------------- data ----------------
89
+ @st.cache_data
90
+ def load():
91
+ hot = pd.read_parquet(PROC / "hotspots.parquet")
92
+ fc = pd.read_parquet(PROC / "forecast.parquet")
93
+ off = pd.read_parquet(PROC / "offenders.parquet")
94
+ meta = json.loads((PROC / "meta.json").read_text())
95
+ fc = fc.merge(hot[["h3", "location", "junction_name", "cii"]], on="h3", how="left")
96
+ return hot, fc, off, meta
97
+
98
+
99
+ @st.cache_data
100
+ def load_trends():
101
+ return pd.read_parquet(PROC / "trends.parquet")
102
+
103
+
104
+ @st.cache_data
105
+ def load_byday():
106
+ return pd.read_parquet(PROC / "trends_byday.parquet")
107
+
108
+
109
+ def cii_color(cii):
110
+ x = max(0.0, min(1.0, cii / 100.0))
111
+ g, y, r = (22, 163, 74), (245, 158, 11), (220, 38, 38)
112
+ if x < 0.5:
113
+ f, a, b = x / 0.5, g, y
114
+ else:
115
+ f, a, b = (x - 0.5) / 0.5, y, r
116
+ return [int(a[i] + (b[i] - a[i]) * f) for i in range(3)] + [185]
117
+
118
+
119
+ def cii_to_hex(c):
120
+ r, g, b, _ = cii_color(c)
121
+ return f"rgb({r},{g},{b})"
122
+
123
+
124
+ # ---------------- theme CSS (theme-AGNOSTIC: adapts to light & dark) ----------------
125
+ def inject_css():
126
+ # No hardcoded background/text colours -> the native Light/Dark theme (⋮ menu)
127
+ # stays fully consistent, including tables. Only shape + accent + font here.
128
+ st.markdown("""
129
+ <style>
130
+ @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap');
131
+ html, body, [class*="css"], .stApp { font-family:'Plus Jakarta Sans',sans-serif; }
132
+ /* translucent violet glow works over both dark and light backgrounds */
133
+ .stApp { background-image:
134
+ radial-gradient(1000px 520px at 80% -10%, rgba(124,77,255,0.16), transparent 60%); }
135
+ footer { visibility:hidden; } /* keep the header so the ⋮ menu + sidebar toggle work */
136
+ .block-container { padding-top:1.6rem; }
137
+ h1,h2,h3,h4 { font-weight:700; letter-spacing:-0.01em; }
138
+ [data-testid="stMetric"] {
139
+ background:rgba(212,175,55,0.07); border:1px solid rgba(212,175,55,0.45);
140
+ border-radius:16px; padding:16px 18px;
141
+ box-shadow:0 8px 30px rgba(160,120,20,0.10); backdrop-filter:blur(8px); }
142
+ .stButton button, .stDownloadButton button {
143
+ background:linear-gradient(135deg,#7C3AED,#A855F7); color:#fff;
144
+ border:none; border-radius:10px; font-weight:600; }
145
+ .stTextInput input, [data-baseweb="select"] > div {
146
+ border:1px solid rgba(139,92,246,0.35) !important; border-radius:10px; }
147
+ </style>""", unsafe_allow_html=True)
148
+
149
+
150
+ # ---------------- voice ----------------
151
+ def play_tts(text, lang_code):
152
+ """Reliable, multilingual TTS via gTTS (plays an MP3). Browser fallback if offline."""
153
+ try:
154
+ from gtts import gTTS
155
+ buf = io.BytesIO()
156
+ gTTS(text=text, lang=lang_code).write_to_fp(buf)
157
+ st.audio(buf.getvalue(), format="audio/mp3", autoplay=True)
158
+ return True
159
+ except Exception:
160
+ loc = {"en": "en-IN", "hi": "hi-IN", "kn": "kn-IN"}.get(lang_code, "en-IN")
161
+ safe = json.dumps(text)
162
+ components.html(f"""<script>
163
+ const u=new SpeechSynthesisUtterance({safe});u.lang="{loc}";
164
+ window.speechSynthesis.cancel();window.speechSynthesis.speak(u);</script>""",
165
+ height=0)
166
+ return False
167
+
168
+
169
+ def parse_command(text):
170
+ low = (text or "").lower().strip()
171
+ out = {"speak": any(w in low for w in ["read", "speak", "say", "aloud", "tell",
172
+ "ಮಾತ", "ಓದ", "बोल", "पढ"])}
173
+ # which language to SPEAK the answer in (overrides the UI language)
174
+ if any(w in low for w in ["hindi", "हिंदी", "हिन्दी", "हिंदी में"]):
175
+ out["say_lang"] = "hi"
176
+ elif any(w in low for w in ["kannada", "ಕನ್ನಡ", "kannad"]):
177
+ out["say_lang"] = "kn"
178
+ elif "english" in low:
179
+ out["say_lang"] = "en"
180
+ m = re.search(r"(?:top|ಮೇಲಿನ|शीर्ष)\s*(\d+)", low)
181
+ if m:
182
+ out["topn"] = max(5, min(50, int(m.group(1))))
183
+ if any(w in low for w in ["worst", "high impact", "critical", "severe",
184
+ "ಕೆಟ್ಟ", "खराब", "गंभीर"]):
185
+ out["min_cii"] = 80
186
+ return out
187
+
188
+
189
+ def speak_summary(rows, say_lang, n):
190
+ """Build a spoken summary of the top-n zones in the requested language."""
191
+ rows = rows.head(n)
192
+ if say_lang == "hi":
193
+ parts = [f"शीर्ष {len(rows)} क्षेत्र।"]
194
+ for i, (_, r) in enumerate(rows.iterrows(), 1):
195
+ parts.append(f"{i}. {r['location'].split(',')[0]}, "
196
+ f"सी आई आई {r['cii']:.0f}, {int(r['n_violations'])} उल्लंघन।")
197
+ return " ".join(parts)
198
+ if say_lang == "kn":
199
+ parts = [f"ಮೇಲಿನ {len(rows)} ಪ್ರದೇಶಗಳು."]
200
+ for i, (_, r) in enumerate(rows.iterrows(), 1):
201
+ parts.append(f"{i}. {r['location'].split(',')[0]}, "
202
+ f"ಸಿ ಐ ಐ {r['cii']:.0f}, {int(r['n_violations'])} ಉಲ್ಲಂಘನೆ.")
203
+ return " ".join(parts)
204
+ nums = ["one", "two", "three", "four", "five", "six", "seven", "eight"]
205
+ parts = [f"Top {len(rows)} zones."]
206
+ for i, (_, r) in enumerate(rows.iterrows()):
207
+ label = nums[i] if i < len(nums) else str(i + 1)
208
+ parts.append(f"{label}: {r['location'].split(',')[0]}, "
209
+ f"C I I {r['cii']:.0f}, {int(r['n_violations'])} violations.")
210
+ return " ".join(parts)
211
+
212
+
213
+ # ---------------- plotly helpers (let theme="streamlit" adapt to light/dark) ----------------
214
+ def _layout(fig, height=240, title=None):
215
+ fig.update_layout(height=height, margin=dict(l=10, r=10, t=36 if title else 8, b=8),
216
+ title=dict(text=title, font=dict(size=14)) if title else None,
217
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
218
+ showlegend=False)
219
+ return fig
220
+
221
+
222
+ def line_chart(daily, title, mark_last=False):
223
+ fig = go.Figure(go.Scatter(x=daily["label"], y=daily["value"], mode="lines",
224
+ line=dict(color=VIOLET, width=2.5), fill="tozeroy",
225
+ fillcolor="rgba(139,92,246,0.22)"))
226
+ if mark_last and len(daily):
227
+ row = daily.iloc[-1]
228
+ fig.add_trace(go.Scatter(x=[row["label"]], y=[row["value"]], mode="markers",
229
+ marker=dict(color="#F472B6", size=13)))
230
+ fig.update_xaxes(showgrid=False)
231
+ return _layout(fig, title=title)
232
+
233
+
234
+ def bar_chart(d, title, ramp=False):
235
+ colors = [cii_to_hex(v / (max(d["value"]) or 1) * 100) for v in d["value"]] if ramp else VIOLET
236
+ fig = go.Figure(go.Bar(x=d["label"], y=d["value"], marker_color=colors))
237
+ fig.update_xaxes(showgrid=False)
238
+ return _layout(fig, title=title)
239
+
240
+
241
+ def rainbow_gauge(value, title):
242
+ stops = [(0.0, (34, 211, 238)), (0.4, (52, 211, 153)),
243
+ (0.7, (250, 204, 21)), (1.0, (244, 114, 182))]
244
+
245
+ def lerp(tt):
246
+ for i in range(len(stops) - 1):
247
+ t0, c0 = stops[i]
248
+ t1, c1 = stops[i + 1]
249
+ if tt <= t1:
250
+ f = (tt - t0) / (t1 - t0 + 1e-9)
251
+ return tuple(int(c0[j] + (c1[j] - c0[j]) * f) for j in range(3))
252
+ return stops[-1][1]
253
+
254
+ seg = 28
255
+ steps = [{"range": [i / seg * 100, (i + 1) / seg * 100],
256
+ "color": f"rgb{lerp((i + 0.5) / seg)}"} for i in range(seg)]
257
+ fig = go.Figure(go.Indicator(
258
+ mode="gauge+number", value=value, number={"suffix": "%", "font": {"size": 38}},
259
+ gauge={"axis": {"range": [0, 100], "tickwidth": 0},
260
+ "bar": {"color": "rgba(255,255,255,0)"}, "borderwidth": 0, "steps": steps}))
261
+ return _layout(fig, height=250, title=title)
262
+
263
+
264
+ # ---------------- gradient KPI card + donut ----------------
265
+ def sparkline_svg(series, color, w=120, h=36):
266
+ if not series or len(series) < 2:
267
+ return ""
268
+ mn, mx = min(series), max(series)
269
+ rng = (mx - mn) or 1
270
+ pts = " ".join(
271
+ f"{i/(len(series)-1)*w:.1f},{h - (v-mn)/rng*(h-7) - 4:.1f}"
272
+ for i, v in enumerate(series))
273
+ last = pts.split()[-1]
274
+ return (f"<svg width='{w}' height='{h}' viewBox='0 0 {w} {h}' "
275
+ f"preserveAspectRatio='none'><polyline points='{pts}' fill='none' "
276
+ f"stroke='{color}' stroke-width='2' stroke-linecap='round' "
277
+ f"stroke-linejoin='round'/><circle cx='{last.split(',')[0]}' "
278
+ f"cy='{last.split(',')[1]}' r='2.6' fill='{color}'/></svg>")
279
+
280
+
281
+ def bars_svg(vals, colors, w=120, h=36):
282
+ mx = max(vals) or 1
283
+ bw = w / (len(vals) * 1.7)
284
+ gap = bw * 0.7
285
+ rects = "".join(
286
+ f"<rect x='{i*(bw+gap)+gap:.1f}' y='{h-(v/mx)*(h-5)-2:.1f}' width='{bw:.1f}' "
287
+ f"height='{(v/mx)*(h-5):.1f}' rx='2' fill='{colors[i]}'/>"
288
+ for i, v in enumerate(vals))
289
+ return f"<svg width='{w}' height='{h}' viewBox='0 0 {w} {h}'>{rects}</svg>"
290
+
291
+
292
+ def _delta(series, lower_is_better=True):
293
+ if not series or len(series) < 14:
294
+ return ""
295
+ k = min(30, len(series) // 2)
296
+ recent = sum(series[-k:]) / k
297
+ prior = sum(series[-2 * k:-k]) / k
298
+ if prior == 0:
299
+ return ""
300
+ pct = (recent - prior) / prior * 100
301
+ up = pct >= 0
302
+ good = (not up) if lower_is_better else up
303
+ color = "#34D399" if good else "#F87171"
304
+ return (f"<span style='color:{color};font-weight:600;'>{'▲' if up else '▼'} "
305
+ f"{abs(pct):.1f}%</span> <span style='opacity:0.55;font-size:0.72rem;'>"
306
+ f"vs prev {k}d</span>")
307
+
308
+
309
+ def kpi_card(icon, label, value, accent=VIOLET, series=None, viz="spark",
310
+ sub=None, lower_is_better=True):
311
+ if viz == "bars" and series:
312
+ chart = bars_svg(series, ["rgba(170,170,190,0.30)", accent])
313
+ elif series:
314
+ chart = sparkline_svg(series, accent)
315
+ else:
316
+ chart = ""
317
+ delta = sub if sub else _delta(series, lower_is_better)
318
+ delta_html = (f"<div style='font-size:0.78rem;margin-top:7px;'>{delta}</div>"
319
+ if delta else "")
320
+ st.markdown(
321
+ f"<div style='background:rgba(139,92,246,0.06);"
322
+ f"border:1px solid rgba(139,92,246,0.28);border-radius:16px;"
323
+ f"padding:16px 18px;height:152px;box-shadow:0 6px 20px rgba(80,40,160,0.10);'>"
324
+ f"<div style='display:flex;justify-content:space-between;align-items:flex-start;'>"
325
+ f"<div style='width:36px;height:36px;border-radius:10px;background:{accent}26;"
326
+ f"display:flex;align-items:center;justify-content:center;font-size:18px;'>{icon}</div>"
327
+ f"<div>{chart}</div></div>"
328
+ f"<div style='font-size:0.82rem;opacity:0.7;margin-top:10px;'>{label}</div>"
329
+ f"<div style='font-size:1.9rem;font-weight:800;line-height:1.1;'>{value}</div>"
330
+ f"{delta_html}</div>", unsafe_allow_html=True)
331
+
332
+
333
+ def donut(labels, values, title):
334
+ fig = go.Figure(go.Pie(labels=list(labels), values=list(values), hole=0.62,
335
+ marker=dict(colors=DONUT_COLORS), textinfo="percent",
336
+ textfont=dict(color="#fff", size=12), sort=True))
337
+ fig.update_layout(height=300, margin=dict(l=10, r=10, t=42, b=10),
338
+ title=dict(text=title, font=dict(size=14)),
339
+ paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
340
+ legend=dict(orientation="v", x=1, y=0.5, font=dict(size=11)))
341
+ return fig
342
+
343
+
344
+ def active_theme():
345
+ """Active theme ('light'/'dark'); defaults to dark on first run / older Streamlit."""
346
+ try:
347
+ tp = st.context.theme.type
348
+ return tp if tp in ("light", "dark") else "dark"
349
+ except Exception:
350
+ return "dark"
351
+
352
+
353
+ # ==================== APP ====================
354
+ inject_css()
355
+ require_login() # comment this line out to disable the login gate
356
+ hot, fc, off, meta = load()
357
+ THEME = active_theme()
358
+ ACCENT = "#FACC15" if THEME == "dark" else "#2563EB" # gold text -> yellow (dark) / blue (light)
359
+ area_vocab = build_area_vocab(hot)
360
+ hot["fill"] = hot["cii"].apply(cii_color)
361
+ mm = meta["model_metrics"]
362
+
363
+ NAV_KEYS = ["tab_map", "tab_ops", "tab_trends", "tab_rank", "tab_off", "tab_fc"]
364
+ NAV_ICONS = ["geo-alt-fill", "broadcast", "graph-up", "list-check",
365
+ "exclamation-triangle-fill", "magic"]
366
+
367
+ with st.sidebar:
368
+ st.markdown(
369
+ "<div style='display:flex;align-items:center;gap:11px;margin:2px 0 14px 0;'>"
370
+ "<div style='width:42px;height:42px;border-radius:12px;"
371
+ "background:linear-gradient(135deg,#7C3AED,#EC4899);color:#fff;font-weight:800;"
372
+ "font-size:23px;display:flex;align-items:center;justify-content:center;"
373
+ "box-shadow:0 4px 16px rgba(124,77,255,0.45);'>D</div>"
374
+ "<div><div style='font-weight:800;font-size:1.18rem;line-height:1.05;'>DRISHTI</div>"
375
+ "<div style='font-size:0.7rem;opacity:0.6;letter-spacing:0.02em;'>"
376
+ "दृष्टि · Bengaluru Traffic Police</div></div></div>",
377
+ unsafe_allow_html=True)
378
+ if st.session_state.get("user"):
379
+ lo1, lo2 = st.columns([2, 1])
380
+ lo1.markdown(
381
+ f"<div style='background:rgba(139,92,246,0.12);border-radius:10px;"
382
+ f"padding:7px 11px;font-size:0.82rem;'>👤 <b>{st.session_state['user']}</b>"
383
+ f"</div>", unsafe_allow_html=True)
384
+ if lo2.button("Log out", use_container_width=True):
385
+ st.session_state.clear()
386
+ st.query_params.clear()
387
+ st.rerun()
388
+ st.markdown("<div style='height:8px;'></div>", unsafe_allow_html=True)
389
+ lang_label = st.selectbox("🌐 " + t("language", "en"), list(LANGS.keys()))
390
+ lang = LANGS[lang_label]
391
+ st.markdown("<div style='font-size:0.7rem;font-weight:700;letter-spacing:0.08em;"
392
+ "opacity:0.45;margin:10px 0 2px 2px;'>NAVIGATION</div>",
393
+ unsafe_allow_html=True)
394
+ labels = [re.sub(r"^[^\w]+", "", t(k, lang)).strip() for k in NAV_KEYS]
395
+ if HAS_MENU:
396
+ choice = option_menu(
397
+ None, labels, icons=NAV_ICONS, default_index=0,
398
+ styles={"container": {"background-color": "transparent", "padding": "2px 0"},
399
+ "icon": {"color": "#9B8FC2", "font-size": "15px"},
400
+ "nav-link": {"color": "#9B8FC2", "font-size": "14px",
401
+ "border-radius": "10px", "margin": "3px 0",
402
+ "--hover-color": "rgba(139,92,246,0.15)"},
403
+ "nav-link-selected": {"background-color": VIOLET, "color": "#fff",
404
+ "font-weight": "600"}})
405
+ else:
406
+ st.caption("⚠️ run `pip install -r requirements.txt` for the icon nav")
407
+ choice = st.radio("Navigate", labels, label_visibility="collapsed")
408
+ section = NAV_KEYS[labels.index(choice)]
409
+
410
+ st.divider()
411
+ st.markdown("<div style='font-size:0.72rem;font-weight:700;letter-spacing:0.06em;"
412
+ "opacity:0.5;margin-bottom:6px;'>🎨 THEME</div>", unsafe_allow_html=True)
413
+ _cur = st.query_params.get("theme", "system")
414
+ _keep = "auth=1&" if st.session_state.get("authed") else ""
415
+ _seg = [("system", "◐", "System"), ("light", "☀", "Light"), ("dark", "🌙", "Dark")]
416
+ _html = ("<div style='display:flex;gap:4px;background:rgba(139,92,246,0.10);"
417
+ "border:1px solid rgba(139,92,246,0.25);border-radius:11px;padding:4px;'>")
418
+ for _val, _ic, _lab in _seg:
419
+ _active = (_cur == _val)
420
+ _href = "?" + _keep + ("" if _val == "system" else f"theme={_val}")
421
+ _href = _href.rstrip("&") or "?"
422
+ _style = ("background:#8B5CF6;color:#fff;box-shadow:0 2px 8px rgba(139,92,246,0.4);"
423
+ if _active else "color:#9B8FC2;")
424
+ _html += (f"<a href='{_href}' target='_self' style='flex:1;text-align:center;"
425
+ f"padding:7px 2px;border-radius:8px;text-decoration:none;line-height:1.25;"
426
+ f"font-size:0.7rem;font-weight:600;{_style}'>"
427
+ f"<div style='font-size:1.05rem;'>{_ic}</div>{_lab}</a>")
428
+ _html += "</div>"
429
+ st.markdown(_html, unsafe_allow_html=True)
430
+ st.divider()
431
+ if "history" not in st.session_state:
432
+ st.session_state.history = []
433
+ st.markdown("**" + t("history", lang) + "**")
434
+ if st.session_state.history:
435
+ for h in reversed(st.session_state.history[-8:]):
436
+ st.caption(f"• {h['q']} _( {h['t']} )_")
437
+ if st.button(t("clear_history", lang)):
438
+ st.session_state.history = []
439
+ st.rerun()
440
+ else:
441
+ st.caption(t("no_history", lang))
442
+ st.markdown(
443
+ f"<div style='margin-top:14px;background:rgba(52,211,153,0.10);"
444
+ f"border:1px solid rgba(52,211,153,0.3);border-radius:12px;padding:10px 12px;"
445
+ f"font-size:0.76rem;'>🟢 <b>Live</b> · {meta['n_cells']:,} zones monitored<br>"
446
+ f"<span style='opacity:0.65;'>Forecast MAE {mm['valid_mae']} · "
447
+ f"↓{mm.get('improvement_pct','')}% vs baseline</span></div>",
448
+ unsafe_allow_html=True)
449
+
450
+ # ----- header + KPIs (always) -----
451
+ st.markdown(
452
+ "<h1 style='line-height:1.55;margin:0 0 2px 0;padding-top:0.14em;font-size:2.55rem;"
453
+ "font-weight:800;letter-spacing:-0.01em;'>दृष्टि — DRISHTI</h1>",
454
+ unsafe_allow_html=True)
455
+ st.markdown(
456
+ f"<div style='color:{ACCENT};font-weight:600;font-size:1.02rem;'>"
457
+ "Digital Real-time Intelligence for Smart Hotspot &amp; Traffic Insights</div>",
458
+ unsafe_allow_html=True)
459
+ st.caption("हर सड़क पर नज़र, हर सफ़र आसान · "
460
+ f"Bengaluru · {meta['date_range'][0]} → {meta['date_range'][1]} · "
461
+ f"{meta['n_records']:,} violations · {meta['n_cells']:,} zones")
462
+
463
+ sp = meta.get("kpi_sparks", {})
464
+ kc = st.columns(4)
465
+ with kc[0]:
466
+ kpi_card("🚗", t("kpi_violations", lang), f"{meta['n_records']:,}",
467
+ accent="#8B5CF6", series=sp.get("violations"))
468
+ with kc[1]:
469
+ kpi_card("📍", t("kpi_zones", lang), f"{meta['n_cells']:,}",
470
+ accent="#22D3EE", series=sp.get("zones"))
471
+ with kc[2]:
472
+ kpi_card("🔥", t("kpi_high", lang), f"{int((hot.cii >= 70).sum()):,}",
473
+ accent=ACCENT, series=sp.get("peak"))
474
+ with kc[3]:
475
+ bl = mm.get("baseline_lag7_mae", 1.0)
476
+ kpi_card("🎯", t("kpi_mae", lang), mm["valid_mae"], accent="#34D399",
477
+ series=[bl, mm["valid_mae"]], viz="bars",
478
+ sub=(f"<span style='color:#34D399;font-weight:600;'>↓ "
479
+ f"{mm['improvement_pct']}% vs baseline</span>"
480
+ if mm.get("improvement_pct") else None))
481
+ st.write("")
482
+
483
+ # ----- voice / command bar (always) -----
484
+ st.subheader("🎙️ " + t("voice_nav", lang))
485
+ cv1, cv2 = st.columns([3, 1])
486
+ with cv1:
487
+ typed = st.text_input(t("ask", lang), placeholder=t("placeholder", lang))
488
+ spoken = None
489
+ with cv2:
490
+ st.write("")
491
+ if HAS_MIC:
492
+ spoken = speech_to_text(language=SPEECH_LANG.get(lang, "en-IN"),
493
+ start_prompt=t("speak", lang), stop_prompt=t("stop", lang),
494
+ just_once=True, use_container_width=True, key="stt")
495
+ mic_status = ("🎤 mic ready (use Chrome, allow the mic, stay online)" if HAS_MIC
496
+ else "⚠️ mic component missing — run `pip install -r requirements.txt`")
497
+ st.caption(mic_status + " · 🔊 read-aloud works in English / Kannada / Hindi")
498
+
499
+ query = spoken or typed
500
+ if query:
501
+ cmd = parse_command(query)
502
+ res = hot.copy()
503
+ area = resolve_area(query, lang, area_vocab)
504
+ if area:
505
+ res = res[res["location"].str.contains(re.escape(area), case=False, na=False)]
506
+ if "min_cii" in cmd:
507
+ res = res[res["cii"] >= cmd["min_cii"]]
508
+ res = res.head(cmd.get("topn", 10))
509
+ st.session_state.history.append(
510
+ {"q": query, "t": datetime.now().strftime("%H:%M"), "lang": lang,
511
+ "area": area or "", "results": len(res)})
512
+ tag = f" → {area}" if area else ""
513
+ st.markdown(f"**{t('understood', lang)}:** _{query}_{tag} ({len(res)})")
514
+ st.dataframe(res[["cii_rank", "cii", "location", "junction_name", "n_violations",
515
+ "top_violation"]].rename(columns={
516
+ "cii_rank": "Rank", "cii": "CII", "location": "Location",
517
+ "junction_name": "Junction", "n_violations": "Violations",
518
+ "top_violation": "Top violation"}), use_container_width=True, hide_index=True)
519
+ if len(res):
520
+ say_lang = cmd.get("say_lang", lang) # query language overrides UI language
521
+ speak_n = min(len(res), cmd.get("topn", 3), 8)
522
+ summary = speak_summary(res, say_lang, speak_n)
523
+ lang_name = {"en": "English", "hi": "Hindi", "kn": "Kannada"}[say_lang]
524
+ if cmd.get("speak"):
525
+ play_tts(summary, say_lang)
526
+ if st.button(f"🔊 Read aloud ({lang_name})", key="read_btn"):
527
+ play_tts(summary, say_lang)
528
+
529
+ st.divider()
530
+
531
+ # ==================== sections ====================
532
+ if section == "tab_map":
533
+ min_cii = st.slider(t("min_cii", lang), 0, 100, 40, 5)
534
+ view = hot[hot.cii >= min_cii]
535
+ st.caption(f"{len(view):,} / {len(hot):,}")
536
+ layer = pdk.Layer("H3HexagonLayer", view, pickable=True, filled=True, extruded=True,
537
+ get_hexagon="h3", get_fill_color="fill",
538
+ get_elevation="cii", elevation_scale=18, opacity=0.55)
539
+ st.pydeck_chart(pdk.Deck(
540
+ layers=[layer],
541
+ initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=45),
542
+ map_style="dark",
543
+ tooltip={"html": "<b>CII {cii}</b> (rank #{cii_rank})<br/>{location}<br/>"
544
+ "<b>{n_violations}</b> violations · <b>{active_days}</b> days<br/>"
545
+ "Top: {top_violation}"},
546
+ ), use_container_width=True, height=560)
547
+
548
+ elif section == "tab_ops":
549
+ st.subheader("🚨 Live operations — congestion alerts & enforcement")
550
+ st.caption("⚠️ Simulated live feed: historical hotspots replayed as real-time alerts. "
551
+ "In production this is driven by live ANPR / e-challan feeds, with push "
552
+ "notifications to field officers and control-room displays.")
553
+
554
+ thr = st.slider("Trigger an alert when CII ≥", 50, 100, 75, 5)
555
+ alerts = (hot[hot.cii >= thr].sort_values("cii", ascending=False)
556
+ .head(20).reset_index(drop=True))
557
+
558
+ def rec_action(c):
559
+ if c >= 88:
560
+ return "🚨 Deploy patrol + initiate towing"
561
+ if c >= 78:
562
+ return "⚠️ On-spot enforcement / challan drive"
563
+ return "👁 Monitor + advisory signage"
564
+ alerts["Recommended action"] = alerts["cii"].apply(rec_action)
565
+
566
+ if st.checkbox("🔴 Live mode (auto-refresh)") and HAS_AUTOREFRESH and len(alerts):
567
+ n = st_autorefresh(interval=2500, key="ops")
568
+ latest = alerts.iloc[n % len(alerts)]
569
+ st.markdown(
570
+ "<div style='background:linear-gradient(135deg,#EF4444,#EC4899);"
571
+ "border-radius:14px;padding:14px 18px;color:#fff;font-weight:600;'>"
572
+ f"🔴 LIVE · {datetime.now().strftime('%H:%M:%S')} · "
573
+ f"{latest['location'].split(',')[0]} · CII {latest['cii']:.0f} · "
574
+ f"{latest['Recommended action']}</div>", unsafe_allow_html=True)
575
+ st.write("")
576
+
577
+ m = st.columns(3)
578
+ m[0].metric("Active alerts", len(alerts))
579
+ m[1].metric("Critical (CII ≥ 88)", int((alerts.cii >= 88).sum()))
580
+ m[2].metric("Zones monitored", f"{len(hot):,}")
581
+
582
+ st.dataframe(alerts[["cii_rank", "cii", "location", "junction_name", "n_violations",
583
+ "top_violation", "Recommended action"]].rename(columns={
584
+ "cii_rank": "Rank", "cii": "CII", "location": "Location",
585
+ "junction_name": "Junction", "n_violations": "Violations",
586
+ "top_violation": "Top violation"}),
587
+ use_container_width=True, hide_index=True, height=340)
588
+
589
+ st.markdown("#### 🚓 Impose a regulatory action")
590
+ if "dispatch_log" not in st.session_state:
591
+ st.session_state.dispatch_log = []
592
+ e1, e2 = st.columns(2)
593
+ zone = e1.selectbox("Zone", alerts["location"].tolist() if len(alerts) else ["—"])
594
+ act = e2.selectbox("Action", ["Deploy patrol", "Issue no-parking enforcement",
595
+ "Tow & fine", "Install signage / barricade",
596
+ "Escalate to control room", "Mark resolved"])
597
+ if st.button("📨 Dispatch action"):
598
+ st.session_state.dispatch_log.insert(0, {
599
+ "Time": datetime.now().strftime("%H:%M:%S"),
600
+ "Officer": st.session_state.get("user", "—"),
601
+ "Zone": zone.split(",")[0], "Action": act})
602
+ st.success(f"Dispatched: {act} → {zone.split(',')[0]}")
603
+ if st.session_state.dispatch_log:
604
+ st.markdown("**Dispatch log — this session**")
605
+ st.dataframe(pd.DataFrame(st.session_state.dispatch_log),
606
+ use_container_width=True, hide_index=True, height=200)
607
+
608
+ elif section == "tab_trends":
609
+ tr = load_trends()
610
+ byday = load_byday()
611
+ daily = tr[tr.kind == "daily"].sort_values("order").reset_index(drop=True)
612
+ DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
613
+
614
+ cursor = None
615
+ live = st.checkbox(t("live_replay", lang), value=False)
616
+ if live and HAS_AUTOREFRESH:
617
+ n = st_autorefresh(interval=1200, key="replay")
618
+ cursor = n % len(daily)
619
+ row = daily.iloc[cursor]
620
+ lc1, lc2 = st.columns(2)
621
+ lc1.metric("Replay day", row["label"])
622
+ lc2.metric("Violations that day", int(row["value"]))
623
+ elif live and not HAS_AUTOREFRESH:
624
+ st.caption("Install `streamlit-autorefresh` to enable live replay.")
625
+
626
+ if cursor is not None:
627
+ # cumulative time-lapse for the line + animated bars
628
+ rday = daily.iloc[cursor]["label"]
629
+ dline = daily.iloc[:cursor + 1]
630
+ bh = (byday[(byday.dim == "hour") & (byday.date <= rday)]
631
+ .groupby("key")["value"].sum().reset_index())
632
+ bh.columns = ["label", "value"]
633
+ bh["order"] = bh["label"].astype(int)
634
+ hourly = bh.sort_values("order")
635
+ dd = dline.copy()
636
+ dd["dow"] = pd.to_datetime(dd["label"]).dt.dayofweek
637
+ bw = dd.groupby("dow")["value"].sum().reindex(range(7), fill_value=0)
638
+ dow = pd.DataFrame({"label": DOW, "value": bw.values, "order": range(7)})
639
+ line = line_chart(dline, t("t_daily", lang), mark_last=True)
640
+ else:
641
+ hourly = tr[tr.kind == "hourly"].sort_values("order")
642
+ dow = tr[tr.kind == "dow"].sort_values("order")
643
+ line = line_chart(daily, t("t_daily", lang))
644
+
645
+ veh = tr[tr.kind == "vehicle"].sort_values("value", ascending=False)
646
+ vtype = tr[tr.kind == "vtype"].sort_values("value", ascending=False)
647
+
648
+ st.plotly_chart(line, use_container_width=True)
649
+ r1 = st.columns(2)
650
+ with r1[0]:
651
+ st.plotly_chart(bar_chart(hourly, t("t_hourly", lang), ramp=True), use_container_width=True)
652
+ with r1[1]:
653
+ st.plotly_chart(bar_chart(dow, t("t_dow", lang)), use_container_width=True)
654
+ r2 = st.columns(2)
655
+ with r2[0]:
656
+ st.plotly_chart(donut(veh["label"], veh["value"], t("t_vehicle", lang)),
657
+ use_container_width=True)
658
+ with r2[1]:
659
+ st.plotly_chart(donut(vtype["label"], vtype["value"], t("t_vtype", lang)),
660
+ use_container_width=True)
661
+ g = st.columns([1, 2, 1])
662
+ with g[1]:
663
+ conc = round(hot.head(100)["n_violations"].sum() / meta["n_records"] * 100, 1)
664
+ st.plotly_chart(rainbow_gauge(conc, t("t_gauge", lang)), use_container_width=True)
665
+
666
+ elif section == "tab_rank":
667
+ topn = st.slider(t("top_n_zones", lang), 5, 50, 20, 5)
668
+ cols = ["cii_rank", "cii", "location", "junction_name", "police_station",
669
+ "n_violations", "active_days", "persistence", "peak_share", "top_violation"]
670
+ tbl = hot[cols].head(topn).copy()
671
+ tbl["persistence"] = (tbl["persistence"] * 100).round(0).astype(int).astype(str) + "%"
672
+ tbl["peak_share"] = (tbl["peak_share"] * 100).round(0).astype(int).astype(str) + "%"
673
+ st.dataframe(tbl.rename(columns={
674
+ "cii_rank": "Rank", "cii": "CII", "location": "Location",
675
+ "junction_name": "Nearest junction", "police_station": "Police station",
676
+ "n_violations": "Violations", "active_days": "Active days",
677
+ "persistence": "Persistence", "peak_share": "Peak share",
678
+ "top_violation": "Top violation"}),
679
+ use_container_width=True, hide_index=True, height=520)
680
+ st.download_button(t("dl_priorities", lang), hot[cols].head(topn).to_csv(index=False),
681
+ "enforcement_priorities.csv", mime="text/csv")
682
+
683
+ elif section == "tab_off":
684
+ s = meta.get("offender_summary", {})
685
+ o1, o2, o3 = st.columns(3)
686
+ o1.metric(t("repeat_offenders", lang), f"{s.get('repeat_offenders', 0):,}")
687
+ o2.metric(t("share_violations", lang), f"{s.get('repeat_share_pct', 0)}%")
688
+ o3.metric(t("worst_vehicle", lang), f"{s.get('worst_count', 0)}")
689
+
690
+ st.markdown("##### Offender intelligence")
691
+ cc = st.columns(2)
692
+ with cc[0]:
693
+ topo = off.head(12)
694
+ st.plotly_chart(bar_chart(
695
+ pd.DataFrame({"label": topo["vehicle_number"], "value": topo["n_violations"]}),
696
+ "Top 12 offenders · violations", ramp=True), use_container_width=True)
697
+ with cc[1]:
698
+ vt = off.groupby("vehicle_type").size().sort_values(ascending=False)
699
+ st.plotly_chart(donut(vt.index.tolist(), vt.values.tolist(),
700
+ "Offenders by vehicle type"), use_container_width=True)
701
+ zh = off.groupby("n_zones").size().reset_index(name="cnt").sort_values("n_zones")
702
+ st.plotly_chart(bar_chart(
703
+ pd.DataFrame({"label": zh["n_zones"].astype(str) + " zone(s)", "value": zh["cnt"]}),
704
+ "Spatial spread · how many distinct zones each offender hits"),
705
+ use_container_width=True)
706
+
707
+ q = st.text_input(t("search_vehicle", lang), key="off_search")
708
+ view = off
709
+ if q:
710
+ view = off[off["vehicle_number"].str.contains(q, case=False, na=False)
711
+ | off["top_location"].str.contains(q, case=False, na=False)]
712
+ n_off = st.slider(t("top_n_off", lang), 5, 100, 25, 5, key="off_n")
713
+ st.dataframe(view.head(n_off)[["rank", "vehicle_number", "n_violations", "n_zones",
714
+ "vehicle_type", "top_location", "first_seen",
715
+ "last_seen"]].rename(columns={
716
+ "rank": "Rank", "vehicle_number": "Vehicle (anon.)", "n_violations": "Violations",
717
+ "n_zones": "Zones hit", "vehicle_type": "Type", "top_location": "Most-seen location",
718
+ "first_seen": "First seen", "last_seen": "Last seen"}),
719
+ use_container_width=True, hide_index=True, height=460)
720
+ st.download_button(t("dl_offenders", lang), view.head(n_off).to_csv(index=False),
721
+ "repeat_offenders.csv", mime="text/csv", key="off_dl")
722
+
723
+ elif section == "tab_fc":
724
+ day = fc["forecast_for"].iat[0]
725
+ st.caption(f"{day}")
726
+ topf = fc.head(25).copy()
727
+ fc_layer = pdk.Layer("ScatterplotLayer", topf, pickable=True, get_position="[lon, lat]",
728
+ get_radius="pred_intensity * 6 + 60",
729
+ get_fill_color="[244, 114, 182, 160]")
730
+ st.pydeck_chart(pdk.Deck(
731
+ layers=[fc_layer],
732
+ initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0),
733
+ map_style="dark",
734
+ tooltip={"html": "Risk #{risk_rank} · {pred_intensity}<br/>{location}"},
735
+ ), use_container_width=True, height=520)
736
+ st.dataframe(topf[["risk_rank", "location", "junction_name",
737
+ "pred_intensity", "cii"]].rename(columns={
738
+ "risk_rank": "Risk rank", "location": "Location", "junction_name": "Nearest junction",
739
+ "pred_intensity": "Predicted intensity", "cii": "Current CII"}),
740
+ use_container_width=True, hide_index=True)
741
+
742
+ st.divider()
743
+ st.caption(f"CII = severity-weighted volume (45%) + persistence (30%) + peak "
744
+ f"concentration (25%), amplified near junctions. Forecast: LightGBM, "
745
+ f"MAE {mm['valid_mae']} ({mm['improvement_pct']}% better than baseline).")
data/processed/forecast.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a3dc6af6e39b79339ec846f8910b3aa1474501ba5bb1c81fb15cd16b7e441707
3
+ size 38055
data/processed/hotspots.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da19eea574bc73e04e05843033d342fba40cde8d2b4c61139aed3824e098183f
3
+ size 197264
data/processed/meta.json ADDED
@@ -0,0 +1,503 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "date_range": [
3
+ "2023-11-10",
4
+ "2024-04-08"
5
+ ],
6
+ "total_days": 151,
7
+ "n_records": 298445,
8
+ "n_cells": 2534,
9
+ "h3_res": 9,
10
+ "severity_weights": {
11
+ "PARKING IN A MAIN ROAD": 1.0,
12
+ "PARKING NEAR ROAD CROSSING": 0.9,
13
+ "PARKING NEAR TRAFFIC LIGHT OR ZEBRA CROSS": 0.9,
14
+ "DOUBLE PARKING": 0.85,
15
+ "PARKING NEAR BUSTOP/SCHOOL/HOSPITAL ETC": 0.7,
16
+ "PARKING OPPOSITE TO ANOTHER PARKED VEHICLE": 0.6,
17
+ "PARKING ON FOOTPATH": 0.5,
18
+ "WRONG PARKING": 0.5,
19
+ "PARKING OTHER THAN BUS STOP": 0.45,
20
+ "NO PARKING": 0.4
21
+ },
22
+ "cii_weights": {
23
+ "volume": 0.45,
24
+ "persistence": 0.3,
25
+ "peak": 0.25
26
+ },
27
+ "cii_junction_alpha": 0.5,
28
+ "offender_summary": {
29
+ "distinct_vehicles": 231890,
30
+ "repeat_offenders": 35585,
31
+ "repeat_share_pct": 34.2,
32
+ "worst_count": 55
33
+ },
34
+ "model_metrics": {
35
+ "valid_mae": 0.708,
36
+ "valid_rmse": 2.747,
37
+ "baseline_lag7_mae": 1.078,
38
+ "improvement_pct": 34.4,
39
+ "n_modeled_cells": 1047,
40
+ "best_iteration": 282
41
+ },
42
+ "kpi_sparks": {
43
+ "violations": [
44
+ 1173,
45
+ 2094,
46
+ 2521,
47
+ 1594,
48
+ 2194,
49
+ 2202,
50
+ 2327,
51
+ 2719,
52
+ 2974,
53
+ 2554,
54
+ 1758,
55
+ 2111,
56
+ 2206,
57
+ 2047,
58
+ 1003,
59
+ 1965,
60
+ 2253,
61
+ 1661,
62
+ 2316,
63
+ 1998,
64
+ 1834,
65
+ 2175,
66
+ 1898,
67
+ 2215,
68
+ 1621,
69
+ 1398,
70
+ 1943,
71
+ 2249,
72
+ 2207,
73
+ 2226,
74
+ 2321,
75
+ 1537,
76
+ 2061,
77
+ 2418,
78
+ 2437,
79
+ 1517,
80
+ 1828,
81
+ 2342,
82
+ 1472,
83
+ 2102,
84
+ 2187,
85
+ 2346,
86
+ 1987,
87
+ 2244,
88
+ 2711,
89
+ 1394,
90
+ 1912,
91
+ 2301,
92
+ 2111,
93
+ 2056,
94
+ 1926,
95
+ 2775,
96
+ 1460,
97
+ 2351,
98
+ 1969,
99
+ 2182,
100
+ 2464,
101
+ 2535,
102
+ 2991,
103
+ 1644,
104
+ 2366,
105
+ 2122,
106
+ 2081,
107
+ 2111,
108
+ 2022,
109
+ 2662,
110
+ 1744,
111
+ 2690,
112
+ 2243,
113
+ 2395,
114
+ 2404,
115
+ 2046,
116
+ 2924,
117
+ 2455,
118
+ 2052,
119
+ 2207,
120
+ 2508,
121
+ 1228,
122
+ 1208,
123
+ 1075,
124
+ 1293,
125
+ 2334,
126
+ 1713,
127
+ 1854,
128
+ 1994,
129
+ 1885,
130
+ 1832,
131
+ 1499,
132
+ 2056,
133
+ 2244,
134
+ 1943,
135
+ 1771,
136
+ 1946,
137
+ 2219,
138
+ 1515,
139
+ 1763,
140
+ 1780,
141
+ 2046,
142
+ 2094,
143
+ 1900,
144
+ 2049,
145
+ 1479,
146
+ 1631,
147
+ 1637,
148
+ 1730,
149
+ 2085,
150
+ 2432,
151
+ 2051,
152
+ 1737,
153
+ 1793,
154
+ 1650,
155
+ 2045,
156
+ 1587,
157
+ 2002,
158
+ 2106,
159
+ 1316,
160
+ 2002,
161
+ 1928,
162
+ 1758,
163
+ 1922,
164
+ 2054,
165
+ 1879,
166
+ 1113,
167
+ 2289,
168
+ 2099,
169
+ 1961,
170
+ 1938,
171
+ 1656,
172
+ 1708,
173
+ 1381,
174
+ 1497,
175
+ 1369,
176
+ 1503,
177
+ 1441,
178
+ 1543,
179
+ 2070,
180
+ 1274,
181
+ 1604,
182
+ 1887,
183
+ 2153,
184
+ 1570,
185
+ 2267,
186
+ 2576,
187
+ 1689,
188
+ 2175,
189
+ 1871,
190
+ 2037,
191
+ 1418,
192
+ 1872,
193
+ 2326,
194
+ 2044
195
+ ],
196
+ "zones": [
197
+ 157,
198
+ 314,
199
+ 326,
200
+ 224,
201
+ 359,
202
+ 327,
203
+ 351,
204
+ 329,
205
+ 343,
206
+ 331,
207
+ 227,
208
+ 330,
209
+ 319,
210
+ 311,
211
+ 133,
212
+ 298,
213
+ 295,
214
+ 212,
215
+ 330,
216
+ 311,
217
+ 319,
218
+ 353,
219
+ 290,
220
+ 301,
221
+ 206,
222
+ 201,
223
+ 311,
224
+ 359,
225
+ 338,
226
+ 327,
227
+ 328,
228
+ 215,
229
+ 332,
230
+ 317,
231
+ 321,
232
+ 303,
233
+ 307,
234
+ 294,
235
+ 216,
236
+ 303,
237
+ 358,
238
+ 318,
239
+ 353,
240
+ 324,
241
+ 333,
242
+ 187,
243
+ 287,
244
+ 285,
245
+ 333,
246
+ 299,
247
+ 314,
248
+ 335,
249
+ 209,
250
+ 327,
251
+ 322,
252
+ 331,
253
+ 312,
254
+ 347,
255
+ 353,
256
+ 199,
257
+ 329,
258
+ 341,
259
+ 321,
260
+ 316,
261
+ 341,
262
+ 315,
263
+ 231,
264
+ 371,
265
+ 321,
266
+ 342,
267
+ 302,
268
+ 316,
269
+ 316,
270
+ 223,
271
+ 316,
272
+ 303,
273
+ 322,
274
+ 110,
275
+ 195,
276
+ 154,
277
+ 189,
278
+ 319,
279
+ 294,
280
+ 305,
281
+ 309,
282
+ 255,
283
+ 260,
284
+ 194,
285
+ 316,
286
+ 303,
287
+ 295,
288
+ 264,
289
+ 235,
290
+ 303,
291
+ 178,
292
+ 278,
293
+ 286,
294
+ 289,
295
+ 309,
296
+ 294,
297
+ 293,
298
+ 171,
299
+ 271,
300
+ 245,
301
+ 252,
302
+ 268,
303
+ 348,
304
+ 291,
305
+ 241,
306
+ 300,
307
+ 264,
308
+ 310,
309
+ 277,
310
+ 285,
311
+ 260,
312
+ 198,
313
+ 287,
314
+ 308,
315
+ 254,
316
+ 307,
317
+ 270,
318
+ 261,
319
+ 183,
320
+ 303,
321
+ 295,
322
+ 335,
323
+ 312,
324
+ 262,
325
+ 237,
326
+ 156,
327
+ 235,
328
+ 257,
329
+ 273,
330
+ 253,
331
+ 305,
332
+ 301,
333
+ 185,
334
+ 267,
335
+ 302,
336
+ 307,
337
+ 274,
338
+ 311,
339
+ 341,
340
+ 216,
341
+ 336,
342
+ 303,
343
+ 317,
344
+ 227,
345
+ 294,
346
+ 328,
347
+ 227
348
+ ],
349
+ "peak": [
350
+ 322,
351
+ 652,
352
+ 982,
353
+ 386,
354
+ 666,
355
+ 672,
356
+ 511,
357
+ 718,
358
+ 1007,
359
+ 826,
360
+ 498,
361
+ 710,
362
+ 631,
363
+ 589,
364
+ 282,
365
+ 668,
366
+ 644,
367
+ 678,
368
+ 573,
369
+ 635,
370
+ 412,
371
+ 552,
372
+ 516,
373
+ 765,
374
+ 340,
375
+ 529,
376
+ 504,
377
+ 685,
378
+ 666,
379
+ 713,
380
+ 615,
381
+ 377,
382
+ 549,
383
+ 574,
384
+ 588,
385
+ 329,
386
+ 380,
387
+ 677,
388
+ 554,
389
+ 701,
390
+ 446,
391
+ 566,
392
+ 501,
393
+ 728,
394
+ 754,
395
+ 408,
396
+ 596,
397
+ 726,
398
+ 603,
399
+ 452,
400
+ 500,
401
+ 1127,
402
+ 602,
403
+ 709,
404
+ 450,
405
+ 599,
406
+ 687,
407
+ 609,
408
+ 947,
409
+ 536,
410
+ 777,
411
+ 493,
412
+ 444,
413
+ 478,
414
+ 479,
415
+ 922,
416
+ 394,
417
+ 777,
418
+ 733,
419
+ 604,
420
+ 770,
421
+ 526,
422
+ 933,
423
+ 926,
424
+ 624,
425
+ 784,
426
+ 644,
427
+ 405,
428
+ 320,
429
+ 370,
430
+ 371,
431
+ 714,
432
+ 491,
433
+ 545,
434
+ 465,
435
+ 460,
436
+ 637,
437
+ 585,
438
+ 581,
439
+ 683,
440
+ 493,
441
+ 548,
442
+ 481,
443
+ 783,
444
+ 483,
445
+ 486,
446
+ 467,
447
+ 721,
448
+ 402,
449
+ 645,
450
+ 555,
451
+ 527,
452
+ 531,
453
+ 524,
454
+ 491,
455
+ 529,
456
+ 402,
457
+ 440,
458
+ 518,
459
+ 455,
460
+ 316,
461
+ 651,
462
+ 364,
463
+ 558,
464
+ 575,
465
+ 264,
466
+ 468,
467
+ 557,
468
+ 587,
469
+ 599,
470
+ 527,
471
+ 521,
472
+ 322,
473
+ 788,
474
+ 488,
475
+ 627,
476
+ 470,
477
+ 471,
478
+ 443,
479
+ 443,
480
+ 286,
481
+ 284,
482
+ 447,
483
+ 378,
484
+ 410,
485
+ 686,
486
+ 324,
487
+ 308,
488
+ 513,
489
+ 680,
490
+ 543,
491
+ 573,
492
+ 828,
493
+ 684,
494
+ 633,
495
+ 646,
496
+ 616,
497
+ 419,
498
+ 592,
499
+ 947,
500
+ 444
501
+ ]
502
+ }
503
+ }
data/processed/offenders.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:2ecb4d5fa48b4a710454fa6afa3355e4d6a771c03be4d20101a8cd1cb73bc3d8
3
+ size 20041
data/processed/trends.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9e4e3d2ba5eaeb71beaf3da62eab97479f3591fff1be16c16b82c41af10a1bff
3
+ size 5847
data/processed/trends_byday.parquet ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:cae90c7bc913a452e66d3098ceb8002d1fdf4db0804186eede01006a740d9c62
3
+ size 15406
docs/methodology.md ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Methodology — Congestion Impact Index (CII)
2
+
3
+ ## Problem framing
4
+ The brief asks us to *detect illegal-parking hotspots and quantify their impact
5
+ on traffic flow*. The dataset contains 298k+ parking-enforcement records with
6
+ location, time, and violation type, but **no direct traffic-speed/volume
7
+ measurement**. Rather than claim a flow metric we do not have, we build a
8
+ **transparent proxy** for flow impact, derived only from enforcement data, with
9
+ every weight exposed and defensible.
10
+
11
+ ## Pipeline
12
+ 1. **Clean** — keep records inside the Bengaluru bounding box with valid
13
+ timestamps; parse the multi-label `violation_type`; keep only parking-
14
+ relevant violations.
15
+ 2. **Spatial unit** — index every record to an **H3 resolution-9 hexagon**
16
+ (~174 m edge ≈ a block / deployable patrol zone). Deterministic and
17
+ map-friendly.
18
+ 3. **Per-cell statistics** — severity-weighted volume, active-day persistence,
19
+ peak-hour concentration, junction proximity.
20
+ 4. **CII** — combine the components (below).
21
+ 5. **Forecast** — a LightGBM model predicts next-day intensity per cell.
22
+
23
+ ## Severity weighting
24
+ Each violation type is weighted by how much it physically blocks *moving*
25
+ traffic (not legal severity):
26
+
27
+ | Violation | Weight | Why |
28
+ |---|---|---|
29
+ | Parking in a main road | 1.00 | blocks the carriageway |
30
+ | Near road crossing / traffic light | 0.90 | blocks turning / sightlines |
31
+ | Double parking | 0.85 | removes a live lane |
32
+ | Near bus-stop / school / hospital | 0.70 | high-churn frontage |
33
+ | On footpath | 0.50 | pushes pedestrians into the road |
34
+ | Wrong parking | 0.50 | partial obstruction |
35
+ | No parking | 0.40 | designated-clear zone |
36
+
37
+ A record's severity is the **max** weight across its violations.
38
+
39
+ ## The index
40
+ For each cell, with rank-normalised components in [0, 1]:
41
+
42
+ ```
43
+ base = 0.45·volume + 0.30·persistence + 0.25·peak_concentration
44
+ CII = base × (1 + 0.50·junction_share) # then scaled to 0–100
45
+ ```
46
+
47
+ - **Volume** — total severity-weighted violations (how bad, weighted).
48
+ - **Persistence** — active days ÷ total days. Separates a *chronic* daily
49
+ bottleneck from a one-off spike. (In this data the worst cells are active
50
+ ~149/151 days.)
51
+ - **Peak concentration** — share of violations in commute windows (08–11,
52
+ 17–20). A blockage that happens exactly at rush hour hurts flow more.
53
+ - **Junction proximity** — amplifies cells near junctions, where a blockage
54
+ cascades upstream.
55
+
56
+ All weights live in `src/config.py` and can be re-tuned in seconds.
57
+
58
+ ## Forecast model
59
+ LightGBM regression on a (cell × day) panel. Features: cell location + junction
60
+ share, calendar (day-of-week, month, weekend), and **lag/rolling** features
61
+ (1/7/14/28-day lags, 7/28-day rolling mean & max). Target: next-day
62
+ severity-weighted intensity. Validated on the final 21 days, compared against a
63
+ naive "same weekday last week" baseline.
experiments/mae_objectives.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Quick check: is any other objective better than L1 on MAE? (same split)"""
2
+ import warnings, numpy as np, pandas as pd, lightgbm as lgb
3
+ from src import config
4
+ from src.data_prep import load_clean
5
+ from src.features import add_h3
6
+ from src.model import build_panel, FEATURES
7
+ warnings.filterwarnings("ignore")
8
+
9
+ df = add_h3(load_clean())
10
+ panel, _, all_dates = build_panel(df)
11
+ m = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
12
+ cut = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
13
+ tr, va = m[m.date <= cut], m[m.date > cut]
14
+ yv = va["y"].values
15
+ base = float(np.mean(np.abs(va["lag_7"].values - yv)))
16
+ print(f"baseline MAE {base:.3f}\n")
17
+
18
+ def run(obj, label, extra=None):
19
+ p = dict(metric="mae", learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
20
+ feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
21
+ seed=config.RANDOM_STATE, verbose=-1, objective=obj)
22
+ if extra: p.update(extra)
23
+ d = lgb.Dataset(tr[FEATURES], tr["y"])
24
+ dv = lgb.Dataset(va[FEATURES], va["y"], reference=d)
25
+ mdl = lgb.train(p, d, 1500, valid_sets=[dv],
26
+ callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
27
+ pred = np.clip(mdl.predict(va[FEATURES], num_iteration=mdl.best_iteration), 0, None)
28
+ mae = float(np.mean(np.abs(pred - yv)))
29
+ print(f" {label:<34} MAE={mae:.3f} ({100*(base-mae)/base:+.1f}%)")
30
+ return mae
31
+
32
+ run("regression_l1", "L1 / MAE [CURRENT]")
33
+ run("huber", "Huber")
34
+ run("poisson", "Poisson")
35
+ for vp in (1.1, 1.3, 1.5):
36
+ run("tweedie", f"Tweedie (variance_power={vp})", {"tweedie_variance_power": vp})
37
+ run("regression_l1", "L1 + tuned (leaves 31, lr .03)",
38
+ {"num_leaves": 31, "learning_rate": 0.03, "min_data_in_leaf": 100, "lambda_l2": 2.0})
experiments/mae_tuning.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Offline experiment: can we lower validation MAE without breaking the pipeline?
2
+
3
+ Tests objective choices and extra features on the SAME time-split as production,
4
+ so the comparison is apples-to-apples. Prints a table; ships nothing.
5
+ """
6
+ import warnings
7
+ import numpy as np
8
+ import pandas as pd
9
+ import lightgbm as lgb
10
+
11
+ from src import config
12
+ from src.data_prep import load_clean
13
+ from src.features import add_h3
14
+ from src.model import build_panel, FEATURES as BASE_FEATURES
15
+
16
+ warnings.filterwarnings("ignore")
17
+ np.random.seed(config.RANDOM_STATE)
18
+
19
+ print("Loading + indexing data ...")
20
+ df = add_h3(load_clean())
21
+ panel, keep_cells, all_dates = build_panel(df)
22
+
23
+ # ---- add candidate features on top of the production panel ----
24
+ panel = panel.sort_values(["h3", "date"]).reset_index(drop=True)
25
+ g = panel.groupby("h3")["y"]
26
+ yshift = g.shift(1)
27
+ # extra short/seasonal lags
28
+ for L in [2, 3, 21]:
29
+ panel[f"lag_{L}"] = g.shift(L)
30
+ # rolling std (volatility) + a mid window mean
31
+ for W in [7, 14, 28]:
32
+ panel[f"roll_std_{W}"] = (yshift.groupby(panel["h3"]).rolling(W, min_periods=2)
33
+ .std().reset_index(level=0, drop=True))
34
+ panel["roll_mean_14"] = (yshift.groupby(panel["h3"]).rolling(14, min_periods=1)
35
+ .mean().reset_index(level=0, drop=True))
36
+ # EWMA (recency-weighted level)
37
+ panel["ewm_7"] = (yshift.groupby(panel["h3"]).ewm(span=7, min_periods=1)
38
+ .mean().reset_index(level=0, drop=True))
39
+ # same-weekday mean over the last 4 occurrences (weekly seasonality, no leak)
40
+ sd = panel.groupby(["h3", "dow"])["y"].shift(1)
41
+ panel["samedow_mean4"] = (sd.groupby([panel["h3"], panel["dow"]])
42
+ .rolling(4, min_periods=1).mean()
43
+ .reset_index(level=[0, 1], drop=True))
44
+ # expanding cell mean (shifted -> no leak)
45
+ panel["cell_expmean"] = (g.apply(lambda s: s.shift(1).expanding().mean())
46
+ .reset_index(level=0, drop=True))
47
+
48
+ panel["roll_std_7"] = panel["roll_std_7"].fillna(0)
49
+ panel["roll_std_14"] = panel["roll_std_14"].fillna(0)
50
+ panel["roll_std_28"] = panel["roll_std_28"].fillna(0)
51
+
52
+ EXTRA = ["lag_2", "lag_3", "lag_21", "roll_std_7", "roll_std_14", "roll_std_28",
53
+ "roll_mean_14", "ewm_7", "samedow_mean4", "cell_expmean"]
54
+
55
+ model_df = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
56
+ cutoff = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
57
+ train = model_df[model_df["date"] <= cutoff]
58
+ valid = model_df[model_df["date"] > cutoff]
59
+ yv = valid["y"].values
60
+ base_mae = float(np.mean(np.abs(valid["lag_7"].values - yv)))
61
+ print(f"rows train/valid: {len(train):,}/{len(valid):,} | "
62
+ f"same-weekday baseline MAE: {base_mae:.3f}\n")
63
+
64
+
65
+ def run(feats, objective, label, log=False, extra_params=None):
66
+ p = dict(metric="mae", learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
67
+ feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
68
+ seed=config.RANDOM_STATE, verbose=-1, objective=objective)
69
+ if extra_params:
70
+ p.update(extra_params)
71
+ ytr = np.log1p(train["y"]) if log else train["y"]
72
+ dtr = lgb.Dataset(train[feats], ytr)
73
+ dva = lgb.Dataset(valid[feats], (np.log1p(valid["y"]) if log else valid["y"]),
74
+ reference=dtr)
75
+ m = lgb.train(p, dtr, num_boost_round=1200, valid_sets=[dva],
76
+ callbacks=[lgb.early_stopping(80), lgb.log_evaluation(0)])
77
+ pred = m.predict(valid[feats], num_iteration=m.best_iteration)
78
+ if log:
79
+ pred = np.expm1(pred)
80
+ mae = float(np.mean(np.abs(np.clip(pred, 0, None) - yv)))
81
+ imp = 100 * (base_mae - mae) / base_mae
82
+ print(f" {label:<46} MAE={mae:.3f} ({imp:+.1f}% vs baseline) it={m.best_iteration}")
83
+ return mae
84
+
85
+
86
+ print("=== production config (reproduce) ===")
87
+ run(BASE_FEATURES, "regression", "L2 objective, base features [CURRENT]")
88
+ print("\n=== change the training objective only (base features) ===")
89
+ run(BASE_FEATURES, "regression_l1", "L1/MAE objective")
90
+ run(BASE_FEATURES, "huber", "Huber objective")
91
+ run(BASE_FEATURES, "fair", "Fair objective")
92
+ print("\n=== add features ===")
93
+ run(BASE_FEATURES + EXTRA, "regression", "L2 + extra features")
94
+ run(BASE_FEATURES + EXTRA, "regression_l1", "L1 + extra features")
95
+ run(BASE_FEATURES + EXTRA, "huber", "Huber + extra features")
96
+ print("\n=== best combo + mild regularization ===")
97
+ run(BASE_FEATURES + EXTRA, "regression_l1", "L1 + extra + L2 reg + smaller leaves",
98
+ extra_params=dict(lambda_l2=1.0, num_leaves=48, min_data_in_leaf=80))
99
+ run(BASE_FEATURES + EXTRA, "huber", "Huber + extra + reg",
100
+ extra_params=dict(lambda_l2=1.0, num_leaves=48, min_data_in_leaf=80))
101
+ print("\n=== log1p target variants ===")
102
+ run(BASE_FEATURES + EXTRA, "regression", "log1p + L2 + extra", log=True)
103
+ run(BASE_FEATURES + EXTRA, "regression_l1", "log1p + L1 + extra", log=True)
hash.py ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate a bcrypt hash for a password, to paste into auth_config.yaml.
2
+
3
+ Usage:
4
+ python hash.py
5
+ > Password to hash: ********
6
+ <prints the hash>
7
+
8
+ Then replace the plain-text `password:` value in auth_config.yaml with the hash.
9
+ """
10
+ import getpass
11
+
12
+ import streamlit_authenticator as stauth
13
+
14
+ if __name__ == "__main__":
15
+ pw = getpass.getpass("Password to hash: ")
16
+ try:
17
+ hashed = stauth.Hasher([pw]).generate()[0] # older API
18
+ except Exception:
19
+ hashed = stauth.Hasher.hash(pw) # newer API
20
+ print(hashed)
models/lgbm_intensity.txt ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ streamlit>=1.46
2
+ pandas>=2.0
3
+ numpy>=1.24
4
+ pyarrow>=14.0
5
+ h3>=4.0
6
+ lightgbm>=4.0
7
+ pydeck>=0.9
8
+ streamlit-mic-recorder>=0.0.8
9
+ indic-transliteration>=2.3
10
+ streamlit-autorefresh>=1.0
11
+ streamlit-option-menu>=0.3.6
12
+ gTTS>=2.5
13
+ plotly>=5.20
14
+ # --- optional login add-on (see src/auth.py) ---
15
+ streamlit-authenticator>=0.3.2
16
+ PyYAML>=6.0
src/__init__.py ADDED
File without changes
src/auth.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """OPTIONAL login wrapper (streamlit-authenticator).
2
+
3
+ This is NOT wired into app.py by default, so the core demo always runs. To turn
4
+ it on, add these two lines at the top of app.py (right after the imports):
5
+
6
+ from src.auth import require_login
7
+ name, username, roles = require_login()
8
+
9
+ Security notes:
10
+ * Passwords are bcrypt-hashed by the library (auto_hash=True). For real
11
+ security, pre-hash with `python hash.py` and store ONLY the hash in
12
+ auth_config.yaml — never commit plain-text passwords to a public repo.
13
+ * The login version's `login()` signature has changed across releases; this
14
+ reads auth state from st.session_state (the stable pattern). Pin your
15
+ installed version and test the flow locally.
16
+ """
17
+ import yaml
18
+ from yaml.loader import SafeLoader
19
+
20
+ import streamlit as st
21
+ import streamlit_authenticator as stauth
22
+
23
+
24
+ def get_authenticator(path="auth_config.yaml"):
25
+ with open(path) as f:
26
+ config = yaml.load(f, Loader=SafeLoader)
27
+ return stauth.Authenticate(
28
+ config["credentials"],
29
+ config["cookie"]["name"],
30
+ config["cookie"]["key"],
31
+ config["cookie"]["expiry_days"],
32
+ auto_hash=True,
33
+ )
34
+
35
+
36
+ def require_login(path="auth_config.yaml"):
37
+ """Render the login widget; stop the app unless the user is authenticated.
38
+
39
+ Returns (name, username, roles) once logged in.
40
+ """
41
+ authenticator = get_authenticator(path)
42
+ authenticator.login(location="main")
43
+
44
+ status = st.session_state.get("authentication_status")
45
+ if status is False:
46
+ st.error("Invalid username or password.")
47
+ st.stop()
48
+ if status is None:
49
+ st.info("Please log in to continue.")
50
+ st.stop()
51
+
52
+ authenticator.logout("Logout", "sidebar")
53
+ return (st.session_state.get("name"),
54
+ st.session_state.get("username"),
55
+ st.session_state.get("roles") or [])
src/build_artifacts.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Run the full offline pipeline: raw CSV -> small artifacts the app serves.
2
+
3
+ python -m src.build_artifacts # run from the repo root
4
+
5
+ Outputs:
6
+ data/processed/hotspots.parquet per-cell stats + CII
7
+ data/processed/forecast.parquet next-day predicted intensity per cell
8
+ data/processed/meta.json config, date range, model metrics
9
+ models/lgbm_intensity.txt saved LightGBM model
10
+ """
11
+ import json
12
+
13
+ from src import config
14
+ from src.data_prep import load_clean
15
+ from src.features import add_h3
16
+ from src.hotspots import build_cell_stats
17
+ from src.impact_index import add_cii
18
+ from src.model import train_and_forecast
19
+ from src.offenders import build_offender_stats, offender_summary
20
+ from src.trends import build_trends, build_byday
21
+
22
+
23
+ def main():
24
+ config.DATA_PROCESSED.mkdir(parents=True, exist_ok=True)
25
+ config.MODELS_DIR.mkdir(parents=True, exist_ok=True)
26
+
27
+ print("[1/4] Loading & cleaning ...")
28
+ df = load_clean()
29
+ print(f" clean parking records: {len(df):,}")
30
+
31
+ df = add_h3(df)
32
+ total_days = (df["ts"].dt.date.max() - df["ts"].dt.date.min()).days + 1
33
+
34
+ print("[2/4] Building hotspot stats & CII ...")
35
+ stats = add_cii(build_cell_stats(df, total_days))
36
+ stats.to_parquet(config.HOTSPOTS_PARQUET, index=False)
37
+ print(f" {len(stats):,} cells -> {config.HOTSPOTS_PARQUET.name}")
38
+
39
+ print("[2b] Repeat-offender intelligence ...")
40
+ offenders = build_offender_stats(df, top_n=500)
41
+ offenders.to_parquet(config.OFFENDERS_PARQUET, index=False)
42
+ off_summary = offender_summary(df)
43
+ print(f" {len(offenders):,} offenders -> {config.OFFENDERS_PARQUET.name} | {off_summary}")
44
+
45
+ print("[2c] Trend aggregates ...")
46
+ build_trends(df).to_parquet(config.TRENDS_PARQUET, index=False)
47
+ build_byday(df).to_parquet(config.TRENDS_BYDAY_PARQUET, index=False)
48
+ print(f" -> {config.TRENDS_PARQUET.name}, {config.TRENDS_BYDAY_PARQUET.name}")
49
+
50
+ print("[3/4] Training LightGBM & forecasting ...")
51
+ model, forecast, metrics = train_and_forecast(df)
52
+ model.save_model(str(config.MODEL_PATH))
53
+ forecast.to_parquet(config.FORECAST_PARQUET, index=False)
54
+ print(f" {len(forecast):,} cells -> {config.FORECAST_PARQUET.name}")
55
+ print(f" metrics: {metrics}")
56
+
57
+ print("[4/4] Writing meta.json ...")
58
+ # small real daily series for the KPI sparklines
59
+ dord = sorted(df["date"].unique())
60
+ gd = df.groupby("date")
61
+ kpi_sparks = dict(
62
+ violations=gd["id"].size().reindex(dord, fill_value=0).astype(int).tolist(),
63
+ zones=gd["h3"].nunique().reindex(dord, fill_value=0).astype(int).tolist(),
64
+ peak=(df[df["is_peak"]].groupby("date")["id"].size()
65
+ .reindex(dord, fill_value=0).astype(int).tolist()),
66
+ )
67
+ meta = dict(
68
+ date_range=[df["ts"].dt.date.min().isoformat(),
69
+ df["ts"].dt.date.max().isoformat()],
70
+ total_days=total_days,
71
+ n_records=int(len(df)),
72
+ n_cells=int(len(stats)),
73
+ h3_res=config.H3_RES,
74
+ severity_weights=config.PARKING_SEVERITY,
75
+ cii_weights=config.CII_WEIGHTS,
76
+ cii_junction_alpha=config.CII_JUNCTION_ALPHA,
77
+ offender_summary=off_summary,
78
+ model_metrics=metrics,
79
+ kpi_sparks=kpi_sparks,
80
+ )
81
+ config.META_JSON.write_text(json.dumps(meta, indent=2))
82
+ print(f" -> {config.META_JSON.name}")
83
+ print("Done. You can now run: streamlit run app.py")
84
+
85
+
86
+ if __name__ == "__main__":
87
+ main()
src/config.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration for the parking congestion-impact pipeline.
2
+
3
+ Every tunable knob lives here so the rest of the code stays declarative and
4
+ the choices are transparent (useful when you defend the design to judges).
5
+ """
6
+ from pathlib import Path
7
+
8
+ # ---- Paths -----------------------------------------------------------------
9
+ ROOT = Path(__file__).resolve().parents[1]
10
+ DATA_RAW = ROOT / "data" / "raw" / "violations.csv"
11
+ DATA_PROCESSED = ROOT / "data" / "processed"
12
+ MODELS_DIR = ROOT / "models"
13
+ HOTSPOTS_PARQUET = DATA_PROCESSED / "hotspots.parquet"
14
+ FORECAST_PARQUET = DATA_PROCESSED / "forecast.parquet"
15
+ OFFENDERS_PARQUET = DATA_PROCESSED / "offenders.parquet"
16
+ TRENDS_PARQUET = DATA_PROCESSED / "trends.parquet"
17
+ TRENDS_BYDAY_PARQUET = DATA_PROCESSED / "trends_byday.parquet"
18
+ META_JSON = DATA_PROCESSED / "meta.json"
19
+ MODEL_PATH = MODELS_DIR / "lgbm_intensity.txt"
20
+
21
+ # ---- Geography (Bengaluru bounding box, for sanity filtering) --------------
22
+ LAT_MIN, LAT_MAX = 12.70, 13.40
23
+ LON_MIN, LON_MAX = 77.30, 77.90
24
+ H3_RES = 9 # ~174 m edge hexagons ~ a block / deployable patrol zone
25
+
26
+ # ---- Time ------------------------------------------------------------------
27
+ TZ = "Asia/Kolkata"
28
+ PEAK_WINDOWS = [(8, 11), (17, 20)] # morning & evening commute hours (IST)
29
+
30
+ # ---- Severity weights: how much each violation blocks MOVING traffic -------
31
+ # Tied to physical flow disruption, not legal severity. Tune and justify.
32
+ PARKING_SEVERITY = {
33
+ "PARKING IN A MAIN ROAD": 1.00, # blocks carriageway
34
+ "PARKING NEAR ROAD CROSSING": 0.90,
35
+ "PARKING NEAR TRAFFIC LIGHT OR ZEBRA CROSS": 0.90,
36
+ "DOUBLE PARKING": 0.85, # blocks a lane
37
+ "PARKING NEAR BUSTOP/SCHOOL/HOSPITAL ETC": 0.70,
38
+ "PARKING OPPOSITE TO ANOTHER PARKED VEHICLE": 0.60,
39
+ "PARKING ON FOOTPATH": 0.50, # pushes pedestrians to road
40
+ "WRONG PARKING": 0.50,
41
+ "PARKING OTHER THAN BUS STOP": 0.45,
42
+ "NO PARKING": 0.40,
43
+ }
44
+ # Any violation not in this map contributes 0 (non-parking, e.g. number plate).
45
+
46
+ # ---- Congestion Impact Index (CII) -----------------------------------------
47
+ CII_WEIGHTS = {"volume": 0.45, "persistence": 0.30, "peak": 0.25} # sum = 1
48
+ CII_JUNCTION_ALPHA = 0.50 # junction-proximity amplification strength
49
+
50
+ # ---- Modelling -------------------------------------------------------------
51
+ MIN_CELL_VIOLATIONS = 20 # only model cells with >= this many records
52
+ LAGS = [1, 7, 14, 28] # day lags for the intensity forecast
53
+ ROLL_WINDOWS = [7, 28] # rolling-window features
54
+ VALID_DAYS = 21 # last N days held out for validation
55
+ RANDOM_STATE = 42
src/data_prep.py ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Load and clean the raw violation data into a tidy parking-records frame."""
2
+ import ast
3
+ import json
4
+
5
+ import pandas as pd
6
+
7
+ from src import config
8
+
9
+
10
+ def _parse_list(x):
11
+ """`violation_type` arrives as a JSON-ish string like ["NO PARKING"]."""
12
+ if isinstance(x, list):
13
+ return x
14
+ if not isinstance(x, str) or not x.strip():
15
+ return []
16
+ try:
17
+ return json.loads(x)
18
+ except Exception:
19
+ try:
20
+ return ast.literal_eval(x)
21
+ except Exception:
22
+ return []
23
+
24
+
25
+ def record_severity(violations):
26
+ """Max flow-disruption weight across a record's violations (0 if none parking)."""
27
+ return max((config.PARKING_SEVERITY.get(v, 0.0) for v in violations), default=0.0)
28
+
29
+
30
+ def _is_peak(hour):
31
+ return any(lo <= hour < hi for lo, hi in config.PEAK_WINDOWS)
32
+
33
+
34
+ def load_clean():
35
+ """Return one row per parking violation with engineered time/severity fields."""
36
+ df = pd.read_csv(config.DATA_RAW, low_memory=False)
37
+
38
+ # --- coordinates: drop missing / out-of-Bengaluru ---
39
+ df = df.dropna(subset=["latitude", "longitude"])
40
+ df = df[df["latitude"].between(config.LAT_MIN, config.LAT_MAX)
41
+ & df["longitude"].between(config.LON_MIN, config.LON_MAX)].copy()
42
+
43
+ # --- timestamps -> IST ---
44
+ df["created_dt"] = pd.to_datetime(df["created_datetime"], errors="coerce", utc=True)
45
+ df = df.dropna(subset=["created_dt"]).copy()
46
+ df["ts"] = df["created_dt"].dt.tz_convert(config.TZ)
47
+ df["date"] = df["ts"].dt.date
48
+ df["hour"] = df["ts"].dt.hour
49
+ df["dow"] = df["ts"].dt.dayofweek
50
+ df["month"] = df["ts"].dt.month
51
+ df["is_peak"] = df["hour"].apply(_is_peak)
52
+
53
+ # --- violations & severity ---
54
+ df["violations"] = df["violation_type"].apply(_parse_list)
55
+ df["severity"] = df["violations"].apply(record_severity)
56
+ df = df[df["severity"] > 0].copy() # keep only parking-relevant records
57
+
58
+ # --- junction presence ---
59
+ df["junction_name"] = df["junction_name"].fillna("No Junction")
60
+ df["has_junction"] = (df["junction_name"].str.strip().str.lower() != "no junction")
61
+
62
+ keep = ["id", "latitude", "longitude", "location", "police_station",
63
+ "junction_name", "has_junction", "vehicle_type", "vehicle_number", "violations",
64
+ "severity", "ts", "date", "hour", "dow", "month", "is_peak"]
65
+ return df[keep].reset_index(drop=True)
66
+
67
+
68
+ if __name__ == "__main__":
69
+ d = load_clean()
70
+ print(d.shape)
71
+ print(d.head())
src/features.py ADDED
@@ -0,0 +1,23 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Spatial (H3) indexing helpers, version-robust across h3 v3 and v4."""
2
+ import h3
3
+
4
+ from src import config
5
+
6
+
7
+ def latlng_to_cell(lat, lng, res=config.H3_RES):
8
+ if hasattr(h3, "latlng_to_cell"): # h3 v4
9
+ return h3.latlng_to_cell(lat, lng, res)
10
+ return h3.geo_to_h3(lat, lng, res) # h3 v3
11
+
12
+
13
+ def cell_to_latlng(cell):
14
+ if hasattr(h3, "cell_to_latlng"): # h3 v4
15
+ return tuple(h3.cell_to_latlng(cell))
16
+ return tuple(h3.h3_to_geo(cell)) # h3 v3
17
+
18
+
19
+ def add_h3(df):
20
+ """Attach an H3 cell id to every record (the unit we aggregate hotspots over)."""
21
+ df = df.copy()
22
+ df["h3"] = [latlng_to_cell(la, lo) for la, lo in zip(df["latitude"], df["longitude"])]
23
+ return df
src/hotspots.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Aggregate cleaned records into per-H3-cell hotspot statistics."""
2
+ from collections import Counter
3
+
4
+ import pandas as pd
5
+
6
+ from src import config
7
+ from src.features import cell_to_latlng
8
+
9
+
10
+ def _mode_or_blank(series):
11
+ s = series.dropna()
12
+ return s.mode().iat[0] if not s.empty else ""
13
+
14
+
15
+ def _main_junction(series):
16
+ s = series.fillna("No Junction")
17
+ s = s[s.str.strip().str.lower() != "no junction"]
18
+ return s.mode().iat[0] if not s.empty else ""
19
+
20
+
21
+ def _top_violation(series):
22
+ c = Counter(v for lst in series for v in lst if v in config.PARKING_SEVERITY)
23
+ return c.most_common(1)[0][0] if c else ""
24
+
25
+
26
+ def build_cell_stats(df, total_days):
27
+ """One row per H3 cell with the components the CII is built from."""
28
+ g = df.groupby("h3")
29
+ stats = g.agg(
30
+ n_violations=("id", "size"),
31
+ weighted_volume=("severity", "sum"),
32
+ active_days=("date", "nunique"),
33
+ peak_violations=("is_peak", "sum"),
34
+ junction_share=("has_junction", "mean"),
35
+ )
36
+ stats["persistence"] = stats["active_days"] / float(total_days)
37
+ stats["peak_share"] = stats["peak_violations"] / stats["n_violations"]
38
+
39
+ # representative centroid for each hexagon (for map centring / scatter)
40
+ centroids = {c: cell_to_latlng(c) for c in stats.index}
41
+ stats["lat"] = [centroids[c][0] for c in stats.index]
42
+ stats["lon"] = [centroids[c][1] for c in stats.index]
43
+
44
+ # human-readable context
45
+ stats["location"] = g["location"].agg(_mode_or_blank)
46
+ stats["police_station"] = g["police_station"].agg(_mode_or_blank)
47
+ stats["junction_name"] = g["junction_name"].agg(_main_junction)
48
+ stats["top_violation"] = g["violations"].agg(_top_violation)
49
+
50
+ return stats.reset_index()
src/i18n.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Light i18n layer.
2
+
3
+ Two jobs:
4
+ 1. UI string translations for English / Kannada / Hindi.
5
+ 2. Resolve a native-script (Kannada/Hindi) place query to the English place
6
+ names in the data, via transliteration + fuzzy matching.
7
+
8
+ NOTE: the Kannada/Hindi UI strings are standard civic terms but should be
9
+ sanity-checked by a native speaker before the finale.
10
+ """
11
+ import re
12
+ import difflib
13
+ import unicodedata
14
+
15
+ from indic_transliteration import sanscript
16
+ from indic_transliteration.sanscript import transliterate
17
+
18
+ # label -> code, and code -> Web Speech API locale
19
+ LANGS = {"English": "en", "ಕನ್ನಡ": "kn", "हिन्दी": "hi"}
20
+ SPEECH_LANG = {"en": "en-IN", "kn": "kn-IN", "hi": "hi-IN"}
21
+
22
+ STRINGS = {
23
+ "title": {
24
+ "en": "ParkSight — Parking-Induced Congestion Intelligence",
25
+ "kn": "ಪಾರ್ಕ್‌ಸೈಟ್ — ಪಾರ್ಕಿಂಗ್ ದಟ್ಟಣೆ ವಿಶ್ಲೇಷಣೆ",
26
+ "hi": "पार्कसाइट — पार्किंग जनित भीड़ विश्लेषण"},
27
+ "kpi_violations": {"en": "Parking violations", "kn": "ಪಾರ್ಕಿಂಗ್ ಉಲ್ಲಂಘನೆಗಳು",
28
+ "hi": "पार्किंग उल्लंघन"},
29
+ "kpi_zones": {"en": "Impact zones", "kn": "ಪ್ರಭಾವ ವಲಯಗಳು", "hi": "प्रभाव क्षेत्र"},
30
+ "kpi_high": {"en": "High-impact (CII ≥ 70)", "kn": "ಹೆಚ್ಚು-ಪ್ರಭಾವ (CII ≥ 70)",
31
+ "hi": "उच्च-प्रभाव (CII ≥ 70)"},
32
+ "kpi_mae": {"en": "Forecast MAE", "kn": "ಮುನ್ಸೂಚನೆ MAE", "hi": "पूर्वानुमान MAE"},
33
+ "voice_nav": {"en": "Voice / command navigation", "kn": "ಧ್ವನಿ / ಆದೇಶ ಸಂಚಲನೆ",
34
+ "hi": "वॉइस / कमांड नेविगेशन"},
35
+ "ask": {"en": "Ask in plain language", "kn": "ಕನ್ನಡದಲ್ಲಿ ಹುಡುಕಿ", "hi": "हिंदी में खोजें"},
36
+ "placeholder": {"en": "e.g. 'show worst zones in Shivaji Nagar' · 'read top 5'",
37
+ "kn": "ಉದಾ: 'ಶಿವಾಜಿನಗರದ ಕೆಟ್ಟ ವಲಯಗಳು'",
38
+ "hi": "उदा: 'शिवाजी नगर के सबसे खराब क्षेत्र'"},
39
+ "speak": {"en": "🎤 Speak", "kn": "🎤 ಮಾತನಾಡಿ", "hi": "🎤 बोलें"},
40
+ "stop": {"en": "⏹ Stop", "kn": "⏹ ನಿಲ್ಲಿಸಿ", "hi": "⏹ रोकें"},
41
+ "understood": {"en": "Understood", "kn": "ಅರ್ಥವಾಯಿತು", "hi": "समझ गया"},
42
+ "tab_map": {"en": "🗺️ Impact map", "kn": "🗺️ ಪ್ರಭಾವ ನಕ್ಷೆ", "hi": "🗺️ प्रभाव नक्शा"},
43
+ "tab_ops": {"en": "🚨 Live alerts", "kn": "🚨 ಲೈವ್ ಎಚ್ಚರಿಕೆ", "hi": "🚨 लाइव अलर्ट"},
44
+ "tab_rank": {"en": "📋 Enforcement priorities", "kn": "📋 ಜಾರಿ ಆದ್ಯತೆಗಳು",
45
+ "hi": "📋 प्रवर्तन प्राथमिकताएँ"},
46
+ "tab_off": {"en": "🚨 Repeat offenders", "kn": "🚨 ಪುನರಾವರ್ತಿತ ಅಪರಾಧಿಗಳು",
47
+ "hi": "🚨 बार-बार उल्लंघनकर्ता"},
48
+ "tab_fc": {"en": "🔮 Tomorrow's forecast", "kn": "🔮 ನಾಳಿನ ಮುನ್ಸೂಚನೆ",
49
+ "hi": "🔮 कल का पूर्वानुमान"},
50
+ "min_cii": {"en": "Minimum CII to display", "kn": "ಪ್ರದರ್ಶಿಸಲು ಕನಿಷ್ಠ CII",
51
+ "hi": "दिखाने हेतु न्यूनतम CII"},
52
+ "top_n_zones": {"en": "Show top N zones", "kn": "ಮೇಲಿನ N ವಲಯಗಳನ್ನು ತೋರಿಸಿ",
53
+ "hi": "शीर्ष N क्षेत्र दिखाएँ"},
54
+ "dl_priorities": {"en": "⬇️ Download enforcement priorities (CSV)",
55
+ "kn": "⬇️ ಜಾರಿ ಆದ್ಯತೆಗಳನ್ನು ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ (CSV)",
56
+ "hi": "⬇️ प्रवर्तन प्राथमिकताएँ डाउनलोड करें (CSV)"},
57
+ "repeat_offenders": {"en": "Repeat offenders", "kn": "ಪುನರಾವರ್ತಿತ ಅಪರಾಧಿಗಳು",
58
+ "hi": "बार-बार उल्लंघनकर्ता"},
59
+ "share_violations": {"en": "Share of all violations", "kn": "ಎಲ್ಲಾ ಉಲ್ಲಂಘನೆಗಳ ಪಾಲು",
60
+ "hi": "कुल उल्लंघनों में हिस्सा"},
61
+ "worst_vehicle": {"en": "Worst single vehicle", "kn": "ಅತಿ ಕೆಟ್ಟ ಏಕೈಕ ವಾಹನ",
62
+ "hi": "सबसे खराब एकल वाहन"},
63
+ "search_vehicle": {"en": "Search a vehicle ID or area",
64
+ "kn": "ವಾಹನ ID ಅಥವಾ ಪ್ರದೇಶ ಹುಡುಕಿ", "hi": "वाहन ID या क्षेत्र खोजें"},
65
+ "top_n_off": {"en": "Show top N offenders", "kn": "ಮೇಲಿನ N ಅಪರಾಧಿಗಳನ್ನು ತೋರಿಸಿ",
66
+ "hi": "शीर्ष N उल्लंघनकर्ता दिखाएँ"},
67
+ "dl_offenders": {"en": "⬇️ Download offender watchlist (CSV)",
68
+ "kn": "⬇️ ಅಪರಾಧಿಗಳ ಪಟ್ಟಿ ಡೌನ್‌ಲೋಡ್ ಮಾಡಿ (CSV)",
69
+ "hi": "⬇️ उल्लंघनकर्ता सूची डाउनलोड करें (CSV)"},
70
+ "language": {"en": "Language", "kn": "ಭಾಷೆ", "hi": "भाषा"},
71
+ "theme": {"en": "Theme", "kn": "ಥೀಮ್", "hi": "थीम"},
72
+ "dark": {"en": "🌙 Dark", "kn": "🌙 ಕಪ್ಪು", "hi": "🌙 डार्क"},
73
+ "light": {"en": "☀️ Light", "kn": "☀️ ಬೆಳಕು", "hi": "☀️ लाइट"},
74
+ "history": {"en": "🕘 Your searches this session", "kn": "🕘 ಈ ಅವಧಿಯ ಹುಡುಕಾಟಗಳು",
75
+ "hi": "🕘 इस सत्र की खोजें"},
76
+ "clear_history": {"en": "Clear history", "kn": "ಇತಿಹಾಸ ಅಳಿಸಿ", "hi": "इतिहास साफ़ करें"},
77
+ "no_history": {"en": "No searches yet.", "kn": "ಇನ್ನೂ ಹುಡುಕಾಟಗಳಿಲ್ಲ.",
78
+ "hi": "अभी तक कोई खोज नहीं।"},
79
+ "tab_trends": {"en": "📈 Trends", "kn": "📈 ಪ್ರವೃತ್ತಿಗಳು", "hi": "📈 रुझान"},
80
+ "t_daily": {"en": "Daily violations", "kn": "ದೈನಂದಿನ ಉಲ್ಲಂಘನೆಗಳು", "hi": "दैनिक उल्लंघन"},
81
+ "t_hourly": {"en": "By hour of day", "kn": "ಗಂಟೆಯ ಪ್ರಕಾರ", "hi": "घंटे के अनुसार"},
82
+ "t_vehicle": {"en": "By vehicle type", "kn": "ವಾಹನ ಪ್ರಕಾರ", "hi": "वाहन प्रकार के अनुसार"},
83
+ "t_vtype": {"en": "By violation type", "kn": "ಉಲ್ಲಂಘನೆ ಪ್ರಕಾರ", "hi": "उल्लंघन प्रकार के अनुसार"},
84
+ "t_dow": {"en": "By day of week", "kn": "ವಾರದ ದಿನದ ಪ್ರಕಾರ", "hi": "सप्ताह के दिन अनुसार"},
85
+ "t_gauge": {"en": "Top-100 zones · share of all violations",
86
+ "kn": "ಮೇಲಿನ 100 ವಲಯಗಳ ಪಾಲು", "hi": "शीर्ष 100 क्षेत्रों का हिस्सा"},
87
+ "live_replay": {"en": "▶ Live replay (simulated from historical data)",
88
+ "kn": "▶ ಲೈವ್ ರಿಪ್ಲೇ (ಐತಿಹಾಸಿಕ ದತ್ತಾಂಶದಿಂದ)",
89
+ "hi": "▶ लाइव रीप्ले (ऐतिहासिक डेटा से)"},
90
+ }
91
+
92
+
93
+ def t(key, lang):
94
+ """Translate a UI string key into the chosen language (falls back to English)."""
95
+ return STRINGS.get(key, {}).get(lang) or STRINGS.get(key, {}).get("en", key)
96
+
97
+
98
+ def _norm(s):
99
+ s = unicodedata.normalize("NFKD", s).encode("ascii", "ignore").decode()
100
+ s = re.sub(r"[^a-z]", "", s.lower())
101
+ s = re.sub(r"([a-z])\1+", r"\1", s) # collapse doubled letters
102
+ return s.replace("nagara", "nagar")
103
+
104
+
105
+ def romanize(text, lang):
106
+ scr = {"kn": sanscript.KANNADA, "hi": sanscript.DEVANAGARI}.get(lang)
107
+ if scr is None:
108
+ return text
109
+ try:
110
+ return transliterate(text, scr, sanscript.IAST)
111
+ except Exception:
112
+ return text
113
+
114
+
115
+ def build_area_vocab(hot):
116
+ """Distinct locality names from the hotspot location strings."""
117
+ areas = set()
118
+ for loc in hot["location"].dropna():
119
+ for p in [x.strip() for x in str(loc).split(",")][1:4]:
120
+ pl = p.lower()
121
+ if p and "bengaluru" not in pl and "karnataka" not in pl and "pin" not in pl:
122
+ areas.add(p)
123
+ return sorted(areas)
124
+
125
+
126
+ def resolve_area(query, lang, area_vocab):
127
+ """Map a (possibly Kannada/Hindi) place query to an English locality name."""
128
+ has_native = any(ord(c) > 127 for c in query)
129
+ rom = romanize(query, lang) if (has_native or lang != "en") else query
130
+ key = _norm(rom)
131
+ if not key:
132
+ return None
133
+ norm_map = {_norm(a): a for a in area_vocab}
134
+ best = difflib.get_close_matches(key, list(norm_map.keys()), n=1, cutoff=0.55)
135
+ return norm_map[best[0]] if best else None
src/impact_index.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Compute the Congestion Impact Index (CII) per hotspot cell.
2
+
3
+ CII is a transparent, rank-normalised blend of four ideas:
4
+ - severity-weighted VOLUME (how much weighted illegal parking happens here)
5
+ - PERSISTENCE (chronic every-day spot vs one-off spike)
6
+ - PEAK-hour concentration (bad exactly when the road is busiest)
7
+ - junction proximity (blocking near junctions cascades downstream)
8
+
9
+ It is explicitly a *proxy* for traffic-flow impact, derived only from
10
+ enforcement data, with every weight exposed in config.py.
11
+ """
12
+ from src import config
13
+
14
+
15
+ def _norm(series):
16
+ """Rank-based normalisation to [0, 1] (robust to heavy-tailed counts)."""
17
+ return series.rank(method="average", pct=True).fillna(0.0)
18
+
19
+
20
+ def add_cii(stats):
21
+ stats = stats.copy()
22
+ w = config.CII_WEIGHTS
23
+
24
+ v = _norm(stats["weighted_volume"])
25
+ p = _norm(stats["persistence"])
26
+ k = _norm(stats["peak_share"])
27
+
28
+ base = w["volume"] * v + w["persistence"] * p + w["peak"] * k
29
+ junction = stats["junction_share"].clip(0, 1)
30
+ cii = base * (1 + config.CII_JUNCTION_ALPHA * junction)
31
+
32
+ # scale to a friendly 0-100
33
+ cii = (cii - cii.min()) / (cii.max() - cii.min() + 1e-9) * 100
34
+ stats["cii"] = cii.round(1)
35
+ stats["cii_rank"] = stats["cii"].rank(ascending=False, method="min").astype(int)
36
+
37
+ return stats.sort_values("cii", ascending=False).reset_index(drop=True)
src/model.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """LightGBM model: forecast next-day severity-weighted violation intensity
2
+ per hotspot cell. Turns the project from descriptive ("where it's bad now")
3
+ into predictive ("where it will be bad tomorrow").
4
+ """
5
+ import numpy as np
6
+ import pandas as pd
7
+ import lightgbm as lgb
8
+
9
+ from src import config
10
+
11
+ FEATURES = (
12
+ ["lat", "lon", "junction", "dow", "month", "day", "is_weekend"]
13
+ + [f"lag_{L}" for L in config.LAGS]
14
+ + [f"roll_mean_{W}" for W in config.ROLL_WINDOWS]
15
+ + [f"roll_max_{W}" for W in config.ROLL_WINDOWS]
16
+ )
17
+
18
+
19
+ def build_panel(df):
20
+ """Build a (cell x day) panel of weighted-violation intensity with lags."""
21
+ counts = df.groupby("h3")["id"].size()
22
+ keep_cells = counts[counts >= config.MIN_CELL_VIOLATIONS].index
23
+ d = df[df["h3"].isin(keep_cells)].copy()
24
+
25
+ daily = (d.groupby(["h3", "date"])
26
+ .agg(y=("severity", "sum"), n=("id", "size"))
27
+ .reset_index())
28
+ daily["date"] = pd.to_datetime(daily["date"])
29
+
30
+ # complete the grid so quiet days become explicit zeros
31
+ all_dates = pd.date_range(daily["date"].min(), daily["date"].max(), freq="D")
32
+ idx = pd.MultiIndex.from_product([keep_cells, all_dates], names=["h3", "date"])
33
+ panel = daily.set_index(["h3", "date"]).reindex(idx).reset_index()
34
+ panel["y"] = panel["y"].fillna(0.0)
35
+ panel["n"] = panel["n"].fillna(0.0)
36
+
37
+ # cell-static features
38
+ cell_meta = (d.groupby("h3")
39
+ .agg(lat=("latitude", "mean"),
40
+ lon=("longitude", "mean"),
41
+ junction=("has_junction", "mean"))
42
+ .reset_index())
43
+ panel = panel.merge(cell_meta, on="h3", how="left")
44
+
45
+ # calendar features
46
+ panel["dow"] = panel["date"].dt.dayofweek
47
+ panel["month"] = panel["date"].dt.month
48
+ panel["day"] = panel["date"].dt.day
49
+ panel["is_weekend"] = (panel["dow"] >= 5).astype(int)
50
+
51
+ # lag & rolling features, strictly per cell and time-ordered
52
+ panel = panel.sort_values(["h3", "date"]).reset_index(drop=True)
53
+ for L in config.LAGS:
54
+ panel[f"lag_{L}"] = panel.groupby("h3")["y"].shift(L)
55
+ panel["_yshift"] = panel.groupby("h3")["y"].shift(1)
56
+ for W in config.ROLL_WINDOWS:
57
+ panel[f"roll_mean_{W}"] = (panel.groupby("h3")["_yshift"]
58
+ .rolling(W, min_periods=1).mean()
59
+ .reset_index(level=0, drop=True))
60
+ panel[f"roll_max_{W}"] = (panel.groupby("h3")["_yshift"]
61
+ .rolling(W, min_periods=1).max()
62
+ .reset_index(level=0, drop=True))
63
+ panel = panel.drop(columns="_yshift")
64
+ return panel, keep_cells, all_dates
65
+
66
+
67
+ def train_and_forecast(df):
68
+ panel, keep_cells, all_dates = build_panel(df)
69
+ model_df = panel.dropna(subset=[f"lag_{max(config.LAGS)}"]).copy()
70
+
71
+ cutoff = all_dates.max() - pd.Timedelta(days=config.VALID_DAYS)
72
+ train = model_df[model_df["date"] <= cutoff]
73
+ valid = model_df[model_df["date"] > cutoff]
74
+
75
+ # objective=regression_l1 (MAE) matches the metric and is far better on the
76
+ # right-skewed daily counts than L2 (which chases rare high-count days).
77
+ params = dict(objective="regression_l1", metric="mae",
78
+ learning_rate=0.05, num_leaves=63, min_data_in_leaf=50,
79
+ feature_fraction=0.8, bagging_fraction=0.8, bagging_freq=1,
80
+ seed=config.RANDOM_STATE, verbose=-1)
81
+ dtrain = lgb.Dataset(train[FEATURES], train["y"])
82
+ dvalid = lgb.Dataset(valid[FEATURES], valid["y"], reference=dtrain)
83
+ model = lgb.train(params, dtrain, num_boost_round=800,
84
+ valid_sets=[dvalid],
85
+ callbacks=[lgb.early_stopping(60), lgb.log_evaluation(0)])
86
+
87
+ # --- validation metrics vs a naive "same as last week" baseline ---
88
+ pred_v = model.predict(valid[FEATURES], num_iteration=model.best_iteration)
89
+ yv = valid["y"].values
90
+ mae = float(np.mean(np.abs(pred_v - yv)))
91
+ rmse = float(np.sqrt(np.mean((pred_v - yv) ** 2)))
92
+ base_mae = float(np.mean(np.abs(valid["lag_7"].values - yv)))
93
+
94
+ # --- next-day forecast: most recent row per cell ---
95
+ last_rows = model_df.sort_values("date").groupby("h3").tail(1).copy()
96
+ fc = model.predict(last_rows[FEATURES], num_iteration=model.best_iteration)
97
+ forecast = last_rows[["h3", "lat", "lon"]].copy()
98
+ forecast["pred_intensity"] = np.clip(fc, 0, None).round(2)
99
+ forecast["forecast_for"] = (all_dates.max() + pd.Timedelta(days=1)).date().isoformat()
100
+ forecast = forecast.sort_values("pred_intensity", ascending=False).reset_index(drop=True)
101
+ forecast["risk_rank"] = forecast.index + 1
102
+
103
+ metrics = dict(
104
+ valid_mae=round(mae, 3),
105
+ valid_rmse=round(rmse, 3),
106
+ baseline_lag7_mae=round(base_mae, 3),
107
+ improvement_pct=round(100 * (base_mae - mae) / base_mae, 1) if base_mae else None,
108
+ n_modeled_cells=int(len(keep_cells)),
109
+ best_iteration=int(model.best_iteration),
110
+ )
111
+ return model, forecast, metrics
src/offenders.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Repeat-offender intelligence from the (anonymised) `vehicle_number` column.
2
+
3
+ This is the honest, data-grounded reframe of "license plate recognition": the
4
+ plate is already present in the records, so instead of OCR-from-images we surface
5
+ *chronic* offenders across time and space — something a single-snapshot CV
6
+ pipeline structurally cannot do. All IDs are anonymised, so this is privacy-safe.
7
+ """
8
+ import pandas as pd
9
+
10
+
11
+ def _clean_plates(df):
12
+ v = df.dropna(subset=["vehicle_number"]).copy()
13
+ v = v[~v["vehicle_number"].astype(str).str.lower().isin(["nan", "none", ""])]
14
+ return v
15
+
16
+
17
+ def _mode(series):
18
+ s = series.dropna()
19
+ return s.mode().iat[0] if not s.empty else ""
20
+
21
+
22
+ def build_offender_stats(df, top_n=500):
23
+ """Top repeat offenders with where/what/when context."""
24
+ v = _clean_plates(df)
25
+ g = v.groupby("vehicle_number")
26
+ stats = g.agg(
27
+ n_violations=("id", "size"),
28
+ weighted_severity=("severity", "sum"),
29
+ n_zones=("h3", "nunique"),
30
+ vehicle_type=("vehicle_type", _mode),
31
+ top_location=("location", _mode),
32
+ first_seen=("ts", "min"),
33
+ last_seen=("ts", "max"),
34
+ ).reset_index()
35
+ stats["first_seen"] = stats["first_seen"].dt.date.astype(str)
36
+ stats["last_seen"] = stats["last_seen"].dt.date.astype(str)
37
+ stats["weighted_severity"] = stats["weighted_severity"].round(1)
38
+ stats = stats.sort_values("n_violations", ascending=False).reset_index(drop=True)
39
+ stats["rank"] = stats.index + 1
40
+ return stats.head(top_n)
41
+
42
+
43
+ def offender_summary(df):
44
+ """Headline KPIs about repeat offending."""
45
+ v = _clean_plates(df)
46
+ counts = v["vehicle_number"].value_counts()
47
+ repeat = counts[counts >= 2]
48
+ return dict(
49
+ distinct_vehicles=int(counts.size),
50
+ repeat_offenders=int(repeat.size),
51
+ repeat_share_pct=round(100 * repeat.sum() / len(v), 1),
52
+ worst_count=int(counts.max()),
53
+ )
src/streamlit_app.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import altair as alt
2
+ import numpy as np
3
+ import pandas as pd
4
+ import streamlit as st
5
+
6
+ """
7
+ # Welcome to Streamlit!
8
+
9
+ Edit `/streamlit_app.py` to customize this app to your heart's desire :heart:.
10
+ If you have any questions, checkout our [documentation](https://docs.streamlit.io) and [community
11
+ forums](https://discuss.streamlit.io).
12
+
13
+ In the meantime, below is an example of what you can do with just a few lines of code:
14
+ """
15
+
16
+ num_points = st.slider("Number of points in spiral", 1, 10000, 1100)
17
+ num_turns = st.slider("Number of turns in spiral", 1, 300, 31)
18
+
19
+ indices = np.linspace(0, 1, num_points)
20
+ theta = 2 * np.pi * num_turns * indices
21
+ radius = indices
22
+
23
+ x = radius * np.cos(theta)
24
+ y = radius * np.sin(theta)
25
+
26
+ df = pd.DataFrame({
27
+ "x": x,
28
+ "y": y,
29
+ "idx": indices,
30
+ "rand": np.random.randn(num_points),
31
+ })
32
+
33
+ st.altair_chart(alt.Chart(df, height=700, width=700)
34
+ .mark_point(filled=True)
35
+ .encode(
36
+ x=alt.X("x", axis=None),
37
+ y=alt.Y("y", axis=None),
38
+ color=alt.Color("idx", legend=None, scale=alt.Scale()),
39
+ size=alt.Size("rand", legend=None, scale=alt.Scale(range=[1, 150])),
40
+ ))
src/trends.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Precompute small aggregates for the dashboard charts.
2
+
3
+ Long format -> one tiny trends.parquet the app loads for line/bar/donut charts:
4
+ kind ∈ {daily, hourly, vehicle, dow, vtype}
5
+ label the x-axis label
6
+ value the count
7
+ order sort key
8
+ """
9
+ from collections import Counter
10
+
11
+ import pandas as pd
12
+
13
+ from src import config
14
+
15
+ DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
16
+
17
+
18
+ def build_trends(df):
19
+ rows = []
20
+
21
+ # daily time series (for the line chart)
22
+ daily = df.groupby("date").size().reset_index(name="value").sort_values("date")
23
+ for i, (d, v) in enumerate(zip(daily["date"], daily["value"])):
24
+ rows.append(("daily", str(d), int(v), i))
25
+
26
+ # hourly profile (bar)
27
+ hourly = df.groupby("hour").size()
28
+ for h in range(24):
29
+ rows.append(("hourly", f"{h:02d}", int(hourly.get(h, 0)), h))
30
+
31
+ # by vehicle type (top 10 bar)
32
+ for i, (k, v) in enumerate(df["vehicle_type"].value_counts().head(10).items()):
33
+ rows.append(("vehicle", str(k), int(v), i))
34
+
35
+ # by day-of-week (bar)
36
+ dow = df.groupby("dow").size()
37
+ for i in range(7):
38
+ rows.append(("dow", DOW[i], int(dow.get(i, 0)), i))
39
+
40
+ # by violation type (donut) — only parking-relevant labels
41
+ vt = Counter()
42
+ for lst in df["violations"]:
43
+ for v in lst:
44
+ if v in config.PARKING_SEVERITY:
45
+ vt[v] += 1
46
+ for i, (k, c) in enumerate(vt.most_common(8)):
47
+ rows.append(("vtype", str(k).title(), int(c), i))
48
+
49
+ return pd.DataFrame(rows, columns=["kind", "label", "value", "order"])
50
+
51
+
52
+ def build_byday(df):
53
+ """Per-day aggregates so charts can animate cumulatively during replay.
54
+
55
+ Long format: date, dim ∈ {hour, vehicle}, key, value.
56
+ """
57
+ rows = []
58
+ h = df.groupby(["date", "hour"]).size().reset_index(name="value")
59
+ for _, r in h.iterrows():
60
+ rows.append((str(r["date"]), "hour", f'{int(r["hour"]):02d}', int(r["value"])))
61
+ v = df.groupby(["date", "vehicle_type"]).size().reset_index(name="value")
62
+ for _, r in v.iterrows():
63
+ rows.append((str(r["date"]), "vehicle", str(r["vehicle_type"]), int(r["value"])))
64
+ return pd.DataFrame(rows, columns=["date", "dim", "key", "value"])