krz/omaha-metro-blotter

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

main: 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    The baseline is drawn only from agencies that report stops. Comparing stops
117    against every incident in the archive instead compares Sarpy and Council
118    Bluffs stops with a baseline that is mostly Omaha, a city reporting no stops
119    at all, and the geography alone then makes stops look far closer to cameras
120    than they are: 2.6x within 200 m across all agencies, 1.2x within the ones
121    actually being measured.
122
123    Even restricted this way it is not evidence of causation. Cameras and
124    enforcement both concentrate on arterials."""
125    if df.empty or cameras.empty:
126        return pd.DataFrame(columns=["distance_m", "kind", "share"])
127    reporting = df.loc[df["is_stop"] == 1, "agency"].unique()
128    df = df[df["agency"].isin(reporting)]
129    if df.empty:
130        return pd.DataFrame(columns=["distance_m", "kind", "share"])
131    d = df.assign(dist=nearest_camera_m(df, cameras))
132    d = d[d["dist"] <= max_m]
133    if d.empty:
134        return pd.DataFrame(columns=["distance_m", "kind", "share"])
135    d["kind"] = np.where(d["is_stop"] == 1, "Vehicle stops", "All other incidents")
136    d["distance_m"] = (d["dist"] // bin_m * bin_m).astype(int)
137    g = (d.groupby(["kind", "distance_m"], as_index=False)
138           .size().rename(columns={"size": "n"}))
139    g["share"] = g["n"] / g.groupby("kind")["n"].transform("sum")
140    return g
141
142
143def amendment_history(conn, source, source_key):
144    """Every version of one record, oldest first."""
145    return pd.read_sql_query(
146        """SELECT 'original' AS version, occurred_at, category, call_type,
147                  disposition, is_stop, address, first_seen AS seen_at
148             FROM incidents WHERE source = ? AND source_key = ?
149           UNION ALL
150           SELECT 'amended', occurred_at, category, call_type,
151                  disposition, is_stop, address, seen_at
152             FROM incident_amendments WHERE source = ? AND source_key = ?
153           ORDER BY seen_at""",
154        conn, params=[source, source_key, source, source_key])
155
156
157def changed_stop_outcomes(conn):
158    """Stops whose disposition the agency changed after first publishing it."""
159    return pd.read_sql_query(
160        """SELECT o.agency, o.case_id, o.occurred_at,
161                  o.disposition AS first_published,
162                  a.disposition AS later_published, a.seen_at
163             FROM incident_amendments a
164             JOIN incidents o
165               ON o.source = a.source AND o.source_key = a.source_key
166            WHERE o.is_stop = 1
167              AND IFNULL(a.disposition, '') <> IFNULL(o.disposition, '')
168            ORDER BY a.seen_at DESC""", conn)
169
170
171def search_audit(conn):
172    """Per-agency summary of ALPR searches from the Flock transparency portals.
173
174    reason is free text the searching officer typed, and most searches have
175    none despite the portals stating that access requires one. user_id is
176    redacted upstream, so nothing here attributes a search to a person."""
177    return pd.read_sql_query(
178        """SELECT agency,
179                  COUNT(*) AS searches,
180                  SUM(reason IS NOT NULL) AS with_reason,
181                  1.0 * SUM(reason IS NOT NULL) / COUNT(*) AS reason_rate,
182                  CAST(AVG(network_count) AS INT) AS avg_networks,
183                  MAX(network_count) AS max_networks,
184                  MIN(searched_at) AS earliest, MAX(searched_at) AS latest
185             FROM alpr_searches GROUP BY agency ORDER BY searches DESC""", conn)
186
187
188def agency_options(conn):
189    rows = conn.execute(
190        "SELECT agency, COUNT(*) FROM incidents_current GROUP BY agency"
191        " ORDER BY 2 DESC").fetchall()
192    return [a for a, _ in rows]
193
194
195def category_options(conn):
196    rows = conn.execute(
197        "SELECT category, COUNT(*) FROM incidents_current"
198        " WHERE category IS NOT NULL GROUP BY category ORDER BY 2 DESC").fetchall()
199    return [c for c, _ in rows]
200
201
202def date_bounds(conn):
203    lo, hi = conn.execute(
204        "SELECT MIN(occurred_at), MAX(occurred_at) FROM incidents_current").fetchone()
205    return lo[:10], hi[:10]