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:

  1. Use a BallTree with the haversine metric to identify the 3 geographically closest candidate capitals for each municipality.
  2. Query the OSRM routing API to compute actual travel times along the road network.
  3. Assign each municipality (point) to the capital (center) with the minimum travel time.
  4. 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