"""ParkSight — Parking-Induced Congestion Intelligence for Bengaluru. Serving layer: reads only the small precomputed artifacts in data/processed/. Run locally: streamlit run app.py """ import io import re import json import hashlib from datetime import datetime from pathlib import Path import pandas as pd import pydeck as pdk import plotly.graph_objects as go import streamlit as st import streamlit.components.v1 as components try: from streamlit_option_menu import option_menu HAS_MENU = True except Exception: HAS_MENU = False try: from streamlit_mic_recorder import speech_to_text HAS_MIC = True except Exception: HAS_MIC = False try: from streamlit_autorefresh import st_autorefresh HAS_AUTOREFRESH = True except Exception: HAS_AUTOREFRESH = False # Make this folder importable no matter where the app is launched from # (fixes "ModuleNotFoundError: No module named 'src'" on some setups). import os import sys sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from src.i18n import LANGS, SPEECH_LANG, t, build_area_vocab, resolve_area from src import ops import time as _time ROOT = Path(__file__).resolve().parent PROC = ROOT / "data" / "processed" VIOLET = "#8B5CF6" GOLD = "#D4AF37" DONUT_COLORS = ["#8B5CF6", "#22D3EE", "#EC4899", "#6366F1", "#0EA5E9", "#F472B6", "#A855F7", "#34D399", "#FB7185", "#818CF8"] # ---- demo-grade login (self-contained, no fragile dependency) ---- # NOTE: demonstration auth — credentials are shown on the login screen so judges # can always get in. Production would use a real identity provider. To DISABLE # the login entirely, comment out the `require_login()` call below. _USERS = {"admin": "Traffic Admin (BTP)", "officer": "Patrol Officer"} _PW = {"admin": hashlib.sha256(b"admin123").hexdigest(), "officer": hashlib.sha256(b"officer123").hexdigest()} def require_login(): if st.session_state.get("authed"): return # keep the session logged in across theme-switch page reloads (demo only) if st.query_params.get("auth") == "1": st.session_state.authed = True st.session_state.user = "Traffic Admin (BTP)" return st.markdown( "
" "
दृष्टि — DRISHTI
" "
Digital Real-time Intelligence for " "Smart Hotspot & Traffic Insights · हर सड़क पर नज़र, हर सफ़र आसान
", unsafe_allow_html=True) st.subheader("🔐 Secure sign-in") with st.form("login_form"): u = st.text_input("Username") p = st.text_input("Password", type="password") ok = st.form_submit_button("Sign in") if ok: if u in _PW and _PW[u] == hashlib.sha256(p.encode()).hexdigest(): st.session_state.authed = True st.session_state.user = _USERS[u] st.query_params["auth"] = "1" st.rerun() st.error("Invalid username or password.") st.caption("Demo credentials — sign in with either account:") st.table(pd.DataFrame( {"USERNAME": ["admin", "officer"], "PASSWORD": ["admin123", "officer123"]} ).set_index("USERNAME")) st.stop() # sidebar starts open so the nav is always visible st.set_page_config(page_title="DRISHTI · Bengaluru", page_icon="🚦", layout="wide", initial_sidebar_state="expanded") # ---------------- data ---------------- @st.cache_data def load(): hot = pd.read_parquet(PROC / "hotspots.parquet") fc = pd.read_parquet(PROC / "forecast.parquet") off = pd.read_parquet(PROC / "offenders.parquet") meta = json.loads((PROC / "meta.json").read_text(encoding="utf-8")) fc = fc.merge(hot[["h3", "location", "junction_name", "cii"]], on="h3", how="left") return hot, fc, off, meta @st.cache_data def load_trends(): return pd.read_parquet(PROC / "trends.parquet") @st.cache_data def load_byday(): return pd.read_parquet(PROC / "trends_byday.parquet") def cii_color(cii): x = max(0.0, min(1.0, cii / 100.0)) g, y, r = (22, 163, 74), (245, 158, 11), (220, 38, 38) if x < 0.5: f, a, b = x / 0.5, g, y else: f, a, b = (x - 0.5) / 0.5, y, r return [int(a[i] + (b[i] - a[i]) * f) for i in range(3)] + [185] def cii_to_hex(c): r, g, b, _ = cii_color(c) return f"rgb({r},{g},{b})" # ---------------- theme CSS (theme-AGNOSTIC: adapts to light & dark) ---------------- def inject_css(): # No hardcoded background/text colours -> the native Light/Dark theme (⋮ menu) # stays fully consistent, including tables. Only shape + accent + font here. st.markdown(""" """, unsafe_allow_html=True) # ---------------- voice ---------------- def play_tts(text, lang_code): """Reliable, multilingual TTS via gTTS (plays an MP3). Browser fallback if offline.""" try: from gtts import gTTS buf = io.BytesIO() gTTS(text=text, lang=lang_code).write_to_fp(buf) st.audio(buf.getvalue(), format="audio/mp3", autoplay=True) return True except Exception: loc = {"en": "en-IN", "hi": "hi-IN", "kn": "kn-IN"}.get(lang_code, "en-IN") safe = json.dumps(text) components.html(f"""""", height=0) return False def parse_command(text): low = (text or "").lower().strip() out = {"speak": any(w in low for w in ["read", "speak", "say", "aloud", "tell", "ಮಾತ", "ಓದ", "बोल", "पढ"])} # which language to SPEAK the answer in (overrides the UI language) if any(w in low for w in ["hindi", "हिंदी", "हिन्दी", "हिंदी में"]): out["say_lang"] = "hi" elif any(w in low for w in ["kannada", "ಕನ್ನಡ", "kannad"]): out["say_lang"] = "kn" elif "english" in low: out["say_lang"] = "en" m = re.search(r"(?:top|ಮೇಲಿನ|शीर्ष)\s*(\d+)", low) if m: out["topn"] = max(5, min(50, int(m.group(1)))) if any(w in low for w in ["worst", "high impact", "critical", "severe", "ಕೆಟ್ಟ", "खराब", "गंभीर"]): out["min_cii"] = 80 return out def speak_summary(rows, say_lang, n): """Build a spoken summary of the top-n zones in the requested language.""" rows = rows.head(n) if say_lang == "hi": parts = [f"शीर्ष {len(rows)} क्षेत्र।"] for i, (_, r) in enumerate(rows.iterrows(), 1): parts.append(f"{i}. {r['location'].split(',')[0]}, " f"सी आई आई {r['cii']:.0f}, {int(r['n_violations'])} उल्लंघन।") return " ".join(parts) if say_lang == "kn": parts = [f"ಮೇಲಿನ {len(rows)} ಪ್ರದೇಶಗಳು."] for i, (_, r) in enumerate(rows.iterrows(), 1): parts.append(f"{i}. {r['location'].split(',')[0]}, " f"ಸಿ ಐ ಐ {r['cii']:.0f}, {int(r['n_violations'])} ಉಲ್ಲಂಘನೆ.") return " ".join(parts) nums = ["one", "two", "three", "four", "five", "six", "seven", "eight"] parts = [f"Top {len(rows)} zones."] for i, (_, r) in enumerate(rows.iterrows()): label = nums[i] if i < len(nums) else str(i + 1) parts.append(f"{label}: {r['location'].split(',')[0]}, " f"C I I {r['cii']:.0f}, {int(r['n_violations'])} violations.") return " ".join(parts) # ---------------- plotly helpers (let theme="streamlit" adapt to light/dark) ---------------- def _layout(fig, height=240, title=None): fig.update_layout(height=height, margin=dict(l=10, r=10, t=36 if title else 8, b=8), title=dict(text=title, font=dict(size=14)) if title else None, paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", showlegend=False) return fig def line_chart(daily, title, mark_last=False): fig = go.Figure(go.Scatter(x=daily["label"], y=daily["value"], mode="lines", line=dict(color=VIOLET, width=2.5), fill="tozeroy", fillcolor="rgba(139,92,246,0.22)")) if mark_last and len(daily): row = daily.iloc[-1] fig.add_trace(go.Scatter(x=[row["label"]], y=[row["value"]], mode="markers", marker=dict(color="#F472B6", size=13))) fig.update_xaxes(showgrid=False) return _layout(fig, title=title) def bar_chart(d, title, ramp=False): colors = [cii_to_hex(v / (max(d["value"]) or 1) * 100) for v in d["value"]] if ramp else VIOLET fig = go.Figure(go.Bar(x=d["label"], y=d["value"], marker_color=colors)) fig.update_xaxes(showgrid=False) return _layout(fig, title=title) def rainbow_gauge(value, title): stops = [(0.0, (34, 211, 238)), (0.4, (52, 211, 153)), (0.7, (250, 204, 21)), (1.0, (244, 114, 182))] def lerp(tt): for i in range(len(stops) - 1): t0, c0 = stops[i] t1, c1 = stops[i + 1] if tt <= t1: f = (tt - t0) / (t1 - t0 + 1e-9) return tuple(int(c0[j] + (c1[j] - c0[j]) * f) for j in range(3)) return stops[-1][1] seg = 28 steps = [{"range": [i / seg * 100, (i + 1) / seg * 100], "color": f"rgb{lerp((i + 0.5) / seg)}"} for i in range(seg)] fig = go.Figure(go.Indicator( mode="gauge+number", value=value, number={"suffix": "%", "font": {"size": 38}}, gauge={"axis": {"range": [0, 100], "tickwidth": 0}, "bar": {"color": "rgba(255,255,255,0)"}, "borderwidth": 0, "steps": steps})) return _layout(fig, height=250, title=title) # ---------------- gradient KPI card + donut ---------------- def sparkline_svg(series, color, w=120, h=36): if not series or len(series) < 2: return "" mn, mx = min(series), max(series) rng = (mx - mn) or 1 pts = " ".join( f"{i/(len(series)-1)*w:.1f},{h - (v-mn)/rng*(h-7) - 4:.1f}" for i, v in enumerate(series)) last = pts.split()[-1] return (f"") def bars_svg(vals, colors, w=120, h=36): mx = max(vals) or 1 bw = w / (len(vals) * 1.7) gap = bw * 0.7 rects = "".join( f"" for i, v in enumerate(vals)) return f"{rects}" def _delta(series, lower_is_better=True): if not series or len(series) < 14: return "" k = min(30, len(series) // 2) recent = sum(series[-k:]) / k prior = sum(series[-2 * k:-k]) / k if prior == 0: return "" pct = (recent - prior) / prior * 100 up = pct >= 0 good = (not up) if lower_is_better else up color = "#34D399" if good else "#F87171" return (f"{'▲' if up else '▼'} " f"{abs(pct):.1f}% " f"vs prev {k}d") def kpi_card(icon, label, value, accent=VIOLET, series=None, viz="spark", sub=None, lower_is_better=True): if viz == "bars" and series: chart = bars_svg(series, ["rgba(170,170,190,0.30)", accent]) elif series: chart = sparkline_svg(series, accent) else: chart = "" delta = sub if sub else _delta(series, lower_is_better) delta_html = (f"
{delta}
" if delta else "") st.markdown( f"
" f"
" f"
{icon}
" f"
{chart}
" f"
{label}
" f"
{value}
" f"{delta_html}
", unsafe_allow_html=True) def donut(labels, values, title): fig = go.Figure(go.Pie(labels=list(labels), values=list(values), hole=0.62, marker=dict(colors=DONUT_COLORS), textinfo="percent", textfont=dict(color="#fff", size=12), sort=True)) fig.update_layout(height=300, margin=dict(l=10, r=10, t=42, b=10), title=dict(text=title, font=dict(size=14)), paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)", legend=dict(orientation="v", x=1, y=0.5, font=dict(size=11))) return fig def active_theme(): """Active theme ('light'/'dark'); defaults to dark on first run / older Streamlit.""" try: tp = st.context.theme.type return tp if tp in ("light", "dark") else "dark" except Exception: return "dark" def theme_css(mode): """Light / system overrides layered on top of the dark default. No !important on text colours, so inline-coloured bits (logo, KPI accents, status cards) keep their colours; only the *defaults* get recoloured.""" light = """ .stApp { background-color:#F6F3FF; } .stApp p, .stApp li, .stApp label, .stApp h1, .stApp h2, .stApp h3, .stApp h4 { color:#241748; } [data-testid="stCaptionContainer"], [data-testid="stCaptionContainer"] p { color:#5f548b; } section[data-testid="stSidebar"] > div:first-child { background:#ECE6FB; } section[data-testid="stSidebar"], section[data-testid="stSidebar"] p, section[data-testid="stSidebar"] label, section[data-testid="stSidebar"] div { color:#2b1d55; } [data-testid="stSegmentedControl"] button p { color:#2b1d55; } .drishti-sub { color:#2563EB !important; } """ if mode == "light": return f"" if mode == "system": return f"" return "" # ==================== APP ==================== inject_css() require_login() # comment this line out to disable the login gate hot, fc, off, meta = load() THEME = active_theme() # follows the ⋮ menu (top-right) -> Settings -> Theme ACCENT = "#FACC15" if THEME == "dark" else "#2563EB" # gold text -> yellow (dark) / blue (light) MAP_STYLE = "dark" if THEME == "dark" else "light" NAV_COLOR = "#9B8FC2" if THEME == "dark" else "#4C3A82" area_vocab = build_area_vocab(hot) hot["fill"] = hot["cii"].apply(cii_color) mm = meta["model_metrics"] NAV_KEYS = ["tab_map", "tab_ops", "tab_trends", "tab_rank", "tab_off", "tab_fc", "tab_patrol", "tab_whatif", "tab_event"] NAV_ICONS = ["geo-alt-fill", "broadcast", "graph-up", "list-check", "exclamation-triangle-fill", "magic", "signpost-split-fill", "sliders", "calendar-event-fill"] with st.sidebar: st.markdown( "
" "
D
" "
DRISHTI
" "
" "दृष्टि · Bengaluru Traffic Police
", unsafe_allow_html=True) if st.session_state.get("user"): lo1, lo2 = st.columns([2, 1]) lo1.markdown( f"
👤 {st.session_state['user']}" f"
", unsafe_allow_html=True) if lo2.button("Log out", use_container_width=True): st.session_state.clear() st.query_params.clear() st.rerun() st.markdown("
", unsafe_allow_html=True) lang_label = st.selectbox("🌐 " + t("language", "en"), list(LANGS.keys())) lang = LANGS[lang_label] st.markdown("
NAVIGATION
", unsafe_allow_html=True) labels = [re.sub(r"^[^\w]+", "", t(k, lang)).strip() for k in NAV_KEYS] if HAS_MENU: choice = option_menu( None, labels, icons=NAV_ICONS, default_index=0, styles={"container": {"background-color": "transparent", "padding": "2px 0"}, "icon": {"color": NAV_COLOR, "font-size": "15px"}, "nav-link": {"color": NAV_COLOR, "font-size": "14px", "border-radius": "10px", "margin": "3px 0", "--hover-color": "rgba(139,92,246,0.15)"}, "nav-link-selected": {"background-color": VIOLET, "color": "#fff", "font-weight": "600"}}) else: st.caption("⚠️ run `pip install -r requirements.txt` for the icon nav") choice = st.radio("Navigate", labels, label_visibility="collapsed") section = NAV_KEYS[labels.index(choice)] st.divider() if "history" not in st.session_state: st.session_state.history = [] st.markdown("**" + t("history", lang) + "**") if st.session_state.history: for h in reversed(st.session_state.history[-8:]): st.caption(f"• {h['q']} _( {h['t']} )_") if st.button(t("clear_history", lang)): st.session_state.history = [] st.rerun() else: st.caption(t("no_history", lang)) st.markdown( f"
🟢 Live · {meta['n_cells']:,} zones monitored
" f"Forecast MAE {mm['valid_mae']} · " f"↓{mm.get('improvement_pct','')}% vs baseline
", unsafe_allow_html=True) st.divider() st.markdown("
🎨 THEME
", unsafe_allow_html=True) st.caption("Switch theme via the ⋮ menu (top-right) → Settings → Theme.") # ----- header + KPIs (always) ----- st.markdown( "

दृष्टि — DRISHTI

", unsafe_allow_html=True) st.markdown( f"
" "Digital Real-time Intelligence for Smart Hotspot & Traffic Insights
", unsafe_allow_html=True) st.caption("हर सड़क पर नज़र, हर सफ़र आसान · Bengaluru Traffic Police") # ----- command / voice bar (top of content, prominent) ----- _mic_ready = "🎤 ready" if HAS_MIC else "⚠️ mic component missing" st.markdown( "
" "
🎙️
" f"
{t('voice_nav', lang)}
" "
" f"
{_mic_ready}" " · 🔊 EN · ಕನ್ನಡ · हिन्दी
", unsafe_allow_html=True) cb1, cb2 = st.columns([6, 1]) with cb1: typed = st.text_input(t("ask", lang), placeholder="🔍 " + t("placeholder", lang), label_visibility="collapsed") spoken = None with cb2: if HAS_MIC: spoken = speech_to_text(language=SPEECH_LANG.get(lang, "en-IN"), start_prompt="🎤", stop_prompt="⏹", just_once=True, use_container_width=True, key="stt") query = spoken or typed if query: cmd = parse_command(query) res = hot.copy() area = resolve_area(query, lang, area_vocab) if area: res = res[res["location"].str.contains(re.escape(area), case=False, na=False)] if "min_cii" in cmd: res = res[res["cii"] >= cmd["min_cii"]] res = res.head(cmd.get("topn", 10)) st.session_state.history.append( {"q": query, "t": datetime.now().strftime("%H:%M"), "lang": lang, "area": area or "", "results": len(res)}) tag = f" → {area}" if area else "" st.markdown(f"**{t('understood', lang)}:** _{query}_{tag} ({len(res)})") st.dataframe(res[["cii_rank", "cii", "location", "junction_name", "n_violations", "top_violation"]].rename(columns={ "cii_rank": "Rank", "cii": "CII", "location": "Location", "junction_name": "Junction", "n_violations": "Violations", "top_violation": "Top violation"}), use_container_width=True, hide_index=True) if len(res): say_lang = cmd.get("say_lang", lang) # query language overrides UI language speak_n = min(len(res), cmd.get("topn", 3), 8) summary = speak_summary(res, say_lang, speak_n) lang_name = {"en": "English", "hi": "Hindi", "kn": "Kannada"}[say_lang] if cmd.get("speak"): play_tts(summary, say_lang) if st.button(f"🔊 Read aloud ({lang_name})", key="read_btn"): play_tts(summary, say_lang) st.write("") sp = meta.get("kpi_sparks", {}) kc = st.columns(4) with kc[0]: kpi_card("🚗", t("kpi_violations", lang), f"{meta['n_records']:,}", accent="#8B5CF6", series=sp.get("violations")) with kc[1]: kpi_card("📍", t("kpi_zones", lang), f"{meta['n_cells']:,}", accent="#22D3EE", series=sp.get("zones")) with kc[2]: kpi_card("🔥", t("kpi_high", lang), f"{int((hot.cii >= 70).sum()):,}", accent=ACCENT, series=sp.get("peak")) with kc[3]: bl = mm.get("baseline_lag7_mae", 1.0) kpi_card("🎯", t("kpi_mae", lang), mm["valid_mae"], accent="#34D399", series=[bl, mm["valid_mae"]], viz="bars", sub=(f"↓ " f"{mm['improvement_pct']}% vs baseline" if mm.get("improvement_pct") else None)) st.write("") st.divider() # ==================== sections ==================== if section == "tab_map": mc1, mc2 = st.columns([1.1, 2.6]) with mc1: min_cii = st.slider(t("min_cii", lang), 0, 100, 50, 5) view = hot[hot.cii >= min_cii] with mc2: st.markdown( "
" f"Showing {len(view):,} of " f"{len(hot):,} zones" "" "Low" "
" "High" "CII 0–100
", unsafe_allow_html=True) layer = pdk.Layer("H3HexagonLayer", view, pickable=True, filled=True, extruded=True, get_hexagon="h3", get_fill_color="fill", get_elevation="cii", elevation_scale=8, opacity=0.7) st.pydeck_chart(pdk.Deck( layers=[layer], initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=45), map_style=MAP_STYLE, tooltip={ "html": "
CII {cii}" " · rank #{cii_rank}
" "
{location}
" "
{n_violations} " "violations · {active_days} days
" "
Top: {top_violation}
", "style": {"backgroundColor": "rgba(20,12,46,0.94)", "color": "#F4F1FF", "borderRadius": "10px", "padding": "10px 12px", "maxWidth": "250px", "whiteSpace": "normal", "lineHeight": "1.35", "fontFamily": "Plus Jakarta Sans, sans-serif", "border": "1px solid rgba(139,92,246,0.45)", "boxShadow": "0 8px 26px rgba(0,0,0,0.4)"}}, ), use_container_width=True, height=560, key=f"impactmap_{THEME}") elif section == "tab_ops": st.subheader("🚨 Live operations — congestion alerts & enforcement") st.caption("⚠️ Simulated live feed: historical hotspots replayed as real-time alerts. " "In production this is driven by live ANPR / e-challan feeds, with push " "notifications to field officers and control-room displays.") thr = st.slider("Trigger an alert when CII ≥", 50, 100, 75, 5) alerts = (hot[hot.cii >= thr].sort_values("cii", ascending=False) .head(20).reset_index(drop=True)) def rec_action(c): if c >= 88: return "🚨 Deploy patrol + initiate towing" if c >= 78: return "⚠️ On-spot enforcement / challan drive" return "👁 Monitor + advisory signage" alerts["Recommended action"] = alerts["cii"].apply(rec_action) if st.checkbox("🔴 Live mode (auto-refresh)") and HAS_AUTOREFRESH and len(alerts): n = st_autorefresh(interval=2500, key="ops") latest = alerts.iloc[n % len(alerts)] st.markdown( "
" f"🔴 LIVE · {datetime.now().strftime('%H:%M:%S')} · " f"{latest['location'].split(',')[0]} · CII {latest['cii']:.0f} · " f"{latest['Recommended action']}
", unsafe_allow_html=True) st.write("") m = st.columns(3) m[0].metric("Active alerts", len(alerts)) m[1].metric("Critical (CII ≥ 88)", int((alerts.cii >= 88).sum())) m[2].metric("Zones monitored", f"{len(hot):,}") st.dataframe(alerts[["cii_rank", "cii", "location", "junction_name", "n_violations", "top_violation", "Recommended action"]].rename(columns={ "cii_rank": "Rank", "cii": "CII", "location": "Location", "junction_name": "Junction", "n_violations": "Violations", "top_violation": "Top violation"}), use_container_width=True, hide_index=True, height=340) # ---- cross-officer dispatch & coordination board (shared across sessions) ---- st.markdown("#### 🚓 Dispatch & coordination board") st.caption("Shared live across every signed-in officer. Tow/crane units are a simulated " "fleet; in production these are live GPS units with push-to-mobile alerts.") fleet = ops.make_fleet(hot) watch = st.checkbox("🔔 Live board (auto-refresh every 5s)", value=True) if watch and HAS_AUTOREFRESH: st_autorefresh(interval=5000, key="board_refresh") e1, e2, e3 = st.columns([2.2, 2.2, 1.1]) zopts = alerts["location"].tolist() if len(alerts) else hot["location"].head(20).tolist() zone = e1.selectbox("Zone", zopts, key="disp_zone") act = e2.selectbox("Action", ["Deploy patrol", "Tow & fine", "Install signage / barricade", "On-spot challan drive", "Escalate to control room"], key="disp_act") auto_tow = e3.checkbox("Assign nearest unit", value=True) if st.button("📨 Dispatch", type="primary"): zrow = hot[hot["location"] == zone] unit, dist = (None, None) if auto_tow and len(zrow): unit, dist = ops.nearest_unit(float(zrow["lat"].iat[0]), float(zrow["lon"].iat[0]), fleet) ops.add_dispatch({ "ts": _time.time(), "time": datetime.now().strftime("%H:%M:%S"), "officer": st.session_state.get("user", "—"), "zone": zone.split(",")[0], "cii": float(zrow["cii"].iat[0]) if len(zrow) else None, "action": act, "unit": unit["unit"] if unit else "—", "eta_km": dist if dist is not None else None, "status": "Dispatched"}) msg = f"Dispatched: {act} → {zone.split(',')[0]}" if unit: msg += f" · nearest unit {unit['unit']} (~{dist} km)" st.success(msg) board = ops.read_dispatches() # toast when a NEW dispatch from another officer appears since we last looked max_id = max([b.get("id", 0) for b in board], default=0) if max_id > st.session_state.get("board_seen", 0): newest = board[0] if newest.get("officer") != st.session_state.get("user"): st.toast(f"🔔 {newest.get('officer')} → {newest.get('action')} @ " f"{newest.get('zone')}", icon="🚓") st.session_state.board_seen = max_id open_n = sum(1 for b in board if b.get("status") != "Resolved") clears = [b["clear_min"] for b in board if b.get("clear_min") is not None] s1, s2, s3 = st.columns(3) s1.metric("Open dispatches", open_n) s2.metric("Resolved", len(clears)) s3.metric("Avg time-to-clear", f"{(sum(clears)/len(clears)):.0f} min" if clears else "—") if board: bdf = pd.DataFrame(board) for c in ["time", "officer", "zone", "action", "unit", "status"]: if c not in bdf.columns: bdf[c] = "—" st.dataframe(bdf[["time", "officer", "zone", "action", "unit", "status"]].rename(columns={ "time": "Time", "officer": "Officer", "zone": "Zone", "action": "Action", "unit": "Unit", "status": "Status"}), use_container_width=True, hide_index=True, height=240) open_ids = [b["id"] for b in board if b.get("status") != "Resolved"] rc1, rc2, rc3 = st.columns([2.5, 1, 1]) if open_ids: rid = rc1.selectbox("Resolve a dispatch (logs time-to-clear)", open_ids, format_func=lambda i: f"#{i} · " + next((b.get("zone", "") for b in board if b.get("id") == i), ""), key="resolve_pick") if rc2.button("✅ Resolve", key="resolve_btn"): ops.resolve_dispatch(rid) st.rerun() if rc3.button("🗑 Reset board", key="clear_board"): ops.clear_dispatches() st.session_state.board_seen = 0 st.rerun() else: st.info("No dispatches yet — dispatch an action above and it appears instantly " "for every officer on the board.") elif section == "tab_trends": tr = load_trends() byday = load_byday() daily = tr[tr.kind == "daily"].sort_values("order").reset_index(drop=True) DOW = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] cursor = None live = st.checkbox(t("live_replay", lang), value=False) if live and HAS_AUTOREFRESH: n = st_autorefresh(interval=1200, key="replay") cursor = n % len(daily) row = daily.iloc[cursor] lc1, lc2 = st.columns(2) lc1.metric("Replay day", row["label"]) lc2.metric("Violations that day", int(row["value"])) elif live and not HAS_AUTOREFRESH: st.caption("Install `streamlit-autorefresh` to enable live replay.") if cursor is not None: # cumulative time-lapse for the line + animated bars rday = daily.iloc[cursor]["label"] dline = daily.iloc[:cursor + 1] bh = (byday[(byday.dim == "hour") & (byday.date <= rday)] .groupby("key")["value"].sum().reset_index()) bh.columns = ["label", "value"] bh["order"] = bh["label"].astype(int) hourly = bh.sort_values("order") dd = dline.copy() dd["dow"] = pd.to_datetime(dd["label"]).dt.dayofweek bw = dd.groupby("dow")["value"].sum().reindex(range(7), fill_value=0) dow = pd.DataFrame({"label": DOW, "value": bw.values, "order": range(7)}) line = line_chart(dline, t("t_daily", lang), mark_last=True) else: hourly = tr[tr.kind == "hourly"].sort_values("order") dow = tr[tr.kind == "dow"].sort_values("order") line = line_chart(daily, t("t_daily", lang)) veh = tr[tr.kind == "vehicle"].sort_values("value", ascending=False) vtype = tr[tr.kind == "vtype"].sort_values("value", ascending=False) st.plotly_chart(line, use_container_width=True) r1 = st.columns(2) with r1[0]: st.plotly_chart(bar_chart(hourly, t("t_hourly", lang), ramp=True), use_container_width=True) with r1[1]: st.plotly_chart(bar_chart(dow, t("t_dow", lang)), use_container_width=True) r2 = st.columns(2) with r2[0]: st.plotly_chart(donut(veh["label"], veh["value"], t("t_vehicle", lang)), use_container_width=True) with r2[1]: st.plotly_chart(donut(vtype["label"], vtype["value"], t("t_vtype", lang)), use_container_width=True) g = st.columns([1, 2, 1]) with g[1]: conc = round(hot.head(100)["n_violations"].sum() / meta["n_records"] * 100, 1) st.plotly_chart(rainbow_gauge(conc, t("t_gauge", lang)), use_container_width=True) elif section == "tab_rank": topn = st.slider(t("top_n_zones", lang), 5, 50, 20, 5) cols = ["cii_rank", "cii", "location", "junction_name", "police_station", "n_violations", "active_days", "persistence", "peak_share", "top_violation"] tbl = hot[cols].head(topn).copy() tbl["persistence"] = (tbl["persistence"] * 100).round(0).astype(int).astype(str) + "%" tbl["peak_share"] = (tbl["peak_share"] * 100).round(0).astype(int).astype(str) + "%" st.dataframe(tbl.rename(columns={ "cii_rank": "Rank", "cii": "CII", "location": "Location", "junction_name": "Nearest junction", "police_station": "Police station", "n_violations": "Violations", "active_days": "Active days", "persistence": "Persistence", "peak_share": "Peak share", "top_violation": "Top violation"}), use_container_width=True, hide_index=True, height=520) st.download_button(t("dl_priorities", lang), hot[cols].head(topn).to_csv(index=False), "enforcement_priorities.csv", mime="text/csv") elif section == "tab_off": s = meta.get("offender_summary", {}) o1, o2, o3 = st.columns(3) o1.metric(t("repeat_offenders", lang), f"{s.get('repeat_offenders', 0):,}") o2.metric(t("share_violations", lang), f"{s.get('repeat_share_pct', 0)}%") o3.metric(t("worst_vehicle", lang), f"{s.get('worst_count', 0)}") st.markdown("##### Offender intelligence") cc = st.columns(2) with cc[0]: topo = off.head(12) st.plotly_chart(bar_chart( pd.DataFrame({"label": topo["vehicle_number"], "value": topo["n_violations"]}), "Top 12 offenders · violations", ramp=True), use_container_width=True) with cc[1]: vt = off.groupby("vehicle_type").size().sort_values(ascending=False) st.plotly_chart(donut(vt.index.tolist(), vt.values.tolist(), "Offenders by vehicle type"), use_container_width=True) zh = off.groupby("n_zones").size().reset_index(name="cnt").sort_values("n_zones") st.plotly_chart(bar_chart( pd.DataFrame({"label": zh["n_zones"].astype(str) + " zone(s)", "value": zh["cnt"]}), "Spatial spread · how many distinct zones each offender hits"), use_container_width=True) # ---- escalation ladder (chronic-offender enforcement tiers) ---- st.markdown("##### ⚖️ Repeat-offender escalation ladder") st.caption("Chronic plates are auto-tiered for escalating action — in production these " "fire as e-challan notices / RTO referrals, directly targeting the 34%.") tiers_all = off["n_violations"].apply(ops.escalation_tier) off_e = off.copy() off_e["tier"] = [x[0] for x in tiers_all] off_e["tier_icon"] = [x[1] for x in tiers_all] off_e["rec_action"] = [x[2] for x in tiers_all] tc = off_e.groupby("tier_icon").size() tcols = st.columns(4) for col, (ic, nm) in zip(tcols, [("🔴", "Chronic"), ("🟠", "Habitual"), ("🟡", "Repeat"), ("🔵", "Watchlist")]): col.metric(f"{ic} {nm}", int(tc.get(ic, 0))) q = st.text_input(t("search_vehicle", lang), key="off_search") view = off_e if q: view = off_e[off_e["vehicle_number"].str.contains(q, case=False, na=False) | off_e["top_location"].str.contains(q, case=False, na=False)] n_off = st.slider(t("top_n_off", lang), 5, 100, 25, 5, key="off_n") view = view.copy() view["Tier"] = view["tier_icon"] + " " + view["tier"] st.dataframe(view.head(n_off)[["rank", "vehicle_number", "n_violations", "n_zones", "vehicle_type", "Tier", "rec_action", "last_seen"]].rename( columns={"rank": "Rank", "vehicle_number": "Vehicle (anon.)", "n_violations": "Violations", "n_zones": "Zones hit", "vehicle_type": "Type", "rec_action": "Recommended action", "last_seen": "Last seen"}), use_container_width=True, hide_index=True, height=420) st.download_button(t("dl_offenders", lang), view.head(n_off).drop(columns=["tier_icon"]).to_csv(index=False), "repeat_offenders.csv", mime="text/csv", key="off_dl") with st.expander("📄 Generate a formal notice (e-challan draft)"): pick = st.selectbox("Vehicle", view.head(n_off)["vehicle_number"].tolist(), key="notice_v") r = off_e[off_e["vehicle_number"] == pick].iloc[0] notice = (f"BENGALURU TRAFFIC POLICE — REPEAT-OFFENDER NOTICE\n" f"-----------------------------------------------\n" f"Vehicle (anonymised): {pick}\n" f"Vehicle type : {r['vehicle_type']}\n" f"Recorded violations : {r['n_violations']} across {r['n_zones']} zone(s)\n" f"Period : {r['first_seen']} to {r['last_seen']}\n" f"Most-seen location : {r['top_location']}\n" f"Escalation tier : {r['tier_icon']} {r['tier']}\n" f"Recommended action : {r['rec_action']}\n\n" f"As per repeat-violation provisions, the above vehicle is liable for " f"escalated penalty. This is a system-generated draft for review.\n") st.code(notice) st.download_button("⬇️ Download notice (TXT)", notice, f"notice_{pick}.txt", mime="text/plain", key="notice_dl") elif section == "tab_fc": day = fc["forecast_for"].iat[0] st.caption(f"{day}") topf = fc.head(25).copy() fc_layer = pdk.Layer("ScatterplotLayer", topf, pickable=True, get_position="[lon, lat]", get_radius="pred_intensity * 6 + 60", get_fill_color="[244, 114, 182, 160]") st.pydeck_chart(pdk.Deck( layers=[fc_layer], initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0), map_style=MAP_STYLE, tooltip={"html": "Risk #{risk_rank} · {pred_intensity}
{location}"}, ), use_container_width=True, height=520, key=f"fcmap_{THEME}") st.dataframe(topf[["risk_rank", "location", "junction_name", "pred_intensity", "cii"]].rename(columns={ "risk_rank": "Risk rank", "location": "Location", "junction_name": "Nearest junction", "pred_intensity": "Predicted intensity", "cii": "Current CII"}), use_container_width=True, hide_index=True) elif section == "tab_patrol": st.subheader("🗓️ Patrol-beat & shift planner") day = fc["forecast_for"].iat[0] st.caption(f"Tomorrow's predicted hotspots ({day}) grouped by station jurisdiction — " "a deployable morning briefing. Zones from the LightGBM forecast; suggested " "shift windows from each zone's peak-hour profile.") plan = ops.patrol_plan(fc, hot, top_zones=60) if not len(plan): st.info("No forecast zones available to plan.") else: summ = (plan.groupby("police_station") .agg(zones=("h3", "count"), intensity=("pred_intensity", "sum"), units=("units", "sum")) .sort_values("intensity", ascending=False).reset_index()) c = st.columns(3) c[0].metric("Hotspot zones tomorrow", len(plan)) c[1].metric("Stations to brief", plan["police_station"].nunique()) c[2].metric("Patrol units to deploy", int(plan["units"].sum())) st.markdown("##### 🚦 Priority stations tomorrow") st.plotly_chart(bar_chart( pd.DataFrame({"label": summ["police_station"].head(10), "value": summ["intensity"].head(10).round(0)}), "Top 10 stations by total predicted intensity", ramp=True), use_container_width=True) stations = ["All divisions"] + summ["police_station"].tolist() pick = st.selectbox("Division / station beat", stations, key="patrol_div") view = plan if pick == "All divisions" else plan[plan["police_station"] == pick] mlayer = pdk.Layer("ScatterplotLayer", view, pickable=True, get_position="[lon, lat]", get_radius="pred_intensity * 6 + 80", get_fill_color="[124, 58, 237, 170]") st.pydeck_chart(pdk.Deck( layers=[mlayer], initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0), map_style=MAP_STYLE, tooltip={"html": "{location}
pred {pred_intensity} · " "{units} unit(s)
{shift}"}), use_container_width=True, height=420, key=f"patrolmap_{THEME}") brief = view.copy() brief["order"] = range(1, len(brief) + 1) show = brief[["order", "police_station", "location", "junction_name", "pred_intensity", "shift", "units"]].rename(columns={ "order": "#", "police_station": "Station", "location": "Zone", "junction_name": "Junction", "pred_intensity": "Predicted intensity", "shift": "Suggested shift", "units": "Units"}) st.dataframe(show, use_container_width=True, hide_index=True, height=380) st.download_button("⬇️ Download tomorrow's patrol briefing (CSV)", show.to_csv(index=False), "patrol_briefing.csv", mime="text/csv", key="patrol_dl") elif section == "tab_whatif": st.subheader("🧪 What-if intervention simulator") st.caption("Project the CII drop from an intervention using the *same* published CII " "weights (volume 45% · persistence 30% · peak 25%). Lever effects are " "transparent, configurable assumptions — not a black box.") wcol = st.columns([2.4, 2]) zopts = hot.sort_values("cii", ascending=False)["location"].head(150).tolist() zsel = wcol[0].selectbox("Target zone", zopts, key="wi_zone") levers = wcol[1].multiselect("Intervention(s)", list(ops.INTERVENTIONS.keys()), default=["Bollards / barricade"], key="wi_lev") zrow = hot[hot["location"] == zsel].iloc[0] cur_cii = float(zrow["cii"]) reductions = ops.combine_interventions(levers) new_cii, eff_pct = ops.whatif_new_cii(cur_cii, reductions, meta.get("cii_weights", {})) cur_rank = int(zrow["cii_rank"]) proj_rank = ops.new_rank(new_cii, hot["cii"].tolist()) k = st.columns(3) with k[0]: kpi_card("🎯", "Projected CII", f"{new_cii:.0f}", accent="#22D3EE", sub=f"↓ from {cur_cii:.0f} (−{eff_pct:.0f}%)") with k[1]: kpi_card("📊", "Projected city rank", f"#{proj_rank}", accent=VIOLET, sub=f"from #{cur_rank} " f"(↓ {max(0, proj_rank - cur_rank)} places)") with k[2]: kpi_card("🧩", "Levers applied", f"{len(levers)}", accent="#FACC15", sub="combined with diminishing returns") st.write("") st.plotly_chart(bar_chart( pd.DataFrame({"label": ["Current CII", "Projected CII"], "value": [cur_cii, new_cii]}), f"{zsel.split(',')[0]} — projected impact of intervention", ramp=False), use_container_width=True) comp = pd.DataFrame({ "Component": ["Severity-weighted volume", "Persistence", "Peak concentration"], "Weight": ["45%", "30%", "25%"], "Modelled reduction": [f"−{reductions['volume'] * 100:.0f}%", f"−{reductions['persistence'] * 100:.0f}%", f"−{reductions['peak'] * 100:.0f}%"]}) st.markdown("##### How the projection is built") st.dataframe(comp, use_container_width=True, hide_index=True) st.caption("Each lever reduces components by configurable fractions; multiple levers " "combine multiplicatively. The headline change is the weight-blended " "reduction applied to this zone's CII — fully auditable.") elif section == "tab_event": st.subheader("🎪 Event mode — venue surge projection") st.caption("Project congestion around a known venue on event days (match / concert / " "sale). Surge = each nearby zone's CII × an event multiplier, from its " "historical pattern — useful for pre-positioning before the rush.") VENUES = { "M. Chinnaswamy Stadium (cricket)": (12.9788, 77.5996), "Sree Kanteerava Stadium": (12.9617, 77.5972), "Orion Mall, Rajajinagar": (13.0108, 77.5550), "Phoenix Marketcity, Whitefield": (12.9959, 77.6965), "Mantri Square, Malleshwaram": (13.0068, 77.5705), "Kempegowda Bus Station (Majestic)": (12.9774, 77.5717), "MG Road / Brigade Road": (12.9756, 77.6068), } EVENTS = {"Cricket match / concert (high)": 1.6, "Mall sale / festival (medium)": 1.4, "Weekday office rush (low)": 1.25} ec = st.columns([2.3, 2, 1]) venue = ec[0].selectbox("Venue", list(VENUES.keys()), key="ev_venue") etype = ec[1].selectbox("Event type", list(EVENTS.keys()), key="ev_type") kr = ec[2].slider("Radius (rings)", 1, 3, 2, key="ev_k") vlat, vlon = VENUES[venue] mult = EVENTS[etype] tmp = hot[["h3", "lat", "lon"]].copy() tmp["_d"] = (tmp["lat"] - vlat) ** 2 + (tmp["lon"] - vlon) ** 2 center_h3 = tmp.nsmallest(1, "_d")["h3"].iat[0] surge = ops.event_surge(hot, center_h3, kr, mult) if not len(surge): st.info("No mapped hotspot zones near this venue in the dataset.") else: m = st.columns(3) m[0].metric("Zones in surge radius", len(surge)) m[1].metric("Peak projected CII", f"{surge['surge_cii'].max():.0f}") m[2].metric("Avg added load", f"+{surge['delta'].mean():.0f} CII") surge2 = surge.copy() surge2["radius"] = surge2["surge_cii"] * 5 + 60 slayer = pdk.Layer("ScatterplotLayer", surge2, pickable=True, get_position="[lon, lat]", get_radius="radius", get_fill_color="[239, 68, 68, 160]") vlayer = pdk.Layer("ScatterplotLayer", pd.DataFrame([{"lat": vlat, "lon": vlon}]), get_position="[lon, lat]", get_radius=180, get_fill_color="[124, 58, 237, 230]") st.pydeck_chart(pdk.Deck( layers=[slayer, vlayer], initial_view_state=pdk.ViewState(latitude=vlat, longitude=vlon, zoom=13, pitch=0), map_style=MAP_STYLE, tooltip={"html": "{location}
CII {cii} → surge {surge_cii} (+{delta})"}), use_container_width=True, height=440, key=f"eventmap_{THEME}") show = surge[["location", "junction_name", "cii", "surge_cii", "delta", "n_violations"]].rename(columns={ "location": "Zone", "junction_name": "Junction", "cii": "Normal CII", "surge_cii": "Event CII", "delta": "Δ surge", "n_violations": "Hist. violations"}) st.dataframe(show, use_container_width=True, hide_index=True, height=320) st.caption(f"Projection: normal CII × {mult} (event multiplier), capped at 100. " "The violet dot is the venue; red dots are expected surge zones.") st.divider() st.caption(f"CII = severity-weighted volume (45%) + persistence (30%) + peak " f"concentration (25%), amplified near junctions. Forecast: LightGBM, " f"MAE {mm['valid_mae']} ({mm['improvement_pct']}% better than baseline).")