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

flock-search-audit: analysis.py · raw

  1"""Queries behind the dashboard: agency activity and distance to the nearest ALPR.
  2
  3Reads incidents_current, the newest known version of each record. The originals
  4stay in incidents and every superseded version in incident_amendments, so a
  5disposition an agency changed after the fact is still recoverable."""
  6
  7import sqlite3
  8from pathlib import Path
  9
 10import numpy as np
 11import pandas as pd
 12
 13DB = Path(__file__).parent / "raw_data" / "metro.db"
 14
 15# Sarpy and Council Bluffs publish officer-initiated stops; ingest.py flags them
 16# as is_stop. OPD publishes NIBRS offences only and contributes no stops.
 17POLICE_AGENCIES = ("Omaha PD", "Council Bluffs PD", "Bellevue PD", "Papillion PD",
 18                   "La Vista PD", "Sarpy County SO")
 19
 20EARTH_M = 6371000.0
 21
 22
 23def connect():
 24    return sqlite3.connect(DB)
 25
 26
 27def load_incidents(conn, agencies=None, start=None, end=None, categories=None):
 28    where, params = ["lat IS NOT NULL", "lon IS NOT NULL"], []
 29    if agencies:
 30        where.append(f"agency IN ({','.join('?' * len(agencies))})")
 31        params += list(agencies)
 32    if categories:
 33        where.append(f"category IN ({','.join('?' * len(categories))})")
 34        params += list(categories)
 35    if start:
 36        where.append("occurred_at >= ?")
 37        params.append(f"{start}T00:00:00")
 38    if end:
 39        where.append("occurred_at <= ?")
 40        params.append(f"{end}T23:59:59")
 41    df = pd.read_sql_query(
 42        f"""SELECT source, agency, case_id, occurred_at, category, call_type,
 43                   disposition, offense_desc, is_stop, address, lat, lon, amended
 44            FROM incidents_current WHERE {' AND '.join(where)}""",
 45        conn, params=params)
 46    df["occurred_at"] = pd.to_datetime(df["occurred_at"])
 47    return df
 48
 49
 50def load_cameras(conn):
 51    return pd.read_sql_query(
 52        "SELECT osm_id, lat, lon, manufacturer, operator, direction, first_seen"
 53        " FROM alpr_cameras", conn)
 54
 55
 56def nearest_camera_m(df, cameras):
 57    """Great-circle distance in metres from each incident to the closest camera.
 58
 59    164 cameras against ~250k incidents, so the full distance matrix is computed
 60    in chunks rather than all at once."""
 61    if df.empty or cameras.empty:
 62        return np.full(len(df), np.nan)
 63
 64    lat1 = np.radians(df["lat"].to_numpy(dtype=float))
 65    lon1 = np.radians(df["lon"].to_numpy(dtype=float))
 66    lat2 = np.radians(cameras["lat"].to_numpy(dtype=float))
 67    lon2 = np.radians(cameras["lon"].to_numpy(dtype=float))
 68
 69    out = np.empty(len(df))
 70    cos2 = np.cos(lat2)
 71    for i in range(0, len(df), 20000):
 72        s = slice(i, i + 20000)
 73        dlat = lat2[None, :] - lat1[s, None]
 74        dlon = lon2[None, :] - lon1[s, None]
 75        a = (np.sin(dlat / 2) ** 2
 76             + np.cos(lat1[s, None]) * cos2[None, :] * np.sin(dlon / 2) ** 2)
 77        out[s] = (2 * EARTH_M * np.arcsin(np.sqrt(a))).min(axis=1)
 78    return out
 79
 80
 81def daily_counts(df):
 82    if df.empty:
 83        return pd.DataFrame(columns=["date", "agency", "incidents"])
 84    g = (df.assign(date=df["occurred_at"].dt.floor("D"))
 85           .groupby(["date", "agency"], as_index=False)
 86           .size().rename(columns={"size": "incidents"}))
 87    return g
 88
 89
 90def stop_outcomes(df):
 91    """Citation and arrest rate on vehicle stops, per agency.
 92
 93    The two CAD systems use different disposition vocabularies -- Sarpy writes
 94    WRITTEN WARNING / CITATION, Council Bluffs writes "3 - Citation" and folds
 95    warnings into "7 - Handled by Officer" -- and both allow several outcomes per
 96    stop. Substring matching on citation and arrest is the only comparison the
 97    two vocabularies actually support, and a department that records warnings
 98    less thoroughly will show a higher citation rate for that reason alone."""
 99    stops = df[(df["is_stop"] == 1) & df["disposition"].notna()]
100    if stops.empty:
101        return pd.DataFrame(columns=["agency", "outcome", "rate", "stops"])
102    d = stops["disposition"].str.upper()
103    stops = stops.assign(cited=d.str.contains("CITATION"),
104                         arrested=d.str.contains("ARREST"))
105    g = stops.groupby("agency").agg(stops=("cited", "size"),
106                                    Cited=("cited", "mean"),
107                                    Arrested=("arrested", "mean")).reset_index()
108    return (g.melt(id_vars=["agency", "stops"], value_vars=["Cited", "Arrested"],
109                   var_name="outcome", value_name="rate")
110             .sort_values("rate", ascending=False))
111
112
113def camera_proximity(df, cameras, bin_m=200, max_m=2000):
114    """Share of stops vs other incidents falling in each distance band.
115
116    Both series are normalised, so a gap between them means stops cluster
117    differently around cameras than the rest of the call volume does. It is not
118    evidence of causation: cameras and stops both concentrate on arterials."""
119    if df.empty or cameras.empty:
120        return pd.DataFrame(columns=["distance_m", "kind", "share"])
121    d = df.assign(dist=nearest_camera_m(df, cameras))
122    d = d[d["dist"] <= max_m]
123    if d.empty:
124        return pd.DataFrame(columns=["distance_m", "kind", "share"])
125    d["kind"] = np.where(d["is_stop"] == 1, "Vehicle stops", "All other incidents")
126    d["distance_m"] = (d["dist"] // bin_m * bin_m).astype(int)
127    g = (d.groupby(["kind", "distance_m"], as_index=False)
128           .size().rename(columns={"size": "n"}))
129    g["share"] = g["n"] / g.groupby("kind")["n"].transform("sum")
130    return g
131
132
133def amendment_history(conn, source, source_key):
134    """Every version of one record, oldest first."""
135    return pd.read_sql_query(
136        """SELECT 'original' AS version, occurred_at, category, call_type,
137                  disposition, is_stop, address, first_seen AS seen_at
138             FROM incidents WHERE source = ? AND source_key = ?
139           UNION ALL
140           SELECT 'amended', occurred_at, category, call_type,
141                  disposition, is_stop, address, seen_at
142             FROM incident_amendments WHERE source = ? AND source_key = ?
143           ORDER BY seen_at""",
144        conn, params=[source, source_key, source, source_key])
145
146
147def changed_stop_outcomes(conn):
148    """Stops whose disposition the agency changed after first publishing it."""
149    return pd.read_sql_query(
150        """SELECT o.agency, o.case_id, o.occurred_at,
151                  o.disposition AS first_published,
152                  a.disposition AS later_published, a.seen_at
153             FROM incident_amendments a
154             JOIN incidents o
155               ON o.source = a.source AND o.source_key = a.source_key
156            WHERE o.is_stop = 1
157              AND IFNULL(a.disposition, '') <> IFNULL(o.disposition, '')
158            ORDER BY a.seen_at DESC""", conn)
159
160
161def search_audit(conn):
162    """Per-agency summary of ALPR searches from the Flock transparency portals.
163
164    reason is free text the searching officer typed, and most searches have
165    none despite the portals stating that access requires one. user_id is
166    redacted upstream, so nothing here attributes a search to a person."""
167    return pd.read_sql_query(
168        """SELECT agency,
169                  COUNT(*) AS searches,
170                  SUM(reason IS NOT NULL) AS with_reason,
171                  1.0 * SUM(reason IS NOT NULL) / COUNT(*) AS reason_rate,
172                  CAST(AVG(network_count) AS INT) AS avg_networks,
173                  MAX(network_count) AS max_networks,
174                  MIN(searched_at) AS earliest, MAX(searched_at) AS latest
175             FROM alpr_searches GROUP BY agency ORDER BY searches DESC""", conn)
176
177
178def agency_options(conn):
179    rows = conn.execute(
180        "SELECT agency, COUNT(*) FROM incidents_current GROUP BY agency"
181        " ORDER BY 2 DESC").fetchall()
182    return [a for a, _ in rows]
183
184
185def category_options(conn):
186    rows = conn.execute(
187        "SELECT category, COUNT(*) FROM incidents_current"
188        " WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC").fetchall()
189    return [c for c, _ in rows]
190
191
192def date_bounds(conn):
193    lo, hi = conn.execute(
194        "SELECT MIN(occurred_at), MAX(occurred_at) FROM incidents_current").fetchone()
195    return lo[:10], hi[:10]