krz/omaha-metro-blotter

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

main: README.nfo · raw

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