Configuration¶
Every run is driven by one BNAConfig — a plain dataclass tree the
stages read. You never mutate it in place; you build one with
build_config, optionally layering a scenario and/or scalar overrides
over the package defaults.
from bikescore_bna import build_config
config = build_config("default") # bundled scenario
config = build_config("default", {"graph.low_stress_threshold": 2}) # + overrides
config = build_config(Path("my-scenario.yaml")) # a scenario file
build_config¶
build_config(scenario: str | dict | Path | None = None,
overrides: dict | None = None) -> BNAConfig
scenario |
resolves to |
|---|---|
None |
BNAConfig.with_defaults() — the compiled-in defaults, nothing layered |
"default" / a name |
a bundled scenario under bikescore-bna/scenarios/data/ (see list_bundled_scenarios) |
a Path to a .yaml |
a caller-supplied scenario document on disk |
a dict |
an in-memory scenario document |
overrides is a flat {"section.field": value} map applied last, over whatever the
scenario produced — the same key space the CLI's --set uses (e.g.
--set city.default_speed=40). Overrides are scalar-only; structural changes
(rules, catalogs, attributes) belong in a scenario.
Scenarios, not a stack
A complete scenario is a self-contained snapshot — all config options plus the
rule sets, attributes, and destination catalogs — and is the structural source of
truth the resolver block-replaces from. A sparse scenario is just a dict of
config-namespace deltas read over the defaults. There is no scenario stack: to
customise, derive a new scenario from a base (copy + edit) rather than layering. The
bundled default scenario is complete and is what new cities are seeded from.
The config tree¶
BNAConfig holds a handful of top-level scalars plus nine typed sub-configs. Each stage
reads only its own slice.
| Sub-config | Type | Feeds | Selected fields |
|---|---|---|---|
city |
CityIdentityConfig |
attributes, speed limits | default_speed, state_default_speed, country |
imputation |
ImputationConfig |
attributes | bare_speed_unit, default_facility_width_ft |
stress |
StressConfig |
stress | segment_rules, intersection_rules, crossing_speed_defaults, level_names |
graph |
GraphConfig |
graph | low_stress_threshold, extra_thresholds, link_stress_model |
connectivity |
ConnectivityConfig |
connectivity | include_self_pairs, low_stress_ratio, use_turn_restrictions |
scoring |
ScoringConfig |
scores | people, category_weights, population |
export |
ExportConfig |
export | base_dir |
cache |
CacheConfig |
parse | cache_dir — where the clipped-PBF cache lives |
boundary |
BoundaryConfig |
acquire → parse, census, segment | fill_holes, keep_largest_part, clip_shape, clip_size_m, convex_hull, override_geometry, network_buffer_m (see Boundary transforms) |
Top-level scalars include max_trip_distance (2680 m — the reachability horizon),
block_road_buffer / block_road_min_length / block_boundary_overlap (block↔road
association), exclude_water_blocks, the trail thresholds min_path_length /
min_bbox_length, and extra_regions (extra Geofabrik/state extracts to acquire when a
boundary transform expands the extent — see Boundary transforms).
Three fields hold the structural layers a complete scenario supplies:
| Field | Holds | Reference |
|---|---|---|
attributes |
the road-attribute registry (functional class, speed/lane/width defaults, bike-infra, derived flags) | Extensibility |
destinations |
the destination catalog (the 13 standard types + any custom) | Destination catalogs |
intersection_attributes |
the node-attribute matchers (signalized, stop, rrfb, island) |
Extensibility |
User-defined variables¶
A scenario may declare variables — named values the rule sets reference by $var:name
— plus required_variables, names a run must supply. This lets a ruleset stay generic
while a scenario (or --set variables.x=…) pins the numbers. A missing required variable
fails config.validate() before any stage runs.
Boundary transforms¶
The boundary sub-config reshapes the analysis boundary — the polygon parse,
census, and segment actually consume — before scoring. Transforms are applied once, at
acquire time, by
prepare_boundary, in the fixed order keep_largest_part → fill_holes → clip (with
make_valid run unconditionally first to repair self-intersections). With no field set the
transform is identity and the analysis boundary is byte-for-byte the fetched one, so default
output — and reference parity — is unchanged.
| Field | Type | Effect |
|---|---|---|
fill_holes |
bool |
Rebuild each polygon from its exterior ring, absorbing interior enclave holes completely (predictable, not radius-dependent). |
keep_largest_part |
bool |
Drop detached exclaves/islands, keeping only the largest polygon of a MultiPolygon boundary. |
clip_shape |
"box" \| "circle" \| None |
Region-restrict to a shape centered on the boundary centroid, intersected with the real boundary (keeps realistic edges). Requires clip_size_m. |
clip_size_m |
float \| None |
Box side length / circle diameter, in metres. Required when clip_shape is set; must be positive. |
convex_hull |
bool |
Replace the boundary with its convex hull. Extent-expanding. |
override_geometry |
path | bbox | None |
Replace the fetched city boundary at the source — a GeoJSON/vector path or an inline WGS84 bbox [minx, miny, maxx, maxy]. Not a transform: the transforms above run on top of it. Extent-expanding. |
network_buffer_m |
float |
Buffer (m) applied to the analysis boundary when clipping the OSM road network only — never the scoring boundary. 0.0 = exact clip (default parity); >0 is opt-in and extent-expanding. |
Subsetting vs. expanding. fill_holes, keep_largest_part, and the box/circle clip
either shrink the extent or fill interior holes, so they stay inside the already-downloaded
regional data — no re-acquire. The three expanding knobs (convex_hull,
override_geometry, a non-zero network_buffer_m) can push the analysis + clip extent past
the fetched region(s). When they do, acquire's coverage guard requires the missing regions
in the top-level extra_regions list (e.g. extra_regions: ["maryland", "virginia"])
and otherwise raises an actionable error naming them — no surprise downloads. Preview the
plan with acquire --dry-run; the mechanics are covered under
Data acquisition → Boundary manipulation.
The original fetched boundary is always kept alongside the derived one for provenance, and the census stage additionally emits an inert excluded-block layer so dropped water/out-of-boundary blocks are distinguishable from missing data.
Validation¶
config.validate() raises ConfigValidationError on an inconsistent config — an unknown
scoring-category weight, a rule referencing an undeclared variable, or a stage producing a
column no downstream consumer's schema expects. build_config validates for you; call it
directly only when assembling a config by hand.