krz/omaha-metro-blotter

Archive of police activity and ALPR surveillance across the Omaha metro.

clone: git clone https://gitbay.org/krz/omaha-metro-blotter.git

archive: analysis.py · raw

  1"""Queries behind the dashboard: agency activity and distance to the nearest ALPR."""
  2
  3import sqlite3
  4from pathlib import Path
  5
  6import numpy as np
  7import pandas as pd
  8
  9DB = Path(__file__).parent / "raw_data" / "metro.db"
 10
 11# Sarpy and Council Bluffs publish officer-initiated stops; ingest.py flags them
 12# as is_stop. OPD publishes NIBRS offences only and contributes no stops.
 13POLICE_AGENCIES = ("Omaha PD", "Council Bluffs PD", "Bellevue PD", "Papillion PD",
 14                   "La Vista PD", "Sarpy County SO")
 15
 16EARTH_M = 6371000.0
 17
 18
 19def connect():
 20    return sqlite3.connect(DB)
 21
 22
 23def load_incidents(conn, agencies=None, start=None, end=None, categories=None):
 24    where, params = ["lat IS NOT NULL", "lon IS NOT NULL"], []
 25    if agencies:
 26        where.append(f"agency IN ({','.join('?' * len(agencies))})")
 27        params += list(agencies)
 28    if categories:
 29        where.append(f"category IN ({','.join('?' * len(categories))})")
 30        params += list(categories)
 31    if start:
 32        where.append("occurred_at >= ?")
 33        params.append(f"{start}T00:00:00")
 34    if end:
 35        where.append("occurred_at <= ?")
 36        params.append(f"{end}T23:59:59")
 37    df = pd.read_sql_query(
 38        f"""SELECT source, agency, case_id, occurred_at, category, call_type,
 39                   disposition, offense_desc, is_stop, address, lat, lon
 40            FROM incidents WHERE {' AND '.join(where)}""",
 41        conn, params=params)
 42    df["occurred_at"] = pd.to_datetime(df["occurred_at"])
 43    return df
 44
 45
 46def load_cameras(conn):
 47    return pd.read_sql_query(
 48        "SELECT osm_id, lat, lon, manufacturer, operator, direction, first_seen"
 49        " FROM alpr_cameras", conn)
 50
 51
 52def nearest_camera_m(df, cameras):
 53    """Great-circle distance in metres from each incident to the closest camera.
 54
 55    164 cameras against ~250k incidents, so the full distance matrix is computed
 56    in chunks rather than all at once."""
 57    if df.empty or cameras.empty:
 58        return np.full(len(df), np.nan)
 59
 60    lat1 = np.radians(df["lat"].to_numpy(dtype=float))
 61    lon1 = np.radians(df["lon"].to_numpy(dtype=float))
 62    lat2 = np.radians(cameras["lat"].to_numpy(dtype=float))
 63    lon2 = np.radians(cameras["lon"].to_numpy(dtype=float))
 64
 65    out = np.empty(len(df))
 66    cos2 = np.cos(lat2)
 67    for i in range(0, len(df), 20000):
 68        s = slice(i, i + 20000)
 69        dlat = lat2[None, :] - lat1[s, None]
 70        dlon = lon2[None, :] - lon1[s, None]
 71        a = (np.sin(dlat / 2) ** 2
 72             + np.cos(lat1[s, None]) * cos2[None, :] * np.sin(dlon / 2) ** 2)
 73        out[s] = (2 * EARTH_M * np.arcsin(np.sqrt(a))).min(axis=1)
 74    return out
 75
 76
 77def daily_counts(df):
 78    if df.empty:
 79        return pd.DataFrame(columns=["date", "agency", "incidents"])
 80    g = (df.assign(date=df["occurred_at"].dt.floor("D"))
 81           .groupby(["date", "agency"], as_index=False)
 82           .size().rename(columns={"size": "incidents"}))
 83    return g
 84
 85
 86def stop_outcomes(df):
 87    """Citation and arrest rate on vehicle stops, per agency.
 88
 89    The two CAD systems use different disposition vocabularies -- Sarpy writes
 90    WRITTEN WARNING / CITATION, Council Bluffs writes "3 - Citation" and folds
 91    warnings into "7 - Handled by Officer" -- and both allow several outcomes per
 92    stop. Substring matching on citation and arrest is the only comparison the
 93    two vocabularies actually support, and a department that records warnings
 94    less thoroughly will show a higher citation rate for that reason alone."""
 95    stops = df[(df["is_stop"] == 1) & df["disposition"].notna()]
 96    if stops.empty:
 97        return pd.DataFrame(columns=["agency", "outcome", "rate", "stops"])
 98    d = stops["disposition"].str.upper()
 99    stops = stops.assign(cited=d.str.contains("CITATION"),
100                         arrested=d.str.contains("ARREST"))
101    g = stops.groupby("agency").agg(stops=("cited", "size"),
102                                    Cited=("cited", "mean"),
103                                    Arrested=("arrested", "mean")).reset_index()
104    return (g.melt(id_vars=["agency", "stops"], value_vars=["Cited", "Arrested"],
105                   var_name="outcome", value_name="rate")
106             .sort_values("rate", ascending=False))
107
108
109def camera_proximity(df, cameras, bin_m=200, max_m=2000):
110    """Share of stops vs other incidents falling in each distance band.
111
112    Both series are normalised, so a gap between them means stops cluster
113    differently around cameras than the rest of the call volume does. It is not
114    evidence of causation: cameras and stops both concentrate on arterials."""
115    if df.empty or cameras.empty:
116        return pd.DataFrame(columns=["distance_m", "kind", "share"])
117    d = df.assign(dist=nearest_camera_m(df, cameras))
118    d = d[d["dist"] <= max_m]
119    if d.empty:
120        return pd.DataFrame(columns=["distance_m", "kind", "share"])
121    d["kind"] = np.where(d["is_stop"] == 1, "Vehicle stops", "All other incidents")
122    d["distance_m"] = (d["dist"] // bin_m * bin_m).astype(int)
123    g = (d.groupby(["kind", "distance_m"], as_index=False)
124           .size().rename(columns={"size": "n"}))
125    g["share"] = g["n"] / g.groupby("kind")["n"].transform("sum")
126    return g
127
128
129def agency_options(conn):
130    rows = conn.execute(
131        "SELECT agency, COUNT(*) FROM incidents GROUP BY agency ORDER BY 2 DESC"
132    ).fetchall()
133    return [a for a, _ in rows]
134
135
136def category_options(conn):
137    rows = conn.execute(
138        "SELECT category, COUNT(*) FROM incidents WHERE category IS NOT NULL"
139        " GROUP BY category ORDER BY 2 DESC").fetchall()
140    return [c for c, _ in rows]
141
142
143def date_bounds(conn):
144    lo, hi = conn.execute(
145        "SELECT MIN(occurred_at), MAX(occurred_at) FROM incidents").fetchone()
146    return lo[:10], hi[:10]