# Emergency Dispatch and Routing¶

Authors: Tianjian Chen, Aswin Raj Giri and Vishnu Priya Rajendran
Course: ECE1724H: Bio-inspired Algorithms for Smart Mobility - Fall 2021
Instructor: Dr. Alaa Khamis
Department: Edward S. Rogers Sr. Department of Electrical & Computer Engineering, University of Toronto

## Introduction¶

As cities and grow and the number of cars on roads increase, accidents and emergency situations will also continue to increase. This makes the problem of dispatching and routing emergency vehicles even more critical.

Dispatching is the concept of allotting and sending the nearby available vehicles to the location of an emergency, while routing deals with selecting the ideal route to reach that destination. This particular example places an emphasis on incident response time, which is considered as the primary cost function. The incident response time is approximated using the route distance and the number of turns (as turning generally takes more time).

Two example solutions are provided, using Ant Colony Optimization and the Firefly Algorithm.

import osmnx
from smart_mobility_utilities.common import Node, cost, randomized_search
from smart_mobility_utilities.problem import haversine_distance, cross_over
import math
import random
from tqdm.notebook import tqdm
import osmnx as ox
from numpy.random import choice as weighted_choice
import pandas as pd
from copy import copy, deepcopy
import folium

### Datasets¶

1. 50 buildings in the area surrouding the University of Toronto were selected as destinations, or “points of emergency”.

2. The real locations of 8 fire stations, 5 police stations, and 10 hospitals in that area were selected as vehicle depots for various types of emergency vehicles (fire trucks, police cars, ambulances).

### Constraints¶

1. Only one request at a time.

2. Emergency sites have an associated “emergency level” and emergency vehicles have a “capacity” to satisfy those levels. Emergency sites can only be satisfied by a vehicle of an equal or higher capacity, or by multiple vehicles whose sum meets or exceeds the emergency site’s emergency level.

3. The three emergency levels are high, medium, and low.

4. All emergency vehicles are initialized at their starting depot.

## Functions and Classes¶

### Turns in Route¶

This function calculates the number of turns in a route between two nodes as the number of unique street ids - 1.

# Determines the number of turns in a route between two locations

def turns(G,route):
for i in range(0,len(route)-1):
way =G[route[i]][route[i+1]]
return turn

### Closest Node¶

This function looks for the OSMID of the closest node in an OSMNX graph for a given geocode.

def closest_node_osmid(G, lat, lng):
id = ox.distance.nearest_nodes(G,lng,lat)
return Node(G,id)

### Emergency Class¶

class Emergency:
def __init__(self, building, level, type):
self.level = level
self.type = type
self.building = building

def __repr__(self):
return f"[Level:{self.level}, Type:{self.type}, Building: {self.building.name}]"

### Vehicle Class¶

class Vehicle:
def __init__(self, name, capacity, vehicle_type, location):
self.name = name
self.capacity = capacity
self.vehicle_type = vehicle_type
self.location = location

def __repr__(self):
return f"[Name:{self.name}, Type:{self.vehicle_type}, Capacity: {self.capacity}, Location: {self.location}]"

### Building Class¶

class Building:
def __init__(self, name, lat, lng, level):
self.name = name
self.location = (lat, lng)
self.level = level

## Problem Class¶

class EmergencyRouting:
vehicles = []
locations = []
emergencies = []
results = []

def __init__(self):

for index, row in df.iterrows():
self.vehicles.append(
Vehicle(
row.name,
row.casualities_expected_to_treat,
row.vehicle_type,
(row.vehicle_lat, row.vehicle_lng),
)
)

for index, row in df.iterrows():
self.locations.append(
Building(
row.building_name,
row.building_lat,
row.building_lng,
row.casualities_expected,
)
)

def generate_emergencies(self, num_emergencies, allow_repeat_building=False):
results = []
buildings = copy(self.locations)
for i in range(num_emergencies):
# Select a random emergency level, weighted towards less severe ones
e_level = weighted_choice([1, 2, 3], 1, [0.5, 0.3, 0.2])[0]
# Select a random emergency type
e_type = random.choice([1, 2, 3])
# Select a random building
if allow_repeat_building:
e_building = random.choice(buildings)
else:
e_building = buildings.pop(random.randrange(len(buildings)))
results.append(Emergency(e_building, e_level, e_type))
self.emergencies = results

def route(self, G, vehicles, destination, algorithm):
routes = []
destination_node = closest_node_osmid(G, destination[0], destination[1])
for v in vehicles:
origin_node = closest_node_osmid(G, v.location[0], v.location[1])
route = algorithm.run(G, origin_node, destination_node)
routes.append(route)

# Update vehicle location
v.location = (destination[0], destination[1])
self.vehicles[self.vehicles.index(v)] = v

return routes

# Takes the next emergency and assigns vehicles to be dispatched
def dispatch(self):
dispatched = []
emergency = self.emergencies.pop(0)
level = emergency.level

# Filter to only keep relevant vehicles
available_vehicles = [
v for v in self.vehicles if v.vehicle_type == emergency.type
]
available_vehicles = sorted(
available_vehicles,
key=lambda x: haversine_distance(
emergency.building.location[1],
emergency.building.location[0],
x.location[1],
x.location[0],
),
)
while level > 0:
current = available_vehicles.pop(0)
dispatched.append(current)
level -= current.capacity

results = copy(dispatched)
dispatched.reverse()
if level < 0:
# Try to remove some vehicles
for v in dispatched:
if level + v.capacity <= 0:
results.pop(results.index(v))
level += v.capacity
if level == 0:
break

return emergency, results

def visualize(self, G, result):

print(result["emergency"])
print(f"Number of Vehicles:{len(result['active_vehicles'])}")
print(f"Total Distance: {sum([cost(G,r) for r in result['routes']])}")
print(f"Total Turns: {sum([turns(G,r) for r in result['routes']])}")
icons = ["fire-extinguisher", "gavel", "ambulance"]
v_colors = ["red", "blue", "green"]
m = folium.Map((43.654589, -79.389229), zoom_start=10)
# Vehicle Markers
for v in result["vehicle"]:
color = (
v_colors[v.vehicle_type - 1]
if v.name in result["active_vehicles"]
else "gray"
)
folium.Marker(
location=v.location,
icon=folium.Icon(
color=color,
icon=icons[v.vehicle_type - 1],
prefix="fa"
),
popup=f"Capacity: {v.capacity}"

# Destination Marker
folium.Marker(
location=result["emergency"].building.location,
icon=folium.Icon(color="orange", icon="exclamation", prefix="fa"),
popup=f"Level: {result['emergency'].level}"

# Routes
for r in result['routes']:
ox.plot_route_folium(G,r,m)
return m

def run(self, G, num_emergencies, allow_repeat_locations):

# Generate emergencies
self.generate_emergencies(num_emergencies, allow_repeat_locations)

def next(self, algorithm):
if len(self.emergencies) == 0:
return "No more emergencies to respond to."
# Dispatch
emergency, vehicles = self.dispatch()
result_obj = {
"emergency": emergency,
"vehicle": deepcopy(self.vehicles),
"active_vehicles": [x.name for x in vehicles]
}

# Route
routes = self.route(G, vehicles, emergency.building.location, algorithm)
result_obj["routes"] = routes
return self.visualize(G, result_obj)

### Dispatching¶

Vehicles are dispatched as follows:

1. The list of all vehicles is filtered to remove any vehicles that do not match the emergency type. For example, ambulances will not be considered when responding to a fire call.

2. The Haversine distance between the remaining vehicles and the emergency location is determined, and the list is sorted in ascending order.

3. Vehicles are added to the dispatch list until the severity level of the emergency is satisfied. If the final level of the dispatched fleet is greater than what is required, we will try to remove low level vehicles to match the emergency level perfectly.

For example:
If there is an emergency of level 3, and the closest vehicles are:
A (level 1): 2 km away
B (level 1): 4 km away
C (level 3): 6 km away
The dispatching algorithm will select all 3 vehicles (A, B, C), but then remove both A and B as they are not required to respond to the emergency.

## Solutions¶

# Initialize problem
problem = EmergencyRouting()
G = osmnx.graph_from_address("University Of Toronto", dist=3000, network_type="drive_service")

# Initialize some parameters
num_emergencies = 9
allow_repeat_locations = False

problem.run(G, num_emergencies, allow_repeat_locations)

### Example 1: Dispatching + Routing Emergencies using ACO¶

#### Ant Colony Optimization¶

class ACO:
def __init__(self, alpha=2, beta=2, n=500, Q=1):
self.alpha = alpha
self.beta = beta
self.n = n
self.Q = Q

def pheremone(self, level, distance):
return level ** self.alpha * ((1/distance)) ** self.beta

def run(self, G, origin, destination):
# randomize the pheromones
pheremone_concentrations = {(u,v):random.uniform(0,0.5) for [u,v] in G.edges()}
known_routes = dict()

for ant in tqdm(range(self.n)):
# Place the ant at the colony
frontier = [origin]
explored = set()
route = []
found = False

parent = frontier.pop(0)

children = []
children_pheremones = []
for child in parent.expand():
# If we see the destination, ignore all pheremones
if child == destination:
found = True
route = child.path()
continue
if child not in explored:
children.append(child)
children_pheremones.append(
self.pheremone(
pheremone_concentrations[(parent.osmid, child.osmid)],
child.distance
)
)

if len(children) == 0:
continue  # The ant is stuck, go back.

transition_probability = [
children_pheremones[i] / sum(children_pheremones)
for i in range(len(children_pheremones))
]

# Probabilistically choose a child to explore based weighted by transition probability
chosen = random.choices(children, weights=transition_probability, k=1)[0]

# Add all the non-explored children in case we need to explore them later
children.pop(children.index(chosen))
frontier.extend(children)

# Set the chosen child to be the next node to explore
frontier.insert(0, chosen)

# We now have a completed route, we can increase pheremone levels
# on that route for the next ant to detect.

for u, v in zip(route[:-1], route[1:]):
length_of_edge = G[u][v][0]['length']
pheremone_concentrations[(u,v)] += self.Q/length_of_edge

# If the route is newly discovered, add it to the list
route = tuple(route)
if route in known_routes:
known_routes[route] += 1
else:
known_routes[route] = 1
return list(max(known_routes, key=known_routes.get))

#### Results and Visualization¶

problem.next(ACO())
[Level:1, Type:3, Building: building_27]
Number of Vehicles:1
Total Distance: 1282.447
Total Turns: 15
Make this Notebook Trusted to load map: File -> Trust Notebook
problem.next(ACO())
[Level:3, Type:2, Building: building_25]
Number of Vehicles:2
Total Distance: 2810.679
Total Turns: 18
Make this Notebook Trusted to load map: File -> Trust Notebook
problem.next(ACO())
[Level:2, Type:1, Building: building_14]
Number of Vehicles:1
Total Distance: 2306.254
Total Turns: 22
Make this Notebook Trusted to load map: File -> Trust Notebook

As you can see from the above three examples, the ACO solver does not take into account the number of turns in a route, as pheremones are deposited on node-to-node edges. Since the scoring of routes in ACO is the sum of individual scores, we cannot penalize excess turns.

### Example 2: Dispatching + Routing Emergencies using Firefly Algorithm¶

#### Firefly Algorithm Class¶

class Firefly:
def __init__(self, num_iterations=15, pop_size=100, gamma=2, k=100):
self.num_iterations = num_iterations
self.pop_size = pop_size
self.gamma = gamma
self.k = k

# Cost as length + complexity of route
def turns(G,route):
for i in range(0,len(route)-1):
way =G[route[i]][route[i+1]]
return turn

def luminosity(self, G, route):

return cost(G,route) + self.k*turns(G,route)

# Define the distance between two routes as the number of common nodes they possess
def distance(self, route1,route2):
return len(set(route1) & set(route2))

def run(self, G, origin, destination):
# Initialize population
flies = [randomized_search(G,origin.osmid, destination.osmid) for _ in range(self.pop_size)]

for _ in tqdm(range(self.num_iterations)):
for i in range(self.pop_size):
flies_luminosity = list()  # for all flies except i
for j in range(self.pop_size):
if i == j:
continue
flies_luminosity.append((j, self.luminosity(G, flies[j])))
moving_fly = flies[i]
# Using min here as the "most" luminous is the one with lowest cost + complexity
target_fly = min(
flies_luminosity,
key=lambda fly: fly[1]
* math.exp(-1 * self.gamma * self.distance(moving_fly, flies[fly[0]]))
)
target_fly = flies[target_fly[0]]
# update the position, moving is just a crossover
flies[i] = cross_over(target_fly,moving_fly)

return min(flies, key=lambda fly: self.luminosity(G,fly))

#### Results and Visualization¶

problem.next(Firefly())
[Level:2, Type:2, Building: building_35]
Number of Vehicles:1
Total Distance: 2385.908
Total Turns: 16
Make this Notebook Trusted to load map: File -> Trust Notebook
problem.next(Firefly())
[Level:3, Type:2, Building: building_5]
Number of Vehicles:1
Total Distance: 1720.557
Total Turns: 11
Make this Notebook Trusted to load map: File -> Trust Notebook
problem.next(Firefly())
[Level:1, Type:3, Building: building_2]
Number of Vehicles:1
Total Distance: 327.496
Total Turns: 2
Make this Notebook Trusted to load map: File -> Trust Notebook

As you can see, the results generated by the Firefly algorithm produce routes with fewer turns, as a penalty was applied to the cost function based on the number of turns in a route.