krz/omaha-metro-blotter
clone: git clone https://gitbay.org/krz/omaha-metro-blotter.git
main: build_site.py · raw
1"""Precompute the public site's figures into a self-contained site/index.html.
2
3The dashboard in app.py needs a live Python process and refilters 300k rows on
4every interaction. This does the aggregation once, at ingest time, and emits one
5static file: no server, no dependencies, no per-visit cost."""
6
7import json
8import sqlite3
9from datetime import datetime, timezone
10from pathlib import Path
11
12import numpy as np
13
14import analysis
15
16ROOT = Path(__file__).parent
17OUT = ROOT / "site"
18# Grid cells for the map. ~0.004 deg is roughly 300m of latitude here, fine
19# enough to show which corridors stops sit on without shipping 40k points.
20CELL = 0.004
21
22
23def summary(conn):
24 rows = conn.execute(
25 """SELECT agency, COUNT(*), SUM(is_stop),
26 MIN(occurred_at), MAX(occurred_at)
27 FROM incidents_current GROUP BY agency ORDER BY COUNT(*) DESC"""
28 ).fetchall()
29 return [{"agency": a, "incidents": n, "stops": s or 0,
30 "first": lo[:10], "last": hi[:10]} for a, n, s, lo, hi in rows]
31
32
33def totals(conn):
34 q = lambda sql: conn.execute(sql).fetchone()[0]
35 return {
36 "incidents": q("SELECT COUNT(*) FROM incidents_current"),
37 "stops": q("SELECT COUNT(*) FROM incidents_current WHERE is_stop=1"),
38 "cameras": q("SELECT COUNT(*) FROM alpr_cameras"),
39 "searches": q("SELECT COUNT(*) FROM alpr_searches"),
40 "amendments": q("SELECT COUNT(*) FROM incident_amendments"),
41 "raw": q("SELECT COUNT(*) FROM raw_records"),
42 "built": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
43 }
44
45
46def outcomes(conn):
47 df = analysis.stop_outcomes(analysis.load_incidents(conn))
48 if df.empty:
49 return []
50 wide = df.pivot(index="agency", columns="outcome", values="rate")
51 counts = df.groupby("agency")["stops"].first()
52 return [{"agency": a, "stops": int(counts[a]),
53 "cited": round(float(wide.loc[a, "Cited"]), 4),
54 "arrested": round(float(wide.loc[a, "Arrested"]), 4)}
55 for a in wide.sort_values("Cited", ascending=False).index]
56
57
58def proximity(conn):
59 df = analysis.load_incidents(conn)
60 cams = analysis.load_cameras(conn)
61 prox = analysis.camera_proximity(df, cams)
62 if prox.empty:
63 return []
64 wide = prox.pivot(index="distance_m", columns="kind", values="share")
65 return [{"m": int(m),
66 "stops": round(float(r.get("Vehicle stops", 0)), 5),
67 "other": round(float(r.get("All other incidents", 0)), 5)}
68 for m, r in wide.iterrows()]
69
70
71def weekly(conn):
72 df = analysis.load_incidents(conn)
73 if df.empty:
74 return {}
75 w = (df.assign(week=df["occurred_at"].dt.to_period("W").dt.start_time)
76 .groupby(["agency", "week"]).size().reset_index(name="n"))
77 out = {}
78 for agency, g in w.groupby("agency"):
79 g = g.sort_values("week")
80 # drop the trailing partial week so the last point is not a false dip
81 g = g.iloc[:-1] if len(g) > 1 else g
82 out[agency] = {"weeks": [d.strftime("%Y-%m-%d") for d in g["week"]],
83 "counts": [int(x) for x in g["n"]]}
84 return out
85
86
87def boundaries():
88 """City limits and the county line, rounded to ~11 m for the wire."""
89 src = ROOT / "raw_data" / "boundaries.geojson"
90 if not src.exists():
91 return []
92 gj = json.loads(src.read_text())
93 out = []
94 for f in gj["features"]:
95 g = f["geometry"]
96 polys = ([g["coordinates"]] if g["type"] == "Polygon"
97 else g["coordinates"])
98 rings = [[[round(x, 4), round(y, 4)] for x, y in ring]
99 for poly in polys for ring in poly]
100 out.append({"name": f["properties"]["name"],
101 "kind": f["properties"]["kind"], "rings": rings})
102 return out
103
104
105def map_layers(conn):
106 df = analysis.load_incidents(conn)
107 stops = df[df["is_stop"] == 1].dropna(subset=["lat", "lon"])
108 lat = (np.floor(stops["lat"] / CELL) * CELL).round(4)
109 lon = (np.floor(stops["lon"] / CELL) * CELL).round(4)
110 grid = (stops.assign(clat=lat, clon=lon)
111 .groupby(["clat", "clon"]).size().reset_index(name="n"))
112 grid = grid[grid["n"] >= 2] # single stops are noise at this zoom
113 cams = analysis.load_cameras(conn)
114 return {
115 "cell": CELL,
116 "boundaries": boundaries(),
117 "cells": [[round(r.clat, 4), round(r.clon, 4), int(r.n)]
118 for r in grid.itertuples()],
119 "cameras": [[round(r.lat, 5), round(r.lon, 5)] for r in cams.itertuples()],
120 }
121
122
123def searches(conn):
124 audit = analysis.search_audit(conn)
125 if audit.empty:
126 return None
127 row = audit.iloc[0]
128 counts = [r[0] for r in conn.execute(
129 "SELECT network_count FROM alpr_searches WHERE network_count IS NOT NULL")]
130 reasons = conn.execute(
131 """SELECT LOWER(reason), COUNT(*) FROM alpr_searches
132 WHERE reason IS NOT NULL GROUP BY 1 ORDER BY 2 DESC LIMIT 8""").fetchall()
133 edges = [0, 1, 10, 100, 500, 1000, 2500, 10000]
134 hist = []
135 for lo, hi in zip(edges, edges[1:]):
136 hist.append({"lo": lo, "hi": hi,
137 "n": sum(1 for c in counts if lo < c <= hi)})
138 return {
139 "agency": row["agency"], "searches": int(row["searches"]),
140 "with_reason": int(row["with_reason"]),
141 "reason_rate": round(float(row["reason_rate"]), 4),
142 "median_networks": int(np.median(counts)) if counts else 0,
143 "max_networks": int(max(counts)) if counts else 0,
144 "first": row["earliest"][:10], "last": row["latest"][:10],
145 "histogram": hist,
146 "reasons": [{"reason": r, "n": n} for r, n in reasons],
147 }
148
149
150def build():
151 conn = sqlite3.connect(analysis.DB)
152 data = {
153 "totals": totals(conn),
154 "agencies": summary(conn),
155 "outcomes": outcomes(conn),
156 "proximity": proximity(conn),
157 "weekly": weekly(conn),
158 "map": map_layers(conn),
159 "searches": searches(conn),
160 }
161 conn.close()
162
163 OUT.mkdir(exist_ok=True)
164 template = (ROOT / "site_template.html").read_text()
165 payload = json.dumps(data, separators=(",", ":"))
166 (OUT / "index.html").write_text(template.replace("/*DATA*/null", payload))
167 return data, len(payload)
168
169
170if __name__ == "__main__":
171 data, size = build()
172 print(f" site/index.html written, {size/1024:.0f} KiB of data")
173 for k, v in data["totals"].items():
174 print(f" {k}: {v}")