Skip to content

Repository files navigation

English · Русский

passus

ci python licence

How many minutes it takes to reach a shop, a school and a bus stop — along the streets, not across them.

Passus is the Latin pace. A thousand of them made a Roman mile, and distance has been measured in what a person walks ever since, not in what a map shows.

passus services
passus analyse --place "Kaluga, Russia" --mode walk
passus isochrone --from 54.5138,36.2612 --minutes 5,10,15

Out come hexagons with the time to each service, a composite score, and a map with a layer switch.


Limits

A circular buffer lies about a city. A shop two hundred metres away across a railway is two kilometres on foot, and the difference there is a multiple, not a percentage. This measures along the network: it builds a street graph and counts time down it.

What it does not see: the bus timetable, the traffic light, the ice, the entrance on the other side of the building. The times are ideal ones — what the walk would take for somebody moving at exactly 4.8 km/h and waiting for nothing. Read them as one district against another, not as a promise.

How it works

  1. Graph. Streets from OpenStreetMap, a node at every junction. Node identity comes from the rounded coordinate, not from the OSM node id. Shared points in the data match bit for bit, so rounding to a centimetre merges nothing.
  2. Mode. An edge filter plus a speed. A pedestrian does not use a motorway, a bicycle does not use stairs, and stairs on foot cost three times a pavement.
  3. Time. One Dijkstra pass from every service point at once.
  4. Cells. Node times collapse onto H3 hexagons by median.

Decisions

One pass instead of five hundred. The naive way runs Dijkstra from each shop and takes the minimum: in a city with five hundred shops that is five hundred passes over the graph. Here a virtual node joins every shop at once with zero-cost edges. A path through it costs exactly what the path to the shop it goes through costs, so a single pass gives every node the time to the nearest one.

The search runs on the transposed graph. What is asked is the time from home to the shop, and a pass from the virtual node gives the time from the shop to home. On a pedestrian network those are one number. On a bicycle network with one-way streets they are not, and the difference shows up exactly where it exists in life. A test on a one-way street checks it.

A cell gets the median of its nodes, not the minimum. The minimum describes the street corner the bus stop happens to stand on. The median describes the block.

The snapping error is printed, not hidden. A shop attaches to the nearest node of the network, and if that node is a hundred metres off, every time to that shop is a hundred metres too long. The median snap distance is reported with the result: on a sparse network it is the dominant source of error.

Fifty at the threshold, not a hundred inside it. Awarding full marks anywhere inside the standard is tempting and useless. On a real city two thirds of the cells then score exactly 100, and the map stops telling "just made it" from "I live above the shop". The scale runs linearly from a hundred at zero minutes to zero at twice the threshold, so the standard reads as "scores at least fifty".

Install

pip install -e .

Six dependencies, all wheels: h3, shapely, numpy, pandas, scipy, requests. No GDAL, no PostGIS, no API keys. The routing is scipy.sparse.csgraph, not a separate engine.

Use

passus services                                     # what can be measured
passus analyse --place "Kaluga, Russia" --mode walk
passus analyse --bbox 54.46,36.15,54.58,36.33 --mode bike --services grocery,transit
passus isochrone --from 54.5138,36.2612 --minutes 5,10,15 --mode walk
--mode walk (4.8 km/h) or bike (14 km/h). The mode changes the speed and the set of passable streets.
--services Comma-separated. Five by default: groceries, schools, health, transit, greenery.
--resolution H3 resolution. 9 is a block, 10 a group of buildings.
--refresh Ignore the cache and refetch from Overpass.

Output

file
cells.geojson Hexagons with the time to each service and the scores
cells.csv The same as a table
run.json Configuration, graph size, medians and shares per service
map.html Opens in a browser, layers switch

Metrics

Two things are computed per service: the median time across the city, and the share of cells that meet the standard. Each service has its own: five minutes to a grocery, ten to a school, fifteen to a clinic. Folding them into one number without naming the thresholds would report an average temperature.

Cells with no streets stay empty. Filling them from their neighbours would draw accessibility in the middle of a field.

Results

Kaluga, a 155 km² box over the built-up part of the city: 1592 hexagons at resolution 9, 1378 of them with streets. The pedestrian graph is 67 521 nodes and 158 126 edges, 4 655 km of network. One Overpass fetch, then both modes off the same cache.

service standard walk bike walk within bike within
grocery 5 min 11.6 4.3 26% 56%
transit 7 min 8.7 3.2 42% 80%
school 10 min 15.8 5.6 31% 72%
park 10 min 13.8 5.0 41% 76%
health 15 min 14.8 5.5 51% 89%
composite 26 71

Kaluga on foot

The median walk to a grocery is 11.6 minutes against a standard of five. A quarter of the cells meet it, and the map shows which: the core is green, then a ring of yellow, then the private housing and the industrial belt.

A bicycle changes the answer outright. The composite goes from 26 to 71, and the share meeting the school standard from 31% to 72%. The network is nearly the same: 67 369 nodes against 67 521, because the only thing closed to a bicycle is the stairs. The difference is speed, not connectivity.

One command per mode reproduces it:

passus analyse --bbox 54.46,36.15,54.58,36.33 --mode walk
passus analyse --bbox 54.46,36.15,54.58,36.33 --mode bike

The first run takes about twelve minutes, eleven of them fetching the network from Overpass. The second takes fourteen seconds: the network and the points come from the cache, and the whole computation is five Dijkstra passes over a graph of 158 thousand edges.

Services

standard weight
grocery 5 min 1.5 food for the day
transit 7 min 1.3 where everything else in a city starts
school 10 min 1.2 the walk a child makes twice a day
park 10 min 1.0 park, square, playground
eat 10 min 0.8 somewhere to eat that is not home
bank 10 min 0.7 cash machine, post, parcel locker
health 15 min 1.0 clinic, pharmacy, doctor
culture 15 min 0.8 library, cinema, gym

The first five make up the default composite. Adding one is a dataclass:

from passus.services import SERVICES, Service

SERVICES.register(Service(
    name="veterinary",
    title="Veterinary clinics",
    tags={"amenity": ("veterinary",)},
    minutes=20,
    weight=0.5,
    description="Care for animals.",
))

Tests

pytest -q
ruff check src tests

The tests run on the synthetic city in tests/conftest.py: a five by five street grid with a two hundred metre step, and no network. On it the right answer can be worked out by hand — corner to corner is eight blocks, so 1600 metres — and a test checks exactly that.

They cover what breaks silently. A point shared by two streets becomes one node. A one-way street is one-way for the bicycle only. An isolated node stays unreachable. A service absent from the city drops out of the average rather than zeroing the city's score.

Data and licence

Code: MIT, see LICENSE.

Map data from OpenStreetMap, © OpenStreetMap contributors, ODbL. Whatever a run produces inherits that licence. Queries go through the public Overpass API and place names through Nominatim. Both live on donations, so the client caches every response, identifies itself, and backs off when asked.

About

How long it takes to reach everyday services on foot, measured along the street network rather than across it.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages