Spaces:
Sleeping
Sleeping
Upload 4 files
Browse filesAdd Google Maps ETA and ui fixes
- utils/config.py +103 -102
- utils/home.py +1 -1
- utils/predict.py +454 -442
- utils/utils.py +172 -169
utils/config.py
CHANGED
@@ -1,102 +1,103 @@
|
|
1 |
-
import os
|
2 |
-
from dotenv import
|
3 |
-
from pathlib import Path
|
4 |
-
from ipyleaflet import basemaps
|
5 |
-
|
6 |
-
from shiny.express import ui
|
7 |
-
|
8 |
-
|
9 |
-
# Paths
|
10 |
-
# ENV when using standalone shiny server, shiny for python runs from the root of the project
|
11 |
-
ENV_PATH = Path("
|
12 |
-
|
13 |
-
DATA = Path(__file__).parent.parent / "data/"
|
14 |
-
TEST_FILE = DATA / "Test.csv"
|
15 |
-
TRAIN_FILE = DATA / "Train.csv"
|
16 |
-
WEATHER_FILE = DATA / "Weather.csv"
|
17 |
-
HISTORY = DATA / "history/"
|
18 |
-
HISTORY_FILE = HISTORY / "history.csv"
|
19 |
-
|
20 |
-
|
21 |
-
# Models
|
22 |
-
ALL_MODELS = [
|
23 |
-
"AdaBoostRegressor",
|
24 |
-
"DecisionTreeRegressor",
|
25 |
-
"GradientBoostingRegressor",
|
26 |
-
"HistGradientBoostingRegressor",
|
27 |
-
"LinearRegression",
|
28 |
-
# "RandomForestRegressor",
|
29 |
-
"XGBRegressor",
|
30 |
-
]
|
31 |
-
|
32 |
-
BEST_MODELS = ["RandomForestRegressor", "XGBRegressor"]
|
33 |
-
|
34 |
-
|
35 |
-
# Urls
|
36 |
-
TEST_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Test.csv"
|
37 |
-
TRAIN_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Train.csv"
|
38 |
-
WEATHER_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Weather.csv"
|
39 |
-
|
40 |
-
|
41 |
-
# Load environment variables from .env file into a dictionary
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
# Google Maps Directions API
|
46 |
-
# https://maps.googleapis.com/maps/api/distancematrix/
|
47 |
-
MAPS_API_KEY = os.getenv("MAPS_API_KEY")
|
48 |
-
|
49 |
-
# https://maps.app.goo.gl/Fx5rdPs1KeA6jCeB8
|
50 |
-
KENYA_LAT = 0.15456
|
51 |
-
KENYA_LON = 37.908383
|
52 |
-
|
53 |
-
|
54 |
-
BASEMAPS = {
|
55 |
-
"DarkMatter": basemaps.CartoDB.DarkMatter,
|
56 |
-
"Mapnik": basemaps.OpenStreetMap.Mapnik,
|
57 |
-
"NatGeoWorldMap": basemaps.Esri.NatGeoWorldMap,
|
58 |
-
"WorldImagery": basemaps.Esri.WorldImagery,
|
59 |
-
}
|
60 |
-
|
61 |
-
# Yassir
|
62 |
-
BRANDCOLORS = {
|
63 |
-
"red": "#FB2576",
|
64 |
-
"purple-light": "#6316DB",
|
65 |
-
"purple-dark": "#08031A",
|
66 |
-
}
|
67 |
-
|
68 |
-
BRANDTHEMES = {
|
69 |
-
"red": ui.value_box_theme(bg=BRANDCOLORS['red'], fg='white'),
|
70 |
-
"purple-light": ui.value_box_theme(bg=BRANDCOLORS['purple-light'], fg='white'),
|
71 |
-
"purple-dark": ui.value_box_theme(bg=BRANDCOLORS['purple-dark'], fg='white'),
|
72 |
-
}
|
73 |
-
|
74 |
-
|
75 |
-
# Nairobi, https://maps.app.goo.gl/oPbLBYHuicjrC22J9
|
76 |
-
# National Museum of Kenya, https://maps.app.goo.gl/zbmUpe71admABU9i9
|
77 |
-
# Closest location
|
78 |
-
LOCATIONS = {
|
79 |
-
"Nairobi": {"latitude": -1.3032036, "longitude": 36.6825914},
|
80 |
-
"National Museum of Kenya": {"latitude": -1.2739575, "longitude": 36.8118501},
|
81 |
-
"Mombasa": {"latitude": -1.3293123, "longitude": 36.8717466},
|
82 |
-
}
|
83 |
-
|
84 |
-
|
85 |
-
HOURS = [f"{i:02}" for i in range(0, 24)]
|
86 |
-
|
87 |
-
MINUTES = [f"{i:02}" for i in range(0, 12)]
|
88 |
-
|
89 |
-
SECONDS = [f"{i:02}" for i in range(0, 60)]
|
90 |
-
|
91 |
-
|
92 |
-
ONE_MINUTE_SEC = 60
|
93 |
-
|
94 |
-
ONE_HOUR_SEC = ONE_MINUTE_SEC * 60
|
95 |
-
|
96 |
-
ONE_DAY_SEC = ONE_HOUR_SEC * 24
|
97 |
-
|
98 |
-
ONE_WEEK_SEC = ONE_DAY_SEC * 7
|
99 |
-
|
100 |
-
|
101 |
-
# Default trip distance
|
102 |
-
TRIP_DISTANCE = 30275.7
|
|
|
|
1 |
+
import os
|
2 |
+
from dotenv import load_dotenv
|
3 |
+
from pathlib import Path
|
4 |
+
from ipyleaflet import basemaps
|
5 |
+
|
6 |
+
from shiny.express import ui
|
7 |
+
|
8 |
+
|
9 |
+
# Paths
|
10 |
+
# ENV when using standalone shiny server, shiny for python runs from the root of the project
|
11 |
+
ENV_PATH = Path("online.env")
|
12 |
+
|
13 |
+
DATA = Path(__file__).parent.parent / "data/"
|
14 |
+
TEST_FILE = DATA / "Test.csv"
|
15 |
+
TRAIN_FILE = DATA / "Train.csv"
|
16 |
+
WEATHER_FILE = DATA / "Weather.csv"
|
17 |
+
HISTORY = DATA / "history/"
|
18 |
+
HISTORY_FILE = HISTORY / "history.csv"
|
19 |
+
|
20 |
+
|
21 |
+
# Models
|
22 |
+
ALL_MODELS = [
|
23 |
+
"AdaBoostRegressor",
|
24 |
+
"DecisionTreeRegressor",
|
25 |
+
"GradientBoostingRegressor",
|
26 |
+
"HistGradientBoostingRegressor",
|
27 |
+
"LinearRegression",
|
28 |
+
# "RandomForestRegressor",
|
29 |
+
"XGBRegressor",
|
30 |
+
]
|
31 |
+
|
32 |
+
BEST_MODELS = ["RandomForestRegressor", "XGBRegressor"]
|
33 |
+
|
34 |
+
|
35 |
+
# Urls
|
36 |
+
TEST_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Test.csv"
|
37 |
+
TRAIN_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Train.csv"
|
38 |
+
WEATHER_FILE_URL = "https://raw.githubusercontent.com/valiantezabuku/Yassir-ETA-Prediction-Challenge-For-Azubian-Team-Curium/main/Data/Weather.csv"
|
39 |
+
|
40 |
+
|
41 |
+
# Load environment variables from .env file into a dictionary
|
42 |
+
load_dotenv(ENV_PATH)
|
43 |
+
|
44 |
+
|
45 |
+
# Google Maps Directions API
|
46 |
+
# https://maps.googleapis.com/maps/api/distancematrix/
|
47 |
+
MAPS_API_KEY = os.getenv("MAPS_API_KEY")
|
48 |
+
|
49 |
+
# https://maps.app.goo.gl/Fx5rdPs1KeA6jCeB8
|
50 |
+
KENYA_LAT = 0.15456
|
51 |
+
KENYA_LON = 37.908383
|
52 |
+
|
53 |
+
|
54 |
+
BASEMAPS = {
|
55 |
+
"DarkMatter": basemaps.CartoDB.DarkMatter,
|
56 |
+
"Mapnik": basemaps.OpenStreetMap.Mapnik,
|
57 |
+
"NatGeoWorldMap": basemaps.Esri.NatGeoWorldMap,
|
58 |
+
"WorldImagery": basemaps.Esri.WorldImagery,
|
59 |
+
}
|
60 |
+
|
61 |
+
# Yassir
|
62 |
+
BRANDCOLORS = {
|
63 |
+
"red": "#FB2576",
|
64 |
+
"purple-light": "#6316DB",
|
65 |
+
"purple-dark": "#08031A",
|
66 |
+
}
|
67 |
+
|
68 |
+
BRANDTHEMES = {
|
69 |
+
"red": ui.value_box_theme(bg=BRANDCOLORS['red'], fg='white'),
|
70 |
+
"purple-light": ui.value_box_theme(bg=BRANDCOLORS['purple-light'], fg='white'),
|
71 |
+
"purple-dark": ui.value_box_theme(bg=BRANDCOLORS['purple-dark'], fg='white'),
|
72 |
+
}
|
73 |
+
|
74 |
+
|
75 |
+
# Nairobi, https://maps.app.goo.gl/oPbLBYHuicjrC22J9
|
76 |
+
# National Museum of Kenya, https://maps.app.goo.gl/zbmUpe71admABU9i9
|
77 |
+
# Closest location
|
78 |
+
LOCATIONS = {
|
79 |
+
"Nairobi": {"latitude": -1.3032036, "longitude": 36.6825914},
|
80 |
+
"National Museum of Kenya": {"latitude": -1.2739575, "longitude": 36.8118501},
|
81 |
+
"Mombasa": {"latitude": -1.3293123, "longitude": 36.8717466},
|
82 |
+
}
|
83 |
+
|
84 |
+
|
85 |
+
HOURS = [f"{i:02}" for i in range(0, 24)]
|
86 |
+
|
87 |
+
MINUTES = [f"{i:02}" for i in range(0, 12)]
|
88 |
+
|
89 |
+
SECONDS = [f"{i:02}" for i in range(0, 60)]
|
90 |
+
|
91 |
+
|
92 |
+
ONE_MINUTE_SEC = 60
|
93 |
+
|
94 |
+
ONE_HOUR_SEC = ONE_MINUTE_SEC * 60
|
95 |
+
|
96 |
+
ONE_DAY_SEC = ONE_HOUR_SEC * 24
|
97 |
+
|
98 |
+
ONE_WEEK_SEC = ONE_DAY_SEC * 7
|
99 |
+
|
100 |
+
|
101 |
+
# Default trip distance
|
102 |
+
TRIP_DISTANCE = 30275.7
|
103 |
+
ETA = 18000
|
utils/home.py
CHANGED
@@ -73,7 +73,7 @@ def home_page(input: Inputs, output: Outputs, session: Session):
|
|
73 |
For companies like Yassir, the ability to
|
74 |
""",
|
75 |
ui.strong("predict the estimated time of arrival (ETA)"),
|
76 |
-
"for trips in ",
|
77 |
ui.strong("real-time"),
|
78 |
" is crucial. Our mission is to enhance the Yassir experience by leveraging ",
|
79 |
ui.strong("data"),
|
|
|
73 |
For companies like Yassir, the ability to
|
74 |
""",
|
75 |
ui.strong("predict the estimated time of arrival (ETA)"),
|
76 |
+
" for trips in ",
|
77 |
ui.strong("real-time"),
|
78 |
" is crucial. Our mission is to enhance the Yassir experience by leveraging ",
|
79 |
ui.strong("data"),
|
utils/predict.py
CHANGED
@@ -1,442 +1,454 @@
|
|
1 |
-
import os
|
2 |
-
import re
|
3 |
-
import sys
|
4 |
-
from dotenv import load_dotenv
|
5 |
-
from datetime import datetime
|
6 |
-
import time
|
7 |
-
import logging
|
8 |
-
|
9 |
-
import httpx
|
10 |
-
import pandas as pd
|
11 |
-
|
12 |
-
from pydantic import BaseModel, Field
|
13 |
-
from typing import List, Optional
|
14 |
-
|
15 |
-
from shiny import reactive, Inputs, Outputs, Session
|
16 |
-
from shiny.express import module, render, ui
|
17 |
-
from shinywidgets import render_widget
|
18 |
-
|
19 |
-
import ipyleaflet as L
|
20 |
-
from faicons import icon_svg
|
21 |
-
from geopy.distance import geodesic
|
22 |
-
|
23 |
-
from utils.utils import *
|
24 |
-
from utils.config import LOCATIONS, BRANDTHEMES, KENYA_LAT, KENYA_LON, HOURS, MINUTES, SECONDS, ALL_MODELS
|
25 |
-
from utils.config import HISTORY_FILE, ENV_PATH
|
26 |
-
from utils.url_to_coordinates import get_full_url, on_convert
|
27 |
-
|
28 |
-
load_dotenv(ENV_PATH)
|
29 |
-
|
30 |
-
|
31 |
-
class EtaFeatures(BaseModel):
|
32 |
-
timestamp: List[datetime] = Field(
|
33 |
-
description="Timestamp: Time that the trip was started")
|
34 |
-
origin_lat: List[float] = Field(
|
35 |
-
description="Origin_lat: Origin latitude (in degrees)")
|
36 |
-
origin_lon: List[float] = Field(
|
37 |
-
description="Origin_lon: Origin longitude (in degrees)")
|
38 |
-
destination_lat: List[float] = Field(
|
39 |
-
description="Destination_lat: Destination latitude (in degrees)")
|
40 |
-
destination_lon: List[float] = Field(
|
41 |
-
description="Destination_lon: Destination longitude (in degrees)")
|
42 |
-
trip_distance: List[float] = Field(
|
43 |
-
description="Trip_distance: Distance in meters on a driving route")
|
44 |
-
|
45 |
-
|
46 |
-
# Log
|
47 |
-
logging.basicConfig(level=logging.ERROR,
|
48 |
-
format='%(asctime)s - %(levelname)s - %(message)s')
|
49 |
-
|
50 |
-
|
51 |
-
lat_min, lat_max, lon_min, lon_max = get_bounds(country='Kenya')
|
52 |
-
|
53 |
-
|
54 |
-
async def endpoint(model_name: str) -> str:
|
55 |
-
api_url = os.getenv("API_URL")
|
56 |
-
model_endpoint = f"{api_url}={model_name}"
|
57 |
-
return model_endpoint
|
58 |
-
|
59 |
-
|
60 |
-
async def predict_eta(data: EtaFeatures, model_name: str) -> Optional[float]:
|
61 |
-
prediction = None
|
62 |
-
try:
|
63 |
-
# Get model endpoint
|
64 |
-
model_endpoint = await endpoint(model_name)
|
65 |
-
|
66 |
-
if "pyodide" in sys.modules:
|
67 |
-
import pyodide.http
|
68 |
-
|
69 |
-
response = await pyodide.http.pyfetch(
|
70 |
-
model_endpoint,
|
71 |
-
method="POST",
|
72 |
-
body=data,
|
73 |
-
headers={"Content-Type": "application/json"}
|
74 |
-
)
|
75 |
-
|
76 |
-
# Handle the response
|
77 |
-
if response.ok:
|
78 |
-
# .json() parses the response as JSON and converts to dictionary.
|
79 |
-
result = await response.json()['result']
|
80 |
-
|
81 |
-
else:
|
82 |
-
# Send POST request with JSON data using the json parameter
|
83 |
-
async with httpx.AsyncClient() as client:
|
84 |
-
response = await client.post(model_endpoint, json=data, timeout=30)
|
85 |
-
response.raise_for_status() # Ensure we catch any HTTP errors
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
df
|
96 |
-
df['
|
97 |
-
df['
|
98 |
-
|
99 |
-
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
ui.
|
131 |
-
|
132 |
-
|
133 |
-
@reactive.
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
ui.
|
138 |
-
|
139 |
-
|
140 |
-
ui.
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
@reactive.
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
170 |
-
|
171 |
-
|
172 |
-
|
173 |
-
|
174 |
-
|
175 |
-
|
176 |
-
|
177 |
-
|
178 |
-
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
-
|
183 |
-
|
184 |
-
|
185 |
-
|
186 |
-
|
187 |
-
|
188 |
-
|
189 |
-
|
190 |
-
|
191 |
-
|
192 |
-
|
193 |
-
|
194 |
-
|
195 |
-
|
196 |
-
|
197 |
-
|
198 |
-
|
199 |
-
|
200 |
-
|
201 |
-
|
202 |
-
|
203 |
-
|
204 |
-
|
205 |
-
|
206 |
-
ui.
|
207 |
-
ui.
|
208 |
-
|
209 |
-
|
210 |
-
|
211 |
-
|
212 |
-
|
213 |
-
|
214 |
-
|
215 |
-
|
216 |
-
|
217 |
-
"
|
218 |
-
|
219 |
-
|
220 |
-
|
221 |
-
|
222 |
-
|
223 |
-
|
224 |
-
|
225 |
-
"
|
226 |
-
|
227 |
-
|
228 |
-
|
229 |
-
|
230 |
-
|
231 |
-
|
232 |
-
|
233 |
-
|
234 |
-
|
235 |
-
|
236 |
-
|
237 |
-
|
238 |
-
|
239 |
-
|
240 |
-
|
241 |
-
|
242 |
-
|
243 |
-
|
244 |
-
|
245 |
-
|
246 |
-
|
247 |
-
|
248 |
-
|
249 |
-
|
250 |
-
|
251 |
-
|
252 |
-
|
253 |
-
|
254 |
-
|
255 |
-
|
256 |
-
|
257 |
-
|
258 |
-
|
259 |
-
|
260 |
-
|
261 |
-
|
262 |
-
|
263 |
-
|
264 |
-
|
265 |
-
|
266 |
-
# print(
|
267 |
-
|
268 |
-
|
269 |
-
|
270 |
-
'
|
271 |
-
'
|
272 |
-
'
|
273 |
-
'
|
274 |
-
'
|
275 |
-
|
276 |
-
|
277 |
-
|
278 |
-
|
279 |
-
|
280 |
-
|
281 |
-
|
282 |
-
|
283 |
-
|
284 |
-
|
285 |
-
|
286 |
-
|
287 |
-
|
288 |
-
|
289 |
-
|
290 |
-
|
291 |
-
|
292 |
-
|
293 |
-
|
294 |
-
|
295 |
-
|
296 |
-
|
297 |
-
|
298 |
-
|
299 |
-
|
300 |
-
|
301 |
-
|
302 |
-
|
303 |
-
|
304 |
-
|
305 |
-
|
306 |
-
|
307 |
-
|
308 |
-
|
309 |
-
|
310 |
-
|
311 |
-
|
312 |
-
|
313 |
-
|
314 |
-
|
315 |
-
|
316 |
-
|
317 |
-
|
318 |
-
|
319 |
-
|
320 |
-
|
321 |
-
|
322 |
-
|
323 |
-
|
324 |
-
|
325 |
-
|
326 |
-
|
327 |
-
|
328 |
-
|
329 |
-
|
330 |
-
|
331 |
-
|
332 |
-
|
333 |
-
|
334 |
-
|
335 |
-
|
336 |
-
|
337 |
-
|
338 |
-
|
339 |
-
|
340 |
-
|
341 |
-
|
342 |
-
|
343 |
-
|
344 |
-
|
345 |
-
|
346 |
-
|
347 |
-
|
348 |
-
|
349 |
-
|
350 |
-
|
351 |
-
|
352 |
-
|
353 |
-
|
354 |
-
|
355 |
-
|
356 |
-
|
357 |
-
|
358 |
-
|
359 |
-
|
360 |
-
|
361 |
-
|
362 |
-
|
363 |
-
|
364 |
-
|
365 |
-
|
366 |
-
|
367 |
-
|
368 |
-
|
369 |
-
|
370 |
-
|
371 |
-
|
372 |
-
|
373 |
-
|
374 |
-
|
375 |
-
|
376 |
-
|
377 |
-
|
378 |
-
|
379 |
-
|
380 |
-
|
381 |
-
|
382 |
-
|
383 |
-
|
384 |
-
|
385 |
-
|
386 |
-
|
387 |
-
|
388 |
-
|
389 |
-
|
390 |
-
|
391 |
-
|
392 |
-
|
393 |
-
|
394 |
-
|
395 |
-
|
396 |
-
|
397 |
-
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
-
|
402 |
-
|
403 |
-
|
404 |
-
|
405 |
-
|
406 |
-
|
407 |
-
|
408 |
-
|
409 |
-
|
410 |
-
|
411 |
-
|
412 |
-
|
413 |
-
|
414 |
-
|
415 |
-
|
416 |
-
|
417 |
-
|
418 |
-
|
419 |
-
|
420 |
-
|
421 |
-
|
422 |
-
|
423 |
-
|
424 |
-
|
425 |
-
|
426 |
-
|
427 |
-
|
428 |
-
|
429 |
-
|
430 |
-
|
431 |
-
|
432 |
-
|
433 |
-
|
434 |
-
|
435 |
-
|
436 |
-
|
437 |
-
|
438 |
-
|
439 |
-
|
440 |
-
|
441 |
-
|
442 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
import os
|
2 |
+
import re
|
3 |
+
import sys
|
4 |
+
from dotenv import load_dotenv
|
5 |
+
from datetime import datetime
|
6 |
+
import time
|
7 |
+
import logging
|
8 |
+
|
9 |
+
import httpx
|
10 |
+
import pandas as pd
|
11 |
+
|
12 |
+
from pydantic import BaseModel, Field
|
13 |
+
from typing import List, Optional
|
14 |
+
|
15 |
+
from shiny import reactive, Inputs, Outputs, Session
|
16 |
+
from shiny.express import module, render, ui
|
17 |
+
from shinywidgets import render_widget
|
18 |
+
|
19 |
+
import ipyleaflet as L
|
20 |
+
from faicons import icon_svg
|
21 |
+
from geopy.distance import geodesic
|
22 |
+
|
23 |
+
from utils.utils import *
|
24 |
+
from utils.config import LOCATIONS, BRANDTHEMES, KENYA_LAT, KENYA_LON, HOURS, MINUTES, SECONDS, ALL_MODELS
|
25 |
+
from utils.config import HISTORY_FILE, ENV_PATH
|
26 |
+
from utils.url_to_coordinates import get_full_url, on_convert
|
27 |
+
|
28 |
+
load_dotenv(ENV_PATH)
|
29 |
+
|
30 |
+
|
31 |
+
class EtaFeatures(BaseModel):
|
32 |
+
timestamp: List[datetime] = Field(
|
33 |
+
description="Timestamp: Time that the trip was started")
|
34 |
+
origin_lat: List[float] = Field(
|
35 |
+
description="Origin_lat: Origin latitude (in degrees)")
|
36 |
+
origin_lon: List[float] = Field(
|
37 |
+
description="Origin_lon: Origin longitude (in degrees)")
|
38 |
+
destination_lat: List[float] = Field(
|
39 |
+
description="Destination_lat: Destination latitude (in degrees)")
|
40 |
+
destination_lon: List[float] = Field(
|
41 |
+
description="Destination_lon: Destination longitude (in degrees)")
|
42 |
+
trip_distance: List[float] = Field(
|
43 |
+
description="Trip_distance: Distance in meters on a driving route")
|
44 |
+
|
45 |
+
|
46 |
+
# Log
|
47 |
+
logging.basicConfig(level=logging.ERROR,
|
48 |
+
format='%(asctime)s - %(levelname)s - %(message)s')
|
49 |
+
|
50 |
+
|
51 |
+
lat_min, lat_max, lon_min, lon_max = get_bounds(country='Kenya')
|
52 |
+
|
53 |
+
|
54 |
+
async def endpoint(model_name: str) -> str:
|
55 |
+
api_url = os.getenv("API_URL")
|
56 |
+
model_endpoint = f"{api_url}={model_name}"
|
57 |
+
return model_endpoint
|
58 |
+
|
59 |
+
|
60 |
+
async def predict_eta(data: EtaFeatures, model_name: str) -> Optional[float]:
|
61 |
+
prediction = None
|
62 |
+
try:
|
63 |
+
# Get model endpoint
|
64 |
+
model_endpoint = await endpoint(model_name)
|
65 |
+
|
66 |
+
if "pyodide" in sys.modules:
|
67 |
+
import pyodide.http
|
68 |
+
|
69 |
+
response = await pyodide.http.pyfetch(
|
70 |
+
model_endpoint,
|
71 |
+
method="POST",
|
72 |
+
body=data,
|
73 |
+
headers={"Content-Type": "application/json"}
|
74 |
+
)
|
75 |
+
|
76 |
+
# Handle the response
|
77 |
+
if response.ok:
|
78 |
+
# .json() parses the response as JSON and converts to dictionary.
|
79 |
+
result = await response.json()['result']
|
80 |
+
|
81 |
+
else:
|
82 |
+
# Send POST request with JSON data using the json parameter
|
83 |
+
async with httpx.AsyncClient() as client:
|
84 |
+
response = await client.post(model_endpoint, json=data, timeout=30)
|
85 |
+
response.raise_for_status() # Ensure we catch any HTTP errors
|
86 |
+
|
87 |
+
# print(response.json())
|
88 |
+
if (response.status_code == 200):
|
89 |
+
result = response.json()['result']
|
90 |
+
|
91 |
+
if result:
|
92 |
+
prediction = float(result['prediction'][0])
|
93 |
+
|
94 |
+
# Create dataframe
|
95 |
+
df = pd.DataFrame.from_dict(data)
|
96 |
+
df['eta_prediction'] = prediction
|
97 |
+
df['time_of_prediction'] = pd.Timestamp(datetime.now())
|
98 |
+
df['model_used'] = model_name
|
99 |
+
|
100 |
+
# Save to history csv file
|
101 |
+
df.to_csv(HISTORY_FILE, mode='a',
|
102 |
+
header=not (HISTORY_FILE.exists()), index=False)
|
103 |
+
except Exception as e:
|
104 |
+
logging.error(f"Oops, an error occured: {e} {response}")
|
105 |
+
|
106 |
+
return prediction
|
107 |
+
|
108 |
+
|
109 |
+
@module
|
110 |
+
def predict_page(input: Inputs, output: Outputs, session: Session):
|
111 |
+
# Disable loading spinners, use elegant pulse
|
112 |
+
ui.busy_indicators.use(spinners=False)
|
113 |
+
|
114 |
+
ui.panel_title(title=ui.h1(ui.strong("Eta Prediction 🔮")),
|
115 |
+
window_title="Eta Prediction")
|
116 |
+
|
117 |
+
with ui.layout_sidebar():
|
118 |
+
with ui.sidebar():
|
119 |
+
# Cordinates features
|
120 |
+
ui.input_numeric("origin_lat", "Origin Latitude °",
|
121 |
+
value=LOCATIONS["Nairobi"]['latitude'], min=lat_min, max=lat_max, step=1)
|
122 |
+
ui.input_numeric("origin_lon", "Origin Longitude °",
|
123 |
+
value=LOCATIONS["Nairobi"]['longitude'], min=lon_min, max=lon_max, step=1)
|
124 |
+
ui.input_numeric("destination_lat", "Destination Latitude °",
|
125 |
+
value=LOCATIONS["National Museum of Kenya"]['latitude'], min=lat_min, max=lat_max, step=1)
|
126 |
+
ui.input_numeric("destination_lon", "Destination Longitude °",
|
127 |
+
value=LOCATIONS["National Museum of Kenya"]['longitude'], min=lon_min, max=lon_max, step=1)
|
128 |
+
|
129 |
+
# Google Maps Url to Coordinates
|
130 |
+
ui.help_text("Convert Google Maps Url to Latitude and Longitudes")
|
131 |
+
ui.input_action_button("map_url", "Convert")
|
132 |
+
|
133 |
+
@reactive.effect
|
134 |
+
@reactive.event(input.map_url)
|
135 |
+
def maps_url_modal():
|
136 |
+
m = ui.modal(
|
137 |
+
ui.help_text("From Origin:"),
|
138 |
+
ui.input_text("origin_url", "Google Maps url:"),
|
139 |
+
|
140 |
+
ui.help_text("To Destination:"),
|
141 |
+
ui.input_text("destination_url", "Google Maps url:"),
|
142 |
+
|
143 |
+
ui.input_action_button("convert_url", "Convert"),
|
144 |
+
|
145 |
+
title="Google Maps Url to Coordinates",
|
146 |
+
easy_close=True,
|
147 |
+
footer=None,
|
148 |
+
)
|
149 |
+
ui.modal_show(m)
|
150 |
+
|
151 |
+
@reactive.effect
|
152 |
+
@reactive.event(input.convert_url, ignore_init=True)
|
153 |
+
def update_coordinates_from_url() -> Optional[float]:
|
154 |
+
try:
|
155 |
+
origin_url = get_full_url(input.origin_url())
|
156 |
+
destination_url = get_full_url(input.destination_url())
|
157 |
+
|
158 |
+
# Coordinates are yet to be known
|
159 |
+
origin_latitude = None
|
160 |
+
origin_longitude = None
|
161 |
+
destination_latitude = None
|
162 |
+
destination_longitude = None
|
163 |
+
|
164 |
+
# Regular expression to find coordinates in the URL
|
165 |
+
pattern = re.compile(r"@(-?\d+\.\d+),(-?\d+\.\d+)")
|
166 |
+
match = []
|
167 |
+
for url in [origin_url, destination_url]:
|
168 |
+
match.append(pattern.search(url))
|
169 |
+
|
170 |
+
if all(match):
|
171 |
+
origin_latitude = float(match[0].group(1))
|
172 |
+
origin_longitude = float(match[0].group(2))
|
173 |
+
destination_latitude = float(match[1].group(1))
|
174 |
+
destination_longitude = float(match[1].group(2))
|
175 |
+
|
176 |
+
valid.set(on_convert(origin_latitude, origin_longitude,
|
177 |
+
destination_latitude, destination_longitude))
|
178 |
+
|
179 |
+
if valid():
|
180 |
+
ui.notification_show(
|
181 |
+
f"✅ The coordinates have been updated", duration=3, type="default")
|
182 |
+
else:
|
183 |
+
raise Exception
|
184 |
+
except Exception as e:
|
185 |
+
logging.error(
|
186 |
+
f"Oops, update_coordinates_from_url says an error occured converting maps url to coordinates: {e}")
|
187 |
+
ui.notification_show(
|
188 |
+
f"Error: {e}", duration=3, type="error")
|
189 |
+
ui.notification_show(
|
190 |
+
"🚨 Could not convert url to coordinates. Try again!", duration=6, type="error")
|
191 |
+
|
192 |
+
finally:
|
193 |
+
ui.modal_remove()
|
194 |
+
|
195 |
+
# Rest coordinates back to Kenyan region
|
196 |
+
ui.input_action_button(
|
197 |
+
"reset", "Back to Nairobi", icon=icon_svg("crosshairs"))
|
198 |
+
|
199 |
+
# Trip Distance feature
|
200 |
+
ui.input_numeric("trip_distance", "Trip Distance (meters)",
|
201 |
+
value=1, min=1, max=600000, step=10)
|
202 |
+
ui.input_switch("manual_distance",
|
203 |
+
"Use manual distance", False),
|
204 |
+
|
205 |
+
# Date feature
|
206 |
+
ui.input_date("date", "Select a Date")
|
207 |
+
ui.help_text("Select the UTC time")
|
208 |
+
ui.input_select("hours", "24-hour",
|
209 |
+
choices=HOURS, selected=HOURS[0])
|
210 |
+
ui.input_select("minutes", "Minutes",
|
211 |
+
choices=MINUTES, selected=MINUTES[0])
|
212 |
+
ui.input_select("seconds", "Seconds",
|
213 |
+
choices=SECONDS, selected=SECONDS[0])
|
214 |
+
|
215 |
+
# Select model
|
216 |
+
ui.input_selectize(
|
217 |
+
"modelname",
|
218 |
+
"Choose a model",
|
219 |
+
choices=ALL_MODELS,
|
220 |
+
selected="XGBRegressor",
|
221 |
+
)
|
222 |
+
|
223 |
+
# Base map
|
224 |
+
ui.input_selectize(
|
225 |
+
"basemap",
|
226 |
+
"Choose a basemap",
|
227 |
+
choices=list(BASEMAPS.keys()),
|
228 |
+
selected="Mapnik",
|
229 |
+
)
|
230 |
+
|
231 |
+
# Top 3 cards
|
232 |
+
with ui.layout_column_wrap(fill=False):
|
233 |
+
with ui.value_box(showcase=icon_svg("route"), theme=BRANDTHEMES['purple-dark']):
|
234 |
+
"Trip Distance"
|
235 |
+
|
236 |
+
@render.text
|
237 |
+
def trip_dist_km():
|
238 |
+
return f"{trip_distance()/1000:,.1f} km" if valid else ""
|
239 |
+
|
240 |
+
@render.text
|
241 |
+
def trip_dist_m():
|
242 |
+
return f"{trip_distance():,.1f} m" if valid and trip_distance is not None else ""
|
243 |
+
|
244 |
+
with ui.value_box(showcase=icon_svg("egg"), theme=BRANDTHEMES['purple-dark']):
|
245 |
+
"Geodisic Distance"
|
246 |
+
|
247 |
+
@reactive.calc
|
248 |
+
def geo_dist():
|
249 |
+
dist = geodesic(loc1xy(), loc2xy())
|
250 |
+
return (f"{dist.meters:,.1f} m", f"{dist.kilometers:,.1f} km") if valid and trip_distance is not None else ""
|
251 |
+
|
252 |
+
@render.text
|
253 |
+
def geo_dist_km():
|
254 |
+
return geo_dist()[1] if valid and trip_distance is not None else ""
|
255 |
+
|
256 |
+
@render.text
|
257 |
+
def geo_dist_m():
|
258 |
+
return geo_dist()[0] if valid and trip_distance is not None else ""
|
259 |
+
|
260 |
+
with ui.value_box(showcase=icon_svg("clock"), theme=BRANDTHEMES['red']):
|
261 |
+
"Est. time of arrival"
|
262 |
+
|
263 |
+
@reactive.calc
|
264 |
+
async def eta():
|
265 |
+
try:
|
266 |
+
# print(valid())
|
267 |
+
# print(notification_error())
|
268 |
+
if validate_inputs(origin_lat(), origin_lon(), destination_lat(), destination_lon()) and valid():
|
269 |
+
data: EtaFeatures = {
|
270 |
+
'timestamp': [datetz()],
|
271 |
+
'origin_lat': [origin_lat()],
|
272 |
+
'origin_lon': [origin_lon()],
|
273 |
+
'destination_lat': [destination_lat()],
|
274 |
+
'destination_lon': [destination_lon()],
|
275 |
+
'trip_distance': [trip_distance()]
|
276 |
+
}
|
277 |
+
|
278 |
+
eta_sec = await predict_eta(data, input.modelname())
|
279 |
+
|
280 |
+
eta_hms = time.strftime(
|
281 |
+
'%H:%M:%S', time.gmtime(eta_sec))
|
282 |
+
|
283 |
+
ui.notification_show(
|
284 |
+
f"⏰ ETA: {eta_hms} H:M:S", duration=6, type="default")
|
285 |
+
|
286 |
+
return f"{eta_sec:,.0f} s", f"{eta_hms}"
|
287 |
+
else:
|
288 |
+
raise Exception
|
289 |
+
except Exception as e:
|
290 |
+
logging.error({e})
|
291 |
+
ui.notification_show(
|
292 |
+
"🚨 Could not predict Eta. Median eta is 1000 seconds", duration=3, type="error")
|
293 |
+
return None
|
294 |
+
|
295 |
+
@render.text
|
296 |
+
async def eta_sec():
|
297 |
+
text = await eta()
|
298 |
+
return text[0] if text else ""
|
299 |
+
|
300 |
+
@render.text
|
301 |
+
async def eta_hms():
|
302 |
+
text = await eta()
|
303 |
+
return text[1] if text else ""
|
304 |
+
|
305 |
+
@render.express
|
306 |
+
def eta_info():
|
307 |
+
with ui.tooltip(title="Google Maps ETA"):
|
308 |
+
icon_svg("google")
|
309 |
+
f"{trip_eta():,.0f} s | {time.strftime('%H:%M:%S', time.gmtime(trip_eta()))}"
|
310 |
+
|
311 |
+
|
312 |
+
|
313 |
+
|
314 |
+
# Map (2 indents)
|
315 |
+
with ui.card():
|
316 |
+
ui.card_header(
|
317 |
+
"💡 Map (drag the markers to change locations)")
|
318 |
+
|
319 |
+
@render_widget
|
320 |
+
def map():
|
321 |
+
return L.Map(zoom=9, center=(KENYA_LAT, KENYA_LON))
|
322 |
+
|
323 |
+
######################################################
|
324 |
+
# Reactive values to store location information
|
325 |
+
origin_lat = reactive.value()
|
326 |
+
origin_lon = reactive.value()
|
327 |
+
destination_lat = reactive.value()
|
328 |
+
destination_lon = reactive.value()
|
329 |
+
|
330 |
+
valid = reactive.value()
|
331 |
+
|
332 |
+
# Reactive value to store trip_distance information
|
333 |
+
trip_distance = reactive.value()
|
334 |
+
trip_eta = reactive.value()
|
335 |
+
|
336 |
+
@reactive.effect(priority=100)
|
337 |
+
def _():
|
338 |
+
if (
|
339 |
+
validate_inputs(input.origin_lat(), input.origin_lon(),
|
340 |
+
input.destination_lat(), input.destination_lon())
|
341 |
+
or
|
342 |
+
validate_inputs(origin_lat(), origin_lon(),
|
343 |
+
destination_lat(), destination_lon())
|
344 |
+
):
|
345 |
+
value = True
|
346 |
+
else:
|
347 |
+
value = False
|
348 |
+
|
349 |
+
valid.set(value)
|
350 |
+
|
351 |
+
@reactive.calc
|
352 |
+
def datetz():
|
353 |
+
return f"{input.date()}T{input.hours()}:{input.minutes()}:{input.seconds()}Z"
|
354 |
+
|
355 |
+
@reactive.effect
|
356 |
+
def _():
|
357 |
+
origin_lat.set(input.origin_lat()
|
358 |
+
if valid else LOCATIONS["Nairobi"]['latitude'])
|
359 |
+
origin_lon.set(input.origin_lon()
|
360 |
+
if valid else LOCATIONS["Nairobi"]['longitude'])
|
361 |
+
destination_lat.set(input.destination_lat(
|
362 |
+
) if valid else LOCATIONS["National Museum of Kenya"]['latitude'])
|
363 |
+
destination_lon.set(input.destination_lon(
|
364 |
+
) if valid else LOCATIONS["National Museum of Kenya"]['longitude'])
|
365 |
+
|
366 |
+
# Automate trip distance, eta from Google Maps
|
367 |
+
google_td, google_eta = google_maps_trip_distance_eta(loc1xy(), loc2xy())
|
368 |
+
if isinstance(google_td, float):
|
369 |
+
trip_distance.set(google_td)
|
370 |
+
trip_eta.set(google_eta)
|
371 |
+
else:
|
372 |
+
ui.notification_show(
|
373 |
+
"🚨 Could not estimate trip distance. Using Geosidic distance...", duration=3, type="warning")
|
374 |
+
trip_distance.set(geo_dist())
|
375 |
+
|
376 |
+
# Manual
|
377 |
+
if input.manual_distance() and input.trip_distance() not in [0, None]:
|
378 |
+
trip_distance.set(input.trip_distance())
|
379 |
+
|
380 |
+
@reactive.effect
|
381 |
+
@reactive.event(trip_distance)
|
382 |
+
def _():
|
383 |
+
if valid():
|
384 |
+
# Update the trip distance input with current calculated or manual trip distance
|
385 |
+
ui.update_numeric("trip_distance", value=trip_distance())
|
386 |
+
|
387 |
+
@reactive.calc
|
388 |
+
def loc1xy():
|
389 |
+
return origin_lat(), origin_lon()
|
390 |
+
|
391 |
+
@reactive.calc
|
392 |
+
def loc2xy():
|
393 |
+
return destination_lat(), destination_lon()
|
394 |
+
|
395 |
+
# Add marker for first location
|
396 |
+
|
397 |
+
@reactive.effect
|
398 |
+
def _():
|
399 |
+
if valid():
|
400 |
+
update_marker(map.widget, loc1xy(), on_move1, "origin", icon=L.AwesomeIcon(
|
401 |
+
name='fa-map-marker', marker_color='darkpurple'))
|
402 |
+
|
403 |
+
# Add marker for second location
|
404 |
+
|
405 |
+
@reactive.effect
|
406 |
+
def _():
|
407 |
+
if valid():
|
408 |
+
update_marker(map.widget, loc2xy(), on_move2, "destination", icon=L.AwesomeIcon(
|
409 |
+
name='fa-map-marker', marker_color='purple'))
|
410 |
+
|
411 |
+
# Add line and fit bounds when either marker is moved
|
412 |
+
|
413 |
+
@reactive.effect
|
414 |
+
def _():
|
415 |
+
if valid():
|
416 |
+
update_line(map.widget, loc1xy(), loc2xy())
|
417 |
+
|
418 |
+
# If new bounds fall outside of the current view, fit the bounds if valid coordinates
|
419 |
+
|
420 |
+
@reactive.effect
|
421 |
+
def _():
|
422 |
+
# if valid():
|
423 |
+
l1 = loc1xy()
|
424 |
+
l2 = loc2xy()
|
425 |
+
|
426 |
+
lat_rng = [min(l1[0], l2[0]), max(l1[0], l2[0])]
|
427 |
+
lon_rng = [min(l1[1], l2[1]), max(l1[1], l2[1])]
|
428 |
+
new_bounds = [
|
429 |
+
[lat_rng[0], lon_rng[0]],
|
430 |
+
[lat_rng[1], lon_rng[1]],
|
431 |
+
]
|
432 |
+
|
433 |
+
b = map.widget.bounds
|
434 |
+
if len(b) == 0:
|
435 |
+
map.widget.fit_bounds(new_bounds)
|
436 |
+
elif (
|
437 |
+
lat_min < b[0][0]
|
438 |
+
or lat_max > b[1][0]
|
439 |
+
or lon_min < b[0][1]
|
440 |
+
or lon_max > b[1][1]
|
441 |
+
):
|
442 |
+
map.widget.fit_bounds(new_bounds)
|
443 |
+
|
444 |
+
# Update the basemap
|
445 |
+
|
446 |
+
@reactive.effect(priority=-100) # The last effect that runs
|
447 |
+
def _():
|
448 |
+
if valid():
|
449 |
+
update_basemap(map.widget, input.basemap())
|
450 |
+
|
451 |
+
@reactive.effect(priority=95)
|
452 |
+
@reactive.event(input.reset)
|
453 |
+
def _():
|
454 |
+
back_to_nairobi()
|
utils/utils.py
CHANGED
@@ -1,169 +1,172 @@
|
|
1 |
-
import json
|
2 |
-
import requests
|
3 |
-
import requests_cache as R
|
4 |
-
from cachetools import TTLCache, cached
|
5 |
-
|
6 |
-
from typing import Literal, Tuple, Optional
|
7 |
-
|
8 |
-
import ipyleaflet as L
|
9 |
-
from ipyleaflet import AwesomeIcon
|
10 |
-
|
11 |
-
from shiny.express import ui
|
12 |
-
|
13 |
-
from utils.config import MAPS_API_KEY, BRANDCOLORS, BASEMAPS, ONE_WEEK_SEC, LOCATIONS, TRIP_DISTANCE
|
14 |
-
|
15 |
-
|
16 |
-
# Cache expires after 1 week
|
17 |
-
# R.install_cache('
|
18 |
-
|
19 |
-
|
20 |
-
|
21 |
-
#
|
22 |
-
#
|
23 |
-
|
24 |
-
|
25 |
-
|
26 |
-
|
27 |
-
|
28 |
-
|
29 |
-
|
30 |
-
|
31 |
-
|
32 |
-
|
33 |
-
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
39 |
-
|
40 |
-
|
41 |
-
|
42 |
-
|
43 |
-
|
44 |
-
|
45 |
-
|
46 |
-
|
47 |
-
|
48 |
-
|
49 |
-
|
50 |
-
|
51 |
-
|
52 |
-
|
53 |
-
|
54 |
-
|
55 |
-
|
56 |
-
|
57 |
-
|
58 |
-
|
59 |
-
|
60 |
-
|
61 |
-
if response.status_code == 200:
|
62 |
-
# Decode the response
|
63 |
-
data = response.json()
|
64 |
-
|
65 |
-
# Extract distance information
|
66 |
-
if "rows" in data and len(data["rows"]) > 0:
|
67 |
-
distance_info = data["rows"][0]["elements"][0]["distance"]
|
68 |
-
|
69 |
-
|
70 |
-
|
71 |
-
|
72 |
-
|
73 |
-
|
74 |
-
|
75 |
-
|
76 |
-
|
77 |
-
|
78 |
-
|
79 |
-
|
80 |
-
|
81 |
-
|
82 |
-
|
83 |
-
|
84 |
-
map
|
85 |
-
|
86 |
-
|
87 |
-
|
88 |
-
|
89 |
-
|
90 |
-
|
91 |
-
|
92 |
-
|
93 |
-
|
94 |
-
|
95 |
-
|
96 |
-
|
97 |
-
|
98 |
-
|
99 |
-
map.
|
100 |
-
|
101 |
-
|
102 |
-
|
103 |
-
|
104 |
-
|
105 |
-
|
106 |
-
|
107 |
-
|
108 |
-
|
109 |
-
|
110 |
-
|
111 |
-
|
112 |
-
|
113 |
-
|
114 |
-
|
115 |
-
|
116 |
-
|
117 |
-
|
118 |
-
|
119 |
-
|
120 |
-
|
121 |
-
|
122 |
-
|
123 |
-
|
124 |
-
|
125 |
-
|
126 |
-
|
127 |
-
|
128 |
-
|
129 |
-
|
130 |
-
#
|
131 |
-
|
132 |
-
#
|
133 |
-
|
134 |
-
|
135 |
-
|
136 |
-
|
137 |
-
|
138 |
-
|
139 |
-
ui.update_numeric(
|
140 |
-
|
141 |
-
|
142 |
-
|
143 |
-
|
144 |
-
|
145 |
-
|
146 |
-
|
147 |
-
|
148 |
-
|
149 |
-
|
150 |
-
|
151 |
-
|
152 |
-
|
153 |
-
|
154 |
-
|
155 |
-
|
156 |
-
|
157 |
-
|
158 |
-
|
159 |
-
|
160 |
-
|
161 |
-
|
162 |
-
|
163 |
-
|
164 |
-
|
165 |
-
|
166 |
-
|
167 |
-
|
168 |
-
|
169 |
-
|
|
|
|
|
|
|
|
1 |
+
import json
|
2 |
+
import requests
|
3 |
+
import requests_cache as R
|
4 |
+
from cachetools import TTLCache, cached
|
5 |
+
|
6 |
+
from typing import Literal, Tuple, Optional
|
7 |
+
|
8 |
+
import ipyleaflet as L
|
9 |
+
from ipyleaflet import AwesomeIcon
|
10 |
+
|
11 |
+
from shiny.express import ui
|
12 |
+
|
13 |
+
from utils.config import MAPS_API_KEY, BRANDCOLORS, BASEMAPS, ONE_WEEK_SEC, LOCATIONS, TRIP_DISTANCE, ETA
|
14 |
+
|
15 |
+
|
16 |
+
# Cache expires after 1 week
|
17 |
+
# R.install_cache('yassir_requests_cache', expire_after=ONE_WEEK_SEC) # Sqlite
|
18 |
+
|
19 |
+
|
20 |
+
# ---------------------------------------------------------------
|
21 |
+
# Helper functions for map and location inputs on predict page
|
22 |
+
# ---------------------------------------------------------------
|
23 |
+
|
24 |
+
# @cached(cache=TTLCache(maxsize=300, ttl=ONE_WEEK_SEC)) # Memory
|
25 |
+
def get_bounds(country: str) -> Tuple[float]:
|
26 |
+
headers = {
|
27 |
+
'User-Agent': 'Yassir ETA Shiny App/1.0 ([email protected])'
|
28 |
+
}
|
29 |
+
|
30 |
+
response = requests.get(
|
31 |
+
f"http://nominatim.openstreetmap.org/search?q={country}&format=json", headers=headers)
|
32 |
+
|
33 |
+
boundingbox = json.loads(response.text)[0]["boundingbox"]
|
34 |
+
|
35 |
+
# Extract the bounds as float datatype
|
36 |
+
lat_min, lat_max, lon_min, lon_max = (float(b) for b in boundingbox)
|
37 |
+
|
38 |
+
return lat_min, lat_max, lon_min, lon_max
|
39 |
+
|
40 |
+
|
41 |
+
# @cached(cache=TTLCache(maxsize=3000, ttl=ONE_WEEK_SEC)) # Memory
|
42 |
+
def google_maps_trip_distance_eta(origin: Tuple[float], destination: Tuple[float]) -> Tuple[float]:
|
43 |
+
"""
|
44 |
+
The road distance calculated using Google Maps distance matrix api with the driving car is the shortest
|
45 |
+
or optimal road distance based on the available road data and routing algorithm.
|
46 |
+
|
47 |
+
origin is a tuple of lat, lon
|
48 |
+
destination is a tuple of lat, lon
|
49 |
+
|
50 |
+
Returns: the calculiated trip distance or a default value
|
51 |
+
"""
|
52 |
+
|
53 |
+
# Google Maps API URL
|
54 |
+
url = f"https://maps.googleapis.com/maps/api/distancematrix/json?origins={origin[0]},{origin[1]}&destinations={destination[0]},{destination[1]}&key={MAPS_API_KEY}"
|
55 |
+
|
56 |
+
|
57 |
+
# Send request
|
58 |
+
response = requests.get(url)
|
59 |
+
|
60 |
+
|
61 |
+
if response.status_code == 200:
|
62 |
+
# Decode the response
|
63 |
+
data = response.json()
|
64 |
+
|
65 |
+
# Extract distance information
|
66 |
+
if "rows" in data and len(data["rows"]) > 0:
|
67 |
+
distance_info = data["rows"][0]["elements"][0]["distance"]
|
68 |
+
eta_info = data["rows"][0]["elements"][0]["duration"]
|
69 |
+
distance = float(distance_info['value'])
|
70 |
+
eta = float(eta_info['value'])
|
71 |
+
else:
|
72 |
+
distance = TRIP_DISTANCE # Default
|
73 |
+
back_to_nairobi()
|
74 |
+
else:
|
75 |
+
distance = TRIP_DISTANCE # Default
|
76 |
+
eta = ETA # Default
|
77 |
+
|
78 |
+
back_to_nairobi()
|
79 |
+
|
80 |
+
return distance, eta
|
81 |
+
|
82 |
+
|
83 |
+
def update_marker(map: L.Map, loc: tuple, on_move: object, name: str, icon: AwesomeIcon):
|
84 |
+
remove_layer(map, name)
|
85 |
+
m = L.Marker(location=loc, draggable=True, name=name, icon=icon)
|
86 |
+
m.on_move(on_move)
|
87 |
+
map.add_layer(m)
|
88 |
+
|
89 |
+
|
90 |
+
def update_line(map: L.Map, loc1: tuple, loc2: tuple):
|
91 |
+
remove_layer(map, "line")
|
92 |
+
map.add_layer(
|
93 |
+
L.Polyline(locations=[loc1, loc2],
|
94 |
+
color=BRANDCOLORS['red'], weight=3, name="line")
|
95 |
+
)
|
96 |
+
|
97 |
+
|
98 |
+
def update_basemap(map: L.Map, basemap: str):
|
99 |
+
for layer in map.layers:
|
100 |
+
if isinstance(layer, L.TileLayer):
|
101 |
+
map.remove_layer(layer)
|
102 |
+
map.add_layer(L.basemap_to_tiles(BASEMAPS[basemap]))
|
103 |
+
|
104 |
+
|
105 |
+
def remove_layer(map: L.Map, name: str):
|
106 |
+
for layer in map.layers:
|
107 |
+
if layer.name == name:
|
108 |
+
map.remove_layer(layer)
|
109 |
+
|
110 |
+
|
111 |
+
def on_move1(**kwargs):
|
112 |
+
return on_move("origin", **kwargs)
|
113 |
+
|
114 |
+
|
115 |
+
def on_move2(**kwargs):
|
116 |
+
return on_move("destination", **kwargs)
|
117 |
+
|
118 |
+
# When the markers are moved, update the numeric location inputs to include the new
|
119 |
+
# location (which results in the locations() reactive value getting updated,
|
120 |
+
# which invalidates any downstream reactivity that depends on it)
|
121 |
+
|
122 |
+
|
123 |
+
def on_move(loc_type: Literal['origin', 'destination'], **kwargs):
|
124 |
+
location = kwargs["location"]
|
125 |
+
loc_lat, loc_lon = location
|
126 |
+
|
127 |
+
ui.update_numeric(f"{loc_type}_lat", value=loc_lat)
|
128 |
+
ui.update_numeric(f"{loc_type}_lon", value=loc_lon)
|
129 |
+
|
130 |
+
# origin_lat
|
131 |
+
# origin_lon
|
132 |
+
# destination_lat
|
133 |
+
# destination_lon
|
134 |
+
|
135 |
+
# Re-center to Kenya region
|
136 |
+
|
137 |
+
|
138 |
+
def back_to_nairobi():
|
139 |
+
ui.update_numeric("origin_lat", value=LOCATIONS["Nairobi"]['latitude'])
|
140 |
+
ui.update_numeric(
|
141 |
+
"origin_lon", value=LOCATIONS["Nairobi"]['longitude'])
|
142 |
+
ui.update_numeric(
|
143 |
+
"destination_lat", value=LOCATIONS["National Museum of Kenya"]['latitude'])
|
144 |
+
ui.update_numeric(
|
145 |
+
"destination_lon", value=LOCATIONS["National Museum of Kenya"]['longitude'])
|
146 |
+
|
147 |
+
|
148 |
+
def validate_inputs(origin_lat: float = None, origin_lon: float = None, destination_lat: float = None, destination_lon: float = None) -> bool:
|
149 |
+
lat_min, lat_max, lon_min, lon_max = get_bounds(country='Kenya')
|
150 |
+
|
151 |
+
valid = True
|
152 |
+
|
153 |
+
for lat, lon in [(origin_lat, origin_lon), (destination_lat, destination_lon)]:
|
154 |
+
if lat is not None and lon is not None:
|
155 |
+
if (lat < lat_min or lat > lat_max) or (lon < lon_min or lon > lon_max):
|
156 |
+
ui.notification_show(
|
157 |
+
"😮 Location is outside Kenya, taking you back to Nairobi", type="error")
|
158 |
+
valid = False
|
159 |
+
back_to_nairobi()
|
160 |
+
break
|
161 |
+
|
162 |
+
|
163 |
+
return valid
|
164 |
+
|
165 |
+
|
166 |
+
# Footer
|
167 |
+
footer = ui.tags.footer(
|
168 |
+
ui.tags.div(
|
169 |
+
"© 2024. Made with 💖",
|
170 |
+
style=f"text-align: center; padding: 10px; color: #fff; background-color: {BRANDCOLORS['purple-dark']}; margin-top: 50px;"
|
171 |
+
)
|
172 |
+
)
|