Score every street for walking, and say what the score does not know - #94
Merged
Merged
Conversation
Slice 3b of three. The data repairs landed in #93; this turns them into a reading. A new pure module, measures/walkability.py, rates each street comfortable / usable / tight / hostile / unknown from ten factors split across a safety and a comfort component, and exposes it at /api/v1/workspaces/<slug>/walkability/. The whole module is built around one rule: silence is never a middling score. A factor the data cannot speak to returns None, is dropped from the weighted mean rather than substituted with 0.5, and is named in the street's `unknowns` list. Below min_coverage no class is given at all. That is deliberately conservative — a workspace with no footways layer reports most streets as unknown whatever their traffic, because comfort is 40 % of the picture — and the collection's note says so, since the gap is the actionable finding. The footway join is guided proximity: a street's own `sidewalk` record by osm_id first, then, only where that record says `separate` or there is no record at all, the nearest standalone footway within 25 m. A street surveyed as having no pavement is never rescued by a line that happens to run nearby; a street matched by nearness is flagged and capped at medium confidence. Two over-claims found and fixed while re-reading the diff: a separately-mapped footway is one line beside the street, not two, so it now scores as one side and contributes its width once rather than twice; and the osm_id index holds only street-tag records, since a standalone way's id is not a street's. `_SegmentGrid`, `_seg_dist2` and `_iter_linestrings` move from measures/accident_density.py into measures/geo.py, with the old private names left behind as aliases exactly as `_make_projector` already was. No caller moves, and area_engine's import is untouched. Implicit speed zones stay unresolved unless a workspace supplies its own numbers: what `urban` means is a question of national law, and a table of country defaults in core code is the coupling principle 1 forbids. A test asserts DE, NZ, GB and FR zones all behave identically out of the box. The methodology page gains the walkability weights and thresholds, and — while there — the parked-car parameters, which slice 2 shipped overridable but never printed anywhere. No model change, so no migration. Walkability is a derived endpoint like /parked-cars/, not a synced layer kind. Verified: ruff check backend/ clean; 199 pure-module tests green, of which 97 are new; connectors.tests unchanged at its pre-existing 1 failure + 16 "settings are not configured" errors; methodology.html compiles as a Django template; accident_density smoke-tested through the promoted helpers. `manage.py test` and `docker compose up` could not be run — this container has Django but no GDAL, so every GeoDjango import fails. CI runs both. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKiSPbDwCocoqdk3zoty9w
richardkfm
marked this pull request as ready for review
September 18, 2026 08:48
richardkfm
pushed a commit
that referenced
this pull request
Sep 18, 2026
Slice 3c of three, and the last. #94 computed a walking class for every street; this puts it on the map, next to the cars the same street stores. One line layer, two readings of one request. `classes` colours a street by what it is like on foot — comfortable, usable, tight, hostile, or not enough data. `space_split` colours the same streets by how their width divides between parked cars and people. Every street carries both readings in the one response, so switching mode repaints rather than refetches. No number is drawn over a street. The classes are thresholds on an auditable 0-100 score, but a score printed on a map claims a precision that banded OSM inputs cannot support, and a city-sized network of them is noise before it is information. "Show numeric scores" is unticked by default and puts the values in the popup only; they are always in the API, because principle 3 needs the calculation auditable, not displayed. Streets with too little data are drawn, in their own pale grey, with their own legend row. Omitting them would let a city nobody has surveyed pass for a city with nothing wrong, and the survey gap is usually the most actionable thing the layer can say. Confidence is carried by opacity, so a class resting on thin data — or on a pavement matched by nearness rather than by id — literally looks faint. The popup says which inputs the score could not see, and says in words when a pavement was matched by proximity. The derived-layer registry. `activateStoryView` reaches only `.layer-toggle` checkboxes, so it could hide a derived overlay but never switch one back on. Components now register `{activate, deactivate, sync}` and the sweeps go through the registry. That is what lets the new "Parking vs walking" story view bring both halves up together — and it closes a live bug in the same stroke: a saved view restored the parked-car and availability-gap overlays on the map but left their checkboxes unticked and their data unloaded, so the panel and the map disagreed. `loadStyleState()` rebuilds its object from a whitelist, so `walkability` had to be named there or the mode and the score opt-in would have reset on every reload. Docs: new `docs/PARKING_AND_WALKING.md` covers both halves — what each layer claims, the ten factors and their weights, the classes, coverage and confidence, the footway join, the space split, the per-workspace overrides, the API, and the limits. Linked from the README and summarised in the user guide. VERSION 0.53.0 -> 0.54.0 (MINOR: new map layer, new display mode, new story view, new doc). CHANGELOG under [Unreleased]; README updated. Verification: `ruff check backend/` clean; map.html compiles as a Django template and its extracted JavaScript passes `node --check`; the pure module suites (`measures.test_walkability`, `test_geo`, `test_street_space`, `test_parking_estimate`) stay green at 199 tests; `connectors.tests` is unchanged at its pre-existing 1 failure / 16 "settings are not configured" errors. `manage.py test` and `docker compose up` could not be run here — this container has Django but no GDAL, so every GeoDjango import fails. CI runs both. No new Python tests: the change is template JavaScript, docs and one gating flag, and the repo has no JS test harness; CLAUDE.md asks for UI changes to be verified manually in Docker, which is listed in the PR body. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CKiSPbDwCocoqdk3zoty9w
13 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Slice 3b of three. #93 repaired the data; this turns it into a reading. A new pure module,
measures/walkability.py, rates every street comfortable / usable / tight / hostile / not enough data from ten factors split across a safety component (how exposed a person on foot is to motor traffic) and a comfort one (whether the walk is pleasant and possible at all), and publishes it at/api/v1/workspaces/<slug>/walkability/.The module is built around one rule: silence is never a middling score. A factor the data cannot speak to returns
None, is dropped from the weighted mean rather than substituted with a neutral 0.5, and is named in that street'sunknownslist. Below the coverage threshold no class is given at all. Substituting a middle value would quietly turn "nobody has surveyed this" into "it is average", which is the exact failure this whole feature exists to avoid.That is deliberately conservative, and it has a consequence worth stating plainly: a workspace with no
footwayslayer reports most streets as not enough data whatever their traffic, because comfort is 40 % of the picture and without it even a well-surveyed street cannot reach the threshold. The collection's ownnotesays so. The gap is the actionable finding, not an error to paper over — and it is the argument for syncing the layer.The footway join is guided proximity. A street's own
sidewalkrecord matched byosm_idfirst; then, only where that record saysseparate— slice 3a's explicit pointer — or where there is no record at all, the nearest standalone footway within 25 m via a uniform grid index. A street surveyed as having no pavement is never rescued by a line that happens to run nearby: evidence outranks proximity. A street matched by nearness is flaggedfootway_match: "proximity"and capped at medium confidence, which the map will draw as reduced opacity.Implicit speed zones stay unresolved.
maxspeed=DE:urbangets no number unless the workspace supplies one, because what urban means is a question of national law and a table of country defaults in core code is precisely the coupling CLAUDE.md principle 1 forbids. A test asserts DE, NZ, GB and FR zones all behave identically out of the box.Two over-claims found and fixed while re-reading my own diff, both in the same place — a separately-mapped footway is one line beside the street, not two. The connector writes
sides: "both"on a standalone way to mean the way is walkable end to end, not that the street has two pavements. It now scores as one side (0.8, not 1.0) and contributes its width once rather than twice in the space split. Relatedly, theosm_idindex now holds onlystreet_tagrecords, since a standalone way's id is not a street's.Also here:
?mode=space_splitreports how a street's width divides between parked cars and people on foot, for the minority of streets where both widths are actually tagged — with the collection reporting its own coverage so a sparse answer cannot pass for a complete one. And the methodology page gains the walkability weights and thresholds plus, while there, the parked-car parameters that slice 2 shipped overridable but never printed anywhere._SegmentGrid,_seg_dist2and_iter_linestringsmove frommeasures/accident_density.pyintomeasures/geo.py, with the old private names left behind as aliases exactly as_make_projectoralready was. No caller moves.No model change, so no migration — walkability is a derived endpoint like
/parked-cars/, not a synced layer kind.Type of change
Checklist (CLAUDE.md governance)
CHANGELOG.mdhas a new entry under[Unreleased]README.mdis updated if user-facing behavior changedVERSIONis bumped if warranted (see versioning policy in CLAUDE.md) — 0.52.0 → 0.53.0gettext/{% trans %}docker compose up --buildruns cleanly and core flows workpython manage.py testpassesThe last two are unticked because they could not be run. This container has Django but no GDAL, so every GeoDjango import fails and neither
manage.py testnordocker compose upcan start. CI runs both. Rather than tick a box on a command I did not execute, they are left open.Testing notes
97 new tests in
backend/measures/test_walkability.py, plainunittest, no DB. Everything geometric runs against both Leipzig and Auckland, so nothing can quietly depend on being in the northern hemisphere — or on a country that tags speeds in km/h.The ones that matter most:
test_an_empty_street_leaves_every_factor_silent— the rule the module rests on, asserted across all ten factors at once: with no data, not one of them may return a value.test_a_none_is_dropped_rather_than_scored_half— the composition would return 50 if a silent factor were scored 0.5. It returns 100.test_a_surveyed_absence_is_never_overridden_by_a_nearby_line— the single most important assertion in the join, run against both centres.test_a_synced_but_empty_layer_is_not_the_same_as_no_layer— "nobody has looked" and "somebody looked and found none" are different claims, and only the second may score the street. The endpoint checks for an enabledDataSourceso it can passNonerather than[].test_every_country_zone_behaves_the_same_out_of_the_boxandtest_no_country_is_privileged_with_an_implicit_speed— if a default ever appears in the implicit-speed table, this feature has acquired a home country and these say so.test_a_separately_mapped_pavement_counts_as_one_side_onlyandtest_a_separately_mapped_pavement_is_not_counted_twice— the two over-claims above, now pinned.test_an_unrestricted_road_is_the_worst_case_not_a_gap—maxspeed=nonescores 0, because treating it as 0 km/h would rank an autobahn as the calmest street in town.test_the_thresholds_are_inclusive_at_their_boundaries— every class boundary at 70/69.9, 50/49.9, 30/29.9.test_the_same_input_gives_byte_identical_output— the endpoint sits behind@cache_page.test_without_a_footways_layer_even_a_bad_road_stays_unknown— the conservative consequence, asserted rather than left as a surprise.Verification run:
ruff check backend/— clean.python3 -m unittest measures.test_walkability measures.test_geo measures.test_street_space measures.test_parking_estimate— 199 tests green.python3 -m unittest connectors.tests— 232 tests, unchanged at the same 1 pre-existing failure and 16 pre-existing "settings are not configured" errors as before this branch. Nothing here touches the connectors.methodology.htmlcompiles as a Django template.accident_densitysmoke-tested through the promoted helpers after the move: an accident snaps to its street, the severity score and street name come out right, andarea_engine's_make_projectorimport is untouched.Suggested manual check in
docker compose up --build:curl /api/v1/workspaces/<slug>/walkability/— classes present; everyunknownstreet carries a populatedunknowns;score/safety/comfortin the payload; the same request twice byte-identical.footwayslayer returns a full collection (every streetunknown), not an error.?mode=space_split—space_balancenull on streets without tagged widths, andspace_split_coveragehonest about how few that leaves./<slug>/methodology/shows the walkability weights, the class thresholds and the parked-car parameters, with the "plausible planning defaults" warning on both.Related issues
Next and last in this series: 3c draws the coloured line in both modes, the popup and legend, and the parking-vs-walking story view, and adds
docs/PARKING_AND_WALKING.md.🤖 Generated with Claude Code
https://claude.ai/code/session_01CKiSPbDwCocoqdk3zoty9w
Generated by Claude Code