Skip to content

Score every street for walking, and say what the score does not know - #94

Merged
richardkfm merged 1 commit into
mainfrom
claude/parking-walkable-streets-711krs
Sep 18, 2026
Merged

richardkfm merged 1 commit into
mainfrom
claude/parking-walkable-streets-711krs

Conversation

@richardkfm

Copy link
Copy Markdown
Owner

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's unknowns list. 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 footways layer 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 own note says 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 sidewalk record matched by osm_id first; then, only where that record says separate — 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 flagged footway_match: "proximity" and capped at medium confidence, which the map will draw as reduced opacity.

Implicit speed zones stay unresolved. maxspeed=DE:urban gets 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, the osm_id index now holds only street_tag records, since a standalone way's id is not a street's.

Also here: ?mode=space_split reports 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_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.

No model change, so no migration — walkability is a derived endpoint like /parked-cars/, not a synced layer kind.

Type of change

  • Bug fix
  • New feature / connector / measure rule
  • Refactor or cleanup
  • Documentation
  • CI / infrastructure

Checklist (CLAUDE.md governance)

  • I have read CLAUDE.md and my changes comply with all core principles
  • CHANGELOG.md has a new entry under [Unreleased]
  • README.md is updated if user-facing behavior changed
  • VERSION is bumped if warranted (see versioning policy in CLAUDE.md) — 0.52.0 → 0.53.0
  • No logic is hard-wired to a specific city, country, or language
  • All new user-facing strings use gettext / {% trans %}
  • docker compose up --build runs cleanly and core flows work
  • python manage.py test passes

The 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 test nor docker compose up can 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, plain unittest, 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 enabled DataSource so it can pass None rather than [].
  • test_every_country_zone_behaves_the_same_out_of_the_box and test_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_only and test_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=none scores 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.html compiles as a Django template.
  • accident_density smoke-tested through the promoted helpers after the move: an accident snaps to its street, the severity score and street name come out right, and area_engine's _make_projector import is untouched.

Suggested manual check in docker compose up --build:

  1. curl /api/v1/workspaces/<slug>/walkability/ — classes present; every unknown street carries a populated unknowns; score / safety / comfort in the payload; the same request twice byte-identical.
  2. A workspace with no footways layer returns a full collection (every street unknown), not an error.
  3. ?mode=space_split — space_balance null on streets without tagged widths, and space_split_coverage honest about how few that leaves.
  4. /<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

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
richardkfm marked this pull request as ready for review September 18, 2026 08:48
@richardkfm
richardkfm merged commit ea5fdc9 into main Sep 18, 2026
3 checks passed
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants