Drive time analysis for opioid epidemic

This notebook performs drive time analysis for clinics of opioid epidemic treatment and/or prevention centers in the county of Oakland, Michigan.

Problem

Drug overdoses are a leading cause of injury death in the United States, resulting in approximately 52,000 deaths in 2015. In the same year, the overdose death rate in non metropolitan areas of the country (17 per 100,000) surpassed the metropolitan area rate (16.2 per 100,000), which historically wasn't the case.

While metro areas have more people, and consequently, a greater incidence of drug use and overdose, the one stark disadvantage in the non metro areas is the comparatively limited access to opioid epidemic prevention/treatment facilities and transit.

Data Driven Approach

Here, we demonstrate an analysis of how several parts of Oakland county in Michigan are accessible from their opioid epidemic prevention and/or treatment centers within 5, 10, 15, 20 and 25 minutes of drive time. The areas of the county identified within those time bound service areas are then enriched with factors such as

Health insurance spending

Number of people with a Bachelor's Degree

Total Population in 2017

This provides a better understanding of the population served or under-served by these treatment facilities and the plausible contributing factors.

Steps of analysis

  1. Get data for clinics and census block groups boundary (CBG) in Oakland County
  2. Generate areas served based on time taken to drive to the clinics
  3. Spatially join the service areas generated with the CBG to obtain drive time to closest clinic for each CBG.
  4. Enrich each CBG with additional data to estimate influencing factors.
  5. Visualize results

Let's get started by first importing necessary modules and connecting to our GIS

#Import necessary packages
from arcgis.gis import GIS
from datetime import datetime
from arcgis.features import FeatureLayer

from arcgis.features.use_proximity import create_drive_time_areas
from arcgis.features.analysis import join_features
from arcgis.features.enrich_data import enrich_layer
# Connect to GIS
gis = GIS(profile="your_online_profile") 

1. Reading in clinics and Oakland county census block group (CBG) boundaries

# Layer URLS for the Opioid Epidemic Clinics in Oakland County and Census Block Groups of Oakland County
clinic_url = 'https://services8.arcgis.com/TWfU0bgvgUkCgHLa/arcgis/rest/services/Oakland_County_Licensed_Program_Listings/FeatureServer/0/'
oakland_cbg = 'https://services.arcgis.com/bkrWlSKcjUDFDtgw/arcgis/rest/services/Oakland_2010_census_bg/FeatureServer/0/'

# create feature layers
clinic_layer = FeatureLayer(clinic_url)
oakland_layer = FeatureLayer(oakland_cbg)
# Query all clinics and census block groups as FeatureSets
clinic_features = clinic_layer.query()
print('Total number of rows in the clinic dataset: ' + str(len(clinic_features.features)))

oakland_features = oakland_layer.query()
print('Total number of rows in the oakland dataset: ' + str(len(oakland_features.features)))
Total number of rows in the clinic dataset: 122
Total number of rows in the oakland dataset: 934

We notice that Oakland County, MI has 122 clinics and 934 Census Block Groups (CBG)

# Convert to SpatialDataFrames
clinic = clinic_features.sdf
clinic.head()
NameContact_NameAddressCityStateZipPhone_NumberKey_TypeLicenseNumberFIDSHAPE
0SUNRISE TREATMENT CENTERDr. Carmen Cardona, Program Director989 University Dr., Ste. 108PontiacMI48342248-481-2531Case Management; Integrated Treatment; Outpa...Treatment# SA06313561{"x": -9268869.4566, "y": 5259550.238600001, "...
1EIP, INC. (Education Intervention Programs)Stacy Gutowsky, Program Director4120 W. Maple Rd., Ste. 201Bloomfield HillsMI48301248-693-0336Alcohol Hwy Safety Education; Community Change...Prevention# SA06313302{"x": -9271532.8795, "y": 5242995.079599999, "...
2THE ART OF HOPE, HEALING & HAPPINESSKaren Fraiberg, Program Director3283 Bloomfield Park DrWest BloomfieldMI48323248-229-4717Case Management; Community Change, Alternative...Both# SA06313643{"x": -9278211.6833, "y": 5246510.740000002, "...
3RECOVERY CONSULTANTS, INC.Kathy Smaller, Program Director2710 W. 12 Mile Rd.BerkleyMI48072248-543-1090Case Management; Early Intervention; Integrate...Treatment# SA06311654{"x": -9260261.7618, "y": 5236694.745800003, "...
4RECOVERY CONSULTANTS, INC.Kathy Smaller, Program Director3139 W. Huron St. Waterford, MI 48328WaterfordMI48328248-738-8400Case Management; Early Intervention; Integrate...Treatment# SA06311665{"x": -9278492.0901, "y": 5256952.089100003, "...
oakland = oakland_features.sdf
oakland.head()
FIDNAMELABELTYPELINKCNTY_CODESQKMSQMILESACRESVERLAYOUTPENINSULAShape__AreaShape__LengthSHAPE
011200001BG 1BG125120000112516.4945926.3685684075.85855914aportraitlower30734809.48046922611.540412{"rings": [[[-9250818.94177608, 5289778.006450...
121203001BG 1BG125120300112535.60417113.7467848797.88754114aportraitlower66331290.69140635417.658796{"rings": [[[-9264026.53064651, 5290079.339554...
231210001BG 1BG125121000112527.089910.4594216693.98791514alandscapelower50477874.96484429332.713639{"rings": [[[-9270503.93750677, 5288614.193867...
341214001BG 1BG125121400112521.8522498.4371625399.75020714aportraitlower40705739.75390629772.396132{"rings": [[[-9269511.1796278, 5286236.4642479...
451222002BG 2BG125122200212515.051585.8114213719.28631714aportraitlower28044483.89453123245.466116{"rings": [[[-9281344.03654134, 5290365.890503...
m1 = gis.map('Oakland, Michigan')
m1
m1.basemap.basemap = 'topo-vector'
m1.content.add(oakland_layer)
m1.content.add(clinic_layer)
m1.zoom_to_layer(clinic_layer)
m1.legend.enabled = True

2. Drive time analysis

Service areas around the clinics need to be generated twice for two different sets of drive time inputs:

  1. [5, 10, 15, 20, 25] that is needed to get the drive time required from each CBG to the closest clinic. While adding more/less intervals is certainly an option, the choice of time intervals here is meant to span the range (closest-to-farthest clinic) as well as assume a range of safety, i.e. time within which a person can be driven/drive to a clinic to avoid delays.

  2. [5] that is needed purely for visualization purposes (as seen towards the end of the notebook). Feel free to modify this function parameter based on how you would like to visualize the map.

Service area generation for the [5, 10, 15, 20, 25] minutes layer

# Generate service areas
result = create_drive_time_areas(input_layer = clinic_layer, 
                                 travel_mode = 'Driving Time',
                                 break_values = [5, 10, 15, 20, 25], 
                                 output_name = "DriveTimeToClinics_All_2"+ str(datetime.now().microsecond), 
                                 overlap_policy = 'Dissolve')
# Check type of result
type(result)
arcgis.gis.Item
# Share results for public consumption
result.sharing.sharing_level = 'EVERYONE'
result.sharing.shared_with
{'groups': [], 'level': <SharingLevel.EVERYONE: 'EVERYONE'>}
# Convert it to a FeatureSet
drive_times_all_layer = result.layers[0]
driveAll_features = drive_times_all_layer.query()
print('Total number of rows in the service area (upto 25 mins) dataset: '+ str(len(driveAll_features.features)))
Total number of rows in the service area (upto 25 mins) dataset: 5

This implies that only 5 clinics in this county can be driven to within 5 minutes.

# Get results as a SpatialDataFrame
driveAll = driveAll_features.sdf
driveAll.head()
OBJECTIDNameFromBreakToBreakFacilityOIDAnalysisAreaSHAPE
0120 - 2520.025.0<NA>1324.839701{"rings": [[[-9328831.7041, 5279863.445], [-93...
1215 - 2015.020.0<NA>1201.15137{"rings": [[[-9284931.905, 5211469.241], [-928...
2310 - 1510.015.0<NA>1130.09381{"rings": [[[-9291856.8733, 5223753.8369], [-9...
345 - 105.010.0<NA>1047.34542{"rings": [[[-9273081.9592, 5293568.0208], [-9...
450 - 50.05.0<NA>796.467658{"rings": [[[-9277056.9411, 5277308.8349], [-9...
m_drive_times = gis.map('Oakland, Michigan')
m_drive_times
m_drive_times.content.add(result.layers[0])
m_drive_times.zoom_to_layer(result.layers[0])
m_drive_times.legend.enabled = True

Service area generation for the visible [5] minutes layer

# Generate service areas
visible_result = create_drive_time_areas(input_layer = clinic_layer, 
                                         break_values = [5], 
                                         travel_mode = 'Driving Time',
                                         output_name = "DriveTimeToClinics_5mins"+str(datetime.now().microsecond), 
                                         overlap_policy = 'Dissolve')
# Grant 'visible_result' public access
visible_result.sharing.sharing_level = "EVERYONE"
visible_result.sharing.shared_with
{'groups': [], 'level': <SharingLevel.EVERYONE: 'EVERYONE'>}

3. Spatially join the drive_times_all_layer layer with oakland_cbg layer to obtain drive times for each census block group

# Perform spatial join between CBG layer and the service areas created for all time durations
cbg_with_driveTime = join_features(target_layer = oakland_cbg, 
                                   join_layer = drive_times_all_layer, 
                                   spatial_relationship = 'Intersects', 
                                   output_name = 'Oakland_CBG_allDriveTimes'+str(datetime.now().microsecond))
# Grant 'cbg_with_driveTime' public access
cbg_with_driveTime.sharing.sharing_level = "EVERYONE"
cbg_with_driveTime.sharing.shared_with
{'groups': [], 'level': <SharingLevel.EVERYONE: 'EVERYONE'>}
# Converting it to a FeatureSet
drive_times_cbg = cbg_with_driveTime.layers[0]
drive_times_features = drive_times_cbg.query()
print('Total number of rows in the drive time (upto 25 mins) dataset: '+ str(len(drive_times_features.features)))
Total number of rows in the drive time (upto 25 mins) dataset: 934

This implies that the farthest clinic in the county is within a 25 minute drive time window

# Convert it to a SpatialDataFrame
drive_time_cbg_df = drive_times_features.sdf
drive_time_cbg_df.head()
OBJECTIDJoin_CountTARGET_FIDNAMELABELTYPELINKCNTY_CODESQKMSQMILESACRESVERLAYOUTPENINSULAName_1FromBreakToBreakFacilityOIDAnalysisAreaSHAPE
01211200001BG 1BG125120000112516.4945926.3685684075.85855914aportraitlower20 - 2520.025.0<NA>1324.839701{"rings": [[[-9250818.9418, 5289778.0065], [-9...
12421203001BG 1BG125120300112535.60417113.7467848797.88754114aportraitlower20 - 2520.025.0<NA>1324.839701{"rings": [[[-9264026.5306, 5290079.3396], [-9...
23331210001BG 1BG125121000112527.089910.4594216693.98791514alandscapelower15 - 2015.020.0<NA>1201.15137{"rings": [[[-9270503.9375, 5288614.1939], [-9...
34341214001BG 1BG125121400112521.8522498.4371625399.75020714aportraitlower15 - 2015.020.0<NA>1201.15137{"rings": [[[-9269511.1796, 5286236.4642], [-9...
45351222002BG 2BG125122200212515.051585.8114213719.28631714aportraitlower15 - 2015.020.0<NA>1201.15137{"rings": [[[-9281344.0365, 5290365.8905], [-9...

4. Enrich census block groups with suitable data

The 3 geo-enrichment factors that we will be including in this study are:

  1. 2017 Total Population (Analysis Variable - TOTPOP_CY)
  2. 2017 Health Insurance (Analysis Variable - X8002_X)
  3. 2017 Education : Bachelor's Degree (Analysis Variable - BACHDEG_CY)
# Perform data enrichment
enriched_drive_time_cbg = enrich_layer(drive_times_cbg, 
                                       analysis_variables=['TOTPOP_CY', 'X8002_X', 'BACHDEG_CY'], 
                                       output_name="Enriched_DriveTime_CBG"+str(datetime.now().microsecond))
# Check type of 'enriched_drive_time_cbg'
type(enriched_drive_time_cbg)
arcgis.gis.Item
# Grant 'enriched_drive_time_cbg' public access
enriched_drive_time_cbg.sharing.sharing_level = "EVERYONE"
enriched_drive_time_cbg.sharing.shared_with
{'groups': [], 'level': <SharingLevel.EVERYONE: 'EVERYONE'>}
# Convert it to a Feature Set
enriched_driveTimes_layer = enriched_drive_time_cbg.layers[0]
enriched_driveTimes_features = enriched_driveTimes_layer.query()
print('Total number of rows in the drive time (upto 25 mins) dataset: '+ str(len(enriched_driveTimes_features.features)))
Total number of rows in the drive time (upto 25 mins) dataset: 934
# Convert it to a SpatialDataFrame
enriched_df = enriched_driveTimes_features.sdf
enriched_df.head()
OBJECTIDJoin_CountTARGET_FIDNAMELABELTYPELINKCNTY_CODESQKMSQMILES...sourceCountryENRICH_FIDaggregationMethodpopulationToPolygonSizeRatingapportionmentConfidenceHasDataTOTPOP_CYX8002_XBACHDEG_CYSHAPE
01211200001BG 1BG125120000112516.4945926.368568...US1BlockApportionment:US.BlockGroups;PointsLayer:...2.1912.5761909.02281088.0103.0{"rings": [[[-9250818.9418, 5289778.0065], [-9...
12421203001BG 1BG125120300112535.60417113.746784...US2BlockApportionment:US.BlockGroups;PointsLayer:...2.1912.57611580.03960909.0349.0{"rings": [[[-9264026.5306, 5290079.3396], [-9...
23331210001BG 1BG125121000112527.089910.459421...US3BlockApportionment:US.BlockGroups;PointsLayer:...2.1912.5761642.01598570.0226.0{"rings": [[[-9270503.9375, 5288614.1939], [-9...
34341214001BG 1BG125121400112521.8522498.437162...US4BlockApportionment:US.BlockGroups;PointsLayer:...2.1912.57614799.012224037.0987.0{"rings": [[[-9269511.1796, 5286236.4642], [-9...
45351222002BG 2BG125122200212515.051585.811421...US5BlockApportionment:US.BlockGroups;PointsLayer:...2.1912.57611624.03815530.0239.0{"rings": [[[-9281344.0365, 5290365.8905], [-9...

5 rows × 30 columns

# Print column names for ease of use during visualization
enriched_df.columns
Index(['OBJECTID', 'Join_Count', 'TARGET_FID', 'NAME', 'LABEL', 'TYPE', 'LINK',
       'CNTY_CODE', 'SQKM', 'SQMILES', 'ACRES', 'VER', 'LAYOUT', 'PENINSULA',
       'Name_1', 'FromBreak', 'ToBreak', 'FacilityOID', 'AnalysisArea', 'ID',
       'sourceCountry', 'ENRICH_FID', 'aggregationMethod',
       'populationToPolygonSizeRating', 'apportionmentConfidence', 'HasData',
       'TOTPOP_CY', 'X8002_X', 'BACHDEG_CY', 'SHAPE'],
      dtype='object')

5. Visualize results

# Create map of Oakland county
map1 = gis.map('Oakland, Michigan')
map1.basemap.basemap = 'arcgis-dark-gray'
map1
map1.content.add(enriched_driveTimes_layer)
#Use smart mapping to render size based classes
sm_manager = map1.content.renderer(0).smart_mapping()
sm_manager.class_breaks_renderer(break_type="size", field="X8002_X", num_classes=5)
#Visualize drive time areas
map1.content.add(visible_result.layers[0], options = {'opacity':0.6})
map1.content.add(clinic_layer, options = {'opacity':0.85})
map1.zoom_to_layer(enriched_driveTimes_layer)

Similar maps can be generated for the following variables too

  1. Travel Time (ToBreak)
  2. Population (TOTPOP_CY)
  3. Citizens with Bachelor's Degree (BACHDEG_CY)

Conclusion

As we can see from the map, clinics are concentrated in a certain part of the county. A few counties have relatively larger population, as compared to their health care spending and population count with Bachelor's degrees and don't have clinics very close by (drime time greater than 10 mins). This subtly hints at disparity in location of clinics with population/affluence level of the county.

This analysis also sets stage for bringing in data about patients of this epidemic, to do a socio-economic study of this epidemic.

Your browser is no longer supported. Please upgrade your browser for the best experience. See our browser deprecation post for more details.