Nearest Provincial Capital (Peninsular Spain) by Travel Time¶
This notebook explores how the territory of Spain can be partitioned according to the nearest provincial capital in terms of travel time, rather than simple geographic distance.
In this analysis:
- Municipalities are treated as points (origins).
- Provincial capitals are treated as centers (destinations).
The classical Voronoi diagram divides space by assigning each location to the nearest center using Euclidean distance. However, real accessibility does not depend on straight-line distance: it depends on transport networks and travel time.
To account for this, we compute the nearest provincial capital for every municipality in Spain using routing-based travel times.
Methodology¶
The workflow is the following:
- Use a BallTree with the haversine metric to identify the 3 geographically closest candidate capitals for each municipality.
- Query the OSRM routing API to compute actual travel times along the road network.
- Assign each municipality (point) to the capital (center) with the minimum travel time.
- Visualize the resulting travel-time Voronoi regions.
Imports¶
In [1]:
!pip install -r ../requirements.txt
Requirement already satisfied: pandas in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 1)) (2.3.3) Requirement already satisfied: numpy in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 2)) (2.3.5) Requirement already satisfied: folium in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 3)) (0.20.0) Requirement already satisfied: matplotlib in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 4)) (3.10.6) Requirement already satisfied: scikit-learn in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 5)) (1.7.2) Requirement already satisfied: requests in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 6)) (2.32.5) Requirement already satisfied: geopy in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 7)) (2.4.1) Requirement already satisfied: geopandas in c:\users\carlos\anaconda3\lib\site-packages (from -r ../requirements.txt (line 8)) (1.1.2) Requirement already satisfied: python-dateutil>=2.8.2 in c:\users\carlos\anaconda3\lib\site-packages (from pandas->-r ../requirements.txt (line 1)) (2.9.0.post0) Requirement already satisfied: pytz>=2020.1 in c:\users\carlos\anaconda3\lib\site-packages (from pandas->-r ../requirements.txt (line 1)) (2025.2) Requirement already satisfied: tzdata>=2022.7 in c:\users\carlos\anaconda3\lib\site-packages (from pandas->-r ../requirements.txt (line 1)) (2025.2) Requirement already satisfied: branca>=0.6.0 in c:\users\carlos\anaconda3\lib\site-packages (from folium->-r ../requirements.txt (line 3)) (0.8.2) Requirement already satisfied: jinja2>=2.9 in c:\users\carlos\anaconda3\lib\site-packages (from folium->-r ../requirements.txt (line 3)) (3.1.6) Requirement already satisfied: xyzservices in c:\users\carlos\anaconda3\lib\site-packages (from folium->-r ../requirements.txt (line 3)) (2025.4.0) Requirement already satisfied: contourpy>=1.0.1 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (1.3.3) Requirement already satisfied: cycler>=0.10 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (0.11.0) Requirement already satisfied: fonttools>=4.22.0 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (4.60.1) Requirement already satisfied: kiwisolver>=1.3.1 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (1.4.9) Requirement already satisfied: packaging>=20.0 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (25.0) Requirement already satisfied: pillow>=8 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (12.0.0) Requirement already satisfied: pyparsing>=2.3.1 in c:\users\carlos\anaconda3\lib\site-packages (from matplotlib->-r ../requirements.txt (line 4)) (3.2.5) Requirement already satisfied: scipy>=1.8.0 in c:\users\carlos\anaconda3\lib\site-packages (from scikit-learn->-r ../requirements.txt (line 5)) (1.16.3) Requirement already satisfied: joblib>=1.2.0 in c:\users\carlos\anaconda3\lib\site-packages (from scikit-learn->-r ../requirements.txt (line 5)) (1.5.2) Requirement already satisfied: threadpoolctl>=3.1.0 in c:\users\carlos\anaconda3\lib\site-packages (from scikit-learn->-r ../requirements.txt (line 5)) (3.5.0) Requirement already satisfied: charset_normalizer<4,>=2 in c:\users\carlos\anaconda3\lib\site-packages (from requests->-r ../requirements.txt (line 6)) (3.4.4) Requirement already satisfied: idna<4,>=2.5 in c:\users\carlos\anaconda3\lib\site-packages (from requests->-r ../requirements.txt (line 6)) (3.11) Requirement already satisfied: urllib3<3,>=1.21.1 in c:\users\carlos\anaconda3\lib\site-packages (from requests->-r ../requirements.txt (line 6)) (2.6.3) Requirement already satisfied: certifi>=2017.4.17 in c:\users\carlos\anaconda3\lib\site-packages (from requests->-r ../requirements.txt (line 6)) (2026.1.4) Requirement already satisfied: geographiclib<3,>=1.52 in c:\users\carlos\anaconda3\lib\site-packages (from geopy->-r ../requirements.txt (line 7)) (2.1) Requirement already satisfied: pyogrio>=0.7.2 in c:\users\carlos\anaconda3\lib\site-packages (from geopandas->-r ../requirements.txt (line 8)) (0.12.1) Requirement already satisfied: pyproj>=3.5.0 in c:\users\carlos\anaconda3\lib\site-packages (from geopandas->-r ../requirements.txt (line 8)) (3.7.2) Requirement already satisfied: shapely>=2.0.0 in c:\users\carlos\anaconda3\lib\site-packages (from geopandas->-r ../requirements.txt (line 8)) (2.1.2) Requirement already satisfied: MarkupSafe>=2.0 in c:\users\carlos\anaconda3\lib\site-packages (from jinja2>=2.9->folium->-r ../requirements.txt (line 3)) (3.0.2) Requirement already satisfied: six>=1.5 in c:\users\carlos\anaconda3\lib\site-packages (from python-dateutil>=2.8.2->pandas->-r ../requirements.txt (line 1)) (1.17.0)
In [2]:
import sys, os
import pandas as pd
import geopandas as gpd
import matplotlib.pyplot as plt
import matplotlib.ticker as ticker
from folium.plugins import MarkerCluster
from geopy.geocoders import Nominatim
import openrouteservice
import time
sys.path.append(os.path.abspath("../src"))
from routing import nearest_center_osrm
Read .csv of all the municipalities in Spain¶
In [3]:
df_municipios = pd.read_csv(
"../data/MUNICIPIOS.csv",
sep=";",
encoding="latin1"
)
df_municipios.head()
Out[3]:
| COD_INE | ID_REL | COD_GEO | COD_PROV | PROVINCIA | NOMBRE_ACTUAL | POBLACION_MUNI | SUPERFICIE | PERIMETRO | COD_INE_CAPITAL | CAPITAL | POBLACION_CAPITAL | HOJA_MTN25_ETRS89 | LONGITUD_ETRS89 | LATITUD_ETRS89 | ORIGENCOOR | ALTITUD | ORIGENALTITUD | |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1001000000 | 1010014 | 1010 | 1 | Araba/Álava | Alegría-Dulantzi | 2975 | 1994,5872 | 35069 | 1001000101 | Alegría-Dulantzi | 2860 | 0113-3 | -2,51243731 | 42,83981158 | Mapa | 568 | MDT |
| 1 | 1002000000 | 1010029 | 1020 | 1 | Araba/Álava | Amurrio | 10313 | 9629,68 | 65381 | 1002000201 | Amurrio | 9238 | 0086-4 | -3,00007326 | 43,05427776 | Mapa | 219 | MDT |
| 2 | 1003000000 | 1010035 | 1030 | 1 | Araba/Álava | Aramaio | 1409 | 7308,96 | 42097 | 1003000601 | Ibarra | 758 | 0087-4 | -2,56540037 | 43,05119653 | Mapa | 333 | MDT |
| 3 | 1004000000 | 1010040 | 1040 | 1 | Araba/Álava | Artziniega | 1832 | 2728,73 | 22886 | 1004000101 | Artziniega | 1697 | 0086-1 | -3,12791718 | 43,12084358 | Mapa | 210 | MDT |
| 4 | 1006000000 | 1010066 | 1060 | 1 | Araba/Álava | Armiñón | 232 | 1297,27 | 24707 | 1006000101 | Armiñón | 113 | 0137-4 | -2,87183475 | 42,72326199 | Mapa | 467 | MDT |
Remove columns that are not useful and change the decimal "," in longitudes and latitudes to "."¶
In [4]:
df_municipios = df_municipios.drop(columns=['ID_REL', 'COD_GEO', 'POBLACION_MUNI', 'POBLACION_CAPITAL', 'SUPERFICIE', 'PERIMETRO', 'COD_INE_CAPITAL',
'CAPITAL', 'HOJA_MTN25_ETRS89', 'ORIGENCOOR', 'ALTITUD', 'ORIGENALTITUD'])
df_municipios["LONGITUD_ETRS89"] = (
df_municipios["LONGITUD_ETRS89"]
.astype(str)
.str.replace(",", ".", regex=False)
.astype(float)
)
df_municipios["LATITUD_ETRS89"] = (
df_municipios["LATITUD_ETRS89"]
.astype(str)
.str.replace(",", ".", regex=False)
.astype(float)
)
df_municipios.head()
Out[4]:
| COD_INE | COD_PROV | PROVINCIA | NOMBRE_ACTUAL | LONGITUD_ETRS89 | LATITUD_ETRS89 | |
|---|---|---|---|---|---|---|
| 0 | 1001000000 | 1 | Araba/Álava | Alegría-Dulantzi | -2.512437 | 42.839812 |
| 1 | 1002000000 | 1 | Araba/Álava | Amurrio | -3.000073 | 43.054278 |
| 2 | 1003000000 | 1 | Araba/Álava | Aramaio | -2.565400 | 43.051197 |
| 3 | 1004000000 | 1 | Araba/Álava | Artziniega | -3.127917 | 43.120844 |
| 4 | 1006000000 | 1 | Araba/Álava | Armiñón | -2.871835 | 42.723262 |
Exclude municipalities that are part of the Balearic Islands, the Canary Islands, Ceuta and Melilla (to keep only Peninsular Spain)¶
In [5]:
df_municipios = df_municipios[~df_municipios["COD_PROV"].astype(str).str.zfill(2).isin(["07", "35", "38", "51", "52"])]
len(df_municipios)
Out[5]:
7975
Obtain the capitals of the provinces considered¶
In [6]:
province_capitals = [
"A Coruña","Vitoria-Gasteiz","Albacete","Alacant/Alicante","Almería",
"Ávila","Badajoz","Barcelona","Bilbao","Burgos","Cáceres","Cádiz",
"Castelló de la Plana","Ciudad Real","Córdoba","Cuenca","Donostia/San Sebastián",
"Girona","Granada","Guadalajara","Huelva","Huesca","Jaén","León",
"Lleida","Logroño","Lugo","Madrid","Málaga","Murcia","Ourense",
"Oviedo","Palencia","Pamplona/Iruña","Pontevedra","Salamanca",
"Santander","Segovia","Sevilla","Soria","Tarragona","Teruel",
"Toledo","València","Valladolid","Vigo","Zamora","Zaragoza"
]
df_capitales = df_municipios[
df_municipios["NOMBRE_ACTUAL"].isin(province_capitals)
].copy()
df_capitales.head()
Out[6]:
| COD_INE | COD_PROV | PROVINCIA | NOMBRE_ACTUAL | LONGITUD_ETRS89 | LATITUD_ETRS89 | |
|---|---|---|---|---|---|---|
| 44 | 1059000000 | 1 | Araba/Álava | Vitoria-Gasteiz | -2.672757 | 42.850588 |
| 53 | 2003000000 | 2 | Albacete | Albacete | -1.855747 | 38.995881 |
| 151 | 3014000000 | 3 | Alacant/Alicante | Alacant/Alicante | -0.483183 | 38.345487 |
| 291 | 4013000000 | 4 | Almería | Almería | -2.464132 | 36.838924 |
| 395 | 5019000000 | 5 | Ávila | Ávila | -4.697713 | 40.655870 |
Obtain the nearest capital for each municipalities, using nearest_center_osrm¶
In [7]:
df_municipios_clasif,map1,map2 = nearest_center_osrm(
df_points = df_municipios,
df_centers = df_capitales,
lon_col_points = "LONGITUD_ETRS89",
lat_col_points = "LATITUD_ETRS89",
lon_col_centers = "LONGITUD_ETRS89",
lat_col_centers = "LATITUD_ETRS89",
name_col_points = "NOMBRE_ACTUAL",
name_col_centers = "NOMBRE_ACTUAL",
batch_size = 80,
k = 3,
make_map=True,
sleep = 0.1)
Batch 1–80 (out of 7975) Batch 81–160 (out of 7975) Batch 161–240 (out of 7975) Batch 241–320 (out of 7975) Batch 321–400 (out of 7975) Batch 401–480 (out of 7975) Batch 481–560 (out of 7975) Batch 561–640 (out of 7975) Batch 641–720 (out of 7975) Batch 721–800 (out of 7975) Batch 801–880 (out of 7975) Batch 881–960 (out of 7975) Batch 961–1040 (out of 7975) Batch 1041–1120 (out of 7975) Batch 1121–1200 (out of 7975) Batch 1201–1280 (out of 7975) Batch 1281–1360 (out of 7975) Batch 1361–1440 (out of 7975) Batch 1441–1520 (out of 7975) Batch 1521–1600 (out of 7975) Batch 1601–1680 (out of 7975) Batch 1681–1760 (out of 7975) Batch 1761–1840 (out of 7975) Batch 1841–1920 (out of 7975) Batch 1921–2000 (out of 7975) Batch 2001–2080 (out of 7975) Batch 2081–2160 (out of 7975) Batch 2161–2240 (out of 7975) Batch 2241–2320 (out of 7975) Batch 2321–2400 (out of 7975) Batch 2401–2480 (out of 7975) Batch 2481–2560 (out of 7975) Batch 2561–2640 (out of 7975) Batch 2641–2720 (out of 7975) Batch 2721–2800 (out of 7975) Batch 2801–2880 (out of 7975) Batch 2881–2960 (out of 7975) Batch 2961–3040 (out of 7975) Batch 3041–3120 (out of 7975) Batch 3121–3200 (out of 7975) Batch 3201–3280 (out of 7975) Batch 3281–3360 (out of 7975) Batch 3361–3440 (out of 7975) Batch 3441–3520 (out of 7975) Batch 3521–3600 (out of 7975) Batch 3601–3680 (out of 7975) Batch 3681–3760 (out of 7975) Batch 3761–3840 (out of 7975) Batch 3841–3920 (out of 7975) Batch 3921–4000 (out of 7975) Batch 4001–4080 (out of 7975) Batch 4081–4160 (out of 7975) Batch 4161–4240 (out of 7975) Batch 4241–4320 (out of 7975) Batch 4321–4400 (out of 7975) Batch 4401–4480 (out of 7975) Batch 4481–4560 (out of 7975) Batch 4561–4640 (out of 7975) Batch 4641–4720 (out of 7975) Batch 4721–4800 (out of 7975) Batch 4801–4880 (out of 7975) Batch 4881–4960 (out of 7975) Batch 4961–5040 (out of 7975) Batch 5041–5120 (out of 7975) Batch 5121–5200 (out of 7975) Batch 5201–5280 (out of 7975) Batch 5281–5360 (out of 7975) Batch 5361–5440 (out of 7975) Batch 5441–5520 (out of 7975) Batch 5521–5600 (out of 7975) Batch 5601–5680 (out of 7975) Batch 5681–5760 (out of 7975) Batch 5761–5840 (out of 7975) Batch 5841–5920 (out of 7975) Batch 5921–6000 (out of 7975) Batch 6001–6080 (out of 7975) Batch 6081–6160 (out of 7975) Batch 6161–6240 (out of 7975) Batch 6241–6320 (out of 7975) Batch 6321–6400 (out of 7975) Batch 6401–6480 (out of 7975) Batch 6481–6560 (out of 7975) Batch 6561–6640 (out of 7975) Batch 6641–6720 (out of 7975) Batch 6721–6800 (out of 7975) Batch 6801–6880 (out of 7975) Batch 6881–6960 (out of 7975) Batch 6961–7040 (out of 7975) Batch 7041–7120 (out of 7975) Batch 7121–7200 (out of 7975) Batch 7201–7280 (out of 7975) Batch 7281–7360 (out of 7975) Batch 7361–7440 (out of 7975) Batch 7441–7520 (out of 7975) Batch 7521–7600 (out of 7975) Batch 7601–7680 (out of 7975) Batch 7681–7760 (out of 7975) Batch 7761–7840 (out of 7975) Batch 7841–7920 (out of 7975) Batch 7921–7975 (out of 7975)
In [8]:
df_municipios_clasif[["PROVINCIA","NOMBRE_ACTUAL","nearest_center","travel_time_min"]].head()
Out[8]:
| PROVINCIA | NOMBRE_ACTUAL | nearest_center | travel_time_min | |
|---|---|---|---|---|
| 0 | Araba/Álava | Alegría-Dulantzi | Vitoria-Gasteiz | 20.068333 |
| 1 | Araba/Álava | Amurrio | Bilbao | 29.226667 |
| 2 | Araba/Álava | Aramaio | Vitoria-Gasteiz | 32.035000 |
| 3 | Araba/Álava | Artziniega | Bilbao | 32.250000 |
| 4 | Araba/Álava | Armiñón | Vitoria-Gasteiz | 25.581667 |
Visualization¶
In [11]:
map1
Out[11]:
Make this Notebook Trusted to load map: File -> Trust Notebook
Please note that on the map below, the colors have been selected randomly, so different groups may look the same.
In [12]:
map2
Out[12]:
Make this Notebook Trusted to load map: File -> Trust Notebook
Check if a municipality is closer to the capital of its province or to that of another¶
In [16]:
capital_to_province = dict(
zip(df_capitales["NOMBRE_ACTUAL"], df_capitales["PROVINCIA"])
)
df_municipios_clasif["province_of_center"] = (
df_municipios_clasif["nearest_center"]
.map(capital_to_province)
)
df_municipios_clasif["same_province"] = (
df_municipios_clasif["PROVINCIA"]
== df_municipios_clasif["province_of_center"]
)
In [23]:
import folium
center = [
df_municipios_clasif["LATITUD_ETRS89"].mean(),
df_municipios_clasif["LONGITUD_ETRS89"].mean(),
]
m = folium.Map(location=center, zoom_start=7)
for _, row in df_municipios_clasif.iterrows():
if row["same_province"] == 1:
color = "green"
else:
color = "red"
folium.CircleMarker(
location=[row["LATITUD_ETRS89"], row["LONGITUD_ETRS89"]],
radius=3,
color=color,
fill=True,
fill_color=color,
fill_opacity=0.9,
tooltip=(
f"{row['NOMBRE_ACTUAL']}<br>"
f"Municipio province: {row['PROVINCIA']}<br>"
f"Nearest capital: {row['nearest_center']} ({row['province_of_center']})<br>"
),
).add_to(m)
m
Out[23]:
Make this Notebook Trusted to load map: File -> Trust Notebook