Access scoring¶
The BNA score answers a single question for every census block: how much of what you can reach by bicycle can you reach comfortably?
The fundamental question¶
Bicycle access is scored in two layers:
- High-stress access — how many people / jobs / destinations can you reach by any bicycle route, even stressful ones along busy arterials?
- Low-stress access — how many of those can you reach via calm, comfortable routes (stress level 1 only)?
A block scores well when the low-stress share of what it can reach is high. A neighbourhood where every school, park, and grocery store requires riding on a five-lane highway will score poorly even if it has good cycling distance to many destinations.
Population and employment access¶
For each block, BNA counts:
pop_high_stress— total population living in blocks reachable at allpop_low_stress— population reachable via low-stress routes only
The ratio pop_low_stress / pop_high_stress is then run through a piecewise
linear formula that rewards early gains more than later ones:
ratio = pop_low / pop_high
if ratio == 0: score = 0
if ratio >= 1: score = max_score (1.0)
Otherwise, interpolate linearly between:
0 → 0
step1=3% → score1=0.1
step2=20% → score2=0.4
step3=50% → score3=0.8
100% → max_score=1.0
The shape is deliberately concave: going from 0% to 3% low-stress connectivity (the first few comfortable routes appearing) is worth 10% of the maximum score. Going from 50% to 100% only adds the remaining 20%. This reflects the diminishing returns of additional comfortable routes once a neighbourhood already has broad low-stress access.
Employment (emp_score) uses the same formula, with LODES WAC job counts replacing
population.
Destination access¶
Destinations are counted as clusters (groups of nearby individual POIs), not individual buildings. A block's destination score for a type is:
- Count how many clusters of that type are reachable at all (
high_stress). - Count how many clusters are reachable via low-stress routes (
low_stress). - Apply a stepped formula based on the first few clusters mattering most:
if high == 0: score = NULL (type not present / reachable)
if low == 0: score = 0
if high == low: score = max_score (1.0)
Otherwise (example: first=0.7, second=0.2, third=0):
low == 1 → 0.7
low == 2 → 0.9
low >= 3 → 0.9 + (0.1) × (low - 2) / (high - 2) [linear to max]
The parameters (first, second, third) differ by destination type, reflecting
how much a single accessible hospital matters vs. a single accessible park.
Schools use first=0.3, second=0.2, third=0.2 because the third school adds
meaningful value; hospitals use first=0.7 because reaching even one hospital
comfortably is highly significant.
Standard destination parameters by type:
| Type | first | second | third |
|---|---|---|---|
| colleges | 0.7 | 0 | 0 |
| community_centers | 0.4 | 0.2 | 0.1 |
| dentists | 0.4 | 0.2 | 0.1 |
| doctors | 0.4 | 0.2 | 0.1 |
| hospitals | 0.7 | 0 | 0 |
| parks | 0.3 | 0.2 | 0.2 |
| pharmacies | 0.4 | 0.2 | 0.1 |
| retail | 0.4 | 0.2 | 0.1 |
| schools | 0.3 | 0.2 | 0.2 |
| social_services | 0.7 | 0 | 0 |
| supermarkets | 0.6 | 0.2 | 0 |
| transit | 0.6 | 0 | 0 |
| universities | 0.7 | 0 | 0 |
Trail access¶
Trails are handled differently from destinations because the goal is to count qualifying recreational paths, not POI clusters. The scoring uses reverse Dijkstra rather than a forward search from each block:
- Build the transposed (reversed) routing graphs
G_high.TandG_low.T. - For each qualifying trail (path_length > 4800 m and bbox_length > 3300 m):
- Run Dijkstra on the reversed low-stress graph from the trail's road vertices.
- Any source block with a reachable vert within
max_trip_distancecan reach this trail via low-stress routes → incrementtrails_low_stress. - Repeat on the reversed high-stress graph → increment
trails_high_stress. - Apply the destination-count formula with
first=0.7, second=0.2.
The reverse approach is much faster than forward Dijkstra from each block when there are fewer trails than blocks (typically 50–300 trails vs. thousands of blocks).
Trail scores use NULL when trails_high_stress == 0 (no qualifying trail
reachable at all), 0 when trails are reachable but only via stressful routes,
and the piecewise formula otherwise.
Category scores¶
Destination scores are grouped into five categories:
| Category | Weight | Members |
|---|---|---|
| People | 15 | population (pop_score) |
| Opportunity | 20 | employment, schools (35%), colleges (10%), universities (20%) |
| Core services | 20 | doctors (20%), dentists (10%), hospitals (20%), pharmacies (10%), supermarkets (25%), social_services (15%) |
| Retail | 15 | retail |
| Recreation | 15 | parks (40%), trails (35%), community_centers (25%) |
| Transit | 15 | transit |
The category score is a weighted average of its members, with a conditional
denominator: only members where at least one block has high-stress access in the
city contribute to the denominator. If a city has no hospitals at all, hospitals
is excluded from the core_services denominator — it cannot drag the score down.
Overall block score¶
The per-block overall_score (0–100) combines all category contributions:
overall_score = 100 × (
people_weight × pop_score
+ opportunity_weight × opportunity_contribution
+ core_services_weight × cs_contribution
+ retail_weight × retail_score [if any retail reachable]
+ recreation_weight × rec_contribution
+ transit_weight × transit_score [if any transit reachable]
) / (
people_weight
+ opportunity_weight [if any school/college/university reachable]
+ core_services_weight [if any core service reachable]
+ retail_weight [if any retail reachable]
+ recreation_weight [if any park/trail/cc reachable]
+ transit_weight [if any transit reachable]
)
The conditional denominator is the key design: categories with zero high-stress destinations in the entire city are excluded from both numerator and denominator. A rural city with no transit stops is not penalised for lacking transit — it is scored on what it has.
Blocks with no connectivity at all (isolated road network) receive a score of 0.0
(not NULL) because people always contributes to the denominator.
Implementation notes¶
All scoring is a single vectorised pandas GROUP BY over connectivity_df: one O(pairs) pass through the connectivity table, computing every destination type at once.
Relationship to brokenspoke-analyzer
The SQL scripts this stage replaces — and any points where the output intentionally differs — are catalogued in the Differences from brokenspoke-analyzer section.