Real inputs from map services¶
The solvers want a dense matrix; the world gives you addresses, a brand name and a
road network. skroute.preprocessing.maps bridges the two with three functions that
talk to public map services through the standard library alone — no extra
dependency, no key for the OpenStreetMap ones — and return
Bunch objects ready for fit:
| Function | Question it answers | Default service | Key |
|---|---|---|---|
fetch_pois |
Where are all the X of this area? | Overpass (OpenStreetMap) | none |
geocode |
Where is this address? | Nominatim (OpenStreetMap) | none |
travel_time_matrix |
How long does it take to drive from every stop to every other? | OSRM demo server | none |
Each one takes provider="google" as well (Geocoding API, Distance Matrix API — an
API key, billed to your account). This page walks through the typical sequence on the
case of a maintenance technician who must visit every Burger King of the Madrid region
from an office in Leganés; the examples call the live services, so they are marked
# doctest: +SKIP and their outputs are the ones recorded on 2026-09-05.
Coordinates are (lat, lon)
Everywhere in scikit-route a point is a (latitude, longitude) pair — the order
Google Maps shows and the one geocode/fetch_pois return. OSRM wants lon,lat
in its URLs; travel_time_matrix swaps them for you. Passing (lon, lat) by
mistake is only caught when a "latitude" exceeds 90°, so check the order once.
1. The stops: fetch_pois¶
One Overpass QL query selects an administrative area by its OpenStreetMap name and
every element (nwr: nodes, ways, relations) matching the filters inside it. The most
reliable brand filter is the Wikidata id, immune to spelling variants (Q177054 is
Burger King; look yours up on wikidata.org):
>>> from skroute.preprocessing import fetch_pois
>>> bk = fetch_pois("Comunidad de Madrid", amenity="fast_food", wikidata="Q177054") # doctest: +SKIP
>>> bk # doctest: +SKIP
Bunch(DESCR, addresses, coords, labels, names, tags)
>>> bk.coords.shape, bk.labels[:2].tolist() # doctest: +SKIP
((183, 2), ['node/26289763', 'node/178821228'])
>>> bk.names[1], bk.addresses[1] # doctest: +SKIP
('Burger King', 'Calle de Esparteros 3, 28012 Madrid')
>>> print(bk.DESCR.splitlines()[-1]) # doctest: +SKIP
Data © OpenStreetMap contributors, licensed under the Open Database License (ODbL): https://www.openstreetmap.org/copyright
coords is a float64 (n, 2) array; labels is an object array of the OSM ids
(node/123, way/123, relation/123 — ways and relations, e.g. a restaurant mapped
as a building, are placed at the centre of their bounding box thanks to out center);
names and addresses are lists from the name and addr:* tags ("" when unknown)
and tags keeps everything else. The query itself is in DESCR, together with the ODbL
attribution you must show with the data.
The filters are amenity= (exact tag), brand= (exact), name= (case-insensitive
regular expression) and wikidata= (brand:wikidata, exact); give at least one. The
area name must match the name tag of an administrative boundary exactly
("Comunidad de Madrid", "Leganés", "Catalunya") — when nothing matches you get an
empty result and a UserWarning, not an error.
Near-duplicates are kept
OpenStreetMap sometimes has the same shop twice: as the building (a way) and as a
node inside it. fetch_pois does not guess which one you want. Keep only nodes
(mask = [l.startswith("node/") for l in bk.labels], then bk.coords[mask] and
bk.labels[mask]) or drop anything within a few metres of an earlier point with
the great-circle distances of
haversine_matrix (kilometres):
2. The depot: geocode¶
>>> from skroute.preprocessing import geocode
>>> office = geocode("Calle Ramón y Cajal 18, Leganés") # doctest: +SKIP
>>> office # doctest: +SKIP
Bunch(display_name, lat, lon, raw)
>>> round(office.lat, 4), round(office.lon, 4) # doctest: +SKIP
(40.3296, -3.7373)
>>> office.display_name # doctest: +SKIP
'18, Calle de Ramón y Cajal, Polígono Industrial de Nuestra Señora de Butarque, El Carrascal, Leganés, Comunidad de Madrid, 28916, España'
Nominatim returns its best match (limit=1); read display_name to confirm it is
the place you meant, and raise a clearer question if it is not (add the postcode or
the town). A query with no match raises ValueError("no result for ..."). Nominatim's
usage policy — a descriptive User-Agent and at most one request per second — is
enforced for you: the default agent is scikit-route/<version> (+repository URL)
(pass user_agent= to name your own application), and a second call within a second
of the first simply waits — so does a retry after a 429.
3. The matrix: travel_time_matrix¶
Put the depot first and ask for the table. The default provider is the public OSRM
demo server (https://router.project-osrm.org, car profile), which returns
durations in seconds and distances in metres for every ordered pair — asymmetric when
the roads are:
>>> import numpy as np
>>> from skroute.preprocessing import travel_time_matrix
>>> coords = np.vstack([[office.lat, office.lon], bk.coords]) # doctest: +SKIP
>>> res = travel_time_matrix(coords) # doctest: +SKIP
>>> res # doctest: +SKIP
Bunch(coords, distance, provider, time, units)
>>> res.units, res.time.shape # doctest: +SKIP
({'time': 'min', 'distance': 'm'}, (184, 184))
>>> res.time[0, 1:4].round(1), res.time[1:4, 0].round(1) # doctest: +SKIP
(array([20.3, 18.8, 20.8]), array([21.7, 17.5, 18.6]))
time is in minutes by default (units="s" or "h" to change it), distance always
in metres, the diagonal is 0. Pairs the server cannot route become nan in both
matrices with a single RuntimeWarning naming how many — fill them (a large value, or
the great-circle distance at a plausible speed) before solving, since every solver
needs finite matrices. An OSRM server that returns no distances (a build without
annotations=distance) also gets one RuntimeWarning: time is complete, distance
is nan off the diagonal.
The demo server caps the size of a table request, so the points are tiled in blocks of
chunk_size (default 50): a request carries the row block as sources and the column
block as destinations — at most 100 coordinates — and pause seconds (default 1)
separate consecutive requests. The 184 points of the case are 4 × 4 = 16 requests,
twenty seconds with the default pause; 300 points would be 36. Your own OSRM server takes base_url=, mode= as
the profile name in its URLs (car, bike, foot… — the demo only serves
driving), pause=0 and a larger chunk_size.
With provider="google" the same call delegates to
GoogleDistanceMatrix (install
scikit-route[google]; requests are billed to the account behind api_key) and
converts its hours to the requested unit; departure_time="now" (or a datetime)
asks for traffic-aware durations and timeout= reaches the client, while base_url,
user_agent and pause only concern OSRM. OSRM has no traffic model — a
departure_time given with provider="osrm" is ignored with a UserWarning.
>>> res = travel_time_matrix(coords, provider="google", api_key="<your key>", departure_time="now") # doctest: +SKIP
>>> res.provider, res.units # doctest: +SKIP
('google', {'time': 'min', 'distance': 'm'})
4. Solve¶
The matrix is the time_matrix of the multi-trip model: one working
day per trip, max_time_work in the same minutes, and — to minimise driving — the
time itself as the cost:
>>> from skroute import MultiStart, IteratedLocalSearch
>>> labels = ["office", *bk.labels] # doctest: +SKIP
>>> est = MultiStart(IteratedLocalSearch(time_limit=15), n_restarts=4, random_state=0) # doctest: +SKIP
>>> est.fit(res.time, time_matrix=res.time, labels=labels, depot="office",
... max_time_work=8 * 60, extra_cost=8 * 60, split="optimal") # doctest: +SKIP
MultiStart(estimator=IteratedLocalSearch(time_limit=15), n_restarts=4, random_state=0)
>>> est.n_trips_, round(float(est.trip_costs_.sum())) # days, minutes of driving # doctest: +SKIP
(3, 1164)
Three days of pure driving — but every stop also takes time: half an hour of
maintenance per restaurant here. Pass it as service_time=30 (minutes, the unit of the
matrix; the same at every customer, nothing at the depot) and the budget accounts for it
— see Service times for the model. The 183 half-hours
alone are 91.5 hours of work, so no plan fits in fewer than twelve eight-hour days:
>>> est.fit(res.time, time_matrix=res.time, labels=labels, depot="office",
... max_time_work=8 * 60, extra_cost=8 * 60, split="optimal", service_time=30) # doctest: +SKIP
MultiStart(estimator=IteratedLocalSearch(time_limit=15), n_restarts=4, random_state=0)
>>> est.n_trips_ >= 12 # doctest: +SKIP
True
Being a good citizen¶
The OpenStreetMap services are run by volunteers and donations. Keep the default
User-Agent or, better, name your application; keep the pauses; cache what you
fetched (numpy.save the matrix, write the POIs to a CSV) instead of fetching it on
every run; and display "© OpenStreetMap contributors" wherever the data appears — the
DESCR of fetch_pois carries the exact sentence. The usage policies are at
operations.osmfoundation.org
(Nominatim), github.com/Project-OSRM
(the OSRM demo) and wiki.openstreetmap.org
(Overpass). When a service is down or throttles you, the functions retry three times
with back-off and then raise
MapServiceError with the HTTP status,
the request URL (API keys redacted) and the first 200 characters of the answer.
Contributors: the tests of this module never touch the network (recorded answers under
tests/data/maps/); the live checks carry the network marker and run in the nightly
(pytest -m network).
See also¶
- A real case: the technician's plan — the whole pipeline on 182 restaurants with real driving times.
- Service times — the minutes spent at every stop count against the day.