Figea commited on
Commit
e4955e4
·
1 Parent(s): f5ffebb

First commit

Browse files
Dockerfile ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10.12-slim
2
+
3
+ RUN apt-get update && \
4
+ apt-get upgrade -y && \
5
+ python -m pip install --upgrade pip
6
+
7
+ COPY requirements.txt /
8
+
9
+ RUN pip install --no-cache-dir -r requirements.txt
10
+
11
+ COPY src/ /app/
12
+
13
+ WORKDIR /app
14
+
15
+ EXPOSE 7860
16
+
17
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "4", "flask_app:app"]
18
+
requirements.txt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ bokeh==3.6.2
2
+ flask==3.0.3
3
+ gunicorn==23.0.0
src/data/parking_general_information.csv ADDED
The diff for this file is too large to render. See raw diff
 
src/data/parking_occupancy_history.csv ADDED
The diff for this file is too large to render. See raw diff
 
src/flask_app.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from bokeh.embed import components
2
+ from flask import Flask, render_template
3
+ from scripts.bokeh_plot import bokeh_layout
4
+
5
+
6
+ app = Flask(__name__)
7
+
8
+ @app.route('/')
9
+ def index():
10
+
11
+
12
+ # Embed components
13
+ script, div = components(bokeh_layout)
14
+ return render_template('index.html', script=script, div=div)
15
+
16
+ if __name__ == "__main__":
17
+ app.run(host="0.0.0.0", port=7860, debug=True)
src/scripts/bokeh_plot.html ADDED
The diff for this file is too large to render. See raw diff
 
src/scripts/bokeh_plot.py ADDED
@@ -0,0 +1,418 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ from bokeh.layouts import column, row
3
+ from bokeh.models import CDSView, ColumnDataSource, CustomJS, DataTable, DateFormatter, DatetimeTickFormatter
4
+ from bokeh.models import HTMLTemplateFormatter, HoverTool, IndexFilter, TableColumn, TapTool
5
+ from bokeh.plotting import curdoc, figure, show
6
+ from bokeh.transform import linear_cmap
7
+ import datetime as dt
8
+ import math
9
+ import os
10
+ import pandas as pd
11
+ #from sqlalchemy import create_engine
12
+ import xyzservices.providers as xyz
13
+
14
+ # GENERAL_INFO_URL = "https://data.grandlyon.com/fr/datapusher/ws/rdata/lpa_mobilite.parking_lpa_2_0_0/all.csv?maxfeatures=-1&filename=parkings-lyon-parc-auto-metropole-lyon-v2"
15
+ DIRNAME = os.path.dirname(__file__)
16
+ REALTIME_CSV_FILEPATH = os.path.join(DIRNAME, "../data/parking_occupancy_history.csv")
17
+ GENERAL_INFO_CSV_FILEPATH = os.path.join(DIRNAME, "../data/parking_general_information.csv")
18
+
19
+ LATITUDE_LYON = 45.764043
20
+ LONGITUDE_LYON = 4.835659
21
+ PARKING_ID_HOMEPAGE = 'LPA0740'
22
+
23
+ # # Congigurate PostgreSQL connexion
24
+ # HOST = "localhost"
25
+ # PORT = "5432"
26
+ # DATABASE = "parking_data"
27
+ # USER = "postgres"
28
+ # PASSWORD = "****"
29
+ # TABLE_REALTIME = "parking_data"
30
+
31
+
32
+ def get_address(string_dict):
33
+ """
34
+ Extract an address from a string representing a dictionary.
35
+
36
+ Parameters:
37
+ - string_dict (str): A string containing address information.
38
+
39
+ Returns:
40
+ - str: A formatted address string (street, postal code, locality).
41
+ """
42
+
43
+ address_keys = ["schema:streetAddress", "schema:postalCode", "schema:addressLocality"]
44
+ string_dict = string_dict.strip('"').replace('"', "'")
45
+ string_dict = string_dict.replace("': ", '": "').replace(", '", '", "').replace("'\"", '"' ).replace("\"'", '"' ).replace("{'", '{"').replace("'}", '"}')
46
+ address_dict = ast.literal_eval(string_dict)
47
+ address = [str(address_dict.get(key)) for key in address_keys]
48
+ adress_string = " ".join(address)
49
+
50
+ return adress_string
51
+
52
+ def get_parking_capacity(capacity_str):
53
+ """
54
+ Parse and retrieve the 'mv:maximumValue' from a string representing a list of dictionaries.
55
+
56
+ The input string contains data in a JSON-like format, and this function extracts the
57
+ 'mv:maximumValue' from the last dictionary in the list.
58
+
59
+ Parameters:
60
+ - capacity_str (str): A string representation of a list of dictionaries.
61
+
62
+ Returns:
63
+ - int or None: The value of the 'mv:maximumValue' key, or None if the key is not present.
64
+
65
+ Raises:
66
+ - ValueError: If the input string cannot be evaluated as a valid list of dictionaries.
67
+ """
68
+
69
+ str_clean = capacity_str.replace("'", '"')
70
+ str_clean = str_clean.replace(": ,", ': None,')
71
+ data_list = eval(str_clean)
72
+ last_dict = data_list[-1]
73
+
74
+ return last_dict.get("mv:maximumValue")
75
+
76
+ def clean_phone_number(phone_number):
77
+ """
78
+ Format a phone number by ensuring it starts with '0' and adding spaces every 2 digits.
79
+
80
+ Parameters:
81
+ - phone_number (int or str): The input phone number.
82
+
83
+ Returns:
84
+ - str: A formatted phone number (e.g., "01 23 45 67 89").
85
+ """
86
+ if not pd.isna(phone_number):
87
+ phone_number = "0" + str(int(phone_number))
88
+ phone_number_slices_list = [phone_number[i: i+2] for i in range(0, 10, 2)]
89
+ phone_number = " ".join(phone_number_slices_list)
90
+ return phone_number
91
+
92
+ def latlon_to_webmercator(lat, lon):
93
+ """
94
+ Convert latitude and longitude to Web Mercator coordinates.
95
+
96
+ Parameters:
97
+ - lat (float): Latitude in degrees
98
+ - lon (float): Longitude in degrees
99
+
100
+ Returns:
101
+ - (float, float): Web Mercator x, y coordinates
102
+ """
103
+
104
+ R = 6378137 # Radius of the Earth in meters (WGS 84 standard)
105
+ x = R * math.radians(lon) # Convert longitude to radians and scale
106
+ y = R * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) # Transform latitude
107
+
108
+ return x, y
109
+
110
+ def normalize_number(nb, data_range, expected_range):
111
+ """
112
+ Normalize a number to fit within a target range while preserving its relative position.
113
+
114
+ This function maps a given input value (`nb`) from an original data range (`data_range`)
115
+ to a new expected target range (`expected_range`). The input number is scaled such that
116
+ its relative position in `data_range` is maintained in `expected_range`.
117
+
118
+ Parameters:
119
+ - nb (float): The input number to be normalized.
120
+ - data_range (tuple of float): A tuple containing two floats representing the input's original range (min, max).
121
+ - data_range[0] (float): The lower bound of the input's original range.
122
+ - data_range[1] (float): The upper bound of the input's original range.
123
+ - expected_range (tuple of float): A tuple containing two floats representing the desired target range (min, max).
124
+ - expected_range[0] (float): The lower bound of the desired target range.
125
+ - expected_range[1] (float): The upper bound of the desired target range.
126
+
127
+ Returns:
128
+ - float: The normalized value scaled to fit within the `expected_range`.
129
+ """
130
+ result = nb
131
+
132
+ if (data_range[1] - data_range[0]) != 0:
133
+ result = expected_range[0] + (nb - data_range[0]) / (data_range[1] - data_range[0]) * (expected_range[1] - expected_range[0])
134
+
135
+ return result
136
+
137
+ def prepare_general_info_dataframe(csv_filepath):
138
+ """
139
+ Preprocess parking data from a CSV file.
140
+
141
+ Reads the file at `csv_filepath`, cleans and formats the data,
142
+ including address, phone number, capacity, coordinates (in lat/lon and Web Mercator),
143
+ and fills missing values. Renames columns for clarity.
144
+
145
+ Parameters:
146
+ - csv_filepath (str): Path to the CSV file with parking information.
147
+
148
+ Returns:
149
+ - pd.DataFrame: A cleaned DataFrame with standardized columns for further processing.
150
+ """
151
+
152
+ df_general_info = pd.read_csv(csv_filepath, sep=";")
153
+ df_general_info['adresse'] = df_general_info['address'].apply(get_address)
154
+ df_general_info['capacité_total'] = df_general_info['capacity'].apply(get_parking_capacity)
155
+ df_general_info['téléphone'] = df_general_info['telephone'].apply(clean_phone_number)
156
+ df_general_info['lat'] = df_general_info['lat'].astype(str).str.replace(',', '.').astype(float)
157
+ df_general_info['lon'] = df_general_info['lon'].astype(str).str.replace(',', '.').astype(float)
158
+ df_general_info[["lon_mercator", "lat_mercator"]] = df_general_info.apply(
159
+ lambda row: pd.Series(latlon_to_webmercator(row["lat"], row["lon"])),
160
+ axis=1
161
+ )
162
+ df_general_info["resumetarifshoraires"] = df_general_info["resumetarifshoraires"].fillna(" ")
163
+ df_general_info.rename(
164
+ columns={
165
+ "name": "parking",
166
+ "url": "site_web",
167
+ "numberoflevels": "nombre de niveaux",
168
+ "vehicleheightlimitinm": "hauteur limite (mètre)",
169
+ "resumetarifshoraires": "tarifs",
170
+ },
171
+ inplace=True
172
+ )
173
+ return df_general_info
174
+
175
+ df_general_info = prepare_general_info_dataframe(GENERAL_INFO_CSV_FILEPATH)
176
+
177
+ # engine = create_engine(f"postgresql://{USER}:{PASSWORD}@{HOST}:{PORT}/{DATABASE}")
178
+ # query = f"SELECT * FROM {TABLE_REALTIME};"
179
+
180
+ # df_realtime = pd.read_sql_query(query, engine)
181
+
182
+ df_realtime = pd.read_csv(REALTIME_CSV_FILEPATH, index_col='id', parse_dates=[4])
183
+ df_realtime.rename(
184
+ columns={
185
+ "nb_of_available_parking_spaces": "nombre_de_places_disponibles",
186
+ },
187
+ inplace=True
188
+ )
189
+
190
+ df_global = pd.merge(
191
+ left=df_realtime,
192
+ right=df_general_info[['identifier',
193
+ 'parking',
194
+ 'site_web',
195
+ 'adresse',
196
+ 'nombre de niveaux',
197
+ 'hauteur limite (mètre)',
198
+ 'téléphone',
199
+ 'tarifs',
200
+ 'lon_mercator',
201
+ 'lat_mercator',
202
+ 'capacité_total']],
203
+ how='left', left_on='parking_id',
204
+ right_on='identifier'
205
+ )
206
+
207
+ df_global['heure'] = df_global['date'].apply(lambda x: x.strftime('%d %B %Y %H:%M:%S'))
208
+ df_global.sort_values('date', inplace=True)
209
+
210
+ df_more_recent_value = df_global.groupby('parking_id').agg({'date': 'max'})
211
+
212
+ df_map = df_global.merge(df_more_recent_value , on=['parking_id', 'date'])
213
+
214
+ # Select an arbitrary parking to initialize lineplot and table_plot for homepage template
215
+ df_homepage_line_plot = df_global[df_global['parking_id']==PARKING_ID_HOMEPAGE]
216
+ df_homepage_table = df_map[df_map['parking_id']==PARKING_ID_HOMEPAGE]
217
+
218
+ # Filter and transpose data for Bokeh Data Table display
219
+ filter_columns = [
220
+ "parking",
221
+ "heure",
222
+ "nombre_de_places_disponibles",
223
+ "capacité_total",
224
+ "nombre de niveaux",
225
+ "hauteur limite (mètre)",
226
+ "téléphone",
227
+ "tarifs",
228
+ "adresse"
229
+ ]
230
+ transposed_data = {
231
+ "Field": filter_columns,
232
+ "Value": [df_homepage_table.iloc[0][col] for col in filter_columns]
233
+ }
234
+
235
+ # PREPARE SOURCE FOR BOKEH PLOTTING
236
+ # ---------------------------------
237
+ source_original = ColumnDataSource(df_global)
238
+ source_line_plot = ColumnDataSource(df_homepage_line_plot)
239
+ source_map = ColumnDataSource(df_map)
240
+ source_table = ColumnDataSource(transposed_data)
241
+
242
+ # PREPARE MAP PLOTTING
243
+ # --------------------
244
+ circle_size_bounds = (10, 25)
245
+ available_spaces_range = (
246
+ min(source_map.data["nombre_de_places_disponibles"]),
247
+ max(source_map.data["nombre_de_places_disponibles"])
248
+ )
249
+ color_mapper = linear_cmap(field_name="nombre_de_places_disponibles",
250
+ palette="Viridis256",
251
+ low=available_spaces_range[0],
252
+ high=available_spaces_range[1])
253
+
254
+ normalized_circle_sizes = [normalize_number(x, available_spaces_range, circle_size_bounds)
255
+ for x in source_map.data["nombre_de_places_disponibles"]]
256
+
257
+ source_map.data['normalized_circle_size'] = normalized_circle_sizes
258
+
259
+
260
+ hover_map = HoverTool(
261
+ tooltips = [
262
+ ('nom', '@parking'),
263
+ ('places disponibles', "@nombre_de_places_disponibles"),
264
+ ('capacité', '@{capacité_total}'),
265
+ ],
266
+ )
267
+
268
+ lyon_x, lyon_y = latlon_to_webmercator(LATITUDE_LYON, LONGITUDE_LYON)
269
+ zoom_level = 10000
270
+
271
+ p_map = figure(
272
+ x_range=(lyon_x - zoom_level, lyon_x + zoom_level),
273
+ y_range=(lyon_y - zoom_level, lyon_y + zoom_level),
274
+ x_axis_type="mercator",
275
+ y_axis_type="mercator",
276
+ tools=[hover_map, 'pan', 'wheel_zoom']
277
+ )
278
+
279
+ tap_tool = TapTool()
280
+ p_map.add_tools(tap_tool)
281
+ p_map.toolbar.active_tap = tap_tool
282
+
283
+ p_map.add_tile(xyz.OpenStreetMap.Mapnik)
284
+
285
+ circle_renderer = p_map.scatter(
286
+ x="lon_mercator",
287
+ y="lat_mercator",
288
+ source=source_map,
289
+ size="normalized_circle_size",
290
+ fill_color=color_mapper,
291
+ fill_alpha=1
292
+ )
293
+
294
+ circle_renderer.nonselection_glyph = None
295
+ circle_renderer.selection_glyph = None
296
+
297
+ # PREPARE LINE PLOTTING
298
+ # ---------------------
299
+ hover_line_plot = HoverTool(
300
+ tooltips = [
301
+ ('Places disponibles', "@nombre_de_places_disponibles"),
302
+ ('Heure', '@date{%a-%H:%M:%S}'),
303
+ ],
304
+ formatters={'@date': 'datetime'},
305
+ )
306
+
307
+ p_line_plot = figure(
308
+ title=f"Historique des places disponibles",
309
+ height = 400,
310
+ width = 700,
311
+ x_axis_type="datetime",
312
+ x_axis_label="Date",
313
+ y_axis_label="Nombre de places disponibles",
314
+ tools=[hover_line_plot, "crosshair", "pan", "wheel_zoom"],
315
+
316
+ )
317
+ p_line_plot.line(
318
+ "date",
319
+ "nombre_de_places_disponibles",
320
+ source=source_line_plot,
321
+ line_width=2,
322
+ legend_field = "parking"
323
+ )
324
+
325
+ p_line_plot.xaxis.formatter = DatetimeTickFormatter(days="%d/%m/%Y")
326
+
327
+
328
+ # PREPARE DATA TABLE
329
+ # ------------------
330
+ columns_tranposed = [
331
+ TableColumn(field="Field", title="Champ"),
332
+ TableColumn(field="Value", title="Valeur"),
333
+ ]
334
+
335
+ data_table = DataTable(
336
+ source=source_table,
337
+ columns=columns_tranposed,
338
+ editable=True,
339
+ width=1000,
340
+ height=250,
341
+ index_position=None,
342
+ header_row=False,
343
+ )
344
+
345
+ cds_view = CDSView()
346
+ cds_view.filter = IndexFilter([0])
347
+
348
+ data_url = DataTable(
349
+ source=source_line_plot,
350
+ columns=[TableColumn(field="site_web", title="site web", formatter=HTMLTemplateFormatter(template='<a href="<%= site_web %>"><%= site_web %></a>'))],
351
+ editable=True,
352
+ width=600,
353
+ height=600,
354
+ index_position=None,
355
+ view=cds_view
356
+ )
357
+
358
+ callback = CustomJS(
359
+ args=dict(s1=source_map, s2=source_line_plot, s3=source_table, s4=source_original),
360
+ code=
361
+ """
362
+ var data_map = s1.data
363
+ var data_original = s4.data
364
+ var selected_index = cb_obj.indices[0]
365
+
366
+ if (selected_index !== undefined) {
367
+ var parking_id = data_map['identifier'][selected_index]
368
+
369
+ var line_plot_data = {};
370
+ for (var key in data_original) {
371
+ line_plot_data[key] = [];
372
+ }
373
+
374
+ for (var i = 0; i < data_original['parking_id'].length; i++) {
375
+ if (data_original['parking_id'][i] === parking_id) {
376
+ for (var key in data_original) {
377
+ line_plot_data[key].push(data_original[key][i]);
378
+ }
379
+ }
380
+ }
381
+
382
+ s2.data = line_plot_data
383
+
384
+ var max_date_index = 0
385
+ var max_date = new Date(Math.max(...line_plot_data['date'].map(d => new Date(d))))
386
+
387
+ for (var i = 0; i < line_plot_data['date'].length; i++) {
388
+ if (new Date(line_plot_data['date'][i]).getTime() === max_date.getTime()) {
389
+ max_date_index = i;
390
+ break;
391
+ }
392
+ }
393
+
394
+ // Assurez-vous que `line_plot_data` contient les valeurs requises
395
+ var filter_columns = ["parking", "heure", "capacité_total", "nombre_de_places_disponibles", "nombre de niveaux", "hauteur limite (mètre)", "téléphone", "tarifs", "adresse"];
396
+ var table_data = {
397
+ "Field": [],
398
+ "Value": []
399
+ };
400
+
401
+ for (var key of filter_columns) {
402
+ var value = line_plot_data[key][max_date_index];
403
+
404
+ table_data["Field"].push(key);
405
+ table_data["Value"].push(value);
406
+ }
407
+
408
+ s3.data = table_data
409
+ }
410
+ """
411
+ )
412
+
413
+ source_map.selected.js_on_change('indices', callback)
414
+
415
+ first_row = row([p_map, p_line_plot])
416
+
417
+ bokeh_layout = column([first_row, data_table, data_url])
418
+ show(bokeh_layout)
src/templates/index.html ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Analyse en temps réel de l'occupation de parkings à Lyon</title>
7
+
8
+ <!-- Manually added Bokeh scripts -->
9
+ <script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.6.2.min.js"></script>
10
+ <script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.6.2.min.js"></script>
11
+ <script src="https://cdn.bokeh.org/bokeh/release/bokeh-tables-3.6.2.min.js"></script>
12
+
13
+ <style>
14
+ body {
15
+ display: flex;
16
+ flex-direction: column;
17
+ align-items: center;
18
+ font-family: Arial, sans-serif;
19
+ }
20
+ .bokeh-row {
21
+ display: flex;
22
+ justify-content: center;
23
+ gap: 20px;
24
+ }
25
+ h1 {
26
+ font-size: 2.5rem;
27
+ margin: 20px 0;
28
+ text-align: center;
29
+ color: #333;
30
+ }
31
+ </style>
32
+ {{ script|safe }}
33
+ </head>
34
+ <body>
35
+ <h1>Analyse en temps réel de l'occupation de parkings à Lyon</h1>
36
+ <div class="bokeh-row">
37
+ {{ div|safe }}
38
+ </div>
39
+ </body>
40
+ </html>