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)