222 lines
7.2 KiB
Python
Executable File
222 lines
7.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# coding: utf-8
|
|
"""
|
|
Visualisation of Velib data.
|
|
|
|
The generated maps shows the number of available bikes for each stations,
|
|
plotted on the map of Paris. We use a Voronoi diagram to draw tiles on top of
|
|
Paris map (think of each tiles as the "area of influence" of a given Velib
|
|
station). Green represents a large number of available bikes, red represents a
|
|
low number of available bikes.
|
|
|
|
This script requires a couple of arguments from the command-line:
|
|
* The path to the SQLite DB file to use.
|
|
* The path to the folder in which generated images should be put.
|
|
* [Optional] A timestamp to start from, to resume operation for instance.
|
|
|
|
Note: This code does not take into account the stations events (change of
|
|
station size, new stations, stations deletion). Hence, there might be small
|
|
mistakes in the visualization, such as stations without data. This should be
|
|
handled in an improved version.
|
|
"""
|
|
from __future__ import division
|
|
|
|
import datetime
|
|
import logging
|
|
import os
|
|
import pickle
|
|
import sqlite3
|
|
import sys
|
|
|
|
import matplotlib
|
|
matplotlib.use('AGG') # Use non-interactive backend
|
|
import matplotlib.pyplot as plt
|
|
import progressbar # progressbar2 pip module
|
|
import smopy
|
|
|
|
from scipy.spatial import Voronoi, voronoi_plot_2d
|
|
|
|
|
|
def get_hue(percentage):
|
|
"""
|
|
Convert a percentage to a hue,
|
|
to map a percentage to a color
|
|
in the green - yellow - orange - red scale.
|
|
|
|
Red means 0%, green means 100%.
|
|
"""
|
|
value = (100 - percentage) / 100.0
|
|
hue = (1 - value) * 120
|
|
return hue / 360.0
|
|
|
|
|
|
# Handle arguments from command-line
|
|
if len(sys.argv) < 3:
|
|
sys.exit('Usage: %s db_file out_dir' % sys.argv[0])
|
|
db_file = sys.argv[1]
|
|
out_dir = sys.argv[2]
|
|
|
|
# Handle optional first timestamp argument
|
|
first_timestamp = None
|
|
if len(sys.argv) > 3:
|
|
first_timestamp = int(sys.argv[3])
|
|
|
|
# Init progressbar and logging
|
|
progressbar.streams.wrap_stderr() # Required before logging.basicConfig
|
|
logging.basicConfig(level=logging.INFO)
|
|
|
|
# Ensure out folder exists
|
|
if not os.path.isdir(out_dir):
|
|
logging.info('Creating output folder %s…', out_dir)
|
|
os.makedirs(out_dir)
|
|
|
|
|
|
# Load all stations from the database
|
|
logging.info('Loading all stations from the database…')
|
|
conn = sqlite3.connect(db_file)
|
|
c = conn.cursor()
|
|
stations = c.execute(
|
|
"SELECT id, latitude, longitude, bike_stands, name FROM stations"
|
|
).fetchall()
|
|
stations = [
|
|
station
|
|
for station in stations
|
|
if station[1] > 0 and station[2] > 0
|
|
] # Filter out invalid stations
|
|
logging.info('Loaded %d stations from database.', len(stations))
|
|
|
|
|
|
# Set tiles server and params
|
|
smopy.TILE_SERVER = "http://a.tile.stamen.com/toner-lite/{z}/{x}/{y}@2x.png"
|
|
smopy.TILE_SIZE = 512
|
|
smopy.MAXTILES = 25
|
|
|
|
# Compute map bounds as the extreme stations
|
|
lower_left_corner = (
|
|
min(station[1] for station in stations),
|
|
min(station[2] for station in stations)
|
|
)
|
|
upper_right_corner = (
|
|
max(station[1] for station in stations),
|
|
max(station[2] for station in stations)
|
|
)
|
|
|
|
# Get the tiles
|
|
# Note: Force zoom to 12 to still have city names (and not e.g. street names)
|
|
logging.info('Fetching tiles between %s and %s…' % (lower_left_corner, upper_right_corner))
|
|
map = smopy.Map(lower_left_corner + upper_right_corner, z=12)
|
|
|
|
|
|
# Compute the station points coordinates
|
|
# (converting from lat/lng to pixels for matplotlib)
|
|
station_points = [
|
|
map.to_pixels(station[1], station[2]) for station in stations
|
|
]
|
|
|
|
# Compute Voronoi diagram of available stations
|
|
logging.info('Computing Voronoi diagram of the stations…')
|
|
vor = Voronoi(station_points)
|
|
# This is a mapping between ID of stations and
|
|
# matching Voronoi tile, for faster reuse
|
|
vor_regions = {}
|
|
for point_index, region_index in enumerate(vor.point_region):
|
|
station_id = stations[point_index][0]
|
|
region = vor.regions[region_index]
|
|
if -1 in region: # Discard regions with points out of bounds
|
|
continue
|
|
vor_regions[station_id] = {
|
|
"polygon": [vor.vertices[i] for i in region], # Polygon, as a list of points
|
|
"mpl_surface": None # Will store the drawn matplotlib surface (to update it easily)
|
|
}
|
|
# Dumping Voronoi diagram
|
|
voronoi_file = os.path.join(out_dir, 'voronoi.dat')
|
|
with open(voronoi_file, 'wb') as fh:
|
|
pickle.dump(vor_regions, fh)
|
|
logging.info('Dumped Voronoi diagram to voronoi.dat')
|
|
|
|
|
|
# Plotting
|
|
logging.info('Initializing Matplotlib figure…')
|
|
map_img = map.to_pil()
|
|
aspect_ratio = map_img.size[1] / map_img.size[0]
|
|
|
|
# Create a matplotlib figure
|
|
fig, ax = plt.subplots(figsize=(8, 8 * aspect_ratio))
|
|
ax.set_xticks([])
|
|
ax.set_yticks([])
|
|
ax.grid(False)
|
|
# Compute bounds
|
|
# Note: This is necessary because OSM tiles have some spatial
|
|
# extension and might expand farther than the requested bounds.
|
|
x_min, y_min = map.to_pixels(lower_left_corner)
|
|
x_max, y_max = map.to_pixels(upper_right_corner)
|
|
ax.set_xlim(x_min, x_max)
|
|
ax.set_ylim(y_min, y_max)
|
|
ax.imshow(map_img)
|
|
|
|
# Initialize Voronoi Matplotlib surfaces to grey
|
|
logging.info('Initializing Voronoi surfaces in the figure…')
|
|
for station_id, region in vor_regions.items():
|
|
vor_regions[station_id]["mpl_surface"] = ax.fill(
|
|
alpha=0.25,
|
|
*zip(*region["polygon"]),
|
|
color="#9e9e9e"
|
|
)[0]
|
|
|
|
# Get time steps
|
|
logging.info('Loading time steps from the database.')
|
|
if first_timestamp:
|
|
time_data = c.execute(
|
|
"SELECT DISTINCT updated FROM stationsstats WHERE updated > ? ORDER BY updated ASC",
|
|
(first_timestamp,)
|
|
)
|
|
else:
|
|
time_data = c.execute(
|
|
"SELECT DISTINCT updated FROM stationsstats WHERE updated ORDER BY updated ASC"
|
|
)
|
|
last_t = None
|
|
timesteps = 5 * 60 * 1000 # 5 mins timesteps between each frames
|
|
|
|
logging.info('Plotting graphs!')
|
|
bar = progressbar.ProgressBar()
|
|
for t, in bar(time_data):
|
|
if last_t is None:
|
|
# Initialize last_t
|
|
last_t = t
|
|
|
|
# For each available station, retrieve its time data
|
|
c2 = conn.cursor()
|
|
stations_stats = c2.execute(
|
|
"SELECT station_id, available_bikes FROM stationsstats WHERE updated=?",
|
|
(t,)
|
|
)
|
|
|
|
for station_data in stations_stats:
|
|
# Compute the available bikes percentages for this station over time
|
|
bike_stands = next(station[3] for station in stations if station[0] == station_data[0])
|
|
percentage = station_data[1] / bike_stands * 100.0
|
|
if percentage > 100:
|
|
# TODO: This happens when a station has changed size inside the
|
|
# dataset. Should be handled better.
|
|
percentage = 100
|
|
# Plot "regions of influence" of the velib stations (Voronoi regions)
|
|
try:
|
|
region = vor_regions[station_data[0]]
|
|
region["mpl_surface"].set_color(matplotlib.colors.hsv_to_rgb([get_hue(percentage), 1.0, 1.0]))
|
|
except KeyError:
|
|
# This can happen for a station at the boundaries (we volontarily
|
|
# ignore them) or for station which disappeared at some point in
|
|
# the dataset (as we don't handle stations events by now).
|
|
logging.debug('Unknown Voronoi region for station %d.', station_data[0])
|
|
|
|
# Output frame if necessary
|
|
if t >= last_t + timesteps:
|
|
ax.set_title(datetime.datetime.fromtimestamp(t // 1000).strftime('%d/%m/%Y %H:%M'))
|
|
fig.tight_layout()
|
|
fig.savefig(os.path.join(out_dir, '%d.png' % t))
|
|
last_t = t
|
|
|
|
# Output last frame
|
|
fig.tight_layout()
|
|
fig.savefig(os.path.join(out_dir, '%d.png' % t))
|