This is the practical, end-to-end guide to pointing nearmiss at a city that is not Davis. The promise of the project is config-over-code: a new city is a new config file, three input files, and an exposure layer — no Python changes. This document walks the whole path, from the three inputs you supply, through the city config, the offline geocoder for address-only reports, and the run commands, to an honest accounting of what you still have to do yourself.
Where this guide and the code disagree, the code and its tests
(tests/) are authoritative and this document is the bug. The fastest way to learn the
shape of every input is to copy the committed Davis demo:
config/davis-demo.toml and
tests/fixtures/davis/.
For the statistics behind the numbers this produces, read
docs/METHODOLOGY.md; for what the published dataset does and does not claim, read
docs/DATA-CARD.md; for the privacy rules that govern publishing, read
docs/THREAT-MODEL.md. The Five Hard
Rules apply to your city exactly as they apply to
Davis — most of all the one that says no rate without a denominator, which is why a real exposure
layer is not optional.
- The three inputs you supply
- Writing a city config
- Address-only reports: the offline gazetteer geocoder
- Running it
- What you still have to do yourself (honestly)
- A minimal checklist
nearmiss takes three files. The single most important thing to get right is the segment_id: it
is the join key that ties all three together. Your streets define the segment ids; your exposure file
must use the same ids; and your reports are placed onto those segments by snapping. Get the ids
consistent and everything else follows. Get them inconsistent and the analysis will tell you so,
loudly — see the note on the exposure join below.
A GeoJSON file of LineString features, one per street block (segment). Each feature needs a
segment_id and a human-readable name in its properties. The name is what shows up in the
brief and the map table, so use real block names ("5th St (C–D)"), not "seg-01".
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": [[-121.7413, 38.5449], [-121.7397, 38.5449]]
},
"properties": { "segment_id": "seg-01", "name": "B St (1st–2nd)" }
}
]
}Notes:
- Coordinates are GeoJSON order:
[lon, lat], WGS84 (EPSG:4326). segment_idis required and must be unique. If it is missing the loader falls back toid, but do not rely on that — setsegment_idexplicitly so it matches your exposure file exactly.namedefaults to thesegment_idif absent. That is a code smell, not a feature: supply real names.- This is the only geometry nearmiss publishes. The published GeoJSON is your street centerlines,
never a report location, so these lines are the public face of the dataset. See
schema/dataset.schema.md.
A JSON file giving each segment a denominator: how many people travel it, so a count can become a
rate. The shape is a {"segments": [...]} object (a bare array also works), one row per segment:
{
"segments": [
{ "segment_id": "seg-01", "estimate": 1500.0, "source": "city_bike_count_2025", "date": "2025-05-01" },
{ "segment_id": "seg-03", "estimate": 8000.0, "source": "city_bike_count_2025", "date": "2025-05-01" }
]
}Each row needs four fields:
segment_id— must match a streetsegment_idexactly. This is the join.estimate— the exposure denominator (e.g. average daily bike trips on that block). The unit is up to you; you name it in the config (exposure_unit).source— where the number came from (a count program, a demand model, a Strava/StreetLight-style layer). This is published alongside the rate; be specific and honest.date— when the exposure was measured or modeled, ISOYYYY-MM-DD. Published too.
The exposure join is by exact segment_id, and a mismatch is caught, not swallowed:
- A total mismatch — no exposure id matches any street id — raises a clear error and stops the
run. This almost always means the two layers use different id schemes (e.g. streets use
seg-01but exposure uses OSM way ids). The error names example ids from each side so you can see the mismatch immediately. This is deliberate: silently producing 0% exposure coverage would read as "no denominators exist" instead of the truth, "you wired it up wrong." - A partial mismatch — some exposure ids match, some do not — prints a warning naming the unmatched ids and continues. Segments with no matching exposure are shown as exposure unknown and are not ranked, never silently dropped or falsely rated.
Coverage is reported in the brief ("X% of segments have an exposure denominator"), so a low number is visible, not hidden. You do not have to cover every segment to run, but uncovered segments cannot be ranked, and rule 1 (no rate without a denominator) means an uncovered segment never gets a risk claim.
The reports your contributors submit, validated at intake against
schema/report.schema.json. A report is a JSON object; the file is a
{"reports": [...]} wrapper or a bare array. The required fields are schema_version, id,
occurred_at, mode, hazard_type, and severity, plus a location — and here is the part that
is new and worth emphasizing:
A report must carry either a
location(a preciselat/lon) or anaddress(free text). The schema enforces this as ananyOf: one or the other is required.
A coordinate report:
{
"schema_version": "1.0.0",
"id": "00000000-0000-4000-8000-000000000001",
"occurred_at": "2026-06-10T07:20:00-07:00",
"location": { "lat": 38.544879, "lon": -121.740919, "accuracy_m": 60.0 },
"mode": "cyclist",
"hazard_type": "close_pass",
"severity": "near_miss"
}An address-only report (no coordinates available — a contributor typing where it happened):
{
"schema_version": "1.0.0",
"id": "00000000-0000-4000-8000-000000000002",
"occurred_at": "2026-06-10T08:05:00-07:00",
"address": "B St & 3rd St, Davis CA",
"mode": "pedestrian",
"hazard_type": "sightline",
"severity": "near_miss"
}The address is resolved to coordinates at the geocode stage (see section 3)
and is treated as the location thereafter. An optional BCP-47 language tag (e.g. "en", "es")
records the language the report was submitted in; it defaults to "en" and feeds the bias analysis.
Like all precise location data, both location and address are private. They live under your
raw_dir (which is gitignored) and are never published; the public dataset is aggregated to street
segments. Do not commit raw reports — that is the one rule with no exceptions.
Copy config/davis-demo.toml to config/your-city.toml and edit it.
Paths in the config resolve relative to the config file's own directory, so keep it next to (or
with sensible relative paths to) your data. Here is the demo, annotated for adaptation:
# config/your-city.toml
city = "Your City"
dataset_note = "Pilot dataset — community-collected, see DATA-CARD for limits."
exposure_unit = "bike trips" # the human unit shown in the brief, e.g. "20 reports per 1000 bike trips"
streets = "../data/your-city/streets.geojson" # 1a above
reports = "../data/your-city/reports.json" # 1c above (private inputs; keep out of git if real)
exposure = "../data/your-city/exposure.json" # 1b above
raw_dir = "../data/raw/your-city" # PRIVATE, gitignored — precise reports live here
out_dir = "../data/published" # open, committed — the published GeoJSON lands here
ref_lat = 38.5449 # a reference point near your city centre; used by the
ref_lon = -121.7405 # local equirectangular projection for distances
# gazetteer = "../data/your-city/gazetteer.json" # optional; only if you accept address-only reports (section 3)
[thresholds]
snap_max_m = 25 # a report farther than this from any segment is left unsnapped
dedupe_window_s = 600 # two reports within this time AND distance are treated as duplicates
dedupe_distance_m = 15
small_n = 5 # at or below this report count, a segment is labelled "uncertain"
min_publish_n = 3 # k-anonymity floor: segments with 0 < reports < this are WITHHELD entirely
rate_per = 1000 # rates are expressed per this many exposure units
confidence_z = 1.96 # 95% confidence interval
fdr_alpha = 0.05 # Benjamini-Hochberg false-discovery-rate level for hotspot significance
gi_band_m = 300 # Getis-Ord Gi* neighbourhood NETWORK-distance band (see network.py)
gi_node_snap_m = 5 # street-segment endpoints within this many metres share an intersection
kde_bandwidth_m = 150 # kernel density bandwidth
kde_grid = 20 # KDE grid resolutionKeys worth thinking about for a real city:
min_publish_n(default 3) is the privacy floor. Any segment with a non-zero report count below it is withheld from the GeoJSON, the metadata, and the brief, so no published place can mean "one or two people reported here." Raise it if your community is small and re-identification risk is higher; do not lower it below 3 without readingdocs/THREAT-MODEL.md.small_n(default 5) controls the "uncertain" label, not withholding. A segment abovemin_publish_nbut at or belowsmall_nis published but flagged as low-confidence.exposure_unitis a string, purely cosmetic but important for honesty: it is what the brief prints ("reports per 1000 bike trips"). Make it match what yourestimateactually counts.dataset_noteis a provenance label carried into the brief and the published metadata. Use it to mark a dataset as a pilot, synthetic, or community-collected, so a reader knows what they are looking at.ref_lat/ref_lonanchor the local planar projection used for all distance math. Any point near your city centre is fine; precision here does not matter, but being on the wrong continent does.fdr_alphais the multiple-comparison correction level for Getis-Ord significance. Leave it at 0.05 unless you have a reason and have read the methodology.
Config is loaded by src/nearmiss/config.py; a missing required key or a
non-numeric threshold is a clean configuration error, not a stack trace. TOML is the documented
format; JSON also loads.
If any of your reports use address instead of lat/lon, you need a geocoder to turn the text
into coordinates. nearmiss ships a pluggable Geocoder protocol
(src/nearmiss/geocoder.py); the default is an offline
GazetteerGeocoder backed by a small address-to-coordinate table you supply. It is offline and
deterministic on purpose, so the demo and the tests run anywhere with no network and no API key.
You opt in by setting the gazetteer key in your config to a JSON file:
gazetteer = "../data/your-city/gazetteer.json"The gazetteer JSON is a list of address → coordinate rows:
{
"addresses": [
{ "address": "B St & 3rd St, Davis CA", "lat": 38.5449, "lon": -121.7405 },
{ "address": "5th St & C St, Davis CA", "lat": 38.5461, "lon": -121.7388 }
]
}How it behaves:
- Matching is case-insensitive and whitespace-normalized, so
"b st & 3rd st"resolves the same as"B St & 3rd St". It is otherwise exact: the address string has to be in the table. - It is deterministic: the same address always maps to the same coordinate.
- A report whose address is not in the table is left unplaced. It is not snapped to an invented location; instead it is caught downstream as unsnapped (and counts toward your unsnapped total), which is the honest failure mode.
- If you do not set
gazetteer, address-only reports stay unplaced. So if you accept addresses, you must provide a gazetteer.
A networked geocoder (e.g. Nominatim/OpenStreetMap) would implement the same one-method Geocoder
protocol and could be dropped in — but it is intentionally not the default and is not provided.
The analysis is designed to run with no external service. If you want online geocoding you will write
that adapter yourself; see section 5.
Once the three files and the config exist, the whole thing is one command:
# Full pipeline end to end: intake -> dedupe/geocode/snap/classify/quality -> analyze -> publish -> brief
nearmiss run --config config/your-city.tomlThat writes the published GeoJSON to your out_dir as <city-slug>.geojson (e.g. your-city.geojson)
plus a content-hashed sidecar <city-slug>.metadata.json, and prints a summary. To also write the
advocacy brief to a file, add --out:
nearmiss run --config config/your-city.toml --out build/brief.mdRender the brief in Spanish instead of English with --lang es (the default is en):
nearmiss run --config config/your-city.toml --lang es --out build/brief.es.md
nearmiss brief --config config/your-city.toml --lang es # brief only, to stdoutThe bilingual brief (English/Spanish gettext catalogs under
src/nearmiss/locales/, loaded via
src/nearmiss/i18n.py) carries a plain-language glossary, a bottom-line
sentence, the exposure unit you configured, and a bias counterweight, in whichever of the two
supported languages you choose; an unknown language falls back to English. See
docs/I18N.md for the catalog, the translation workflow, and the merge-blocking gates.
You can also run the stages individually for debugging:
nearmiss intake reports.json --config config/your-city.toml # validate into the private raw store
nearmiss pipeline --config config/your-city.toml --dump # print the intermediate clean records
nearmiss analyze --config config/your-city.toml # rates + CIs + bias + KDE + Getis-Ord
nearmiss publish --config config/your-city.toml # build the open GeoJSON + metadataFinally, serve the accessible map and its equivalent sortable data table (read-only) over the published artifacts:
nearmiss serve # open /web/davis-demo.html in a browserThe local national preview is /web/us-coverage.html. The production-only
/fars/national/ route is created later by the allowlisted site builder.
Reproducibility check: make reproduce rebuilds the published dataset and asserts a clean
git diff on data/published/. If your run is deterministic, re-running it changes nothing.
Adapting nearmiss to a real city is mostly data work, not code work, and the hard parts are genuinely hard. The tool will not paper over them, and this section is the honest list so you can plan the effort.
- Source real exposure data — this is the long pole. The Davis demo ships synthetic exposure,
with planted hotspots; do not read it as evidence that the pipeline finds real ones. Real exposure
has been derived exactly once, from ride GPS traces for Potsdam, Germany — see
findings/2026-08-15-potsdam-real-run.mdfor what that produced and what broke. For a real rate you need a real denominator per segment: a bike/pedestrian count program, manual or sensor counts, a travel-demand model, or a purchased exposure layer (Strava Metro, StreetLight, Replica). Acquiring, cleaning, and joining that to yoursegment_ids is the bulk of the work, and rule 1 means you cannot publish a risk rate for any segment you cannot give a denominator. Plan for partial coverage at first; the brief will report it honestly. - The default geocoder is an offline table, not a real geocoding service. The
GazetteerGeocoderonly knows the addresses you put in its JSON. If your contributors type free-form addresses you have not pre-loaded, those reports go unplaced. A networked geocoder (Nominatim, etc.) is not provided; if you need one you will implement theGeocoderprotocol yourself, and then you own its rate limits, network failure modes, and the privacy implications of sending addresses to a third party. - Get real street names. The pipeline is only as legible as your
nameproperties. Placeholder ids (seg-01) make an unreadable brief. Real block names ("5th St (C–D)") are what make the output usable in front of a city council — and that mapping is yours to build for your street network. - Build the segment network itself. Someone has to decide what a "segment" is for your city (block-by-block centerlines, with stable ids) and produce that GeoJSON. OSM or a city centerline file is a starting point, but segmenting and assigning durable ids is a real task.
- Do the manual accessibility audit. nearmiss runs an automated
axe-corecheck (make axe) and a structural gate (make verify), and the data table is built for keyboard and zoom use. But automated checks are not a screen-reader pass. The manual NVDA/VoiceOver review is still pending for the project itself, and it would be pending for your deployment too. Do not claim full WCAG conformance on the strength of the automated run alone. - Own the privacy decision.
min_publish_ndefaults to 3, but the right floor depends on your community's size and re-identification risk. Readdocs/THREAT-MODEL.mdbefore you publish, and treat raw reports as private data you are responsible for — they are never committed.
None of this is a reason not to start. It is a reason to start with eyes open: stand up the pipeline on whatever exposure you can get, publish with honest coverage and caveats, and improve the inputs over time. The statistics are built to tell the truth about thin data, not to hide it.
- Copy
config/davis-demo.toml→config/your-city.toml; setcity, the three paths,raw_dir,out_dir,ref_lat/ref_lon,exposure_unit, anddataset_note. - Produce
streets.geojson(LineStringfeatures withsegment_id+ realname). - Produce
exposure.jsonkeyed by the samesegment_ids (estimate/source/date). - Collect
reports.json(each withlat/lonoraddress); keep raw reports private. - If you accept addresses, add a
gazetteer.jsonand set thegazetteerconfig key. - Review thresholds, especially
min_publish_n(privacy floor) andsmall_n. nearmiss run --config config/your-city.toml [--lang es] [--out build/brief.md].nearmiss serveto read the accessible map + table.- Read the brief's exposure-coverage and bias sections before you put a number in front of anyone.
When in doubt, diff your files against the committed Davis fixtures in
tests/fixtures/davis/ — they are the known-good shape.