Spaces:
Sleeping
Sleeping
File size: 8,221 Bytes
864d4b1 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 | """Operational layer for DRISHTI.
Backs the six command-and-control features:
1. cross-officer dispatch board -> file-backed shared store (across sessions)
2. tow / crane fleet + nearest-unit + time-to-clear SLA
3. what-if intervention projection (transparent, tied to the CII weights)
4. patrol-beat & shift planning from the forecast (grouped by police_station)
5. repeat-offender escalation tiers
6. event-mode surge projection around a chosen zone (H3 k-ring)
Everything is computed from the provided dataset. The tow fleet and dispatch
records are an operational SIMULATION layer (not external data).
"""
import json
import math
import os
import tempfile
import time
# ----------------------------------------------------------------------
# 1 + 4. shared dispatch board (cross-session, cross-officer)
# /tmp is writable on HF Spaces and shared across all sessions of the container.
# ----------------------------------------------------------------------
DISPATCH_PATH = os.path.join(tempfile.gettempdir(), "drishti_dispatch.json")
def _read_json(path, default):
try:
with open(path, "r", encoding="utf-8") as f:
return json.load(f)
except Exception:
return default
def _write_json(path, data):
tmp = path + ".tmp"
with open(tmp, "w", encoding="utf-8") as f:
json.dump(data, f)
os.replace(tmp, path) # atomic on the same filesystem
def read_dispatches():
data = _read_json(DISPATCH_PATH, [])
return data if isinstance(data, list) else []
def add_dispatch(rec):
data = read_dispatches()
rec["id"] = (max([r.get("id", 0) for r in data]) + 1) if data else 1
data.insert(0, rec)
_write_json(DISPATCH_PATH, data[:200])
return rec["id"]
def resolve_dispatch(did):
data = read_dispatches()
now = time.time()
for r in data:
if r.get("id") == did and r.get("status") != "Resolved":
r["status"] = "Resolved"
r["resolved_ts"] = now
r["clear_min"] = round((now - r.get("ts", now)) / 60.0, 1)
_write_json(DISPATCH_PATH, data)
def clear_dispatches():
_write_json(DISPATCH_PATH, [])
# ----------------------------------------------------------------------
# 2. tow / crane fleet (simulated) + nearest-unit assignment + distance
# ----------------------------------------------------------------------
def make_fleet(hotspots):
"""Anchor a small simulated fleet at spread-out points across the data bbox."""
lat0, lat1 = float(hotspots["lat"].min()), float(hotspots["lat"].max())
lon0, lon1 = float(hotspots["lon"].min()), float(hotspots["lon"].max())
names = ["Tow-North", "Tow-South", "Tow-East", "Tow-West", "Crane-Central", "Tow-SE"]
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)]
fleet = []
for nm, (fy, fx) in zip(names, frac):
fleet.append({"unit": nm,
"lat": lat0 + fy * (lat1 - lat0),
"lon": lon0 + fx * (lon1 - lon0)})
return fleet
def haversine(la1, lo1, la2, lo2):
R = 6371.0
p1, p2 = math.radians(la1), math.radians(la2)
dp = math.radians(la2 - la1)
dl = math.radians(lo2 - lo1)
a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * R * math.asin(math.sqrt(a))
def nearest_unit(lat, lon, fleet):
best, bd = None, 1e9
for u in fleet:
d = haversine(lat, lon, u["lat"], u["lon"])
if d < bd:
best, bd = u, d
return best, round(bd, 2)
# ----------------------------------------------------------------------
# 3. what-if intervention projection
# CII is a weighted sum of (volume, persistence, peak) components, so the
# headline CII drop is just the weighted blend of each lever's effect.
# ----------------------------------------------------------------------
INTERVENTIONS = {
"No-parking signage": {"volume": 0.25, "persistence": 0.05, "peak": 0.10},
"Bollards / barricade": {"volume": 0.45, "persistence": 0.25, "peak": 0.15},
"Towing drive": {"volume": 0.15, "persistence": 0.05, "peak": 0.35},
"Dedicated parking bay": {"volume": 0.40, "persistence": 0.30, "peak": 0.20},
"Static enforcement post": {"volume": 0.30, "persistence": 0.35, "peak": 0.30},
}
def combine_interventions(selected):
"""Combine chosen levers multiplicatively per component (diminishing returns)."""
comp = {"volume": 0.0, "persistence": 0.0, "peak": 0.0}
for name in selected:
eff = INTERVENTIONS.get(name, {})
for k in comp:
comp[k] = 1 - (1 - comp[k]) * (1 - eff.get(k, 0.0))
return comp
def whatif_new_cii(cii, reductions, weights):
"""reductions: component -> fraction (0-1). weights: meta cii_weights dict."""
wv = weights.get("weighted_volume", weights.get("volume", 0.45))
wp = weights.get("persistence", 0.30)
wk = weights.get("peak_share", weights.get("peak", weights.get("peak_concentration", 0.25)))
s = (wv + wp + wk) or 1.0
wv, wp, wk = wv / s, wp / s, wk / s
eff = (wv * reductions.get("volume", 0)
+ wp * reductions.get("persistence", 0)
+ wk * reductions.get("peak", 0))
eff = max(0.0, min(0.95, eff))
return round(float(cii) * (1 - eff), 1), round(eff * 100, 1)
def new_rank(new_cii, all_cii_desc):
"""Rank a projected CII against the existing distribution (1 = worst)."""
above = sum(1 for c in all_cii_desc if c > new_cii)
return above + 1
# ----------------------------------------------------------------------
# 5. repeat-offender escalation tiers (calibrated to the data's 11-55 range)
# ----------------------------------------------------------------------
def escalation_tier(n):
"""Return (tier_label, icon, recommended_action)."""
if n >= 40:
return ("Chronic β license/RC review", "π΄", "RTO referral + court summons")
if n >= 25:
return ("Habitual β court summons", "π ", "Summons + cumulative penalty")
if n >= 16:
return ("Repeat β escalated fine", "π‘", "Escalated fine + formal notice")
return ("Watchlist β formal notice", "π΅", "Formal notice via e-challan")
# ----------------------------------------------------------------------
# 6. patrol-beat & shift planning (beats = police_station jurisdictions)
# ----------------------------------------------------------------------
def patrol_plan(forecast_df, hotspots_df, top_zones=40):
cols = ["h3", "police_station", "peak_share", "location", "cii", "junction_name"]
h = hotspots_df[cols].copy()
plan = forecast_df.merge(h, on="h3", how="left", suffixes=("", "_h"))
plan = plan.dropna(subset=["police_station"])
plan = plan.sort_values("pred_intensity", ascending=False).head(top_zones).copy()
def shift(ps):
try:
ps = float(ps)
except Exception:
return "All-day rotating"
if math.isnan(ps):
return "All-day rotating"
return "Peak hours (08-11, 17-21)" if ps >= 0.45 else "All-day rotating"
plan["shift"] = plan["peak_share"].apply(shift)
pmax = plan["pred_intensity"].max() or 1.0
plan["units"] = (plan["pred_intensity"] / pmax * 2 + 1).round().astype(int)
# prefer the merged location/junction if present
if "location_h" in plan.columns:
plan["location"] = plan["location"].fillna(plan["location_h"])
return plan
# ----------------------------------------------------------------------
# 7. event-mode surge (H3 k-ring around a chosen zone)
# ----------------------------------------------------------------------
def event_surge(hotspots_df, center_h3, k, multiplier):
try:
import h3
try:
ring = set(h3.grid_disk(center_h3, k)) # h3 v4
except Exception:
ring = set(h3.k_ring(center_h3, k)) # h3 v3
except Exception:
ring = {center_h3}
sub = hotspots_df[hotspots_df["h3"].isin(ring)].copy()
sub["surge_cii"] = (sub["cii"] * float(multiplier)).clip(upper=100).round(1)
sub["delta"] = (sub["surge_cii"] - sub["cii"]).round(1)
return sub.sort_values("surge_cii", ascending=False)
|