import datetime
import pandas as pd
import geopandasGenerating your own travel time matrices
There are a wide range of options for generating your own travel time matrices, including various travel time APIs.
You can find an example of how to use the routingpy package, which supports a wide range of APIs, here: https://geographic.hsma.co.uk/mtt_travel_time_apis.html
One good way is the r5py package.
This takes an output from the open street map project as well as bus and light rail (e.g. tram) public tranport data in the gtfs format.
OSM data: https://download.geofabrik.de/europe/united-kingdom/england.html
UK Bus/light rail Data: https://data.bus-data.dft.gov.uk/search/
(at the time of writing, heavy rail data is a bit more of a challenge!)
Because you’re running this locally, you aren’t contending with the number of requests you can make to the online services (API rate limits). Some free services like the openrouteservice don’t provide public transport routing support by default either.
If you’re comfortable with the R language, you might also find the UK2GTFS package useful
One limitation of r5py is that it requires the install of a JDK. This may be challenging if you are working in an organisation and your IT department has concerns about Java.
One option may be to use a service like GitHub codespaces to install the required software - a tutorial on this will be released at a later date.
We’ll kick off with a couple of standard imports.
Let’s start by importing our data to set up a transport network.
import r5py
transport_network = r5py.TransportNetwork(
"../../datasets/devon-260422.osm.pbf",
[
"../../datasets/itm_south_west_gtfs.zip",
]
)Let’s then read in a dataset of destinations - in this case, minor injury units in Devon.
devon_mius = pd.read_csv("../../datasets/devon_miu.csv")
devon_mius| Facility_Name | Latitude | Longitude | |
|---|---|---|---|
| 0 | North Devon District Hospital | 51.09217 | -4.05043 |
| 1 | Honiton Hospital | 50.79492 | -3.18659 |
| 2 | Tiverton & District Hospital | 50.90933 | -3.49308 |
| 3 | Exmouth Minor Injury Unit | 50.62083 | -3.40198 |
| 4 | Victoria Hospital (Sidmouth) | 50.68161 | -3.23966 |
| 5 | Newton Abbot Community Hospital | 50.53926 | -3.61224 |
| 6 | Totnes Community Hospital | 50.43283 | -3.68406 |
| 7 | NHS Walk in Centre (Exeter) | 50.72658 | -3.52521 |
| 8 | Tavistock Hospital | 50.54708 | -4.15376 |
| 9 | South Hams Hospital (Kingsbridge) | 50.28929 | -3.78143 |
| 10 | Cumberland Centre (Plymouth) | 50.37004 | -4.16873 |
| 11 | Derriford Hospital (UTC) | 50.41802 | -4.11890 |
| 12 | Ilfracombe & District Tyrrell Hospital | 51.20468 | -4.12454 |
| 13 | South Molton Hospital | 51.01681 | -3.83823 |
| 14 | Dawlish Community Hospital | 50.58057 | -3.47482 |
Let’s turn this into a geodataframe.
devon_mius_gdf = geopandas.GeoDataFrame(
devon_mius,
geometry = geopandas.points_from_xy(
devon_mius['Longitude'],
devon_mius['Latitude']
),
crs = 'EPSG:4326'
)
devon_mius_gdf| Facility_Name | Latitude | Longitude | geometry | |
|---|---|---|---|---|
| 0 | North Devon District Hospital | 51.09217 | -4.05043 | POINT (-4.05043 51.09217) |
| 1 | Honiton Hospital | 50.79492 | -3.18659 | POINT (-3.18659 50.79492) |
| 2 | Tiverton & District Hospital | 50.90933 | -3.49308 | POINT (-3.49308 50.90933) |
| 3 | Exmouth Minor Injury Unit | 50.62083 | -3.40198 | POINT (-3.40198 50.62083) |
| 4 | Victoria Hospital (Sidmouth) | 50.68161 | -3.23966 | POINT (-3.23966 50.68161) |
| 5 | Newton Abbot Community Hospital | 50.53926 | -3.61224 | POINT (-3.61224 50.53926) |
| 6 | Totnes Community Hospital | 50.43283 | -3.68406 | POINT (-3.68406 50.43283) |
| 7 | NHS Walk in Centre (Exeter) | 50.72658 | -3.52521 | POINT (-3.52521 50.72658) |
| 8 | Tavistock Hospital | 50.54708 | -4.15376 | POINT (-4.15376 50.54708) |
| 9 | South Hams Hospital (Kingsbridge) | 50.28929 | -3.78143 | POINT (-3.78143 50.28929) |
| 10 | Cumberland Centre (Plymouth) | 50.37004 | -4.16873 | POINT (-4.16873 50.37004) |
| 11 | Derriford Hospital (UTC) | 50.41802 | -4.11890 | POINT (-4.1189 50.41802) |
| 12 | Ilfracombe & District Tyrrell Hospital | 51.20468 | -4.12454 | POINT (-4.12454 51.20468) |
| 13 | South Molton Hospital | 51.01681 | -3.83823 | POINT (-3.83823 51.01681) |
| 14 | Dawlish Community Hospital | 50.58057 | -3.47482 | POINT (-3.47482 50.58057) |
We can save this out for easier reuse too.
devon_mius_gdf.to_file("../../../sample_data/devon_mius.geojson", driver="GeoJSON")For using this with r5py, we’ll need to rename our ‘Facility_Name’ column to ‘id’.
devon_mius_gdf = devon_mius_gdf.rename(columns={'Facility_Name':'id'})
devon_mius_gdf.head(2)| id | Latitude | Longitude | geometry | |
|---|---|---|---|---|
| 0 | North Devon District Hospital | 51.09217 | -4.05043 | POINT (-4.05043 51.09217) |
| 1 | Honiton Hospital | 50.79492 | -3.18659 | POINT (-3.18659 50.79492) |
Let’s just have a quick look at these on the map.
devon_mius_gdf.explore()Now we need to grab our sources (where our patients will travel from). We’ll use LSOA centroids for this.
lsoa_centroids = pd.read_csv(
"https://github.com/hsma-programme/h6_3c_interactive_plots_travel/raw/main/h6_3c_interactive_plots_travel/example_code/england_lsoa_2011_centroids.csv"
)lsoa_centroids.head()| name | code | x | y | |
|---|---|---|---|---|
| 0 | Middlesbrough 012A | E01012007 | 449119.175 | 517017.509 |
| 1 | Middlesbrough 010D | E01012085 | 451722.550 | 517577.735 |
| 2 | Hartlepool 014G | E01012005 | 448657.056 | 533984.633 |
| 3 | Middlesbrough 010C | E01012084 | 451977.348 | 517832.468 |
| 4 | Hartlepool 006D | E01012002 | 449565.447 | 533268.699 |
To sense check our filtering, though, we’ll take a look at an LSOA boundary file, which contains the LSOA names in the same format. The ‘name’ column in our centroids file is the same as the LSOA11NM in the boundaries geodataframe.
lsoa_boundaries = geopandas.read_file(
"https://github.com/hsma-programme/h6_3c_interactive_plots_travel/raw/main/h6_3c_interactive_plots_travel/example_code/LSOA_2011_Boundaries_Super_Generalised_Clipped_BSC_EW_V4.geojson"
)lsoa_boundaries.head()| FID | LSOA11CD | LSOA11NM | LSOA11NMW | BNG_E | BNG_N | LONG | LAT | GlobalID | geometry | |
|---|---|---|---|---|---|---|---|---|---|---|
| 0 | 1 | E01000001 | City of London 001A | City of London 001A | 532123 | 181632 | -0.097140 | 51.51816 | a758442e-7679-45d0-95a8-ed4c968ecdaa | POLYGON ((532282.629 181906.496, 532248.25 181... |
| 1 | 2 | E01000002 | City of London 001B | City of London 001B | 532480 | 181715 | -0.091970 | 51.51882 | 861dbb53-dfaf-4f57-be96-4527e2ec511f | POLYGON ((532746.814 181786.892, 532248.25 181... |
| 2 | 3 | E01000003 | City of London 001C | City of London 001C | 532239 | 182033 | -0.095320 | 51.52174 | 9f765b55-2061-484a-862b-fa0325991616 | POLYGON ((532293.068 182068.422, 532419.592 18... |
| 3 | 4 | E01000005 | City of London 001E | City of London 001E | 533581 | 181283 | -0.076270 | 51.51468 | a55c4c31-ef1c-42fc-bfa9-07c8f2025928 | POLYGON ((533604.245 181418.129, 533743.689 18... |
| 4 | 5 | E01000006 | Barking and Dagenham 016A | Barking and Dagenham 016A | 544994 | 184274 | 0.089317 | 51.53875 | 9cdabaa8-d9bd-4a94-bb3b-98a933ceedad | POLYGON ((545271.918 184183.948, 545296.314 18... |
We could also use a geopandas function to get centroids from our boundary dataframe!
We might also want to look for an option like population-weighted centroids.
We may also need to consider looking at smaller units of area, particularly if we’re interested in options like public transport or walking, as LSOAs can cover a vast area compared to something like a postcode sector. However, for the sake of this example, we’re going to stick with LSOAs.
Let’s try out our filter and confirm we’ve correctly filtered by all the right LSOA names.
lsoa_boundaries[lsoa_boundaries["LSOA11NM"].str.contains(
"Devon|Torbay|South Hams|Exeter|Torridge|Teignbridge|Plymouth"
)].plot()
We can also take a look on an interactive plot.
lsoa_boundaries[lsoa_boundaries["LSOA11NM"].str.contains(
"Devon|Torbay|South Hams|Exeter|Torridge|Teignbridge|Plymouth"
)].explore()Let’s now turn this into a geodataframe and convert it from BNH (Northings/Eastings) to latitude and longitude to match our other data (and what r5py expects).
devon_lsoas_with_coords = lsoa_centroids[lsoa_centroids["name"].str.contains(
"Devon|Torbay|South Hams|Exeter|Torridge|Teignbridge|Plymouth"
)]
devon_lsoas_with_coords_gdf = geopandas.GeoDataFrame(
devon_lsoas_with_coords,
geometry = geopandas.points_from_xy(
devon_lsoas_with_coords['x'],
devon_lsoas_with_coords['y']
),
crs = 'EPSG:27700' # as our current dataset is in BNG (northings/eastings)
).to_crs('EPSG:4326').rename(columns={'name':'id'})
devon_lsoas_with_coords_gdf| id | code | x | y | geometry | |
|---|---|---|---|---|---|
| 29583 | Torbay 006C | E01015216 | 292132.142 | 64632.566 | POINT (-3.52139 50.47156) |
| 29585 | Torbay 008A | E01015217 | 291749.626 | 64295.856 | POINT (-3.52668 50.46846) |
| 29587 | Torbay 006A | E01015214 | 291750.141 | 64630.567 | POINT (-3.52677 50.47147) |
| 29589 | Torbay 006B | E01015215 | 292381.278 | 64581.160 | POINT (-3.51787 50.47115) |
| 29591 | Torbay 019C | E01015212 | 290286.739 | 64323.402 | POINT (-3.54729 50.46844) |
| ... | ... | ... | ... | ... | ... |
| 32825 | East Devon 005B | E01019887 | 329961.723 | 98655.512 | POINT (-2.99485 50.78323) |
| 32828 | Torbay 017A | E01015183 | 292872.985 | 55875.060 | POINT (-3.50845 50.39297) |
| 32829 | Torbay 017G | E01015189 | 292320.564 | 56248.741 | POINT (-3.51632 50.39623) |
| 32833 | Torridge 008B | E01020293 | 234408.086 | 104233.376 | POINT (-4.35192 50.81392) |
| 32836 | Plymouth 014C | E01015041 | 245338.352 | 56546.556 | POINT (-4.17702 50.38841) |
707 rows × 5 columns
Generating the matrix
Car travel
travel_time_matrix_car = r5py.TravelTimeMatrix(
transport_network,
origins=devon_lsoas_with_coords_gdf,
destinations=devon_mius_gdf,
transport_modes=[r5py.TransportMode.CAR],
departure=datetime.datetime(2026, 4, 23, 14, 0, 0),
)travel_time_matrix_car| from_id | to_id | travel_time | |
|---|---|---|---|
| 0 | Torbay 006C | North Devon District Hospital | 94.0 |
| 1 | Torbay 006C | Honiton Hospital | 47.0 |
| 2 | Torbay 006C | Tiverton & District Hospital | 51.0 |
| 3 | Torbay 006C | Exmouth Minor Injury Unit | 44.0 |
| 4 | Torbay 006C | Victoria Hospital (Sidmouth) | 49.0 |
| ... | ... | ... | ... |
| 10600 | Plymouth 014C | Cumberland Centre (Plymouth) | 7.0 |
| 10601 | Plymouth 014C | Derriford Hospital (UTC) | 11.0 |
| 10602 | Plymouth 014C | Ilfracombe & District Tyrrell Hospital | NaN |
| 10603 | Plymouth 014C | South Molton Hospital | 90.0 |
| 10604 | Plymouth 014C | Dawlish Community Hospital | 56.0 |
10605 rows × 3 columns
This isn’t in the format lokigi expects - we just need to turn it into a wide format dataframe, where we have one row per source (where our patients will travel from) and one column per destination (where they might turn up to).
travel_time_matrix_car_wide = travel_time_matrix_car.pivot(columns="to_id", index="from_id", values="travel_time").reset_index()
travel_time_matrix_car_wide| to_id | from_id | Cumberland Centre (Plymouth) | Dawlish Community Hospital | Derriford Hospital (UTC) | Exmouth Minor Injury Unit | Honiton Hospital | Ilfracombe & District Tyrrell Hospital | NHS Walk in Centre (Exeter) | Newton Abbot Community Hospital | North Devon District Hospital | South Hams Hospital (Kingsbridge) | South Molton Hospital | Tavistock Hospital | Tiverton & District Hospital | Totnes Community Hospital | Victoria Hospital (Sidmouth) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | East Devon 001A | 75.0 | 44.0 | 70.0 | 38.0 | 16.0 | 86.0 | 35.0 | 47.0 | 77.0 | 71.0 | 55.0 | 72.0 | 34.0 | 56.0 | 29.0 |
| 1 | East Devon 001B | 71.0 | 40.0 | 66.0 | 33.0 | 12.0 | 92.0 | 31.0 | 43.0 | 83.0 | 67.0 | 61.0 | 67.0 | 40.0 | 52.0 | 27.0 |
| 2 | East Devon 001C | 83.0 | 52.0 | 77.0 | 45.0 | 25.0 | 104.0 | 43.0 | 55.0 | 94.0 | 79.0 | 72.0 | 79.0 | 52.0 | 64.0 | 39.0 |
| 3 | East Devon 002A | 67.0 | 36.0 | 61.0 | 29.0 | 3.0 | 84.0 | 27.0 | 39.0 | 75.0 | 63.0 | 53.0 | 63.0 | 33.0 | 48.0 | 19.0 |
| 4 | East Devon 002B | 67.0 | 36.0 | 61.0 | 29.0 | 5.0 | 82.0 | 27.0 | 39.0 | 73.0 | 63.0 | 51.0 | 63.0 | 30.0 | 48.0 | 19.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 702 | West Devon 006D | 27.0 | 67.0 | 16.0 | 70.0 | 73.0 | 103.0 | 60.0 | 52.0 | 90.0 | 52.0 | 84.0 | 11.0 | 77.0 | 45.0 | 75.0 |
| 703 | West Devon 007A | 34.0 | 74.0 | 23.0 | 77.0 | 81.0 | 110.0 | 68.0 | 60.0 | 97.0 | 60.0 | 92.0 | 16.0 | 85.0 | 52.0 | 83.0 |
| 704 | West Devon 007B | 34.0 | 74.0 | 24.0 | 76.0 | 80.0 | 110.0 | 67.0 | 60.0 | 96.0 | 60.0 | 91.0 | 15.0 | 84.0 | 52.0 | 82.0 |
| 705 | West Devon 007C | 29.0 | 69.0 | 18.0 | 74.0 | 78.0 | 109.0 | 67.0 | 54.0 | 96.0 | 54.0 | 91.0 | 17.0 | 82.0 | 47.0 | 79.0 |
| 706 | West Devon 007D | 25.0 | 65.0 | 14.0 | 70.0 | 74.0 | 109.0 | 64.0 | 50.0 | 95.0 | 50.0 | 90.0 | 17.0 | 78.0 | 43.0 | 76.0 |
707 rows × 16 columns
r5py assumes free flowing traffic at the speed limit - so this might not be overly realistic for traffic!
Lokigi can’t handle missing values in a travel matrix, which might exist here if the time between any two points is over 2 hours or otherwise deemed to be impossible, so let’s fill any missing values with a very large travel time.
travel_time_matrix_car_wide = travel_time_matrix_car_wide.fillna(9999.0)We can save this to a csv for convenience if we want to load it in elsewhere later.
travel_time_matrix_car_wide.to_csv("../../../sample_data/devon_miu_travel_matrix.csv", index=False)Public transport
For public transport, we change our transport mode to TRANSIT.
By default, it assumes people can walk to where they will pick up their journey with public transport.
travel_time_matrix_transit = r5py.TravelTimeMatrix(
transport_network,
origins=devon_lsoas_with_coords_gdf,
destinations=devon_mius_gdf,
transport_modes=[r5py.TransportMode.TRANSIT],
departure=datetime.datetime(2026, 4, 23, 14, 0, 0),
)Let’s first just filter down to instances where it’s successfully computed a time.
travel_time_matrix_transit[~travel_time_matrix_transit["travel_time"].isna()]| from_id | to_id | travel_time | |
|---|---|---|---|
| 5 | Torbay 006C | Newton Abbot Community Hospital | 78.0 |
| 6 | Torbay 006C | Totnes Community Hospital | 97.0 |
| 14 | Torbay 006C | Dawlish Community Hospital | 114.0 |
| 20 | Torbay 008A | Newton Abbot Community Hospital | 70.0 |
| 21 | Torbay 008A | Totnes Community Hospital | 79.0 |
| ... | ... | ... | ... |
| 10551 | Torbay 017A | Totnes Community Hospital | 82.0 |
| 10566 | Torbay 017G | Totnes Community Hospital | 77.0 |
| 10598 | Plymouth 014C | Tavistock Hospital | 83.0 |
| 10600 | Plymouth 014C | Cumberland Centre (Plymouth) | 25.0 |
| 10601 | Plymouth 014C | Derriford Hospital (UTC) | 29.0 |
2283 rows × 3 columns
Now let’s make a wide version of this too.
travel_time_matrix_transit_wide = travel_time_matrix_transit.pivot(columns="to_id", index="from_id", values="travel_time").reset_index()
travel_time_matrix_transit_wide| to_id | from_id | Cumberland Centre (Plymouth) | Dawlish Community Hospital | Derriford Hospital (UTC) | Exmouth Minor Injury Unit | Honiton Hospital | Ilfracombe & District Tyrrell Hospital | NHS Walk in Centre (Exeter) | Newton Abbot Community Hospital | North Devon District Hospital | South Hams Hospital (Kingsbridge) | South Molton Hospital | Tavistock Hospital | Tiverton & District Hospital | Totnes Community Hospital | Victoria Hospital (Sidmouth) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | East Devon 001A | NaN | NaN | NaN | NaN | 101.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 1 | East Devon 001B | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 2 | East Devon 001C | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN |
| 3 | East Devon 002A | NaN | NaN | NaN | NaN | 14.0 | NaN | 86.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 63.0 |
| 4 | East Devon 002B | NaN | NaN | NaN | NaN | 18.0 | NaN | 82.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 63.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 702 | West Devon 006D | 83.0 | NaN | 51.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 41.0 | NaN | NaN | NaN |
| 703 | West Devon 007A | NaN | NaN | 113.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 63.0 | NaN | NaN | NaN |
| 704 | West Devon 007B | NaN | NaN | 113.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 63.0 | NaN | NaN | NaN |
| 705 | West Devon 007C | NaN | NaN | 87.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 80.0 | NaN | NaN | NaN |
| 706 | West Devon 007D | 109.0 | NaN | 70.0 | NaN | NaN | NaN | NaN | NaN | NaN | NaN | NaN | 78.0 | NaN | NaN | NaN |
707 rows × 16 columns
There are a lot of NAs this time!
Let’s again fill this for any instances with impossibly long travel times.
travel_time_matrix_transit_wide = travel_time_matrix_transit_wide.fillna(9999.0)Solving a problem with our travel time matrices
Let’s now use this to initialise a lokigi problem.
We’ll conceptualise this as a problem where they need to close two of the current MIUs.
Car
from lokigi.site import SiteProblem
problem = SiteProblem()
problem.add_sites(
devon_mius_gdf,
candidate_id_col="id"
)
problem.add_travel_matrix(
travel_matrix_df=travel_time_matrix_car_wide,
source_col="from_id",
unit="minutes",
)
problem.add_region_geometry_layer(
lsoa_boundaries,
common_col="LSOA11NM"
)problem.plot_sites()
solution_greedy = problem.solve(p=len(problem.show_sites())-2, search_strategy="greedy")C:\lokigi\lokigi\site.py:1414: UserWarning: No demand data was provided. Demand from all regions has been assumed to be equal.If you wish to override this, run .add_demand() to add your site dataframe before running .solve() again.You can use the .show_demand_format() to see the expected format beforehand.
warn(
Best combination for 1 sites: [np.int64(5)]
Best combination for 2 sites: [np.int64(5), np.int64(11)]
Best combination for 3 sites: [np.int64(0), np.int64(5), np.int64(11)]
Best combination for 4 sites: [np.int64(0), np.int64(5), np.int64(7), np.int64(11)]
Best combination for 5 sites: [np.int64(0), np.int64(5), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 6 sites: [np.int64(0), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 7 sites: [np.int64(0), np.int64(2), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 8 sites: [np.int64(0), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 9 sites: [np.int64(0), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(11)]
Best combination for 10 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(11)]
Best combination for 11 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(11), np.int64(12)]
Best combination for 12 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(11), np.int64(12), np.int64(14)]
Best combination for 13 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(11), np.int64(12), np.int64(14)]
fig = solution_greedy.plot_best_combination(unchosen_site_colour="magenta")
fig;
In this instance, we might want to consider the impact of boundary effects. We’ve only mapped the MIUs based in Devon. However, people on the border, where we are seeing extreme travel times, may well pass into a hospital in an adjoining county.
If that’s the case, we need to be sure we provide the OSM data for those regions when we do our initial travel time matrix generation, as otherwise the router won’t be able to work out how to get there! If we just added an extra point in Cornwall to our travel matrix calculation as currently defined, we’d get ‘NA’ for all instances.
Public Transport
Let’s solve again for public transport times.
problem_public_transport = SiteProblem()
problem_public_transport.add_sites(
devon_mius_gdf,
candidate_id_col="id"
)
problem_public_transport.add_region_geometry_layer(
lsoa_boundaries,
common_col="LSOA11NM"
)
problem_public_transport.add_travel_matrix(
travel_matrix_df=travel_time_matrix_transit_wide,
source_col="from_id",
unit="minutes",
)solution_greedy_public_transport = problem_public_transport.solve(
p=len(problem_public_transport.show_sites())-2,
search_strategy="greedy"
)C:\lokigi\lokigi\site.py:1414: UserWarning: No demand data was provided. Demand from all regions has been assumed to be equal.If you wish to override this, run .add_demand() to add your site dataframe before running .solve() again.You can use the .show_demand_format() to see the expected format beforehand.
warn(
Best combination for 1 sites: [np.int64(7)]
Best combination for 2 sites: [np.int64(6), np.int64(7)]
Best combination for 3 sites: [np.int64(6), np.int64(7), np.int64(11)]
Best combination for 4 sites: [np.int64(6), np.int64(7), np.int64(11), np.int64(13)]
Best combination for 5 sites: [np.int64(1), np.int64(6), np.int64(7), np.int64(11), np.int64(13)]
Best combination for 6 sites: [np.int64(1), np.int64(6), np.int64(7), np.int64(11), np.int64(12), np.int64(13)]
Best combination for 7 sites: [np.int64(1), np.int64(6), np.int64(7), np.int64(9), np.int64(11), np.int64(12), np.int64(13)]
Best combination for 8 sites: [np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(9), np.int64(11), np.int64(12), np.int64(13)]
Best combination for 9 sites: [np.int64(0), np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(9), np.int64(11), np.int64(12), np.int64(13)]
Best combination for 10 sites: [np.int64(0), np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(9), np.int64(11), np.int64(12), np.int64(13), np.int64(14)]
Best combination for 11 sites: [np.int64(0), np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(11), np.int64(12), np.int64(13), np.int64(14)]
Best combination for 12 sites: [np.int64(0), np.int64(1), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(11), np.int64(12), np.int64(13), np.int64(14)]
Best combination for 13 sites: [np.int64(0), np.int64(1), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(9), np.int64(10), np.int64(11), np.int64(12), np.int64(13), np.int64(14)]
fig = solution_greedy_public_transport.plot_best_combination(unchosen_site_colour="magenta")
fig;
We can see that our output has some problems!
First, we could try rerunning our public transport with
- a higher maximum journey time
- a wider departure window
- a higher number of transfers
Tweaking a public transport travel matrix
travel_time_matrix_transit = r5py.TravelTimeMatrix(
transport_network,
origins=devon_lsoas_with_coords_gdf,
destinations=devon_mius_gdf,
transport_modes=[r5py.TransportMode.TRANSIT],
departure=datetime.datetime(2026, 4, 23, 14, 0, 0),
max_time=datetime.timedelta(minutes=600),
departure_time_window=datetime.timedelta(minutes=60),
max_public_transport_rides=20
)travel_time_matrix_transit_wide = travel_time_matrix_transit.pivot(columns="to_id", index="from_id", values="travel_time").reset_index()We can check if there are any examples where you can’t get to any hospital.
len(travel_time_matrix_transit_wide)707
travel_time_matrix_transit_wide_extended = travel_time_matrix_transit_wide.set_index("from_id").dropna(how="all").reset_index(drop=False)
travel_time_matrix_transit_wide_extended| to_id | from_id | Cumberland Centre (Plymouth) | Dawlish Community Hospital | Derriford Hospital (UTC) | Exmouth Minor Injury Unit | Honiton Hospital | Ilfracombe & District Tyrrell Hospital | NHS Walk in Centre (Exeter) | Newton Abbot Community Hospital | North Devon District Hospital | South Hams Hospital (Kingsbridge) | South Molton Hospital | Tavistock Hospital | Tiverton & District Hospital | Totnes Community Hospital | Victoria Hospital (Sidmouth) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | East Devon 001A | 260.0 | 217.0 | 268.0 | 211.0 | 76.0 | 365.0 | 137.0 | 213.0 | 288.0 | 292.0 | 245.0 | 316.0 | 190.0 | 259.0 | 160.0 |
| 1 | East Devon 001B | 359.0 | 288.0 | 363.0 | 258.0 | 133.0 | 518.0 | 223.0 | 302.0 | 490.0 | NaN | 433.0 | 415.0 | 317.0 | 326.0 | 198.0 |
| 2 | East Devon 001C | 398.0 | 377.0 | 402.0 | 338.0 | 216.0 | 543.0 | 294.0 | 386.0 | 515.0 | NaN | 458.0 | 454.0 | 388.0 | 361.0 | 260.0 |
| 3 | East Devon 002A | 222.0 | 197.0 | 225.0 | 127.0 | 14.0 | 365.0 | 116.0 | 205.0 | 288.0 | 286.0 | 241.0 | 275.0 | 184.0 | 213.0 | 72.0 |
| 4 | East Devon 002B | 221.0 | 195.0 | 223.0 | 128.0 | 18.0 | 365.0 | 115.0 | 201.0 | 288.0 | 282.0 | 241.0 | 275.0 | 180.0 | 209.0 | 73.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 702 | West Devon 006D | 97.0 | 244.0 | 56.0 | 240.0 | 262.0 | 314.0 | 172.0 | 222.0 | 268.0 | 184.0 | 308.0 | 45.0 | 248.0 | 184.0 | 252.0 |
| 703 | West Devon 007A | 150.0 | 285.0 | 111.0 | 283.0 | 307.0 | 331.0 | 217.0 | 287.0 | 285.0 | 255.0 | 353.0 | 61.0 | 293.0 | 252.0 | 297.0 |
| 704 | West Devon 007B | 150.0 | 285.0 | 111.0 | 283.0 | 307.0 | 331.0 | 217.0 | 287.0 | 285.0 | 255.0 | 353.0 | 61.0 | 293.0 | 252.0 | 297.0 |
| 705 | West Devon 007C | 130.0 | 293.0 | 90.0 | 301.0 | 308.0 | 408.0 | 249.0 | 262.0 | 331.0 | 221.0 | 443.0 | 80.0 | 329.0 | 223.0 | 306.0 |
| 706 | West Devon 007D | 119.0 | 285.0 | 79.0 | 289.0 | 301.0 | 401.0 | 237.0 | 246.0 | 324.0 | 205.0 | 433.0 | 74.0 | 317.0 | 212.0 | 296.0 |
707 rows × 16 columns
len(travel_time_matrix_transit_wide_extended)707
Let’s agains set any NAs to a high value.
travel_time_matrix_transit_wide_extended = travel_time_matrix_transit_wide_extended.fillna(9999.0)And save this for future use.
travel_time_matrix_transit_wide_extended.to_csv("../../../sample_data/devon_miu_travel_matrix_public_transport_extended.csv", index=False)We can then overwrite the existing travel matrix.
problem_public_transport_extended = SiteProblem()
problem_public_transport_extended.add_sites(
devon_mius_gdf,
candidate_id_col="id"
)
problem_public_transport_extended.add_region_geometry_layer(
lsoa_boundaries,
common_col="LSOA11NM"
)
problem_public_transport_extended.add_travel_matrix(
travel_matrix_df=travel_time_matrix_transit_wide_extended,
source_col="from_id",
unit="minutes",
)problem_public_transport_extended.show_travel_matrix()| to_id | from_id | Cumberland Centre (Plymouth) | Dawlish Community Hospital | Derriford Hospital (UTC) | Exmouth Minor Injury Unit | Honiton Hospital | Ilfracombe & District Tyrrell Hospital | NHS Walk in Centre (Exeter) | Newton Abbot Community Hospital | North Devon District Hospital | South Hams Hospital (Kingsbridge) | South Molton Hospital | Tavistock Hospital | Tiverton & District Hospital | Totnes Community Hospital | Victoria Hospital (Sidmouth) |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 0 | East Devon 001A | 260.0 | 217.0 | 268.0 | 211.0 | 76.0 | 365.0 | 137.0 | 213.0 | 288.0 | 292.0 | 245.0 | 316.0 | 190.0 | 259.0 | 160.0 |
| 1 | East Devon 001B | 359.0 | 288.0 | 363.0 | 258.0 | 133.0 | 518.0 | 223.0 | 302.0 | 490.0 | 9999.0 | 433.0 | 415.0 | 317.0 | 326.0 | 198.0 |
| 2 | East Devon 001C | 398.0 | 377.0 | 402.0 | 338.0 | 216.0 | 543.0 | 294.0 | 386.0 | 515.0 | 9999.0 | 458.0 | 454.0 | 388.0 | 361.0 | 260.0 |
| 3 | East Devon 002A | 222.0 | 197.0 | 225.0 | 127.0 | 14.0 | 365.0 | 116.0 | 205.0 | 288.0 | 286.0 | 241.0 | 275.0 | 184.0 | 213.0 | 72.0 |
| 4 | East Devon 002B | 221.0 | 195.0 | 223.0 | 128.0 | 18.0 | 365.0 | 115.0 | 201.0 | 288.0 | 282.0 | 241.0 | 275.0 | 180.0 | 209.0 | 73.0 |
| ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... | ... |
| 702 | West Devon 006D | 97.0 | 244.0 | 56.0 | 240.0 | 262.0 | 314.0 | 172.0 | 222.0 | 268.0 | 184.0 | 308.0 | 45.0 | 248.0 | 184.0 | 252.0 |
| 703 | West Devon 007A | 150.0 | 285.0 | 111.0 | 283.0 | 307.0 | 331.0 | 217.0 | 287.0 | 285.0 | 255.0 | 353.0 | 61.0 | 293.0 | 252.0 | 297.0 |
| 704 | West Devon 007B | 150.0 | 285.0 | 111.0 | 283.0 | 307.0 | 331.0 | 217.0 | 287.0 | 285.0 | 255.0 | 353.0 | 61.0 | 293.0 | 252.0 | 297.0 |
| 705 | West Devon 007C | 130.0 | 293.0 | 90.0 | 301.0 | 308.0 | 408.0 | 249.0 | 262.0 | 331.0 | 221.0 | 443.0 | 80.0 | 329.0 | 223.0 | 306.0 |
| 706 | West Devon 007D | 119.0 | 285.0 | 79.0 | 289.0 | 301.0 | 401.0 | 237.0 | 246.0 | 324.0 | 205.0 | 433.0 | 74.0 | 317.0 | 212.0 | 296.0 |
707 rows × 16 columns
And re-solve.
solution_greedy_public_transport_extended = problem_public_transport_extended.solve(
p=len(problem_public_transport_extended.show_sites())-2,
search_strategy="greedy"
)C:\lokigi\lokigi\site.py:1414: UserWarning: No demand data was provided. Demand from all regions has been assumed to be equal.If you wish to override this, run .add_demand() to add your site dataframe before running .solve() again.You can use the .show_demand_format() to see the expected format beforehand.
warn(
Best combination for 1 sites: [np.int64(7)]
Best combination for 2 sites: [np.int64(7), np.int64(11)]
Best combination for 3 sites: [np.int64(0), np.int64(7), np.int64(11)]
Best combination for 4 sites: [np.int64(0), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 5 sites: [np.int64(0), np.int64(1), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 6 sites: [np.int64(0), np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(11)]
Best combination for 7 sites: [np.int64(0), np.int64(1), np.int64(5), np.int64(6), np.int64(7), np.int64(10), np.int64(11)]
Best combination for 8 sites: [np.int64(0), np.int64(1), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(10), np.int64(11)]
Best combination for 9 sites: [np.int64(0), np.int64(1), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(10), np.int64(11)]
Best combination for 10 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(10), np.int64(11)]
Best combination for 11 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(10), np.int64(11), np.int64(12)]
Best combination for 12 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(10), np.int64(11), np.int64(12), np.int64(14)]
Best combination for 13 sites: [np.int64(0), np.int64(1), np.int64(2), np.int64(3), np.int64(4), np.int64(5), np.int64(6), np.int64(7), np.int64(8), np.int64(10), np.int64(11), np.int64(12), np.int64(14)]
We can see that this time we get a much better output!
(Though some of these people may have had impossible journeys in a practical sense, taking over 4 hours and an unknown number of changes to get where they’re going!)
fig = solution_greedy_public_transport_extended.plot_best_combination(unchosen_site_colour="magenta")
fig;
Lokigi will be extended in future to better handle NA values in travel matrices.
fig = solution_greedy_public_transport_extended.plot_best_combination(unchosen_site_colour="magenta", plot_site_allocation=True, legend_loc="lower right", show_all_locations=False, cmap="Set3")
fig;
solution_greedy_public_transport_extended.show_solutions().problem_df.iloc[0].selected_site.unique()<StringArray>
[ 'Honiton Hospital',
'Victoria Hospital (Sidmouth)',
'NHS Walk in Centre (Exeter)',
'Exmouth Minor Injury Unit',
'Tiverton & District Hospital',
'Ilfracombe & District Tyrrell Hospital',
'North Devon District Hospital',
'Derriford Hospital (UTC)',
'Cumberland Centre (Plymouth)',
'Totnes Community Hospital',
'Dawlish Community Hospital',
'Newton Abbot Community Hospital',
'Tavistock Hospital']
Length: 13, dtype: str
problem.show_sites()| canonical_site_index | id | Latitude | Longitude | geometry | |
|---|---|---|---|---|---|
| 0 | 0 | North Devon District Hospital | 51.09217 | -4.05043 | POINT (256506.101 134540.134) |
| 1 | 1 | Honiton Hospital | 50.79492 | -3.18659 | POINT (316466.043 100155.33) |
| 2 | 2 | Tiverton & District Hospital | 50.90933 | -3.49308 | POINT (295122.857 113268.884) |
| 3 | 3 | Exmouth Minor Injury Unit | 50.62083 | -3.40198 | POINT (300919.29 81063.23) |
| 4 | 4 | Victoria Hospital (Sidmouth) | 50.68161 | -3.23966 | POINT (312514.661 87617.001) |
| 5 | 5 | Newton Abbot Community Hospital | 50.53926 | -3.61224 | POINT (285848.833 72295.946) |
| 6 | 6 | Totnes Community Hospital | 50.43283 | -3.68406 | POINT (280491.309 60575.271) |
| 7 | 7 | NHS Walk in Centre (Exeter) | 50.72658 | -3.52521 | POINT (292444.533 92994.08) |
| 8 | 8 | Tavistock Hospital | 50.54708 | -4.15376 | POINT (247503.629 74139.474) |
| 9 | 9 | South Hams Hospital (Kingsbridge) | 50.28929 | -3.78143 | POINT (273194.03 44777.085) |
| 10 | 10 | Cumberland Centre (Plymouth) | 50.37004 | -4.16873 | POINT (245868.211 54486.789) |
| 11 | 11 | Derriford Hospital (UTC) | 50.41802 | -4.11890 | POINT (249563.619 59719.094) |
| 12 | 12 | Ilfracombe & District Tyrrell Hospital | 51.20468 | -4.12454 | POINT (251678.187 147197.738) |
| 13 | 13 | South Molton Hospital | 51.01681 | -3.83823 | POINT (271156.007 125767.813) |
| 14 | 14 | Dawlish Community Hospital | 50.58057 | -3.47482 | POINT (295677.679 76686.654) |
solution_greedy_public_transport_extended.show_solutions()| site_names | site_indices | coverage_threshold | weighted_average | unweighted_average | 90th_percentile | max | proportion_within_coverage_threshold | problem_df | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | [North Devon District Hospital, Honiton Hospit... | [0, 1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 14] | None | 57.65 | 57.65 | 98.4 | 262.0 | 0.0 | from_id from_id_x North D... |