Intentional deviations¶
Almost everything bikescore-bna computes matches the PeopleForBikes
brokenspoke-analyzer
reference. This page lists the deliberate exceptions — the small set of places
where the output differs on purpose, each with its reasoning. For the
architectural mapping of which Python stage replaces which SQL script (with no
output change), see the stage-by-stage SQL mapping.
Every deviation is one of three types:
- Bug fix — bikescore-bna corrects a defect in the SQL reference
- Architectural difference — different approach, justified and more correct
- Numerical artefact — floating-point sensitivity at a threshold; irreducible
1. SQL bug fixes (classify stage)¶
1a. Parking tag overwrite¶
Affected columns: ft_park, tf_park
Stage: classify
park.sql runs three sequential UPDATEs for both, right, and left
parking lanes. The right and left passes overwrite the both result
unconditionally. A road tagged parking:lane:both=parallel with no separate
:right or :left tags has its parking columns cleared by the later passes.
bikescore-bna evaluates the three cases independently and preserves the both
result whenever right/left would produce NULL.
Assessment: bikescore-bna is correct; the SQL reference silently drops valid
parking data for roads using the both convention.
1b. Opposite-direction bike track dead code¶
Affected column: tf_bike_infra
Stage: classify
A copy-paste error in bike_infra.sql: inside the WHEN one_way_car='ft'
outer CASE branch, two inner conditions test one_way_car='tf' — a condition
that can never be true inside that branch. This makes the tf opposite-track
assignment for ft one-way roads unreachable.
bikescore-bna adds the correct assignment for those two conditions.
Assessment: bikescore-bna is correct; the dead-code branch never fires in the SQL reference so the column is systematically wrong for affected roads.
2. Pipeline ordering (segment stage)¶
2a. Topology-ordering orphan roads¶
Affected rows: a small number of isolated road ways absent from bikescore_bna
output but present in the reference
Stage: classify → segment (cascades downstream)
brokenspoke-analyzer builds road topology (via osm2pgrouting) before
functional_class.sql removes ways with no functional class. A way that
connects only to a to-be-deleted way gets split at that shared node first.
The two resulting segments each reference the other, so both pass the SQL
one-hop orphan check — even though the whole cluster is disconnected from the
main network.
bikescore-bna runs classify (dropping unclassified ways) before segment
(building topology). Without the deleted connecting way, the remaining ways
are correctly identified as orphans and removed.
Assessment: bikescore-bna is more correct. The reference retains isolated dead-end clusters as a pipeline-ordering artefact; they are unreachable from any census block and contribute nothing to scores.
3. Road geometry at the city boundary¶
3a. Boundary polygon clip vs. bounding-box truncation¶
Affected outputs: road segment lengths, routing graph, connectivity, and
all downstream scores
Stage: segment
After osmium extract, brokenspoke passes the city PBF through osmconvert
-b=bbox --drop-broken-refs before database import. The bbox is the axis-aligned
extent of the census block dataset. osmium's complete_ways strategy already gave
both pipelines the full geometry of cross-boundary roads (all nodes present). osmconvert
removes nodes that lie outside the census-block bbox; --drop-broken-refs then
removes those node references from any way that pointed to them. The way is kept but
with a shorter node list — its geometry is truncated to the inside-bbox portion. Roads
whose outside nodes extend beyond the census-block bbox are therefore geometrically
shortened at the bbox edge. The osmconvert step is explicitly acknowledged as
vestigial in the brokenspoke source and rarely affects anything for typical cities
where the census-block bbox closely matches the city extent.
bikescore-bna uses osmium extract --strategy=complete_ways, which is identical to
the brokenspoke default. bikescore-bna skips the osmconvert bbox step entirely, so
cross-boundary roads retain their full geometry regardless of the census-block bbox.
Assessment: bikescore-bna's approach is more precise. The osmconvert bbox clip has no consistent geometric relationship to the city boundary; the brokenspoke developer noted it was intended to be removed. Segment-length differences in affected roads are all attributable to this bbox truncation, not to routing or cost logic.
4. Parse way count¶
4a. Raw parse output vs. classified network¶
Affected output: row count of the parse stage
Stage: parse
bikescore-bna's parse stage produces more ways than the brokenspoke-analyzer reference parquet. The reference was exported after the classify stage, which drops unclassified roads and ways with no functional class. bikescore-bna's parse output includes all highway-tagged ways within the service buffer before any classification.
After classify, way counts align (minus any topology-ordering orphans from deviation §2a).
Assessment: Not a real deviation — the reference parquet does not represent raw parse output. The apparent difference disappears after classify.
5. Clipping approaches¶
The two implementations take fundamentally different approaches to building the study-area dataset from a regional PBF.
brokenspoke-analyzer clipping pipeline¶
brokenspoke clips data in three steps, in sequence:
Step 1 — osmium extract (polygon)
runner.run_osmium_extract clips the regional PBF to the city boundary
polygon using osmium extract -p boundary.geojson. This is a polygon clip
with no buffer; only ways that have at least one node inside the polygon are
kept (with their complete geometry).
Step 2 — osmconvert (bounding box)
runner.run_osm_convert then clips the osmium output further:
osmconvert city.pbf --drop-broken-refs -b=west,south,east,north -o=city.clipped.osm
ST_Extent(census_blocks) in WGS84). The brokenspoke source code comments note
this step as "normally useless now since we clip the data during the prepare phase"
and questions whether it should be removed — but it still executes. Because osmium's
complete_ways strategy already produced complete way geometries (all nodes present),
osmconvert finds no pre-existing broken references. Its effect is to remove nodes
outside the census-block bbox and then, via --drop-broken-refs, to remove those
node references from any way that pointed to them. The way is retained but with a
shorter node list — its geometry is truncated to the portion within the bbox.
For typical cities where the census-block bbox closely matches the city extent,
this step truncates nothing.
Step 3 — clip_osm.sql (service buffer)
After osm2pgsql bulk-imports the osmconvert output into PostGIS, clip_osm.sql
runs:
DELETE FROM neighborhood_ways WHERE NOT ST_DWithin(geom, boundary, :nb_boundary_buffer);
DELETE FROM neighborhood_osm_full_point WHERE NOT ST_DWithin(way, boundary, :nb_boundary_buffer);
-- (same for lines and polygons)
nb_boundary_buffer equals max_trip_distance (2,680 m). Cross-boundary
roads were split by osm2pgrouting at intersection nodes (nodes shared by
multiple ways). Resulting segments may lie wholly inside the city, wholly outside,
or straddle the boundary. clip_osm.sql retains any segment within 2,680 m of the
boundary polygon, creating a buffer zone from the cross-boundary road portions.
Roads entirely outside the city (no node inside the polygon) were never included
by osmium and are absent from both graphs.
The net effect: brokenspoke's routing graph contains outside segments of cross-boundary roads within 2,680 m of the city boundary (from step 4). Outside segments of cross-boundary roads whose outside nodes extended beyond the census-block bounding box are absent (dropped by step 2).
bikescore-bna clipping pipeline¶
bikescore-bna uses two stages:
acquire — exact boundary pre-clip
acquire.py calls pre_clip_pbf(region_pbf, boundary, buffer_m=0.0), which
runs:
osmium extract --strategy=complete_ways -p boundary.geojson region.pbf -o city.pbf
complete_ways strategy keeps the full geometry of any way with at least
one node inside the polygon. No buffer; the output PBF contains only data
within (or crossing) the exact city boundary.
clip stage — keep whole ways that touch the buffer zone
stages/clip.py keeps any way whose geometry intersects the boundary polygon
expanded by max_trip_distance (2,680 m). Ways are kept or discarded as whole
objects — nodes are not trimmed. A cross-boundary way from osmium's complete_ways
output is retained in its entirety, including its outside nodes, because it
intersects the buffered boundary.
segment stage — split at boundary, remove dead ends
After topology splitting at intersection nodes, split_at_boundary() finds segments
that straddle the city boundary polygon, splits each at the exact crossing point
(inserting a virtual node), and then remove_out_of_city_deadends() removes outside
dead-end chains. Roads wholly outside the city (no node inside the polygon) are
absent from both graphs, since osmium never includes them.
Practical consequences¶
| Scenario | brokenspoke | bikescore-bna |
|---|---|---|
| Road inside city boundary | ✓ in graph | ✓ in graph |
| Road crossing boundary, outside nodes within bbox | ✓ outside segments within 2,680 m | ✓ outside portions within 2,680 m |
| Road crossing boundary, outside nodes beyond bbox | ✓ truncated at bbox edge; segments within 2,680 m kept | ✓ full geometry; portions within 2,680 m kept |
| Road entirely outside boundary | ✗ absent (osmium never includes) | ✗ absent (osmium never includes) |
| POI outside boundary, within 2,680 m, inside bbox | ✓ included | ✓ included |
| POI outside boundary, within 2,680 m, outside bbox | ✗ absent (osmconvert removed) | ✗ absent |
Assessment for routing: both pipelines include buffer-zone roads (outside segments of cross-boundary roads within 2,680 m of the boundary). bikescore-bna additionally retains cross-boundary roads that osmconvert would have dropped due to the census-block bbox restriction. For most cities this has minimal practical effect, since the census-block bbox closely matches the city extent.
Assessment for transit points (formerly §5a): transit stops that are outside the city boundary but within 2,680 m are present in brokenspoke's database only if they also fall inside the census-block bounding box. This is an accidental restriction, not the stated intent. bikescore-bna does not apply this bbox restriction and includes any transit stop within 2,680 m of the boundary.
Assessment overall: both implementations provide a routing buffer zone from cross-boundary road segments. brokenspoke's osmconvert bbox step may drop some cross-boundary roads entirely (if their outside nodes extend beyond the census-block bbox); bikescore-bna avoids this by skipping the bbox step. For most cities the difference is minimal.
5a. Transit point count difference¶
Affected output: transit destination cluster count
Stage: destinations
As a consequence of the clipping differences above, brokenspoke may exclude
transit stops that are within the service radius but outside the census-block
bounding box (e.g. ferry terminals beyond a rectangular bbox edge). bikescore-bna
clips transit stops only by DWithin(max_trip_distance) from the city
boundary — the same criterion that brokenspoke's clip_osm.sql intends to
apply, but without the bbox pre-filter.
Assessment: bikescore-bna implements the stated intent of clip_osm.sql.
Count differences in transit are attributable to the bbox relic in
brokenspoke, not to a logic error in bikescore-bna.
6. Destinations retail clustering¶
6a. Floating-point sensitivity at cluster threshold¶
Affected output: retail destination cluster count
Stage: destinations
For a small number of retail POI pairs whose distance is within floating-point rounding of the 50 m cluster threshold, bikescore-bna (scipy) and brokenspoke-analyzer (PostGIS) assign them to different clusters. The difference is typically one or two points on a dataset of hundreds.
Assessment: Irreducible. Replicating PostGIS-specific rounding behaviour would defeat the purpose of a pure-Python reimplementation. The difference is sub-1% and caused by numerical precision, not a logic error.
Summary¶
| # | Deviation | Stage | Type | Assessment |
|---|---|---|---|---|
| §1a | Parking tag overwrite | classify | SQL bug fix | bikescore-bna correct |
| §1b | Opposite-direction track dead code | classify | SQL bug fix | bikescore-bna correct |
| §2a | Topology-ordering orphan roads | segment | Architecture | bikescore-bna more correct |
| §3a | Boundary polygon clip vs. bbox truncation | segment / graph | Architecture | bikescore-bna more principled |
| §5 | Clipping approaches (buffer zone, bbox relic) | parse / clip / destinations | Architecture | bikescore-bna implements stated intent without bbox relic |
| §5a | Transit point count | destinations | Architecture | bikescore-bna implements intent |
| §6a | Retail cluster floating-point | destinations | Numerical artefact | Irreducible, sub-1% |
| §4a | Parse way count | parse | Comparison artefact | Not a real deviation |