Analyse GP Surgery Accessibility in Walthamstow

Locations Provided by Open Street Map Data
Author

Rich Leyshon

Published

February 27, 2024

Introduction

A brief summary of notebook:

  • Travel times from Output Area population-weighted centroids to GP surgeries in Walthamstow.
  • Transport modality is by foot and by private car.
  • Surgeries are geolocated from postcodes using open street map’s Nominatim API. This would not scale well so other location data sources should be explored.
  • Scaling out to other areas hinges on the ability to provide location data for the services of interest.
  • Including bus & rail modes is possible and subject to the peculiarities of GTFS data within the area of interest. Modelling bus & rail for other areas of UK would be trivial.
Click to show code
from datetime import datetime, timedelta
import glob
import os
from pathlib import Path
import subprocess
import tempfile
from time import sleep
import toml

import contextily as ctx
import folium
import geopandas as gpd
from geopy.geocoders import Nominatim
from geopy.exc import GeocoderUnavailable
from geopy import point
import matplotlib.pyplot as plt
import pandas as pd
from pyprojroot import here
import r5py
import requests
from shapely import geometry

from transport_performance.osm.osm_utils import filter_osm

from travel_time_experiments.ingest_ons_geo import (
    get_ons_geo_data, get_ons_geo_paginated
    )
from travel_time_experiments.munge_gpd import add_linestring_col
from travel_time_experiments.viz_gpd import viz_revised_coordinates

# get private user agent
USER_AGENT = toml.load(here(".secrets.toml"))["nominatim"]["USER_AGENT"]

Ingest Data

Ingest London Open Street Map.

London OSM doesn’t include Loughton & Cheshunt. To avoid edge effects, ingest England OSM latest & filter to custom bounding box.

Click to show code
osm_pth = here("data/external/osm/england-latest-osm.pbf")
if not os.path.exists(osm_pth):
    subprocess.run(
        [
            "curl",
            "https://download.geofabrik.de/europe/united-kingdom/england-latest.osm.pbf",
            "-o",
            osm_pth
            ])
# Clip London OSM to custom BBOX
filtered_osm_pth = here("data/external/osm/walthamstow-aoi-osm.pbf")
BBOX = [-0.368022,51.432659,0.296356,51.765395]
if not os.path.exists(filtered_osm_pth):
    filter_osm(
        pbf_pth=osm_pth, out_pth=filtered_osm_pth, bbox=BBOX, tag_filter=False)

Ingest GP Addresses.

From NHS Service Search , surgeries within 50 miles serving Hoe Street, Wolthamstow. Postcodes provided and will need to be geocoded. Investigate whether coordinates are available in NHS developer portal, registration required.

Click to show code
surg_pth = here("data/external/features/nhs-surgeries-E17-3AX.csv")
if not os.path.exists(surg_pth):
    subprocess.run([
        "curl",
        "https://www.nhs.uk/service-search/other-services/GP/E17-3AX/Export/4/-0.0193444043397903/51.5837821960449/4/0?distance=50&ResultsOnPageValue=10&isNational=0&totalItems=2845&currentPage=1",
        "-o",
        surg_pth
    ])
surgeries = pd.read_csv(surg_pth)
surgeries = surgeries.loc[:, ["Organisation Name", "PostCode"]]
surgeries["id"] = list(range(0, len(surgeries)))

Geolocate GP Surgeries from address.

Click to show code
def get_surgery_locs_nominatim(
    df: pd.DataFrame, out_pth: Path, sleep_s:float=2.0, user: str = USER_AGENT
    ) -> None:
    """Use orgnm and then postcode to attempt geolocating surgeries. 
    
    Uses Nominatim. Writes to file if error encountered. 
    """
    tmp_df = df.copy(deep=True)
    # Instantiate a new Nominatim client
    app = Nominatim(
        user_agent="")
    tmp_df["lat"] = 0.0
    tmp_df["lon"] = 0.0
    tmp_df["geocode_type"] = "None"
    geocode_type = []
    for i, row in tmp_df.iterrows():
        loc = {"lon": 0.0, "lat": 0.0}
        geo_type = "None"
        try:
            loc = app.geocode(
                query=row["Organisation Name"],
                country_codes="gb",
                viewbox=[
                    point.Point(51.375, -0.509), point.Point(51.868, 0.530)],
                bounded=True)
            print("Geocode by Org Name success")
            sleep(sleep_s)
            geo_type = "from_nm"
            if not loc:
                # case where name did not return a location
                loc = app.geocode(
                    query=row["PostCode"],
                    country_codes="gb",
                    viewbox=[
                        point.Point(51.375, -0.509), point.Point(51.868, 0.530)
                        ],
                    bounded=True)
                print("Geocode by Postcode success")
                sleep(sleep_s)
                geo_type = "from_pcd"
        except GeocoderUnavailable:
            # nominatim is down
            print(f"Breaking on {row['Organisation Name']}")
            break
        finally:
            # update the geometry column
            if loc:
                loc = loc.raw
                tmp_df.loc[i, ["lat"]] = loc["lat"]
                tmp_df.loc[i, ["lon"]] = loc["lon"]
                tmp_df.loc[i, ["geocode_type"]] = geo_type
            else:
                break
    tmp_df.to_csv(out_pth)
    return tmp_df
# execute once only as hammers the nominatim service
geocoded_surgeries_pth = here(
    "data/external/features/geocoded-london-surgeries.csv")
if not os.path.exists(geocoded_surgeries_pth):
    get_surgery_locs_nominatim(df=surgeries, out_pth=cache_pth, sleep_s=2.0)

Present the geocoded surgeries.

Click to show code
geocd_pth = here("data/external/features/").glob("geocode-surgeries-*")
geocd_surgeries = pd.read_csv(list(geocd_pth)[0], index_col=0)
geocd_surgeries = geocd_surgeries.dropna()
geocd_surgeries = gpd.GeoDataFrame(
    geocd_surgeries,
    geometry=gpd.points_from_xy(
        geocd_surgeries["lon"], geocd_surgeries["lat"]), crs=4326)
geocd_surgeries.drop(["lat", "lon"], axis=1, inplace=True)
geocd_surgeries.explore()
Make this Notebook Trusted to load map: File -> Trust Notebook

Note that the accuracy of surgery location varies dependent upon whether the surgeries were geolocated from the organisation name or the postcode. Most are from postcode. Accuracy of these points will depend on size of postcode.

Click to show code
code_stats = geocd_surgeries["geocode_type"].describe()
print(
    f"{round((code_stats.freq / len(geocd_surgeries)) * 100, 1)} % of "
    f"{len(geocd_surgeries)} surgeries were geocoded by postcode.")
57.4 % of 535 surgeries were geocoded by postcode.

Ingest Population-Weighted Centroids.

Click to show code
ENDPOINT = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/Output_Areas_2021_PWC_V3/FeatureServer/0/query"
PARAMS = {
    "where": "OA21CD like 'E%'",
    "f": "geoJSON", 
    "outFields": "*",
    "outSR": 4326,
    "returnCountOnly": True,
}
resp = requests.get(ENDPOINT, PARAMS)
cont = resp.json()
n_centroids = cont['properties']['count']
print(
    f"There are {n_centroids:,} OA Centroids with the specified OA21CD pattern"
    )
There are 178,605 OA Centroids with the specified OA21CD pattern
Click to show code
PARAMS["returnCountOnly"] = False
PARAMS["resultOffset"] = 0
centroids_pth = here("data/external/ons-geo/oa-centroids-2021.parquet")
if not os.path.exists(centroids_pth):
    centroids = get_ons_geo_paginated(ENDPOINT, PARAMS)
else:
    centroids = gpd.read_parquet(centroids_pth)
print(f"All centroids ingested? {len(centroids) == n_centroids}")
All centroids ingested? True

Ingest Wolthamstow LAD boundary

Use LAD21 to match centroid release, though 23 is available. LADCD for Wolthamstow is E09000031.

Click to show code
ENDPOINT = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/LAD_Dec_2021_GB_BFC_2022/FeatureServer/0/query"
PARAMS["where"] = "LAD21CD = 'E09000031'"
del PARAMS["resultOffset"]
PARAMS["outSR"] = 27700 # get in BNG as will need to buffer
_, boundary = get_ons_geo_data(ENDPOINT, PARAMS)
boundary.explore()
Make this Notebook Trusted to load map: File -> Trust Notebook

Engineering the Data

Buffer the LAD boundary to avoid edge effects - people may prefer surgeries in the adjacent local authority. Buffer by 5km, pretty arbitrary rule of thumb that can be adjusted to suit.

Click to show code
buffered_bound = gpd.GeoDataFrame({"geometry": boundary.buffer(5000)})
imap = boundary.explore(
    map_kwds={
        "center": {"lat": 51.603, "lng": -0.018}
    },
    zoom_start=10)
imap = buffered_bound.explore(
    m=imap,
    style_kwds=dict(
        color="purple", weight=1, fillOpacity=0.3
        ))
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

Use the centroids within the LAD boundary (blue). Use the GP surgeries within the 5km buffer (purple). Clip the points to Polygon extent.

Click to show code
# clip centroids to LAD boundary
boundary = boundary.to_crs(4326)
walth_centroids = centroids.sjoin(boundary)
# clip surgeries to buffer
buffered_bound = buffered_bound.to_crs(4326)
proximal_gps = geocd_surgeries.sjoin(buffered_bound)
imap = walth_centroids.explore(map_kwds={
        "center": {"lat": 51.603, "lng": -0.018}
    },
    zoom_start=11.5, color="navy")
imap = proximal_gps.explore(
    m=imap,
    marker_type="marker",
    marker_kwds={
        "icon": folium.map.Icon(
            color="green", icon="briefcase-medical", prefix="fa")
    }
)
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

These are the journey origins (navy) and destinations (green).

Adjust Point Locations

As many of the points will not be situated in routable locations, it can be beneficial to use the transport network to ‘snap’ the locations to the nearest feature of the network. This will often be the nearest node, but could also be configured as the nearest junction or bus stop.

Click to show code
tn = r5py.TransportNetwork(filtered_osm_pth)
walth_centroids["snapped_geometry"] = tn.snap_to_network(
    walth_centroids["geometry"],
    radius=75,
    street_mode=r5py.TransportMode.WALK,
)
walth_centroids = add_linestring_col(walth_centroids)
# snap surgeries
proximal_gps["snapped_geometry"] = tn.snap_to_network(
    proximal_gps["geometry"],
    radius=115,
    street_mode=r5py.TransportMode.WALK,
)
proximal_gps = add_linestring_col(proximal_gps)
proximal_gps["snap_dist_m"].describe()
count    291.000000
mean      16.186958
std        9.245808
min        0.000000
25%       11.045884
50%       14.546274
75%       19.569081
max      103.341968
Name: snap_dist_m, dtype: float64

75 % of the surgeries were revised within 20 metres.

Click to show code
proximal_gps["snap_dist_m"].plot.hist(
    bins=20, title="Distribution of Surgery Snap Distance in Metres")

Visualise the 10 surgery locations that were snapped farthest.

Click to show code
# visualise snapped geometry
viz_revised_coordinates(proximal_gps.sort_values(
    by=["snap_dist_m"], ascending=[False]).head(10))
Make this Notebook Trusted to load map: File -> Trust Notebook

Note that there are some issues with the data. There are entries that share the same postcode that appear to be duplicate entries, these are overplotted on the maps but can be revealed with a little random noise.

It is also apparent that the quality of geocoding is variable. In the worst case, Nightingale House Surgery has been geocoded from postcode at a local school on Nightingale Road, some 600 metres away. Visual inspection of point samples confirm that this is the exception rather than the rule, but a cut of the geolytix surgery location data or equivalent would be best.

Click to show code
oa_polys_pth = here("data/external/ons-geo/oa-polys-2021-BFC.parquet")
if not os.path.exists(oa_polys_pth):
    ENDPOINT = "https://services1.arcgis.com/ESMARspQHYMw9BZ9/arcgis/rest/services/Output_Areas_2021_EW_BFC_V8/FeatureServer/0/query"
    PARAMS["where"] = "OA21CD LIKE 'E%'"
    PARAMS["resultOffset"] = 0
    PARAMS["outSR"] = 4326
    oa_polys = get_ons_geo_paginated(ENDPOINT, PARAMS)
    oa_polys.to_parquet(oa_polys_pth)
else:
    oa_polys = gpd.read_parquet(oa_polys_pth)

walth_polys = oa_polys.set_index("OA21CD").join(
    walth_centroids.set_index("OA21CD"),
    how="right", lsuffix="_l", rsuffix="_r")
walth_polys.set_geometry("geometry_l", inplace=True)
walth_centroids["snap_dist_m"].describe()
count    771.000000
mean      16.688795
std       11.664236
min        0.017786
25%        7.375896
50%       15.289275
75%       23.811737
max       73.991204
Name: snap_dist_m, dtype: float64

Most of the centroids have been adjusted by less than 25 metres. Though there is at least one case snapped to over 70 metres.

Click to show code
walth_centroids["snap_dist_m"].plot.hist(
    bins=20, title="Distribution of Centroid Snap Distance in Metres")

It is likely that these snapped centroids now fall outside of the output area polygon they were intended to represent. Inspect the 10 largest revisions, check that they are within the bounds of their output area polygon.

Click to show code
imap = viz_revised_coordinates(
    walth_centroids[
        ["snap_dist_m", "geometry", "OA21CD", "snapped_geometry", "lines"]
        ].sort_values(
            by=["snap_dist_m"], ascending=[False]).head(10), zoom=11.5)
imap = walth_polys.reset_index()[["OA21CD", "geometry_l"]].explore(
    m=imap, style_kwds=dict(color="black", weight=1, fillOpacity=0.1)
    )
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

As can be seen from the map, some points now do fall outside of the polygon they are intended to represent. We can account for this adjustment by adding an average walking time penalty to computed travel times.

Calculate Travel Times

Compute travel times from centroids to proximal surgeries. Add a walking time penalty to account for the snap distance. Use walking speed of 4.8km/h, consistent with DfT travel time work. Calculate median travel times across surgeries.

Click to show code
# journey time with todays date
walth_centroids["id"] = list(range(0, len(walth_centroids)))
proximal_gps["id"] = list(range(0, len(proximal_gps)))
dept_time = datetime.now().replace(hour=8, minute=0, second=0, microsecond=0)
travel_time_matrix = r5py.TravelTimeMatrixComputer(
    tn,
    origins=walth_centroids,
    destinations=proximal_gps,
    transport_modes=[r5py.TransportMode.CAR, r5py.TransportMode.WALK],
    departure=dept_time,
    departure_time_window=timedelta(minutes=10), # Up to 08:10
    snap_to_network=False,
    max_time=timedelta(minutes=30), # 30 minute journey max 
).compute_travel_times()
walth_centroids["walk_penalty_mins"] = walth_centroids["snap_dist_m"] / 4800.0\
     * 60
med_tts_1 = travel_time_matrix.drop(
    "to_id", axis=1).groupby("from_id").median()
med_tts_1 = med_tts_1.join(walth_centroids.set_index("id"))
med_tts_1["travel_time"] = med_tts_1["travel_time"] +\
     med_tts_1["walk_penalty_mins"]
med_tts_1 = med_tts_1.set_index("OA21CD").join(
    walth_polys.set_index("OA21CD"), lsuffix="_l", rsuffix="_r")
med_tts_1 = gpd.GeoDataFrame(med_tts_1, geometry="geometry_l", crs=4326)
med_tts_1.head()
travel_time snapped_geometry lines snap_dist_m walk_penalty_mins geometry_l
OA21CD
E00185707 19.212100 POINT (-0.03574 51.59128) LINESTRING (-0.03593 51.59139, -0.03574 51.59128) 16.968013 0.212100 POLYGON ((-0.03442 51.59101, -0.03441 51.59099...
E00022297 17.468352 POINT (-0.01472 51.56531) LINESTRING (-0.01526 51.56533, -0.01472 51.56531) 37.468168 0.468352 POLYGON ((-0.01530 51.56630, -0.01530 51.56629...
E00021864 17.229162 POINT (-0.01775 51.59748) LINESTRING (-0.01775 51.59764, -0.01775 51.59748) 18.332924 0.229162 POLYGON ((-0.01539 51.59756, -0.01545 51.59756...
E00021924 18.139814 POINT (-0.01814 51.62593) LINESTRING (-0.01807 51.62584, -0.01814 51.62593) 11.185109 0.139814 POLYGON ((-0.01537 51.62544, -0.01539 51.62547...
E00022022 15.319025 POINT (0.00281 51.59676) LINESTRING (0.00292 51.59698, 0.00281 51.59676) 25.521986 0.319025 POLYGON ((0.01289 51.59908, 0.01289 51.59907, ...

Present Results

Option 1: Walthamstow 5km Buffer

Use the selection widget on the right of the map to toggle layers on and off.

Click to show code
imap = med_tts_1.explore(
    "travel_time",
    cmap="viridis_r",
    tiles="CartoDB positron",
    zoom_start=12,
    name="OA21 polygons")
imap = proximal_gps.set_geometry("snapped_geometry").explore(
    m = imap,
    marker_type="marker",
    marker_kwds={
        "icon": folium.map.Icon(
            color="green", icon="briefcase-medical", prefix="fa"),
    }, name="destinations"
)
imap = walth_centroids.set_geometry("snapped_geometry").reset_index()[
    ["snapped_geometry", "OA21CD"]].explore(
    m=imap, color="navy", opacity=0.2, name="origins")
imap.save(
    "outputs/walthamstow-surgeries/median_tt_to_surgeries_5km_buffer.html")
folium.LayerControl().add_to(imap)
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

This is an interesting situation - the high density of destinations in this locality could form the basis for a different treatment of proximal surgeries. The median travel time will be from one output area centroid to every GP on the map.

It may not be reasonable to consider all these options for each centroid. You could potentially draw a smaller buffer around each output area instead, and then calculate travel times to a smaller subset of proximal GPs.

Option 2: Nearest 10 Surgeries

This option is used to model greater patient discrimination in surgery selection, due to the wealth of surgeries in the vicinity. In this scenario, patients select from the 10 surgeries with the smallest travel time only. Each centroid gets a median travel time across these 10 shortest journies only.

This is similar to the method used to calculate DfT’s Journey Time Statistics.

Click to show code
fast_n = travel_time_matrix.sort_values(
    ["from_id", "travel_time"], ascending=[True, True]
    ).groupby(
        "from_id").head(10).drop("to_id", axis=1).groupby("from_id").median()

med_tts_2 = fast_n.join(walth_centroids.set_index("id"))
med_tts_2["travel_time"] = med_tts_2["travel_time"] + \
    med_tts_2["walk_penalty_mins"]
med_tts_2 = med_tts_2.set_index(
    "OA21CD"
    ).join(walth_polys.set_index("OA21CD"), lsuffix="_l", rsuffix="_r")
med_tts_2 = gpd.GeoDataFrame(med_tts_2, crs=4326, geometry="geometry_l")

imap = med_tts_2.explore(
    "travel_time",
    cmap="viridis_r",
    tiles="CartoDB positron",
    zoom_start=12,
    name="OA21 polygons")
imap = proximal_gps.explore(
    m = imap,
    marker_type="marker",
    marker_kwds={
        "icon": folium.map.Icon(
            color="green", icon="briefcase-medical", prefix="fa"),
    }, name="destinations"
)
imap = walth_centroids.reset_index().explore(
    m=imap, color="navy", opacity=0.2, name="origins")
imap.save(
    "outputs/walthamstow-surgeries//median_tt_to_surgeries_3_closest.html")
folium.LayerControl().add_to(imap)
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

Option 3: Output Area 1km buffer

Buffer each output area by 1km and consider travel times to surgeries falling within those extents only. Find surgeries for each buffered output area. Check on surgeries within one output area.

Click to show code
walth_polys["buffered_oas"] = walth_polys.to_crs(
    27700).buffer(1000)
walth_polys.set_geometry("buffered_oas", inplace=True)
walth_polys["buffered_area_m"] = walth_polys.area
walth_polys.to_crs(4326, inplace=True)
found_surgeries = gpd.GeoDataFrame()
for i, r in walth_polys.iterrows():
    buff_gdf = gpd.GeoDataFrame(
        {"OA21CD": r["OA21CD"], "geometry": r["buffered_oas"]},
        index=[0], crs=4326)
    out_df = buff_gdf.sjoin(geocd_surgeries, how="right").dropna()
    found_surgeries = gpd.GeoDataFrame(
        pd.concat( [found_surgeries, out_df], ignore_index=True))
which_oa = "E00021834"
imap = found_surgeries.query("OA21CD == @which_oa").explore(
    marker_type="marker",
    zoom_start=11.5,
    marker_kwds={
        "icon": folium.map.Icon(
            color="green", icon="briefcase-medical", prefix="fa"),
    })
# show the buffered polygon
imap = walth_polys.query("OA21CD == @which_oa").explore(
    m=imap, style_kwds={"color":"black", "fillOpacity":0.1})
# show the original polygon
imap = walth_polys.query("OA21CD == @which_oa").set_geometry(
    "geometry_l").explore(
    m=imap, style_kwds={"color": "purple", "fillOpacity":0.5}
)
# get all surgeries that are not within the buffered OA
imap = geocd_surgeries.loc[~geocd_surgeries["geometry"].isin(
    found_surgeries.query("OA21CD == @which_oa")["geometry"]), :].explore(
    m=imap,
    marker_type="marker",
    marker_kwds={
        "icon": folium.map.Icon(
            color="red", icon="briefcase-medical", prefix="fa")
    }
)
imap
Make this Notebook Trusted to load map: File -> Trust Notebook

Calculate Number of surgeries and travel times for each output area. With this option, the output areas are routed to unequal numbers of surgeries. Though every output area has at least one destination.

Click to show code
n_surgeries = found_surgeries.groupby(
    "OA21CD")["Organisation Name"].count().to_frame()
walth_centroids = walth_centroids.set_index(
    "OA21CD").join(n_surgeries).reset_index()
walth_centroids.rename(
    columns={"Organisation Name": "n_surgeries"}, inplace=True)
opt_3_pth = here("data/interim/walthamstow-oa-1km-buffer-surgery-tts.parquet")
if not os.path.exists(opt_3_pth):
    tt_matrix_2 = pd.DataFrame()
    for i, row in walth_centroids.iterrows():
        row_gdf = gpd.GeoDataFrame(
            {"id": row["id"], "geometry": row["snapped_geometry"]},
            index=[0], crs=4326)
        tts = r5py.TravelTimeMatrixComputer(
            tn,
            origins=row_gdf,
            destinations=found_surgeries.query("OA21CD == @row['OA21CD']"),
            transport_modes=[r5py.TransportMode.CAR, r5py.TransportMode.WALK],
            departure=dept_time,
            departure_time_window=timedelta(minutes=10), # Up to 08:10
            snap_to_network=False,
            max_time=timedelta(minutes=30), # 30 minute journey max 
        ).compute_travel_times()
        tt_matrix_2 = pd.concat([tt_matrix_2, tts])
    tt_matrix_2.to_parquet(opt_3_pth)
else:
    tt_matrix_2 = pd.read_parquet(opt_3_pth)

med_tts_3 = tt_matrix_2.drop("to_id", axis=1).groupby("from_id").median()
med_tts_3 = med_tts_3.join(walth_centroids.set_index("id"))
# add the walk time penalty
med_tts_3["travel_time"] = med_tts_3["travel_time"] +\
     med_tts_3["walk_penalty_mins"]
med_tts_3["n_surgeries"].describe()
count    771.000000
mean       5.041505
std        2.460776
min        1.000000
25%        3.000000
50%        5.000000
75%        7.000000
max       12.000000
Name: n_surgeries, dtype: float64

As can be seen below, travel time does trend by the number of surgeries in scope of the buffered output areas. This scenario is distinct to options 1 and 2 in that respect.

Click to show code
# normalise n_surgeries, get areas from the polygons
med_tts_3 = walth_polys.loc[:, ["OA21CD", "buffered_area_m"]].set_index(
    "OA21CD").join(med_tts_3.set_index("OA21CD"))
med_tts_3["surgeries_per_km2"] = med_tts_3["n_surgeries"] / (
    med_tts_3["buffered_area_m"]/1_000_000)
med_tts_3.plot.scatter(
    x="surgeries_per_km2", y="travel_time",
    title="Median Travel Time by Surgeries per KM2")
plt.show()

Click to show code
med_tts_3 = med_tts_3.join(
    walth_polys.set_index("OA21CD"), lsuffix="_l", rsuffix="_r")
med_tts_3 = gpd.GeoDataFrame(
    med_tts_3[
        [
            "geometry_l",
            "travel_time",
            "snap_dist_m",
            "walk_penalty_mins",
            "n_surgeries",
            "surgeries_per_km2"]
            ], geometry="geometry_l", crs=4326)
imap = med_tts_3.explore(
    "travel_time",
    cmap="viridis_r",
    tiles="CartoDB positron",
    zoom_start=12,
    name="OA21 polygons")
imap = proximal_gps.explore(
    m = imap,
    marker_type="marker",
    marker_kwds={
        "icon": folium.map.Icon(
            color="green", icon="briefcase-medical", prefix="fa"),
    }, name="destinations"
)
imap = walth_centroids.reset_index().explore(
    m=imap, color="navy", opacity=0.2, name="origins")
folium.LayerControl().add_to(imap)
imap
Make this Notebook Trusted to load map: File -> Trust Notebook