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: README.nfo · raw
1┌──────────────────────────────────────────────────────────────┐
2│ O M A H A I N C I D E N T S [ KRZ ] krz.sh │
3└──────────────────────────────────────────────────────────────┘
4
5WHAT
6 police activity across the omaha metro, pulled from the agencies'
7 own feeds and joined against alpr camera locations from
8 openstreetmap.
9
10COVERAGE
11 omaha pd dcgis arcgis view, 2022-01-01 onward,
12 nibrs offence records, updated daily.
13 no stop or disposition data.
14 bellevue pd sarpy county publiccrimemap, cad calls
15 papillion pd for service with stop type, disposition
16 la vista pd and category. rolling 12-month window:
17 sarpy county so records age out of the feed, so the local
18 archive is the only long-term copy. gretna
19 and springfield appear as fire only; their
20 police departments do not report to it.
21 council bluffs pd cbpd public cfs feed, refreshed every ten
22 minutes, rolling 12-month window. stop
23 type, disposition, priority, response time.
24 street addresses withheld; points exact.
25 ralston pd no machine-readable feed. absent.
26
27 alpr cameras openstreetmap via overpass, the same data
28 deflock renders. 169 nodes in the metro
29 bbox. odbl, attribution required.
30
31 alpr searches flock transparency portals. council bluffs
32 publishes a downloadable 30-day search
33 audit; sarpy county and douglas county
34 publish counts but no export. omaha pd has
35 no portal.
36
37SETUP
38 uv venv .venv
39 uv pip install --python .venv/bin/python -r requirements.txt
40
41USE
42 .venv/bin/python ingest.py --full # first run, backfill
43 .venv/bin/python ingest.py # daily, last 30 days
44 .venv/bin/python ingest.py cbpd sarpy # one source at a time
45 .venv/bin/python ingest.py opd_csv # 2015-2023 csv archive
46 .venv/bin/python app.py
47
48ARCHIVE
49 .github/workflows/daily-pull.yml runs at 11:17 and 23:17 utc and
50 keeps the database as metro.db.gz on the "archive" release, so the
51 archive does not depend on any one machine. each run restores that
52 asset, pulls, refuses to publish if any source came back with fewer
53 rows than it started with, then uploads and fails loudly if a feed
54 has not moved in seven days.
55
56 sundays it sweeps every feed in full instead of the last 30 days,
57 because a 30-day window cannot see an agency amending a record it
58 filed months ago, and omaha does that.
59
60 first run: trigger it manually with bootstrap enabled, which pulls
61 every feed in full and creates the release. after that the restore
62 step is mandatory -- a bootstrap over a live archive throws away
63 whatever has already aged out of the sarpy and council bluffs
64 feeds.
65
66 github disables scheduled workflows after 60 days without repo
67 activity, and emails first. that is the most likely way this stops
68 quietly.
69
70 to run the pull locally instead:
71
72 0 6 * * * cd /path/to/omaha-incidents && .venv/bin/python ingest.py
73
74RAW
75 raw_records keeps the feed's own json for every version of every
76 record, keyed the same way amendments are. a parse that turns out
77 wrong, or a field a feed adds later, can only be applied to history
78 if the bytes were kept, and the rolling feeds mean there is no
79 second chance to fetch them. the payloads already carry fields
80 ingest does not map: council bluffs response times and priority,
81 sarpy case status.
82
83 it costs about 0.7 mb gzipped a day and roughly triples the
84 database: 20 mb published without it, 52 mb with. 319 records
85 predate it and their raw is gone; the feeds no longer serve them.
86
87FLOCK SEARCH AUDIT
88 transparency.flocksafety.com/<slug> publishes camera counts, plate
89 reads, search counts and each agency's sharing network. council
90 bluffs also offers the search audit itself as a csv: one row per
91 search, with a timestamp, how many camera networks it reached and
92 a free-text reason.
93
94 cloudflare serves a challenge to every non-browser client, so
95 ingest.py cannot fetch it. collecting is manual: open the portal,
96 "download csv", save it as raw_data/flock/<slug>_<date>.csv and
97 commit. loading is not manual -- the flock source runs in the daily
98 job and picks up whatever is committed. search ids are stable
99 uuids, so overlapping exports dedupe.
100
101 the portals keep 30 days. miss a month and that month is gone.
102
103 as of the first export, 100 of 442 council bluffs searches carried
104 any reason at all, against an access policy stating that all access
105 requires one. userid is redacted upstream, so no search can be
106 attributed to a person. the median search reached 466 camera
107 networks; the largest reached 6072.
108
109 all three portals list traffic enforcement under prohibited uses,
110 which is what the camera-proximity panel is measuring against.
111
112AMENDMENTS
113 agencies edit records after publishing them: a disposition changes,
114 a case reopens, a record is withdrawn. nothing in the incidents
115 table is ever updated, so what an agency published first stays
116 readable. every later version the feed serves lands in
117 incident_amendments, and incidents_current is the newest version of
118 each record. analysis.changed_stop_outcomes() lists stops whose
119 disposition changed after filing.
120
121 a version is keyed on the hash of its payload, so a record that
122 reverts to a payload already on file is not recorded again. this
123 holds the set of distinct states observed, not a strict timeline.
124
125 the hash has to survive a round trip through sqlite. a lon of -96
126 arrives from the feed as a json int and comes back out of a REAL
127 column as -96.0, so lat, lon and is_stop are coerced before
128 hashing. get this wrong and every affected record is filed as
129 amended on every run, forever. the workflow fails if any amendment
130 is byte-identical to its original.
131
132NOTES
133 all three arcgis services return utc epochs; their where-clause
134 literals do not agree (opd and council bluffs utc, sarpy central).
135 ingest.py stores occurred_at in local time.
136
137 each feed has its own taxonomy and none of them are comparable, so
138 ingest.py derives one cross-agency flag, is_stop, per source. stop
139 outcomes compare citation and arrest rate by substring, which is
140 all the two disposition vocabularies support: an agency that
141 records warnings less thoroughly shows a higher citation rate for
142 that reason alone.
143
144 colour scheme follows prefers-color-scheme. plotly writes colours
145 into the figure, so assets/theme.js reports the media query into a
146 store and app.py builds each figure from it. restyling after the
147 fact does not work: swapping a maplibre basemap at runtime leaves
148 it rebuilding with no data layers.
149
150 the camera-proximity panel compares stops against a non-stop
151 baseline. cameras and stops both concentrate on arterials, so a
152 gap between the curves is a starting point, not a finding.
153
154 raw_data/ingress.db is the old 2015-2023 sqlite build. nothing
155 reads it any more.
156
157SCREENSHOTS
158 screenshots/*.png are from the previous 2015-2023 dashboard.
159
160┌──────────────────────────────────────────────────────────────┐
161│ krz.sh │
162└──────────────────────────────────────────────────────────────┘