rittika03 commited on
Commit
864d4b1
·
1 Parent(s): c8a8b27

Update existing Space with new files

Browse files
Files changed (5) hide show
  1. app.py +407 -98
  2. data/processed/forecast.parquet +1 -1
  3. models/lgbm_intensity.txt +0 -0
  4. src/i18n.py +6 -0
  5. 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: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)
@@ -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": "#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",
@@ -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 &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:
@@ -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
- 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":
@@ -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
- 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()
@@ -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 = 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}")
@@ -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="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",
@@ -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 &amp; 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 &amp; 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:a3dc6af6e39b79339ec846f8910b3aa1474501ba5bb1c81fb15cd16b7e441707
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)