Spaces:
Sleeping
Sleeping
Update existing Space with new files
Browse files- app.py +407 -98
- data/processed/forecast.parquet +1 -1
- models/lgbm_intensity.txt +0 -0
- src/i18n.py +6 -0
- src/ops.py +206 -0
app.py
CHANGED
|
@@ -32,7 +32,15 @@ try:
|
|
| 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"
|
|
@@ -61,7 +69,7 @@ def require_login():
|
|
| 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:
|
| 65 |
"<div style='opacity:0.92;margin-top:4px;'>Digital Real-time Intelligence for "
|
| 66 |
"Smart Hotspot & Traffic Insights · हर सड़क पर नज़र, हर सफ़र आसान</div></div>",
|
| 67 |
unsafe_allow_html=True)
|
|
@@ -91,7 +99,7 @@ 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 |
|
|
@@ -144,6 +152,7 @@ def inject_css():
|
|
| 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 |
|
|
@@ -350,19 +359,45 @@ def active_theme():
|
|
| 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(
|
|
@@ -396,8 +431,8 @@ with st.sidebar:
|
|
| 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":
|
| 400 |
-
"nav-link": {"color":
|
| 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",
|
|
@@ -407,26 +442,6 @@ with st.sidebar:
|
|
| 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 = []
|
|
@@ -446,6 +461,10 @@ with st.sidebar:
|
|
| 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(
|
|
@@ -453,48 +472,32 @@ st.markdown(
|
|
| 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 & 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 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 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
|
| 490 |
-
st.write("")
|
| 491 |
if HAS_MIC:
|
| 492 |
spoken = speech_to_text(language=SPEECH_LANG.get(lang, "en-IN"),
|
| 493 |
-
start_prompt=
|
| 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:
|
|
@@ -525,24 +528,69 @@ if query:
|
|
| 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 |
-
|
|
|
|
|
|
|
| 534 |
view = hot[hot.cii >= min_cii]
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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=
|
| 543 |
-
tooltip={
|
| 544 |
-
|
| 545 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 546 |
), use_container_width=True, height=560)
|
| 547 |
|
| 548 |
elif section == "tab_ops":
|
|
@@ -586,24 +634,86 @@ elif section == "tab_ops":
|
|
| 586 |
"top_violation": "Top violation"}),
|
| 587 |
use_container_width=True, hide_index=True, height=340)
|
| 588 |
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
|
| 608 |
elif section == "tab_trends":
|
| 609 |
tr = load_trends()
|
|
@@ -704,22 +814,58 @@ elif section == "tab_off":
|
|
| 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 =
|
| 709 |
if q:
|
| 710 |
-
view =
|
| 711 |
-
|
| 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", "
|
| 715 |
-
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 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}")
|
|
@@ -730,7 +876,7 @@ elif section == "tab_fc":
|
|
| 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=
|
| 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",
|
|
@@ -739,6 +885,169 @@ elif section == "tab_fc":
|
|
| 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, "
|
|
|
|
| 32 |
except Exception:
|
| 33 |
HAS_AUTOREFRESH = False
|
| 34 |
|
| 35 |
+
# Make this folder importable no matter where the app is launched from
|
| 36 |
+
# (fixes "ModuleNotFoundError: No module named 'src'" on some setups).
|
| 37 |
+
import os
|
| 38 |
+
import sys
|
| 39 |
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
| 40 |
+
|
| 41 |
from src.i18n import LANGS, SPEECH_LANG, t, build_area_vocab, resolve_area
|
| 42 |
+
from src import ops
|
| 43 |
+
import time as _time
|
| 44 |
|
| 45 |
ROOT = Path(__file__).resolve().parent
|
| 46 |
PROC = ROOT / "data" / "processed"
|
|
|
|
| 69 |
st.markdown(
|
| 70 |
"<div style='background:linear-gradient(135deg,#7C3AED,#EC4899);"
|
| 71 |
"border-radius:18px;padding:26px 30px;color:#fff;margin-bottom:20px;'>"
|
| 72 |
+
"<div style='font-size:2rem;font-weight:600;'>दृष्टि — DRISHTI</div>"
|
| 73 |
"<div style='opacity:0.92;margin-top:4px;'>Digital Real-time Intelligence for "
|
| 74 |
"Smart Hotspot & Traffic Insights · हर सड़क पर नज़र, हर सफ़र आसान</div></div>",
|
| 75 |
unsafe_allow_html=True)
|
|
|
|
| 99 |
hot = pd.read_parquet(PROC / "hotspots.parquet")
|
| 100 |
fc = pd.read_parquet(PROC / "forecast.parquet")
|
| 101 |
off = pd.read_parquet(PROC / "offenders.parquet")
|
| 102 |
+
meta = json.loads((PROC / "meta.json").read_text(encoding="utf-8"))
|
| 103 |
fc = fc.merge(hot[["h3", "location", "junction_name", "cii"]], on="h3", how="left")
|
| 104 |
return hot, fc, off, meta
|
| 105 |
|
|
|
|
| 152 |
border:none; border-radius:10px; font-weight:600; }
|
| 153 |
.stTextInput input, [data-baseweb="select"] > div {
|
| 154 |
border:1px solid rgba(139,92,246,0.35) !important; border-radius:10px; }
|
| 155 |
+
.stTextInput input { padding:0.55rem 0.75rem; font-size:0.95rem; }
|
| 156 |
</style>""", unsafe_allow_html=True)
|
| 157 |
|
| 158 |
|
|
|
|
| 359 |
return "dark"
|
| 360 |
|
| 361 |
|
| 362 |
+
def theme_css(mode):
|
| 363 |
+
"""Light / system overrides layered on top of the dark default.
|
| 364 |
+
No !important on text colours, so inline-coloured bits (logo, KPI accents,
|
| 365 |
+
status cards) keep their colours; only the *defaults* get recoloured."""
|
| 366 |
+
light = """
|
| 367 |
+
.stApp { background-color:#F6F3FF; }
|
| 368 |
+
.stApp p, .stApp li, .stApp label,
|
| 369 |
+
.stApp h1, .stApp h2, .stApp h3, .stApp h4 { color:#241748; }
|
| 370 |
+
[data-testid="stCaptionContainer"], [data-testid="stCaptionContainer"] p { color:#5f548b; }
|
| 371 |
+
section[data-testid="stSidebar"] > div:first-child { background:#ECE6FB; }
|
| 372 |
+
section[data-testid="stSidebar"], section[data-testid="stSidebar"] p,
|
| 373 |
+
section[data-testid="stSidebar"] label, section[data-testid="stSidebar"] div { color:#2b1d55; }
|
| 374 |
+
[data-testid="stSegmentedControl"] button p { color:#2b1d55; }
|
| 375 |
+
.drishti-sub { color:#2563EB !important; }
|
| 376 |
+
"""
|
| 377 |
+
if mode == "light":
|
| 378 |
+
return f"<style>{light}</style>"
|
| 379 |
+
if mode == "system":
|
| 380 |
+
return f"<style>@media (prefers-color-scheme: light) {{{light}}}</style>"
|
| 381 |
+
return ""
|
| 382 |
+
|
| 383 |
+
|
| 384 |
# ==================== APP ====================
|
| 385 |
inject_css()
|
| 386 |
require_login() # comment this line out to disable the login gate
|
| 387 |
hot, fc, off, meta = load()
|
| 388 |
+
THEME = active_theme() # follows the ⋮ menu (top-right) -> Settings -> Theme
|
| 389 |
ACCENT = "#FACC15" if THEME == "dark" else "#2563EB" # gold text -> yellow (dark) / blue (light)
|
| 390 |
+
MAP_STYLE = "dark" if THEME == "dark" else "light"
|
| 391 |
+
NAV_COLOR = "#9B8FC2" if THEME == "dark" else "#4C3A82"
|
| 392 |
area_vocab = build_area_vocab(hot)
|
| 393 |
hot["fill"] = hot["cii"].apply(cii_color)
|
| 394 |
mm = meta["model_metrics"]
|
| 395 |
|
| 396 |
+
NAV_KEYS = ["tab_map", "tab_ops", "tab_trends", "tab_rank", "tab_off", "tab_fc",
|
| 397 |
+
"tab_patrol", "tab_whatif", "tab_event"]
|
| 398 |
NAV_ICONS = ["geo-alt-fill", "broadcast", "graph-up", "list-check",
|
| 399 |
+
"exclamation-triangle-fill", "magic",
|
| 400 |
+
"signpost-split-fill", "sliders", "calendar-event-fill"]
|
| 401 |
|
| 402 |
with st.sidebar:
|
| 403 |
st.markdown(
|
|
|
|
| 431 |
choice = option_menu(
|
| 432 |
None, labels, icons=NAV_ICONS, default_index=0,
|
| 433 |
styles={"container": {"background-color": "transparent", "padding": "2px 0"},
|
| 434 |
+
"icon": {"color": NAV_COLOR, "font-size": "15px"},
|
| 435 |
+
"nav-link": {"color": NAV_COLOR, "font-size": "14px",
|
| 436 |
"border-radius": "10px", "margin": "3px 0",
|
| 437 |
"--hover-color": "rgba(139,92,246,0.15)"},
|
| 438 |
"nav-link-selected": {"background-color": VIOLET, "color": "#fff",
|
|
|
|
| 442 |
choice = st.radio("Navigate", labels, label_visibility="collapsed")
|
| 443 |
section = NAV_KEYS[labels.index(choice)]
|
| 444 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 445 |
st.divider()
|
| 446 |
if "history" not in st.session_state:
|
| 447 |
st.session_state.history = []
|
|
|
|
| 461 |
f"<span style='opacity:0.65;'>Forecast MAE {mm['valid_mae']} · "
|
| 462 |
f"↓{mm.get('improvement_pct','')}% vs baseline</span></div>",
|
| 463 |
unsafe_allow_html=True)
|
| 464 |
+
st.divider()
|
| 465 |
+
st.markdown("<div style='font-size:0.72rem;font-weight:700;letter-spacing:0.06em;"
|
| 466 |
+
"opacity:0.5;margin:4px 0 2px 2px;'>🎨 THEME</div>", unsafe_allow_html=True)
|
| 467 |
+
st.caption("Switch theme via the ⋮ menu (top-right) → Settings → Theme.")
|
| 468 |
|
| 469 |
# ----- header + KPIs (always) -----
|
| 470 |
st.markdown(
|
|
|
|
| 472 |
"font-weight:800;letter-spacing:-0.01em;'>दृष्टि — DRISHTI</h1>",
|
| 473 |
unsafe_allow_html=True)
|
| 474 |
st.markdown(
|
| 475 |
+
f"<div class='drishti-sub' style='color:{ACCENT};font-weight:600;font-size:1.02rem;'>"
|
| 476 |
"Digital Real-time Intelligence for Smart Hotspot & Traffic Insights</div>",
|
| 477 |
unsafe_allow_html=True)
|
| 478 |
+
st.caption("हर सड़क पर नज़र, हर सफ़र आसान · Bengaluru Traffic Police")
|
|
|
|
|
|
|
| 479 |
|
| 480 |
+
# ----- command / voice bar (top of content, prominent) -----
|
| 481 |
+
_mic_ready = "🎤 ready" if HAS_MIC else "⚠️ mic component missing"
|
| 482 |
+
st.markdown(
|
| 483 |
+
"<div style='display:flex;align-items:center;gap:10px;margin:16px 0 8px 0;'>"
|
| 484 |
+
"<div style='width:32px;height:32px;border-radius:10px;flex:none;"
|
| 485 |
+
"background:linear-gradient(135deg,#7C3AED,#EC4899);display:flex;align-items:center;"
|
| 486 |
+
"justify-content:center;font-size:16px;box-shadow:0 3px 12px rgba(124,77,255,.4);'>🎙️</div>"
|
| 487 |
+
f"<div style='font-weight:700;font-size:1.04rem;'>{t('voice_nav', lang)}</div>"
|
| 488 |
+
"<div style='flex:1;'></div>"
|
| 489 |
+
f"<div style='font-size:0.72rem;opacity:.6;white-space:nowrap;'>{_mic_ready}"
|
| 490 |
+
" · 🔊 EN · ಕನ್ನಡ · हिन्दी</div></div>", unsafe_allow_html=True)
|
| 491 |
+
cb1, cb2 = st.columns([6, 1])
|
| 492 |
+
with cb1:
|
| 493 |
+
typed = st.text_input(t("ask", lang), placeholder="🔍 " + t("placeholder", lang),
|
| 494 |
+
label_visibility="collapsed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
spoken = None
|
| 496 |
+
with cb2:
|
|
|
|
| 497 |
if HAS_MIC:
|
| 498 |
spoken = speech_to_text(language=SPEECH_LANG.get(lang, "en-IN"),
|
| 499 |
+
start_prompt="🎤", stop_prompt="⏹",
|
| 500 |
just_once=True, use_container_width=True, key="stt")
|
|
|
|
|
|
|
|
|
|
| 501 |
|
| 502 |
query = spoken or typed
|
| 503 |
if query:
|
|
|
|
| 528 |
play_tts(summary, say_lang)
|
| 529 |
if st.button(f"🔊 Read aloud ({lang_name})", key="read_btn"):
|
| 530 |
play_tts(summary, say_lang)
|
| 531 |
+
st.write("")
|
| 532 |
|
| 533 |
+
sp = meta.get("kpi_sparks", {})
|
| 534 |
+
kc = st.columns(4)
|
| 535 |
+
with kc[0]:
|
| 536 |
+
kpi_card("🚗", t("kpi_violations", lang), f"{meta['n_records']:,}",
|
| 537 |
+
accent="#8B5CF6", series=sp.get("violations"))
|
| 538 |
+
with kc[1]:
|
| 539 |
+
kpi_card("📍", t("kpi_zones", lang), f"{meta['n_cells']:,}",
|
| 540 |
+
accent="#22D3EE", series=sp.get("zones"))
|
| 541 |
+
with kc[2]:
|
| 542 |
+
kpi_card("🔥", t("kpi_high", lang), f"{int((hot.cii >= 70).sum()):,}",
|
| 543 |
+
accent=ACCENT, series=sp.get("peak"))
|
| 544 |
+
with kc[3]:
|
| 545 |
+
bl = mm.get("baseline_lag7_mae", 1.0)
|
| 546 |
+
kpi_card("🎯", t("kpi_mae", lang), mm["valid_mae"], accent="#34D399",
|
| 547 |
+
series=[bl, mm["valid_mae"]], viz="bars",
|
| 548 |
+
sub=(f"<span style='color:#34D399;font-weight:600;'>↓ "
|
| 549 |
+
f"{mm['improvement_pct']}% vs baseline</span>"
|
| 550 |
+
if mm.get("improvement_pct") else None))
|
| 551 |
+
st.write("")
|
| 552 |
st.divider()
|
| 553 |
|
| 554 |
# ==================== sections ====================
|
| 555 |
if section == "tab_map":
|
| 556 |
+
mc1, mc2 = st.columns([1.1, 2.6])
|
| 557 |
+
with mc1:
|
| 558 |
+
min_cii = st.slider(t("min_cii", lang), 0, 100, 50, 5)
|
| 559 |
view = hot[hot.cii >= min_cii]
|
| 560 |
+
with mc2:
|
| 561 |
+
st.markdown(
|
| 562 |
+
"<div style='display:flex;align-items:center;gap:12px;padding-top:1.7rem;"
|
| 563 |
+
"flex-wrap:wrap;'>"
|
| 564 |
+
f"<span style='font-size:0.9rem;'>Showing <b>{len(view):,}</b> of "
|
| 565 |
+
f"{len(hot):,} zones</span>"
|
| 566 |
+
"<span style='flex:1;'></span>"
|
| 567 |
+
"<span style='font-size:0.78rem;opacity:.7;'>Low</span>"
|
| 568 |
+
"<div style='width:150px;height:11px;border-radius:6px;"
|
| 569 |
+
"border:1px solid rgba(125,90,200,0.45);background:linear-gradient(90deg,"
|
| 570 |
+
"rgb(22,163,74) 0%,rgb(245,158,11) 50%,rgb(220,38,38) 100%);'></div>"
|
| 571 |
+
"<span style='font-size:0.78rem;opacity:.7;'>High</span>"
|
| 572 |
+
"<span style='font-size:0.76rem;opacity:.55;'>CII 0–100</span></div>",
|
| 573 |
+
unsafe_allow_html=True)
|
| 574 |
layer = pdk.Layer("H3HexagonLayer", view, pickable=True, filled=True, extruded=True,
|
| 575 |
get_hexagon="h3", get_fill_color="fill",
|
| 576 |
+
get_elevation="cii", elevation_scale=8, opacity=0.55)
|
| 577 |
st.pydeck_chart(pdk.Deck(
|
| 578 |
layers=[layer],
|
| 579 |
initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=45),
|
| 580 |
+
map_style=MAP_STYLE,
|
| 581 |
+
tooltip={
|
| 582 |
+
"html": "<div style='font-weight:700;font-size:13px;'>CII {cii}"
|
| 583 |
+
"<span style='opacity:.7;font-weight:500;'> · rank #{cii_rank}</span></div>"
|
| 584 |
+
"<div style='font-size:11px;opacity:.85;margin-top:1px;'>{location}</div>"
|
| 585 |
+
"<div style='font-size:11px;margin-top:3px;'><b>{n_violations}</b> "
|
| 586 |
+
"violations · <b>{active_days}</b> days</div>"
|
| 587 |
+
"<div style='font-size:11px;'>Top: {top_violation}</div>",
|
| 588 |
+
"style": {"backgroundColor": "rgba(20,12,46,0.94)", "color": "#F4F1FF",
|
| 589 |
+
"borderRadius": "10px", "padding": "10px 12px", "maxWidth": "250px",
|
| 590 |
+
"whiteSpace": "normal", "lineHeight": "1.35",
|
| 591 |
+
"fontFamily": "Plus Jakarta Sans, sans-serif",
|
| 592 |
+
"border": "1px solid rgba(139,92,246,0.45)",
|
| 593 |
+
"boxShadow": "0 8px 26px rgba(0,0,0,0.4)"}},
|
| 594 |
), use_container_width=True, height=560)
|
| 595 |
|
| 596 |
elif section == "tab_ops":
|
|
|
|
| 634 |
"top_violation": "Top violation"}),
|
| 635 |
use_container_width=True, hide_index=True, height=340)
|
| 636 |
|
| 637 |
+
# ---- cross-officer dispatch & coordination board (shared across sessions) ----
|
| 638 |
+
st.markdown("#### 🚓 Dispatch & coordination board")
|
| 639 |
+
st.caption("Shared live across every signed-in officer. Tow/crane units are a simulated "
|
| 640 |
+
"fleet; in production these are live GPS units with push-to-mobile alerts.")
|
| 641 |
+
fleet = ops.make_fleet(hot)
|
| 642 |
+
|
| 643 |
+
watch = st.checkbox("🔔 Live board (auto-refresh every 5s)", value=True)
|
| 644 |
+
if watch and HAS_AUTOREFRESH:
|
| 645 |
+
st_autorefresh(interval=5000, key="board_refresh")
|
| 646 |
+
|
| 647 |
+
e1, e2, e3 = st.columns([2.2, 2.2, 1.1])
|
| 648 |
+
zopts = alerts["location"].tolist() if len(alerts) else hot["location"].head(20).tolist()
|
| 649 |
+
zone = e1.selectbox("Zone", zopts, key="disp_zone")
|
| 650 |
+
act = e2.selectbox("Action", ["Deploy patrol", "Tow & fine", "Install signage / barricade",
|
| 651 |
+
"On-spot challan drive", "Escalate to control room"],
|
| 652 |
+
key="disp_act")
|
| 653 |
+
auto_tow = e3.checkbox("Assign nearest unit", value=True)
|
| 654 |
+
if st.button("📨 Dispatch", type="primary"):
|
| 655 |
+
zrow = hot[hot["location"] == zone]
|
| 656 |
+
unit, dist = (None, None)
|
| 657 |
+
if auto_tow and len(zrow):
|
| 658 |
+
unit, dist = ops.nearest_unit(float(zrow["lat"].iat[0]), float(zrow["lon"].iat[0]), fleet)
|
| 659 |
+
ops.add_dispatch({
|
| 660 |
+
"ts": _time.time(),
|
| 661 |
+
"time": datetime.now().strftime("%H:%M:%S"),
|
| 662 |
+
"officer": st.session_state.get("user", "—"),
|
| 663 |
+
"zone": zone.split(",")[0],
|
| 664 |
+
"cii": float(zrow["cii"].iat[0]) if len(zrow) else None,
|
| 665 |
+
"action": act,
|
| 666 |
+
"unit": unit["unit"] if unit else "—",
|
| 667 |
+
"eta_km": dist if dist is not None else None,
|
| 668 |
+
"status": "Dispatched"})
|
| 669 |
+
msg = f"Dispatched: {act} → {zone.split(',')[0]}"
|
| 670 |
+
if unit:
|
| 671 |
+
msg += f" · nearest unit {unit['unit']} (~{dist} km)"
|
| 672 |
+
st.success(msg)
|
| 673 |
+
|
| 674 |
+
board = ops.read_dispatches()
|
| 675 |
+
# toast when a NEW dispatch from another officer appears since we last looked
|
| 676 |
+
max_id = max([b.get("id", 0) for b in board], default=0)
|
| 677 |
+
if max_id > st.session_state.get("board_seen", 0):
|
| 678 |
+
newest = board[0]
|
| 679 |
+
if newest.get("officer") != st.session_state.get("user"):
|
| 680 |
+
st.toast(f"🔔 {newest.get('officer')} → {newest.get('action')} @ "
|
| 681 |
+
f"{newest.get('zone')}", icon="🚓")
|
| 682 |
+
st.session_state.board_seen = max_id
|
| 683 |
+
|
| 684 |
+
open_n = sum(1 for b in board if b.get("status") != "Resolved")
|
| 685 |
+
clears = [b["clear_min"] for b in board if b.get("clear_min") is not None]
|
| 686 |
+
s1, s2, s3 = st.columns(3)
|
| 687 |
+
s1.metric("Open dispatches", open_n)
|
| 688 |
+
s2.metric("Resolved", len(clears))
|
| 689 |
+
s3.metric("Avg time-to-clear", f"{(sum(clears)/len(clears)):.0f} min" if clears else "—")
|
| 690 |
+
|
| 691 |
+
if board:
|
| 692 |
+
bdf = pd.DataFrame(board)
|
| 693 |
+
for c in ["time", "officer", "zone", "action", "unit", "status"]:
|
| 694 |
+
if c not in bdf.columns:
|
| 695 |
+
bdf[c] = "—"
|
| 696 |
+
st.dataframe(bdf[["time", "officer", "zone", "action", "unit", "status"]].rename(columns={
|
| 697 |
+
"time": "Time", "officer": "Officer", "zone": "Zone", "action": "Action",
|
| 698 |
+
"unit": "Unit", "status": "Status"}),
|
| 699 |
+
use_container_width=True, hide_index=True, height=240)
|
| 700 |
+
open_ids = [b["id"] for b in board if b.get("status") != "Resolved"]
|
| 701 |
+
rc1, rc2, rc3 = st.columns([2.5, 1, 1])
|
| 702 |
+
if open_ids:
|
| 703 |
+
rid = rc1.selectbox("Resolve a dispatch (logs time-to-clear)", open_ids,
|
| 704 |
+
format_func=lambda i: f"#{i} · " +
|
| 705 |
+
next((b.get("zone", "") for b in board if b.get("id") == i), ""),
|
| 706 |
+
key="resolve_pick")
|
| 707 |
+
if rc2.button("✅ Resolve", key="resolve_btn"):
|
| 708 |
+
ops.resolve_dispatch(rid)
|
| 709 |
+
st.rerun()
|
| 710 |
+
if rc3.button("🗑 Reset board", key="clear_board"):
|
| 711 |
+
ops.clear_dispatches()
|
| 712 |
+
st.session_state.board_seen = 0
|
| 713 |
+
st.rerun()
|
| 714 |
+
else:
|
| 715 |
+
st.info("No dispatches yet — dispatch an action above and it appears instantly "
|
| 716 |
+
"for every officer on the board.")
|
| 717 |
|
| 718 |
elif section == "tab_trends":
|
| 719 |
tr = load_trends()
|
|
|
|
| 814 |
"Spatial spread · how many distinct zones each offender hits"),
|
| 815 |
use_container_width=True)
|
| 816 |
|
| 817 |
+
# ---- escalation ladder (chronic-offender enforcement tiers) ----
|
| 818 |
+
st.markdown("##### ⚖️ Repeat-offender escalation ladder")
|
| 819 |
+
st.caption("Chronic plates are auto-tiered for escalating action — in production these "
|
| 820 |
+
"fire as e-challan notices / RTO referrals, directly targeting the 34%.")
|
| 821 |
+
tiers_all = off["n_violations"].apply(ops.escalation_tier)
|
| 822 |
+
off_e = off.copy()
|
| 823 |
+
off_e["tier"] = [x[0] for x in tiers_all]
|
| 824 |
+
off_e["tier_icon"] = [x[1] for x in tiers_all]
|
| 825 |
+
off_e["rec_action"] = [x[2] for x in tiers_all]
|
| 826 |
+
tc = off_e.groupby("tier_icon").size()
|
| 827 |
+
tcols = st.columns(4)
|
| 828 |
+
for col, (ic, nm) in zip(tcols, [("🔴", "Chronic"), ("🟠", "Habitual"),
|
| 829 |
+
("🟡", "Repeat"), ("🔵", "Watchlist")]):
|
| 830 |
+
col.metric(f"{ic} {nm}", int(tc.get(ic, 0)))
|
| 831 |
+
|
| 832 |
q = st.text_input(t("search_vehicle", lang), key="off_search")
|
| 833 |
+
view = off_e
|
| 834 |
if q:
|
| 835 |
+
view = off_e[off_e["vehicle_number"].str.contains(q, case=False, na=False)
|
| 836 |
+
| off_e["top_location"].str.contains(q, case=False, na=False)]
|
| 837 |
n_off = st.slider(t("top_n_off", lang), 5, 100, 25, 5, key="off_n")
|
| 838 |
+
view = view.copy()
|
| 839 |
+
view["Tier"] = view["tier_icon"] + " " + view["tier"]
|
| 840 |
st.dataframe(view.head(n_off)[["rank", "vehicle_number", "n_violations", "n_zones",
|
| 841 |
+
"vehicle_type", "Tier", "rec_action", "last_seen"]].rename(
|
| 842 |
+
columns={"rank": "Rank", "vehicle_number": "Vehicle (anon.)",
|
| 843 |
+
"n_violations": "Violations", "n_zones": "Zones hit", "vehicle_type": "Type",
|
| 844 |
+
"rec_action": "Recommended action", "last_seen": "Last seen"}),
|
| 845 |
+
use_container_width=True, hide_index=True, height=420)
|
| 846 |
+
st.download_button(t("dl_offenders", lang),
|
| 847 |
+
view.head(n_off).drop(columns=["tier_icon"]).to_csv(index=False),
|
| 848 |
"repeat_offenders.csv", mime="text/csv", key="off_dl")
|
| 849 |
|
| 850 |
+
with st.expander("📄 Generate a formal notice (e-challan draft)"):
|
| 851 |
+
pick = st.selectbox("Vehicle", view.head(n_off)["vehicle_number"].tolist(),
|
| 852 |
+
key="notice_v")
|
| 853 |
+
r = off_e[off_e["vehicle_number"] == pick].iloc[0]
|
| 854 |
+
notice = (f"BENGALURU TRAFFIC POLICE — REPEAT-OFFENDER NOTICE\n"
|
| 855 |
+
f"-----------------------------------------------\n"
|
| 856 |
+
f"Vehicle (anonymised): {pick}\n"
|
| 857 |
+
f"Vehicle type : {r['vehicle_type']}\n"
|
| 858 |
+
f"Recorded violations : {r['n_violations']} across {r['n_zones']} zone(s)\n"
|
| 859 |
+
f"Period : {r['first_seen']} to {r['last_seen']}\n"
|
| 860 |
+
f"Most-seen location : {r['top_location']}\n"
|
| 861 |
+
f"Escalation tier : {r['tier_icon']} {r['tier']}\n"
|
| 862 |
+
f"Recommended action : {r['rec_action']}\n\n"
|
| 863 |
+
f"As per repeat-violation provisions, the above vehicle is liable for "
|
| 864 |
+
f"escalated penalty. This is a system-generated draft for review.\n")
|
| 865 |
+
st.code(notice)
|
| 866 |
+
st.download_button("⬇️ Download notice (TXT)", notice, f"notice_{pick}.txt",
|
| 867 |
+
mime="text/plain", key="notice_dl")
|
| 868 |
+
|
| 869 |
elif section == "tab_fc":
|
| 870 |
day = fc["forecast_for"].iat[0]
|
| 871 |
st.caption(f"{day}")
|
|
|
|
| 876 |
st.pydeck_chart(pdk.Deck(
|
| 877 |
layers=[fc_layer],
|
| 878 |
initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0),
|
| 879 |
+
map_style=MAP_STYLE,
|
| 880 |
tooltip={"html": "Risk #{risk_rank} · {pred_intensity}<br/>{location}"},
|
| 881 |
), use_container_width=True, height=520)
|
| 882 |
st.dataframe(topf[["risk_rank", "location", "junction_name",
|
|
|
|
| 885 |
"pred_intensity": "Predicted intensity", "cii": "Current CII"}),
|
| 886 |
use_container_width=True, hide_index=True)
|
| 887 |
|
| 888 |
+
elif section == "tab_patrol":
|
| 889 |
+
st.subheader("🗓️ Patrol-beat & shift planner")
|
| 890 |
+
day = fc["forecast_for"].iat[0]
|
| 891 |
+
st.caption(f"Tomorrow's predicted hotspots ({day}) grouped by station jurisdiction — "
|
| 892 |
+
"a deployable morning briefing. Zones from the LightGBM forecast; suggested "
|
| 893 |
+
"shift windows from each zone's peak-hour profile.")
|
| 894 |
+
|
| 895 |
+
plan = ops.patrol_plan(fc, hot, top_zones=60)
|
| 896 |
+
if not len(plan):
|
| 897 |
+
st.info("No forecast zones available to plan.")
|
| 898 |
+
else:
|
| 899 |
+
summ = (plan.groupby("police_station")
|
| 900 |
+
.agg(zones=("h3", "count"), intensity=("pred_intensity", "sum"),
|
| 901 |
+
units=("units", "sum"))
|
| 902 |
+
.sort_values("intensity", ascending=False).reset_index())
|
| 903 |
+
c = st.columns(3)
|
| 904 |
+
c[0].metric("Hotspot zones tomorrow", len(plan))
|
| 905 |
+
c[1].metric("Stations to brief", plan["police_station"].nunique())
|
| 906 |
+
c[2].metric("Patrol units to deploy", int(plan["units"].sum()))
|
| 907 |
+
|
| 908 |
+
st.markdown("##### 🚦 Priority stations tomorrow")
|
| 909 |
+
st.plotly_chart(bar_chart(
|
| 910 |
+
pd.DataFrame({"label": summ["police_station"].head(10),
|
| 911 |
+
"value": summ["intensity"].head(10).round(0)}),
|
| 912 |
+
"Top 10 stations by total predicted intensity", ramp=True),
|
| 913 |
+
use_container_width=True)
|
| 914 |
+
|
| 915 |
+
stations = ["All divisions"] + summ["police_station"].tolist()
|
| 916 |
+
pick = st.selectbox("Division / station beat", stations, key="patrol_div")
|
| 917 |
+
view = plan if pick == "All divisions" else plan[plan["police_station"] == pick]
|
| 918 |
+
|
| 919 |
+
mlayer = pdk.Layer("ScatterplotLayer", view, pickable=True, get_position="[lon, lat]",
|
| 920 |
+
get_radius="pred_intensity * 6 + 80",
|
| 921 |
+
get_fill_color="[124, 58, 237, 170]")
|
| 922 |
+
st.pydeck_chart(pdk.Deck(
|
| 923 |
+
layers=[mlayer],
|
| 924 |
+
initial_view_state=pdk.ViewState(latitude=12.97, longitude=77.59, zoom=11, pitch=0),
|
| 925 |
+
map_style=MAP_STYLE,
|
| 926 |
+
tooltip={"html": "<b>{location}</b><br/>pred {pred_intensity} · "
|
| 927 |
+
"{units} unit(s)<br/>{shift}"}),
|
| 928 |
+
use_container_width=True, height=420)
|
| 929 |
+
|
| 930 |
+
brief = view.copy()
|
| 931 |
+
brief["order"] = range(1, len(brief) + 1)
|
| 932 |
+
show = brief[["order", "police_station", "location", "junction_name",
|
| 933 |
+
"pred_intensity", "shift", "units"]].rename(columns={
|
| 934 |
+
"order": "#", "police_station": "Station", "location": "Zone",
|
| 935 |
+
"junction_name": "Junction", "pred_intensity": "Predicted intensity",
|
| 936 |
+
"shift": "Suggested shift", "units": "Units"})
|
| 937 |
+
st.dataframe(show, use_container_width=True, hide_index=True, height=380)
|
| 938 |
+
st.download_button("⬇️ Download tomorrow's patrol briefing (CSV)",
|
| 939 |
+
show.to_csv(index=False), "patrol_briefing.csv",
|
| 940 |
+
mime="text/csv", key="patrol_dl")
|
| 941 |
+
|
| 942 |
+
elif section == "tab_whatif":
|
| 943 |
+
st.subheader("🧪 What-if intervention simulator")
|
| 944 |
+
st.caption("Project the CII drop from an intervention using the *same* published CII "
|
| 945 |
+
"weights (volume 45% · persistence 30% · peak 25%). Lever effects are "
|
| 946 |
+
"transparent, configurable assumptions — not a black box.")
|
| 947 |
+
|
| 948 |
+
wcol = st.columns([2.4, 2])
|
| 949 |
+
zopts = hot.sort_values("cii", ascending=False)["location"].head(150).tolist()
|
| 950 |
+
zsel = wcol[0].selectbox("Target zone", zopts, key="wi_zone")
|
| 951 |
+
levers = wcol[1].multiselect("Intervention(s)", list(ops.INTERVENTIONS.keys()),
|
| 952 |
+
default=["Bollards / barricade"], key="wi_lev")
|
| 953 |
+
|
| 954 |
+
zrow = hot[hot["location"] == zsel].iloc[0]
|
| 955 |
+
cur_cii = float(zrow["cii"])
|
| 956 |
+
reductions = ops.combine_interventions(levers)
|
| 957 |
+
new_cii, eff_pct = ops.whatif_new_cii(cur_cii, reductions, meta.get("cii_weights", {}))
|
| 958 |
+
cur_rank = int(zrow["cii_rank"])
|
| 959 |
+
proj_rank = ops.new_rank(new_cii, hot["cii"].tolist())
|
| 960 |
+
|
| 961 |
+
k = st.columns(3)
|
| 962 |
+
with k[0]:
|
| 963 |
+
kpi_card("🎯", "Projected CII", f"{new_cii:.0f}", accent="#22D3EE",
|
| 964 |
+
sub=f"<span style='color:#34D399;'>↓ from {cur_cii:.0f} (−{eff_pct:.0f}%)</span>")
|
| 965 |
+
with k[1]:
|
| 966 |
+
kpi_card("📊", "Projected city rank", f"#{proj_rank}", accent=VIOLET,
|
| 967 |
+
sub=f"<span style='color:#34D399;'>from #{cur_rank} "
|
| 968 |
+
f"(↓ {max(0, proj_rank - cur_rank)} places)</span>")
|
| 969 |
+
with k[2]:
|
| 970 |
+
kpi_card("🧩", "Levers applied", f"{len(levers)}", accent="#FACC15",
|
| 971 |
+
sub="combined with diminishing returns")
|
| 972 |
+
|
| 973 |
+
st.write("")
|
| 974 |
+
st.plotly_chart(bar_chart(
|
| 975 |
+
pd.DataFrame({"label": ["Current CII", "Projected CII"], "value": [cur_cii, new_cii]}),
|
| 976 |
+
f"{zsel.split(',')[0]} — projected impact of intervention", ramp=False),
|
| 977 |
+
use_container_width=True)
|
| 978 |
+
|
| 979 |
+
comp = pd.DataFrame({
|
| 980 |
+
"Component": ["Severity-weighted volume", "Persistence", "Peak concentration"],
|
| 981 |
+
"Weight": ["45%", "30%", "25%"],
|
| 982 |
+
"Modelled reduction": [f"−{reductions['volume'] * 100:.0f}%",
|
| 983 |
+
f"−{reductions['persistence'] * 100:.0f}%",
|
| 984 |
+
f"−{reductions['peak'] * 100:.0f}%"]})
|
| 985 |
+
st.markdown("##### How the projection is built")
|
| 986 |
+
st.dataframe(comp, use_container_width=True, hide_index=True)
|
| 987 |
+
st.caption("Each lever reduces components by configurable fractions; multiple levers "
|
| 988 |
+
"combine multiplicatively. The headline change is the weight-blended "
|
| 989 |
+
"reduction applied to this zone's CII — fully auditable.")
|
| 990 |
+
|
| 991 |
+
elif section == "tab_event":
|
| 992 |
+
st.subheader("🎪 Event mode — venue surge projection")
|
| 993 |
+
st.caption("Project congestion around a known venue on event days (match / concert / "
|
| 994 |
+
"sale). Surge = each nearby zone's CII × an event multiplier, from its "
|
| 995 |
+
"historical pattern — useful for pre-positioning before the rush.")
|
| 996 |
+
|
| 997 |
+
VENUES = {
|
| 998 |
+
"M. Chinnaswamy Stadium (cricket)": (12.9788, 77.5996),
|
| 999 |
+
"Sree Kanteerava Stadium": (12.9617, 77.5972),
|
| 1000 |
+
"Orion Mall, Rajajinagar": (13.0108, 77.5550),
|
| 1001 |
+
"Phoenix Marketcity, Whitefield": (12.9959, 77.6965),
|
| 1002 |
+
"Mantri Square, Malleshwaram": (13.0068, 77.5705),
|
| 1003 |
+
"Kempegowda Bus Station (Majestic)": (12.9774, 77.5717),
|
| 1004 |
+
"MG Road / Brigade Road": (12.9756, 77.6068),
|
| 1005 |
+
}
|
| 1006 |
+
EVENTS = {"Cricket match / concert (high)": 1.6, "Mall sale / festival (medium)": 1.4,
|
| 1007 |
+
"Weekday office rush (low)": 1.25}
|
| 1008 |
+
|
| 1009 |
+
ec = st.columns([2.3, 2, 1])
|
| 1010 |
+
venue = ec[0].selectbox("Venue", list(VENUES.keys()), key="ev_venue")
|
| 1011 |
+
etype = ec[1].selectbox("Event type", list(EVENTS.keys()), key="ev_type")
|
| 1012 |
+
kr = ec[2].slider("Radius (rings)", 1, 3, 2, key="ev_k")
|
| 1013 |
+
|
| 1014 |
+
vlat, vlon = VENUES[venue]
|
| 1015 |
+
mult = EVENTS[etype]
|
| 1016 |
+
tmp = hot[["h3", "lat", "lon"]].copy()
|
| 1017 |
+
tmp["_d"] = (tmp["lat"] - vlat) ** 2 + (tmp["lon"] - vlon) ** 2
|
| 1018 |
+
center_h3 = tmp.nsmallest(1, "_d")["h3"].iat[0]
|
| 1019 |
+
surge = ops.event_surge(hot, center_h3, kr, mult)
|
| 1020 |
+
|
| 1021 |
+
if not len(surge):
|
| 1022 |
+
st.info("No mapped hotspot zones near this venue in the dataset.")
|
| 1023 |
+
else:
|
| 1024 |
+
m = st.columns(3)
|
| 1025 |
+
m[0].metric("Zones in surge radius", len(surge))
|
| 1026 |
+
m[1].metric("Peak projected CII", f"{surge['surge_cii'].max():.0f}")
|
| 1027 |
+
m[2].metric("Avg added load", f"+{surge['delta'].mean():.0f} CII")
|
| 1028 |
+
|
| 1029 |
+
surge2 = surge.copy()
|
| 1030 |
+
surge2["radius"] = surge2["surge_cii"] * 5 + 60
|
| 1031 |
+
slayer = pdk.Layer("ScatterplotLayer", surge2, pickable=True, get_position="[lon, lat]",
|
| 1032 |
+
get_radius="radius", get_fill_color="[239, 68, 68, 160]")
|
| 1033 |
+
vlayer = pdk.Layer("ScatterplotLayer", pd.DataFrame([{"lat": vlat, "lon": vlon}]),
|
| 1034 |
+
get_position="[lon, lat]", get_radius=180,
|
| 1035 |
+
get_fill_color="[124, 58, 237, 230]")
|
| 1036 |
+
st.pydeck_chart(pdk.Deck(
|
| 1037 |
+
layers=[slayer, vlayer],
|
| 1038 |
+
initial_view_state=pdk.ViewState(latitude=vlat, longitude=vlon, zoom=13, pitch=0),
|
| 1039 |
+
map_style=MAP_STYLE,
|
| 1040 |
+
tooltip={"html": "<b>{location}</b><br/>CII {cii} → surge {surge_cii} (+{delta})"}),
|
| 1041 |
+
use_container_width=True, height=440)
|
| 1042 |
+
|
| 1043 |
+
show = surge[["location", "junction_name", "cii", "surge_cii", "delta",
|
| 1044 |
+
"n_violations"]].rename(columns={
|
| 1045 |
+
"location": "Zone", "junction_name": "Junction", "cii": "Normal CII",
|
| 1046 |
+
"surge_cii": "Event CII", "delta": "Δ surge", "n_violations": "Hist. violations"})
|
| 1047 |
+
st.dataframe(show, use_container_width=True, hide_index=True, height=320)
|
| 1048 |
+
st.caption(f"Projection: normal CII × {mult} (event multiplier), capped at 100. "
|
| 1049 |
+
"The violet dot is the venue; red dots are expected surge zones.")
|
| 1050 |
+
|
| 1051 |
st.divider()
|
| 1052 |
st.caption(f"CII = severity-weighted volume (45%) + persistence (30%) + peak "
|
| 1053 |
f"concentration (25%), amplified near junctions. Forecast: LightGBM, "
|
data/processed/forecast.parquet
CHANGED
|
@@ -1,3 +1,3 @@
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
-
oid sha256:
|
| 3 |
size 38055
|
|
|
|
| 1 |
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:1208066f9e9397f36c0c7cd42496592b8318c098c86cffd9f97f89a2a7b8f896
|
| 3 |
size 38055
|
models/lgbm_intensity.txt
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
src/i18n.py
CHANGED
|
@@ -47,6 +47,12 @@ STRINGS = {
|
|
| 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 ವಲಯಗಳನ್ನು ತೋರಿಸಿ",
|
|
|
|
| 47 |
"hi": "🚨 बार-बार उल्लंघनकर्ता"},
|
| 48 |
"tab_fc": {"en": "🔮 Tomorrow's forecast", "kn": "🔮 ನಾಳಿನ ಮುನ್ಸೂಚನೆ",
|
| 49 |
"hi": "🔮 कल का पूर्वानुमान"},
|
| 50 |
+
"tab_patrol": {"en": "🗓️ Patrol planner", "kn": "🗓️ ಗಸ್ತು ಯೋಜನೆ",
|
| 51 |
+
"hi": "🗓️ गश्त योजना"},
|
| 52 |
+
"tab_whatif": {"en": "🧪 What-if simulator", "kn": "🧪 ವಾಟ್-ಇಫ್ ಸಿಮ್ಯುಲೇಟರ್",
|
| 53 |
+
"hi": "🧪 व्हाट-इफ सिम्युलेटर"},
|
| 54 |
+
"tab_event": {"en": "🎪 Event mode", "kn": "🎪 ಈವೆಂಟ್ ಮೋಡ್",
|
| 55 |
+
"hi": "🎪 इवेंट मोड"},
|
| 56 |
"min_cii": {"en": "Minimum CII to display", "kn": "ಪ್ರದರ್ಶಿಸಲು ಕನಿಷ್ಠ CII",
|
| 57 |
"hi": "दिखाने हेतु न्यूनतम CII"},
|
| 58 |
"top_n_zones": {"en": "Show top N zones", "kn": "ಮೇಲಿನ N ವಲಯಗಳನ್ನು ತೋರಿಸಿ",
|
src/ops.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Operational layer for DRISHTI.
|
| 2 |
+
|
| 3 |
+
Backs the six command-and-control features:
|
| 4 |
+
1. cross-officer dispatch board -> file-backed shared store (across sessions)
|
| 5 |
+
2. tow / crane fleet + nearest-unit + time-to-clear SLA
|
| 6 |
+
3. what-if intervention projection (transparent, tied to the CII weights)
|
| 7 |
+
4. patrol-beat & shift planning from the forecast (grouped by police_station)
|
| 8 |
+
5. repeat-offender escalation tiers
|
| 9 |
+
6. event-mode surge projection around a chosen zone (H3 k-ring)
|
| 10 |
+
|
| 11 |
+
Everything is computed from the provided dataset. The tow fleet and dispatch
|
| 12 |
+
records are an operational SIMULATION layer (not external data).
|
| 13 |
+
"""
|
| 14 |
+
import json
|
| 15 |
+
import math
|
| 16 |
+
import os
|
| 17 |
+
import tempfile
|
| 18 |
+
import time
|
| 19 |
+
|
| 20 |
+
# ----------------------------------------------------------------------
|
| 21 |
+
# 1 + 4. shared dispatch board (cross-session, cross-officer)
|
| 22 |
+
# /tmp is writable on HF Spaces and shared across all sessions of the container.
|
| 23 |
+
# ----------------------------------------------------------------------
|
| 24 |
+
DISPATCH_PATH = os.path.join(tempfile.gettempdir(), "drishti_dispatch.json")
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _read_json(path, default):
|
| 28 |
+
try:
|
| 29 |
+
with open(path, "r", encoding="utf-8") as f:
|
| 30 |
+
return json.load(f)
|
| 31 |
+
except Exception:
|
| 32 |
+
return default
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def _write_json(path, data):
|
| 36 |
+
tmp = path + ".tmp"
|
| 37 |
+
with open(tmp, "w", encoding="utf-8") as f:
|
| 38 |
+
json.dump(data, f)
|
| 39 |
+
os.replace(tmp, path) # atomic on the same filesystem
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def read_dispatches():
|
| 43 |
+
data = _read_json(DISPATCH_PATH, [])
|
| 44 |
+
return data if isinstance(data, list) else []
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def add_dispatch(rec):
|
| 48 |
+
data = read_dispatches()
|
| 49 |
+
rec["id"] = (max([r.get("id", 0) for r in data]) + 1) if data else 1
|
| 50 |
+
data.insert(0, rec)
|
| 51 |
+
_write_json(DISPATCH_PATH, data[:200])
|
| 52 |
+
return rec["id"]
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def resolve_dispatch(did):
|
| 56 |
+
data = read_dispatches()
|
| 57 |
+
now = time.time()
|
| 58 |
+
for r in data:
|
| 59 |
+
if r.get("id") == did and r.get("status") != "Resolved":
|
| 60 |
+
r["status"] = "Resolved"
|
| 61 |
+
r["resolved_ts"] = now
|
| 62 |
+
r["clear_min"] = round((now - r.get("ts", now)) / 60.0, 1)
|
| 63 |
+
_write_json(DISPATCH_PATH, data)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def clear_dispatches():
|
| 67 |
+
_write_json(DISPATCH_PATH, [])
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
# ----------------------------------------------------------------------
|
| 71 |
+
# 2. tow / crane fleet (simulated) + nearest-unit assignment + distance
|
| 72 |
+
# ----------------------------------------------------------------------
|
| 73 |
+
def make_fleet(hotspots):
|
| 74 |
+
"""Anchor a small simulated fleet at spread-out points across the data bbox."""
|
| 75 |
+
lat0, lat1 = float(hotspots["lat"].min()), float(hotspots["lat"].max())
|
| 76 |
+
lon0, lon1 = float(hotspots["lon"].min()), float(hotspots["lon"].max())
|
| 77 |
+
names = ["Tow-North", "Tow-South", "Tow-East", "Tow-West", "Crane-Central", "Tow-SE"]
|
| 78 |
+
frac = [(0.78, 0.50), (0.22, 0.50), (0.50, 0.82), (0.50, 0.18), (0.50, 0.50), (0.32, 0.72)]
|
| 79 |
+
fleet = []
|
| 80 |
+
for nm, (fy, fx) in zip(names, frac):
|
| 81 |
+
fleet.append({"unit": nm,
|
| 82 |
+
"lat": lat0 + fy * (lat1 - lat0),
|
| 83 |
+
"lon": lon0 + fx * (lon1 - lon0)})
|
| 84 |
+
return fleet
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def haversine(la1, lo1, la2, lo2):
|
| 88 |
+
R = 6371.0
|
| 89 |
+
p1, p2 = math.radians(la1), math.radians(la2)
|
| 90 |
+
dp = math.radians(la2 - la1)
|
| 91 |
+
dl = math.radians(lo2 - lo1)
|
| 92 |
+
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
|
| 93 |
+
return 2 * R * math.asin(math.sqrt(a))
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def nearest_unit(lat, lon, fleet):
|
| 97 |
+
best, bd = None, 1e9
|
| 98 |
+
for u in fleet:
|
| 99 |
+
d = haversine(lat, lon, u["lat"], u["lon"])
|
| 100 |
+
if d < bd:
|
| 101 |
+
best, bd = u, d
|
| 102 |
+
return best, round(bd, 2)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# ----------------------------------------------------------------------
|
| 106 |
+
# 3. what-if intervention projection
|
| 107 |
+
# CII is a weighted sum of (volume, persistence, peak) components, so the
|
| 108 |
+
# headline CII drop is just the weighted blend of each lever's effect.
|
| 109 |
+
# ----------------------------------------------------------------------
|
| 110 |
+
INTERVENTIONS = {
|
| 111 |
+
"No-parking signage": {"volume": 0.25, "persistence": 0.05, "peak": 0.10},
|
| 112 |
+
"Bollards / barricade": {"volume": 0.45, "persistence": 0.25, "peak": 0.15},
|
| 113 |
+
"Towing drive": {"volume": 0.15, "persistence": 0.05, "peak": 0.35},
|
| 114 |
+
"Dedicated parking bay": {"volume": 0.40, "persistence": 0.30, "peak": 0.20},
|
| 115 |
+
"Static enforcement post": {"volume": 0.30, "persistence": 0.35, "peak": 0.30},
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def combine_interventions(selected):
|
| 120 |
+
"""Combine chosen levers multiplicatively per component (diminishing returns)."""
|
| 121 |
+
comp = {"volume": 0.0, "persistence": 0.0, "peak": 0.0}
|
| 122 |
+
for name in selected:
|
| 123 |
+
eff = INTERVENTIONS.get(name, {})
|
| 124 |
+
for k in comp:
|
| 125 |
+
comp[k] = 1 - (1 - comp[k]) * (1 - eff.get(k, 0.0))
|
| 126 |
+
return comp
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def whatif_new_cii(cii, reductions, weights):
|
| 130 |
+
"""reductions: component -> fraction (0-1). weights: meta cii_weights dict."""
|
| 131 |
+
wv = weights.get("weighted_volume", weights.get("volume", 0.45))
|
| 132 |
+
wp = weights.get("persistence", 0.30)
|
| 133 |
+
wk = weights.get("peak_share", weights.get("peak", weights.get("peak_concentration", 0.25)))
|
| 134 |
+
s = (wv + wp + wk) or 1.0
|
| 135 |
+
wv, wp, wk = wv / s, wp / s, wk / s
|
| 136 |
+
eff = (wv * reductions.get("volume", 0)
|
| 137 |
+
+ wp * reductions.get("persistence", 0)
|
| 138 |
+
+ wk * reductions.get("peak", 0))
|
| 139 |
+
eff = max(0.0, min(0.95, eff))
|
| 140 |
+
return round(float(cii) * (1 - eff), 1), round(eff * 100, 1)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def new_rank(new_cii, all_cii_desc):
|
| 144 |
+
"""Rank a projected CII against the existing distribution (1 = worst)."""
|
| 145 |
+
above = sum(1 for c in all_cii_desc if c > new_cii)
|
| 146 |
+
return above + 1
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# ----------------------------------------------------------------------
|
| 150 |
+
# 5. repeat-offender escalation tiers (calibrated to the data's 11-55 range)
|
| 151 |
+
# ----------------------------------------------------------------------
|
| 152 |
+
def escalation_tier(n):
|
| 153 |
+
"""Return (tier_label, icon, recommended_action)."""
|
| 154 |
+
if n >= 40:
|
| 155 |
+
return ("Chronic — license/RC review", "🔴", "RTO referral + court summons")
|
| 156 |
+
if n >= 25:
|
| 157 |
+
return ("Habitual — court summons", "🟠", "Summons + cumulative penalty")
|
| 158 |
+
if n >= 16:
|
| 159 |
+
return ("Repeat — escalated fine", "🟡", "Escalated fine + formal notice")
|
| 160 |
+
return ("Watchlist — formal notice", "🔵", "Formal notice via e-challan")
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ----------------------------------------------------------------------
|
| 164 |
+
# 6. patrol-beat & shift planning (beats = police_station jurisdictions)
|
| 165 |
+
# ----------------------------------------------------------------------
|
| 166 |
+
def patrol_plan(forecast_df, hotspots_df, top_zones=40):
|
| 167 |
+
cols = ["h3", "police_station", "peak_share", "location", "cii", "junction_name"]
|
| 168 |
+
h = hotspots_df[cols].copy()
|
| 169 |
+
plan = forecast_df.merge(h, on="h3", how="left", suffixes=("", "_h"))
|
| 170 |
+
plan = plan.dropna(subset=["police_station"])
|
| 171 |
+
plan = plan.sort_values("pred_intensity", ascending=False).head(top_zones).copy()
|
| 172 |
+
|
| 173 |
+
def shift(ps):
|
| 174 |
+
try:
|
| 175 |
+
ps = float(ps)
|
| 176 |
+
except Exception:
|
| 177 |
+
return "All-day rotating"
|
| 178 |
+
if math.isnan(ps):
|
| 179 |
+
return "All-day rotating"
|
| 180 |
+
return "Peak hours (08-11, 17-21)" if ps >= 0.45 else "All-day rotating"
|
| 181 |
+
|
| 182 |
+
plan["shift"] = plan["peak_share"].apply(shift)
|
| 183 |
+
pmax = plan["pred_intensity"].max() or 1.0
|
| 184 |
+
plan["units"] = (plan["pred_intensity"] / pmax * 2 + 1).round().astype(int)
|
| 185 |
+
# prefer the merged location/junction if present
|
| 186 |
+
if "location_h" in plan.columns:
|
| 187 |
+
plan["location"] = plan["location"].fillna(plan["location_h"])
|
| 188 |
+
return plan
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ----------------------------------------------------------------------
|
| 192 |
+
# 7. event-mode surge (H3 k-ring around a chosen zone)
|
| 193 |
+
# ----------------------------------------------------------------------
|
| 194 |
+
def event_surge(hotspots_df, center_h3, k, multiplier):
|
| 195 |
+
try:
|
| 196 |
+
import h3
|
| 197 |
+
try:
|
| 198 |
+
ring = set(h3.grid_disk(center_h3, k)) # h3 v4
|
| 199 |
+
except Exception:
|
| 200 |
+
ring = set(h3.k_ring(center_h3, k)) # h3 v3
|
| 201 |
+
except Exception:
|
| 202 |
+
ring = {center_h3}
|
| 203 |
+
sub = hotspots_df[hotspots_df["h3"].isin(ring)].copy()
|
| 204 |
+
sub["surge_cii"] = (sub["cii"] * float(multiplier)).clip(upper=100).round(1)
|
| 205 |
+
sub["delta"] = (sub["surge_cii"] - sub["cii"]).round(1)
|
| 206 |
+
return sub.sort_values("surge_cii", ascending=False)
|