Skip to content

Repair the three layers a walking score has to stand on - #93

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

Groundwork for the walkability view (slice 3a of three). While planning the walk score, three of its four inputs turned out to be broken or missing — and each one would have made the score lie rather than merely fall short. They are repaired here, on their own, before anything is built on top of them.

Pedestrian crossings synced nothing. The Overpass query ended out tags, whose verbosity prints ids and tags without node coordinates, so every crossing was discarded by the element converter before it could be stored. The layer reported success and held an empty set. It now asks for geometry, and a new normaliser gives each crossing a stable reading of whether it is signalised, marked, dropped-kerb and tactile — three-state throughout, so a crossing surveyed as unmarked stays distinct from one nobody has looked at.

Speed limits were unreadable outside the countries that tag a bare number in km/h. maxspeed had no normaliser at all, so 20 mph and DE:urban reached every consumer as raw strings and drew as unknown. They are now parsed once into a comparable figure. Two cases deliberately carry no number: an unrestricted road, because 0 km/h would rank an autobahn as the calmest street in town, and a national zone, because what urban means is a question of local law — the zone is preserved and the workspace supplies the value, rather than a table of one country's defaults being baked into core code.

A street whose pavement is mapped separately was recorded as having none. sidewalk=separate says the footway exists and is drawn as its own line; it was sitting in the set of values meaning "surveyed, nothing here". Left alone, the walking score would have condemned exactly the cities that map pedestrian space best. It now reads as a pointer — footway_present: null with sides: "separate" — and outranks a no on the opposite side, because one side mapped separately still means there is a pavement to find.

Also: kissing gates and stiles now report affects: "walking", a value docs/AREA_TARGETS.md has described since it was written but which nothing ever emitted; and the map's speed colouring reads the new numeric field, keeping the old string match behind a coalesce guard so workspaces synced before this change render exactly as they do today.

No model changes, so no migration.

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.51.0 → 0.52.0
  • No logic is hard-wired to a specific city, country, or language
  • All new user-facing strings use gettext / {% trans %} — no new user-facing strings in this change
  • 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

34 new connector tests, all green, plus the existing suite unchanged:

  • MaxspeedParserTests — km/h, mph (30 mph and 30mph), walk, none, four national zones, nonsense, absence. One test asserts DE:urban and NZ:urban parse identically, so that if either ever resolves to a number this feature has acquired a home country and the test says so.
  • StreetsWithSpeedNormalizerTests — the comparable number, the mph city, the preserved zone, and that the raw value always survives whatever we failed to parse.
  • PedestrianCrossingTemplateTests — the template asks for geometry, a crossing node survives the element converter, and a crossing without coordinates is dropped. The last one records the failure mode so a future out tags cannot creep back in unnoticed.
  • PedestrianCrossingNormalizerTests — signalised, marked, unmarked, level crossing, unknown; tactile paving three-state; an unrecognised kerb value is not invented; crossing:markings=surface is a marking (a change of paving, not the absence of paint); signals outrank missing paint.
  • ObstacleAffectsTests — step-over barriers affect walking, ordinary barriers still affect both, tram tracks are still a cycling problem, and affects never leaves its documented domain.
  • FootwayNormalizerTests — the sidewalk=separate test that asserted the old behaviour is replaced by four: separate is a pointer not an absence; separate stays distinct from silence; separate on one side outranks no on the other; a surveyed side still wins over separate.

Verification run:

  • ruff check backend/ — clean.
  • python3 -m unittest connectors.tests — 232 tests, with the same 1 pre-existing failure and 16 pre-existing "settings are not configured" errors as the base commit. Confirmed by running the identical command in a clean git worktree at 28e06d4: 198 tests, identical failure counts. None of them touch changed code.
  • python3 -m unittest measures.test_geo measures.test_street_space measures.test_parking_estimate — 102 tests green.
  • map.html compiles as a Django template, and its extracted JavaScript passes node --check.

Not verified, and it is the first thing worth checking in review: that the crossings layer now stores a non-zero record count. That needs a live Overpass call, and outbound access to overpass-api.de is blocked from this container. The out tags diagnosis is from the Overpass specification and from _osm_element_to_feature returning None for a coordinate-less node — out geom tags is the correct form for a node query either way, so the fix is safe even if the diagnosis is wrong.

Suggested manual check in docker compose up --build:

  1. Sync pedestrian_crossings in a demo workspace; confirm a non-zero record count and that /api/v1/workspaces/<slug>/features/pedestrian_crossings/ returns Point geometries.
  2. Re-sync streets_with_speed; confirm maxspeed_kmh is populated, maxspeed_source is honest, and at least one feature keeps its maxspeed_zone.
  3. Confirm the speed colouring still renders for a workspace synced before this change (the coalesce fallback).
  4. Confirm a sidewalk=separate street reads footway_present: null, sides: "separate".

Related issues

Follow-up worth filing separately, not fixed here: transit_stops and trees use the same out tags query and may be silently empty for the same reason. That is a wider blast radius than this change and deserves its own issue rather than a quietly widened pull request.

Next in this series: 3b adds measures/walkability.py and the /walkability/ endpoint; 3c draws the coloured line and the parking-vs-walking story view.

🤖 Generated with Claude Code

https://claude.ai/code/session_01CKiSPbDwCocoqdk3zoty9w


Generated by Claude Code

Groundwork for the walkability view. Three inputs it needs turned out to be
broken or missing, and each one would have made the score lie rather than
merely fall short, so they are repaired on their own before anything is
built on top of them.

Pedestrian crossings synced nothing. The Overpass query ended `out tags`,
whose verbosity prints ids and tags without node coordinates, so every
crossing was discarded by the element converter before it could be stored.
The layer reported success and held an empty set. It now asks for geometry,
and a new normaliser gives each crossing a stable reading of whether it is
signalised, marked, dropped-kerb and tactile — three-state throughout, so a
crossing surveyed as unmarked stays distinct from one nobody has looked at.

Speed limits were unreadable outside the countries that tag a bare number in
km/h. `maxspeed` had no normaliser at all, so `20 mph` and `DE:urban` reached
every consumer as raw strings and drew as unknown. They are now parsed once
into a comparable figure. Two cases deliberately carry no number: an
unrestricted road, because 0 km/h would rank an autobahn as the calmest
street in town, and a national zone, because what `urban` means is a question
of local law — the zone is preserved and the workspace supplies the value,
rather than a table of one country's defaults being baked into core code.

A street whose pavement is mapped separately was recorded as having none.
`sidewalk=separate` says the footway exists and is drawn as its own line; it
was in the set of values meaning "surveyed, nothing here". Left alone, the
walking score would have condemned exactly the cities that map pedestrian
space best. It now reads as a pointer — `footway_present: null` with
`sides: "separate"` — and outranks a `no` on the opposite side, because one
side mapped separately still means there is a pavement to find.

Also: kissing gates and stiles now report `affects: "walking"`, a value the
area-targets document has described since it was written but which nothing
ever emitted; and the map's speed colouring reads the new numeric field,
keeping the old string match behind a `coalesce` guard so workspaces synced
before this change render exactly as they do today.

Verified: ruff clean; 34 new connector tests green, including that the German
and New Zealand speed zones are treated identically; the full connector module
runs 232 tests with the same single pre-existing failure and 16 pre-existing
settings-not-configured errors as the base commit (confirmed against a clean
worktree at HEAD); 102 pure measures tests still green; map.html compiles as a
Django template and its JavaScript passes node --check.

manage.py test and docker compose could not be run in this container — it has
Django but no GDAL, so every GeoDjango import fails. CI runs both. Confirming
that the crossings layer now stores a non-zero record count needs a live
Overpass call and is the first verification step for review.

Note for follow-up, not fixed here: `transit_stops` and `trees` use the same
`out tags` query and may be silently empty for the same reason. That is a
wider blast radius than this change, and deserves its own issue.

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 06:48
@richardkfm
richardkfm merged commit 9dc15de into main Sep 18, 2026
3 checks passed
richardkfm pushed a commit that referenced this pull request Sep 18, 2026
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
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