# Local path on user's machine
path = '/Users/jgaboardi/FacilityLocation/Data/'

"""
Explanation: Integrating PySAL.Network with Gurobi, CPLEX, & COIN-OR for optimization of the p-Center facility location problem

James Gaboardi &nbsp;| &nbsp;Florida State University&nbsp;| &nbsp;Department of Geography
End of explanation
"""


import pysal as ps
import geopandas as gpd
import numpy as np
import networkx as nx
import shapefile as shp
from shapely.geometry import Point
import shapely
from collections import OrderedDict
import pandas as pd
import qgrid
import gurobipy as gbp
import cplex as cp
import time
import bokeh
from bokeh.plotting import figure, show, ColumnDataSource
from bokeh.io import output_notebook, output_file, show
from bokeh.models import (HoverTool, BoxAnnotation, GeoJSONDataSource, 
                          GMapPlot, GMapOptions, ColumnDataSource, Circle, 
                          DataRange1d, PanTool, WheelZoomTool, BoxSelectTool,
                          ResetTool, MultiLine)
import utm
from cylp.cy import CyCbcModel, CyClpSimplex
import matplotlib as mpl
%matplotlib inline
mpl.rcParams['figure.figsize']=8,8
output_notebook()

"""
Explanation: The p-Center Problem $\Rightarrow$ "minimum worst case" $\Rightarrow$ equity
The objective of the p-center problem, also know as the minimax problem and the PCP, is to 
minimize the worst case cost (W) scenario while siting [p] facilities to serve all demand/client nodes.  It was originally proposed by Minieka as the m-Center problem (1970) and is well-studied in Geography, Operations Research, Mathematics, etc.  In this notebook the network-based vertice PCP is used meaning the cost will be calculated on a topologically accurate road network with pysal.Network and solutions will be determined based on discrete locations with Gurobi, CPLEX, and COIN-OR.  Cost is defined as distance here.
For more information refer to references section.
Minimize
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $W$
Subject to
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $\displaystyle\sum_{j\in m} x_{ij} = 1,$ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;   $\forall i \in n$
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $\displaystyle\sum_{j \in m} y_j = p$
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $x_{ij} - y_j \geq 0,$ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;  $\forall i\in n, j \in m$
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $\displaystyle W \geq \sum_{j \in m} c_{ij} x_{ij}$&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $\forall i \in n$
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $x_{ij}, y_j \in {0,1}$ &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp; $\forall i \in n, j \in m$
where

$W$ = the worst case cost between a client and a service node
$i$ = a specific origin
$j$ = a specific destination
$n$ = the set of origins
$m$ = the set of destinations
$a_i$ = weight at each node
$c_{ij}$ = travel costs between nodes
$x_{ij}$ = the decision variable at each node in the matrix
$y_j$ = nodes chosen as service facilities
$p$ = the number of facilities to be sited


Adapted from:
- Daskin, M. S. 1995. Network and Discrete Location: Models, Algorithms, and Applications. Hoboken, NJ, USA: John Wiley & Sons, Inc.
Imports
End of explanation
"""


STREETS_Orig = gpd.read_file(path+'Waverly_Trim/Waverly.shp')
STREETS = gpd.read_file(path+'Waverly_Trim/Waverly.shp')
STREETS.to_crs(epsg=2779, inplace=True) # NAD83(HARN) / Florida North
STREETS.to_file(path+'WAVERLY/WAVERLY.shp')

"""
Explanation: Data Prep
Reproject the street network with GeoPandas --> Waverly  Hills
End of explanation
"""


# Network
ntw = ps.Network(path+'WAVERLY/WAVERLY.shp')
shp_W = ps.open(path+'WAVERLY/WAVERLY.shp')

"""
Explanation: Instantiate the network
End of explanation
"""


# Buffer
buff = STREETS.buffer(200)  #Buffer
buffU = buff.unary_union  #Buffer Union
buff1 = gpd.GeoSeries(buffU)
buff1.crs = STREETS.crs
Buff = gpd.GeoDataFrame(buff1, crs=STREETS.crs)
Buff.columns = ['geometry']

"""
Explanation: Create 200-meter buffer of the streets then unary union
End of explanation
"""


# Random Points
np.random.seed(352)
x = np.random.uniform(shp_W.bbox[0], shp_W.bbox[2], 1000)
np.random.seed(850)
y = np.random.uniform(shp_W.bbox[1], shp_W.bbox[3], 1000)  
coords0= zip(x,y)
coords = [shapely.geometry.Point(i) for i in coords0]
Rand = gpd.GeoDataFrame(coords)
Rand.crs = STREETS.crs
Rand.columns = ['geometry']

"""
Explanation: Create random points within the bounds of WAVERLY.shp
End of explanation
"""


# Points in Buffer
Inter = [Buff['geometry'].intersection(p) for p in Rand['geometry']]
INTER = gpd.GeoDataFrame(Inter, crs=STREETS.crs)
INTER.columns = ['geometry']

"""
Explanation: Keep only the points within the unary union buffer
End of explanation
"""


# Add records that are points within the buffer
point_in = []
for p in INTER['geometry']:
    if type(p) == shapely.geometry.point.Point:
        point_in.append(p)
# Keep First 100 and last 15 for clients & service
CLIENT = gpd.GeoDataFrame(point_in[:100], crs=STREETS.crs)
CLIENT.columns = ['geometry']
SERVICE = gpd.GeoDataFrame(point_in[-15:], crs=STREETS.crs)
SERVICE.columns = ['geometry']
CLIENT.to_file(path+'CLIENT')
SERVICE.to_file(path+'SERVICE')

"""
Explanation: Simulate clients & service facilities by keeping the 100 and last 15 points, then write each out to a shapefile
End of explanation
"""


g = nx.Graph() # Roads & Nodes
g1 = nx.MultiGraph() # Edges and Vertices
GRAPH_client = nx.Graph() # Clients 
g_client = nx.Graph() # Snapped Clients
GRAPH_service = nx.Graph() # Service
g_service = nx.Graph() # Snapped Service

"""
Explanation: Instanitate non-solution graphs
End of explanation
"""


points_client = {} 
points_service = {}

CLI = ps.open(path+'CLIENT/CLIENT.shp')
for idx, coords in enumerate(CLI):
    GRAPH_client.add_node(idx)
    points_client[idx] = coords
    GRAPH_client.node[idx] = coords

SER = ps.open(path+'SERVICE/SERVICE.shp')
for idx, coords in enumerate(SER):
    GRAPH_service.add_node(idx)
    points_service[idx] = coords
    GRAPH_service.node[idx] = coords

"""
Explanation: Instantiate and fill Client and Service point dictionaries
End of explanation
"""


# Instantiate Client and Service .shp files
client = shp.Writer(shp.POINT) # Client Shapefile
# Add Random Points
for i,j in CLI:
    client.point(i,j)
# Add Fields
client.field('client_ID')
#client.field('Weight')
counter = 0
for i in range(len(CLI)):
    counter = counter + 1
    client.record('client_' + str(counter))
client.save(path+'Simulated/RandomPoints_CLIENT') # Save Shapefile 

service = shp.Writer(shp.POINT) #Service Shapefile
# Add Random Points
for i,j in SER:
    service.point(i,j)
# Add Fields
service.field('y_ID')
service.field('x_ID')
counter = 0
for i in range(len(SER)):
    counter = counter + 1
    service.record('y' + str(counter), 'x' + str(counter))
service.save(path+'Simulated/RandomPoints_SERVICE') # Save Shapefile 

"""
Explanation: Instantiate Client and Service .shp files
End of explanation
"""


Snap_C = ntw.snapobservations(path+'Simulated/RandomPoints_CLIENT.shp', 
                 'Rand_Points_CLIENT', attribute=True)
Snap_S = ntw.snapobservations(path+'Simulated/RandomPoints_SERVICE.shp', 
                 'Rand_Points_SERVICE', attribute=True)

"""
Explanation: Snap Client and Service to network
End of explanation
"""


# Create Lat & Lon lists of the snapped service locations
y_snapped = []
x_snapped = []
for i,j in ntw.pointpatterns['Rand_Points_SERVICE'].snapped_coordinates.iteritems():
    y_snapped.append(j[0]) 
    x_snapped.append(j[1])

# Create and write out snapped service .shp
service_SNAP = shp.Writer(shp.POINT) # Snapped Service Shapefile
# Add Points
for i,j in ntw.pointpatterns['Rand_Points_SERVICE'].snapped_coordinates.iteritems():
    service_SNAP.point(j[0],j[1])
# Add Fields
service_SNAP.field('y_ID')
service_SNAP.field('x_ID')
service_SNAP.field('LAT')
service_SNAP.field('LON')
counter = 0
for i in range(len(ntw.pointpatterns['Rand_Points_SERVICE'].snapped_coordinates)):
    counter = counter + 1
    service_SNAP.record('y' + str(counter), 'x' + str(counter), y_snapped[i], x_snapped[i])
    service_SNAP.save(path+'Snapped/SERVICE_Snapped') # Save Shapefile 

"""
Explanation: Create and write out snapped service .shp
End of explanation
"""


Buff.plot()
STREETS.plot()
CLIENT.plot()
SERVICE.plot(colormap=True)

"""
Explanation: What we have thus far:
End of explanation
"""


All_Neigh_Dist = ntw.allneighbordistances(    # in meters
                        sourcepattern=ntw.pointpatterns['Rand_Points_CLIENT'],
                        destpattern=ntw.pointpatterns['Rand_Points_SERVICE'])
All_Dist_MILES = All_Neigh_Dist * 0.000621371 # to miles

"""
Explanation: Client to Service Distance Matrix
End of explanation
"""


# p-Center Facility Location Problem
# This script creates a linear programming file to be read into an optimizer.
'''
GNU LESSER GENERAL PUBLIC LICENSE
                       Version 3, 29 June 2007

 Copyright (C) 2007 Free Software Foundation, Inc. <http://fsf.org/>
 Everyone is permitted to copy and distribute verbatim copies
 of this license document, but changing it is not allowed.
'''
# Developed by:  James D. Gaboardi, MSGIS
#                03/2015
#                James Gaboardi

#   Terminology & General Background for Facility Location and Summation Notation:

#   *        The objective of the p-center Facility Location Problem is 
#            to minimize the maximum cost of travel between service 
#            facilities and clients on a network.

#   *   [i] - a specific origin
#   *   [j] - a specifc destination
#   *   [n] - the set of origins
#   *   [m] - the set of destinations
#   *   [Cij] - travel costs between nodes
#   *   [W] - the maximum travel costs between service facilities and clients 
#   *   [x#_#] - the client decision variable
#   *   [y#] - the service decision variable
#   *   [p] - the number of facilities to be sited

#****************************************************************************
'''
|    How many candidate sites [p] can be selected?
'''
p = 5

#****************************************************************************


#    DEFINED FUNCTIONS
# Assignment Constraints
def get_assignment_constraints():
    outtext = ' '
    for i in range(1,rows+1):
        temp = ' '
        for j in range(1,cols+1):
            temp += 'x' + str(i) + '_' + str(j) + ' + '
        outtext += temp[:-2] + '= 1\n'
    return outtext

# Facility Constraint  
def get_p_facilities(p):
    outtext = ''
    for i in range(1, cols+1):
        temp = ''
        temp += 'y' + str(i)
        outtext += temp + ' + '
    outtext = ' ' + outtext[:-2] + '= '+ str(p) + '\n'
    return outtext

# Opening Constraints
def get_opening_constraints_p_center():
    outtext = ''
    for i in range(1, cols+1):
        for j in range(1, rows+1):
            outtext += ' - x' + str(j) + '_' + str(i) + ' + ' + 'y' + str(i) +  ' >= 0\n'
    return outtext

# Maximum Cost Constraints
def get_max_cost():
    outtext = ''
    for i in range(rows):
        temp = '  '
        for j in range(cols):
            temp += str(Cij[i,j]) + ' x' + str(i+1) + '_' + str(j+1) + ' + '
        outtext += temp[:-2] + '- W <= 0\n'
    return outtext

# Declaration of Bounds
def get_bounds_allocation():
    outtext = ' '
    for i in range(rows):
        temp = ''
        for j in range(cols):
            temp += ' 0 <= x' + str(i+1) + '_' + str(j+1) + ' <= 1\n'
        outtext += temp    
    return outtext

def get_bounds_facility():
    outtext = ''
    for i in range(cols):
        outtext += ' 0 <= y' + str(i+1) + ' <= 1\n'
    return outtext

# Declaration of Decision Variables (form can be: Binary, Integer, etc.)
def get_decision_variables_p_center():
    outtext = ' '
    for i in range(1, rows+1):
        temp = ''
        for j in range(1, cols+1):
            temp += 'x' + str(i) + '_' + str(j) + '\n'
        outtext += temp
    return outtext
    
def get_facility_decision_variables_p_center():  
    outtext = ''
    for i in range (1, cols+1):
        outtext += 'y' + str(i) + ' '
    return outtext    

#    DATA READS & VARIABLE DECLARATION
Cij = All_Dist_MILES
rows,cols = Cij.shape

#    START TEXT FOR .lp FILE
# Declaration of Objective Function
text = 'Minimize\n'
text += ' obj: W\n'
# Declaration of Constraints
text += 'Subject To\n'                    
text += get_assignment_constraints()
text += get_p_facilities(p)
text += get_opening_constraints_p_center()
text += get_max_cost()
# Declaration of Bounds
text += 'Bounds\n'
text += get_bounds_allocation()
text += get_bounds_facility()
# Declaration of Decision Variables form: Binaries
text += 'Binaries\n'
text += get_decision_variables_p_center()
text += get_facility_decision_variables_p_center()
text += '\n'
text += 'End\n'
text += "'''\n"
text += "James Gaboardi, 2015"
                
#   CREATE & WRITE .lp FILE TO DISK
# Fill path name  --  File name must not have spaces.
outfile = open(path+'LP_Files/pCenter_Manual.lp', 'w')
outfile.write(text)
outfile.close()

"""
Explanation: Create .lp file
End of explanation
"""


t1 = time.time()

gbp.setParam('MIPFocus', 2)                 # Set MIP focus to 'Optimal' --> 2
# Instantiate Optimization model from .lp file
Gurobi_PCP = gbp.read(path+'LP_Files/pCenter_Manual.lp')   

Gurobi_PCP.optimize()
t2 = time.time()-t1

# Record and Display Results
print '\n*************************************************************************'
selected_Gurobi_PCP = []
dbf1 = ps.open(path+'Snapped/SERVICE_Snapped.dbf')
NEW_Records_PCP_Gurobi = []
for v in Gurobi_PCP.getVars():
    if 'x' in v.VarName:
        pass
    elif 'W' in v.VarName:
        pass
    elif v.x > 0:
        var = '%s' % v.VarName
        selected_Gurobi_PCP.append(var)
        for i in range(dbf1.n_records):
            if var in dbf1.read_record(i):
                x = dbf1.read_record(i)
                NEW_Records_PCP_Gurobi.append(x)
            else:
                pass
        print '    |                                            ', var
print '    | Selected Facility Locations -------------- ^^^^ '
print '    | Candidate Facilities [p] ----------------- ', len(selected_Gurobi_PCP)
print '    | Objective Value (miles) ------------------ ', Gurobi_PCP.objVal
print '    | Real Time to Optimize (sec.) ------------- ', t2
print '*************************************************************************'
print ' -- The p-Center Problem Gurobi -- '

"""
Explanation: Solutions
Gurobi
End of explanation
"""


t1 = time.time()

# Instantiate Optimization model from .lp file
CPLEX_PCP = cp.Cplex(path+'LP_Files/pCenter_Manual.lp')   

# Set MIP Emphasis to '2' --> Optimal
CPLEX_PCP.parameters.emphasis.mip.set(2)

CPLEX_PCP.solve()
t2 = time.time()-t1

cplex_vars = []
counter = -1
for f in CPLEX_PCP.solution.get_values():
    counter = counter+1
    if 'y' in CPLEX_PCP.variables.get_names()[counter]:
        if f == 1:
            cplex_vars.append(CPLEX_PCP.variables.get_names()[counter])
print '*************************************************************************'
print '    | Selected Facility Locations -------------- ', cplex_vars
print '    | Candidate Facilities [p] ----------------- ', len(cplex_vars)
print '    | Objective Value (miles) ------------------ ', CPLEX_PCP.solution.get_objective_value()
print '    | Real Time to Optimize (sec.) ------------- ', t2
print '*************************************************************************'
print ' -- The p-Center Problem CPLEX -- '

"""
Explanation: CPLEX
End of explanation
"""


t1 = time.time()

Cy_PCP = CyClpSimplex()

Cy_PCP.readLp(path+'LP_Files/pCenter_Manual.lp')

Cy_PCP.primal()
Cy_PCP.dual()
CyBB_PCP = Cy_PCP.getCbcModel()
CyBB_PCP.branchAndBound()
CyBB_PCP.bestPossibleObjValue

selected_COIN_PCP = []
for i in CyBB_PCP.primalVariableSolution.sol.keys():
    if 'y' in i:
        selected_COIN_PCP.append(i)

t2 = time.time()-t1
        
print '*************************************************************************'
print '    | Selected Facility Locations -------------- ', selected_COIN_PCP
print '    | Candidate Facilities [p] ----------------- ', len(selected_COIN_PCP), '           '
print '    | Objective Value (miles) ------------------ ', CyBB_PCP.bestPossibleObjValue
print '    | Real Time to Optimize (sec.) ------------- ', t2
print '*************************************************************************'
print ' -- The p-Center Problem COIN-OR -- '

"""
Explanation: COIN-OR (CyLP with CLP and CBC)
End of explanation
"""


print 'Equal Objective Values? ', (round(Gurobi_PCP.objVal,15) == 
                                   round(CPLEX_PCP.solution.get_objective_value(),15) == 
                                   round(CyBB_PCP.bestPossibleObjValue,15))

"""
Explanation: Objective Values to 15 significant digits
End of explanation
"""


print 'Equal Objective Values? ', (round(Gurobi_PCP.objVal, 16) == 
                                   round(CPLEX_PCP.solution.get_objective_value(),16) == 
                                   round(CyBB_PCP.bestPossibleObjValue, 16))

"""
Explanation: Objective Values to 16 significant digits
End of explanation
"""


print 'Identical Facility Selection? ', (selected_Gurobi_PCP == 
                                           cplex_vars == 
                                           selected_COIN_PCP)

print 'Gurobi : ', selected_Gurobi_PCP
print 'CPLEX  : ', cplex_vars
print 'COIN-OR: ', selected_COIN_PCP

"""
Explanation: Site Selection
End of explanation
"""


p_center = pd.DataFrame(columns=
                        ['Obj. Value (15 sig.)', 'Obj. Value (16 sig.)'], 
                        index=['Gurobi', 'CPLEX', 'COIN-OR'])
pd.set_option('precision', 20)

objective_values_15 = [round(Gurobi_PCP.objVal,15), 
                        round(CPLEX_PCP.solution.get_objective_value(),15),
                        round(CyBB_PCP.bestPossibleObjValue,15)]
objective_values_16 = [round(Gurobi_PCP.objVal,16), 
                        round(CPLEX_PCP.solution.get_objective_value(),16),
                        round(CyBB_PCP.bestPossibleObjValue,16)]

p_center['Obj. Value (15 sig.)'] = objective_values_15
p_center['Obj. Value (16 sig.)'] = objective_values_16
p_center['Site Selection'] = selected_Gurobi_PCP, cplex_vars, selected_COIN_PCP
p_center.index.name = ' Optimizer ' 
p_center

"""
Explanation: Pandas dataframe
End of explanation
"""


p_list = []
for i in range(1, len(SER)+1):
    p = 'p'+str(i)
    p_list.append(p)

y_list = []
for i in range(1, len(SER)+1):
    y = 'y'+str(i)
    y_list.append(y)

points = SERVICE
points.to_crs(epsg=32616, inplace=True) # UTM 16N
LonLat_Dict = OrderedDict()
LonLat_List = []

for i,j in points['geometry'].iteritems():
    LonLat_Dict[y_list[i]] = utm.to_latlon(j.xy[0][-1], j.xy[1][-1], 16, 'N')  
    LonLat_List.append((utm.to_latlon(j.xy[0][-1], j.xy[1][-1], 16, 'N')))

Service_Lat_List = []
Service_Lon_List = []
    
for i in LonLat_List:
    Service_Lat_List.append(i[0])
for i in LonLat_List:
    Service_Lon_List.append(i[1])

"""
Explanation: The results are highly variable between these optimization suites, most likely due to different default settings and algorithms employeed.


 With various [p] the same facilities can be chosen with differing objective values and vice versa.


Coordinate conversion for Google Map overlay
End of explanation
"""


Gurobi_sites = []
for y in y_list:
    if y in selected_Gurobi_PCP:
        Gurobi_sites.append(' Selected ')
    else:
        Gurobi_sites.append(' -- ')
CPLEX_sites = []
for y in y_list:
    if y in cplex_vars:
        CPLEX_sites.append(' Selected ')
    else:
        CPLEX_sites.append(' -- ')
COIN_sites = []
for y in y_list:
    if y in selected_COIN_PCP:
        COIN_sites.append(' Selected ')
    else:
        COIN_sites.append(' -- ')

map_options = GMapOptions(lat=30.4855, lng=-84.265, map_type="hybrid", zoom=14)

plot = GMapPlot(
    x_range=DataRange1d(), 
    y_range=DataRange1d(), 
    map_options=map_options, title="Waverly Hills")

hover = HoverTool(tooltips="""
        <div>
            <div>
                
            </div>
            <div>
                <span style="font-size: 30px; font-weight: bold;">PCP Site @desc</span> 
            </div>
            <div>
                <span> \b </span>
            </div>
            <div>
                <span style="font-size: 17px; font-weight: bold;">Gurobi: </span> 
            </div>
                <div>
                <span style="font-size: 15px; font-weight: bold; color: #ff4d4d;">@select_center_Gurobi</span>
            </div>
            <div>
                <span> \b </span>
            </div>
            <div>
                <span style="font-size: 17px; font-weight: bold;">CPLEX</span> 
            
            <div>
                <span style="font-size: 14px; font-weight: bold; color: #00b300;">@select_center_CPLEX</span>
            </div>
            <div>
                <span> \b </span>
            </div>
            <div>
                <span style="font-size: 17px; font-weight: bold;">COIN-OR</span> 
            </div>
            <div>
                <span style="font-size: 14px; font-weight: bold; color: #3385ff;">@select_center_COIN</span>
            </div>
        </div>""")

source_1 = ColumnDataSource(
        data=dict(
        lat=Service_Lat_List,
        lon=Service_Lon_List,
        desc=y_list,
        select_center_Gurobi=Gurobi_sites,
        select_center_CPLEX=CPLEX_sites,
        select_center_COIN=COIN_sites))

facilties = Circle(x="lon", y="lat", 
                   size=10, fill_color="yellow", 
                   fill_alpha=0.6, line_color=None)

plot.add_glyph(source_1, facilties)

plot.add_tools(PanTool(), WheelZoomTool(), BoxSelectTool(), ResetTool(), hover)
output_file("gmap_plot.html")
show(plot)

"""
Explanation: Google Map overlay
End of explanation
"""


import datetime as dt
import os
import platform
import sys
import bokeh
import cylp

names = ['OSX', 'Processor ', 'Machine ', 'Python ','PySAL ','Gurobi ','Pandas ','GeoPandas ',
         'Shapely ', 'NumPy ', 'Bokeh ', 'CyLP', 'Date & Time']
versions = [platform.mac_ver()[0], platform.processor(), platform.machine(), platform.python_version(),
            ps.version, gbp.gurobi.version(), pd.__version__, gpd.__version__, 
            str(shapely.__version__), np.__version__, 
            bokeh.__version__, '0.7.1', dt.datetime.now()]
specs = pd.DataFrame(index=names, columns=['Version'])
specs.columns.name = 'Platform & Software Specs'
specs['Version'] = versions
specs # Pandas DF of specifications

"""
Explanation: System Specs
End of explanation
"""


