English · Русский
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,15Out come hexagons with the time to each service, a composite score, and a map with a layer switch.
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.
- 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.
- 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.
- Time. One Dijkstra pass from every service point at once.
- Cells. Node times collapse onto H3 hexagons by median.
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".
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.
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. |
| 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 |
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.
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 |
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 bikeThe 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.
| 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.",
))pytest -q
ruff check src testsThe 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.
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.
