Spaces:
Sleeping
Sleeping
Upload app.py
Browse files
app.py
CHANGED
@@ -1,49 +1,99 @@
|
|
1 |
-
import
|
2 |
-
import json
|
3 |
-
import pytz
|
4 |
-
import numpy as np
|
5 |
import pandas as pd
|
6 |
-
|
7 |
-
|
8 |
-
import plotly.graph_objects as go
|
9 |
-
import base64
|
10 |
-
import gpxpy
|
11 |
-
from gpx_converter import Converter
|
12 |
-
from sunrisesunset import SunriseSunset
|
13 |
-
from datetime import datetime, date, timedelta
|
14 |
-
from beaufort_scale.beaufort_scale import beaufort_scale_kmh
|
15 |
-
from timezonefinder import TimezoneFinder
|
16 |
-
tf = TimezoneFinder()
|
17 |
-
|
18 |
-
from dash import Dash, dcc, html, dash_table, Input, Output, State, no_update, callback, _dash_renderer
|
19 |
-
import dash_bootstrap_components as dbc
|
20 |
-
import dash_mantine_components as dmc
|
21 |
-
_dash_renderer._set_react_version('18.2.0')
|
22 |
-
|
23 |
-
from dash_extensions import Purify
|
24 |
|
25 |
import srtm
|
26 |
elevation_data = srtm.get_data()
|
27 |
|
|
|
|
|
|
|
|
|
|
|
|
|
28 |
import requests_cache
|
29 |
import openmeteo_requests
|
30 |
from retry_requests import retry
|
31 |
|
32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
33 |
|
34 |
-
|
35 |
-
|
36 |
-
|
37 |
-
|
38 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
39 |
|
40 |
-
# Variables to become widgets
|
41 |
-
igpx = 'default_gpx.gpx'
|
42 |
-
hdate = hdate_object.strftime('%Y-%m-%d')
|
43 |
-
time = hour + ':' + minute
|
44 |
-
granularity = frequency * 1000
|
45 |
|
46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
47 |
cache_session = requests_cache.CachedSession('.cache', expire_after = 3600)
|
48 |
retry_session = retry(cache_session, retries = 5, backoff_factor = 0.2)
|
49 |
openmeteo = openmeteo_requests.Client(session = retry_session)
|
@@ -52,8 +102,7 @@ openmeteo = openmeteo_requests.Client(session = retry_session)
|
|
52 |
url = 'https://api.open-meteo.com/v1/forecast'
|
53 |
params = {
|
54 |
'timezone': 'auto',
|
55 |
-
'
|
56 |
-
'hourly': ['rain'],
|
57 |
}
|
58 |
|
59 |
# Load the JSON files mapping weather codes to descriptions and icons
|
@@ -62,33 +111,7 @@ with open('weather_icons_custom.json', 'r') as file:
|
|
62 |
|
63 |
# Weather icons URL
|
64 |
icon_url = 'https://raw.githubusercontent.com/basmilius/weather-icons/refs/heads/dev/production/fill/svg/'
|
65 |
-
sunrise_icon = icon_url + 'sunrise.svg'
|
66 |
-
sunset_icon = icon_url + 'sunset.svg'
|
67 |
-
|
68 |
-
### FUNCTIONS ###
|
69 |
-
|
70 |
-
# Sunrise sunset
|
71 |
-
def sunrise_sunset(lat_start, lon_start, lat_end, lon_end, hdate):
|
72 |
-
|
73 |
-
tz = tf.timezone_at(lng=lon_start, lat=lat_start)
|
74 |
-
zone = pytz.timezone(tz)
|
75 |
-
|
76 |
-
day = datetime.strptime(hdate, '%Y-%m-%d')
|
77 |
|
78 |
-
dt = day.astimezone(zone)
|
79 |
-
|
80 |
-
rs_start = SunriseSunset(dt, lat=lat_start, lon=lon_start, zenith='official')
|
81 |
-
rise_time = rs_start.sun_rise_set[0]
|
82 |
-
|
83 |
-
rs_end = SunriseSunset(dt, lat=lat_end, lon=lon_end, zenith='official')
|
84 |
-
set_time = rs_end.sun_rise_set[1]
|
85 |
-
|
86 |
-
sunrise = rise_time.strftime('%H:%M')
|
87 |
-
sunset = set_time.strftime('%H:%M')
|
88 |
-
|
89 |
-
return sunrise, sunset
|
90 |
-
|
91 |
-
# Map weather codes to descriptions and icons
|
92 |
def map_icons(df):
|
93 |
code = df['weather_code']
|
94 |
|
@@ -122,44 +145,20 @@ def rain_intensity(precipt):
|
|
122 |
rain = 'No rain / No info'
|
123 |
return rain
|
124 |
|
125 |
-
# Function to add elevation
|
126 |
-
def add_ele(row):
|
127 |
-
if pd.isnull(row['altitude']):
|
128 |
-
row['altitude'] = elevation_data.get_elevation(row['latitude'], row['longitude'], 0)
|
129 |
-
else:
|
130 |
-
row['altitude'] = row['altitude']
|
131 |
-
return row
|
132 |
-
|
133 |
-
# Compute distances using the Karney algorith with Euclidian altitude correction
|
134 |
-
def eukarney(lat1, lon1, alt1, lat2, lon2, alt2):
|
135 |
-
p1 = (lat1, lon1)
|
136 |
-
p2 = (lat2, lon2)
|
137 |
-
karney = distance.distance(p1, p2).m
|
138 |
-
return np.sqrt(karney**2 + (alt2 - alt1)**2)
|
139 |
-
|
140 |
# Obtain the weather forecast for each waypoint at each specific time
|
141 |
-
def get_weather(
|
142 |
-
|
143 |
-
params['latitude'] = df_wp['latitude']
|
144 |
-
params['longitude'] = df_wp['longitude']
|
145 |
-
params['elevation'] = df_wp['altitude']
|
146 |
|
147 |
-
|
|
|
|
|
148 |
|
149 |
-
|
150 |
-
delta_read = delta_dt.strftime('%Y-%m-%dT%H:%M')
|
151 |
|
152 |
-
start_period = (
|
153 |
-
end_period =
|
154 |
|
155 |
-
|
156 |
-
|
157 |
-
df_wp['Time'] = time_read
|
158 |
-
|
159 |
-
params['start_minutely_15'] = delta_read
|
160 |
-
params['end_minutely_15'] = delta_read
|
161 |
-
params['start_hour'] = delta_read
|
162 |
-
params['end_hour'] = delta_read
|
163 |
|
164 |
responses = openmeteo.weather_api(url, params=params)
|
165 |
|
@@ -167,397 +166,141 @@ def get_weather(df_wp):
|
|
167 |
response = responses[0]
|
168 |
|
169 |
# Process hourly data. The order of variables needs to be the same as requested.
|
170 |
-
|
171 |
hourly = response.Hourly()
|
172 |
|
173 |
-
minutely_temperature_2m =
|
174 |
-
|
175 |
-
|
176 |
-
weather_code =
|
177 |
-
|
|
|
178 |
|
179 |
-
|
180 |
-
|
181 |
-
|
182 |
|
183 |
v_rain_intensity = np.vectorize(rain_intensity)
|
184 |
-
|
185 |
|
186 |
v_beaufort_scale_kmh = np.vectorize(beaufort_scale_kmh)
|
187 |
|
188 |
-
|
189 |
-
|
190 |
-
df_wp['Rain (mm/h)'] = rain.round(1)
|
191 |
-
df_wp['Wind (km/h)'] = minutely_wind_speed_10m.round(1)
|
192 |
-
|
193 |
-
return df_wp
|
194 |
-
|
195 |
-
# Parse the GPX track
|
196 |
-
def parse_gpx(df_gpx, hdate):
|
197 |
-
|
198 |
-
# Sunrise sunset
|
199 |
-
|
200 |
-
lat_start, lon_start = df_gpx[['latitude', 'longitude']].head(1).values.flatten().tolist()
|
201 |
-
lat_end, lon_end = df_gpx[['latitude', 'longitude']].tail(1).values.flatten().tolist()
|
202 |
-
|
203 |
-
sunrise, sunset = sunrise_sunset(lat_start, lon_start, lat_end, lon_end, hdate)
|
204 |
|
205 |
-
|
|
|
206 |
|
207 |
-
|
208 |
-
centre_lon = (df_gpx['longitude'].max() + df_gpx['longitude'].min()) / 2
|
209 |
|
210 |
-
|
211 |
|
212 |
-
df_gpx['lat_shift'] = df_gpx['latitude'].shift(periods=-1).fillna(df_gpx['latitude'])
|
213 |
-
df_gpx['lon_shift'] = df_gpx['longitude'].shift(periods=-1).fillna(df_gpx['longitude'])
|
214 |
-
df_gpx['alt_shift'] = df_gpx['altitude'].shift(periods=-1).fillna(df_gpx['altitude'])
|
215 |
|
216 |
-
|
217 |
|
218 |
-
|
219 |
-
|
220 |
|
221 |
-
|
|
|
|
|
222 |
|
223 |
-
|
224 |
-
|
225 |
|
226 |
-
|
227 |
-
|
|
|
|
|
228 |
|
229 |
-
|
230 |
-
for waypoint in dist_rang:
|
231 |
-
gpx_dict = df_gpx.iloc[(df_gpx.distance - waypoint).abs().argsort()[:1]].to_dict('records')[0]
|
232 |
-
way_list.append(gpx_dict)
|
233 |
|
234 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
235 |
|
236 |
-
|
|
|
237 |
|
238 |
-
|
|
|
239 |
|
240 |
-
|
241 |
-
df_wp['is_day'] = df_wp['is_day'].astype(int)
|
242 |
-
df_wp['weather_code'] = df_wp['weather_code'].astype(int)
|
243 |
-
df_wp = df_wp.apply(map_icons, axis=1)
|
244 |
|
245 |
-
|
246 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
247 |
|
248 |
-
|
249 |
-
df_wp['Weather outline'] + '</b><br><br>' +
|
250 |
-
df_wp['Temp (°C)'] + '<br><br>' +
|
251 |
-
df_wp['Rain level'] + '<br>' +
|
252 |
-
df_wp['Wind level'] + '<br><br>' +
|
253 |
-
df_wp['Time'] + '<br><br>' +
|
254 |
-
df_wp['distance'].apply(lambda x: str(int(round(x / 1000, 0)))).astype(str) + ' km | ' + df_wp['altitude'].round(0).astype(int).astype(str) + ' m</p>')
|
255 |
|
256 |
-
|
257 |
|
258 |
-
|
259 |
-
|
260 |
-
|
|
|
|
|
|
|
261 |
|
262 |
-
|
263 |
-
dfs['Rain (mm/h)'] = dfs['Rain (mm/h)'].round(1).astype(str).replace('0.0', '')
|
264 |
-
dfs['Temp (°C)'] = dfs['Temp (°C)'].str.replace('C', '')
|
265 |
|
266 |
-
|
267 |
|
268 |
-
|
|
|
269 |
|
270 |
-
|
271 |
|
272 |
-
|
273 |
|
274 |
-
|
275 |
|
276 |
fig = go.Figure()
|
277 |
|
278 |
-
fig.add_trace(go.Scattermap(lon=
|
279 |
-
lat=
|
280 |
-
mode='
|
281 |
-
name='
|
282 |
|
283 |
-
fig.add_trace(go.Scattermap(lon=
|
284 |
-
lat=
|
285 |
-
mode='markers
|
286 |
-
|
287 |
-
text=df_wp.index.astype(str),
|
288 |
-
name='wp_trace'))
|
289 |
|
290 |
fig.update_layout(map_style='open-street-map',
|
291 |
-
map=dict(center=dict(lat=
|
292 |
|
293 |
-
fig.update_traces(showlegend=False, hoverinfo='none', hovertemplate=None, selector=({'name': '
|
294 |
-
fig.update_traces(showlegend=False, hoverinfo='
|
295 |
|
296 |
return fig
|
297 |
|
298 |
-
|
299 |
-
|
300 |
-
external_stylesheets = [dbc.themes.BOOTSTRAP, dmc.styles.ALL]
|
301 |
-
|
302 |
-
app = Dash(__name__, external_stylesheets=external_stylesheets)
|
303 |
-
|
304 |
server = app.server
|
305 |
|
306 |
-
# Layout
|
307 |
-
|
308 |
-
hours = [str(n).zfill(2) for n in range(0, 24)]
|
309 |
-
minutes = [str(n).zfill(2) for n in range(0, 60, 5)]
|
310 |
-
|
311 |
-
picker_style = {
|
312 |
-
'display': 'inline-block',
|
313 |
-
'width': '35px',
|
314 |
-
'height': '32px',
|
315 |
-
'cursor': 'pointer',
|
316 |
-
'border': 'none',
|
317 |
-
}
|
318 |
-
|
319 |
def serve_layout():
|
320 |
|
321 |
layout = html.Div([
|
322 |
-
html.Div([dcc.
|
323 |
-
style={'color': 'darkslategray', 'font-size': 18, 'font-family': 'sans', 'font-weight': 'bold', 'text-decoration': 'none'}),
|
324 |
-
]),
|
325 |
-
html.Div([dcc.Link('Freedom Luxembourg', href='https://www.freeletz.lu/freeletz/',
|
326 |
-
target='_blank', style={'color': 'goldenrod', 'font-size': 14, 'font-family': 'sans', 'text-decoration': 'none'}),
|
327 |
-
]),
|
328 |
-
html.Div([html.Br(),
|
329 |
-
dbc.Row([
|
330 |
-
dbc.Col([dcc.Upload(id='upload-gpx', children=html.Div(id='name-gpx'),
|
331 |
-
accept='.gpx, .GPX', max_size=10000000, min_size=100,
|
332 |
-
style={
|
333 |
-
'width': '174px',
|
334 |
-
'height': '48px',
|
335 |
-
'lineWidth': '174px',
|
336 |
-
'lineHeight': '48px',
|
337 |
-
'borderWidth': '2px',
|
338 |
-
'borderStyle': 'solid',
|
339 |
-
'borderColor': 'goldenrod',
|
340 |
-
'textAlign': 'center',
|
341 |
-
},
|
342 |
-
), dcc.Store(id='store-gpx')], width={'size': 'auto', 'offset': 1}),
|
343 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}),
|
344 |
-
dbc.Col([dbc.Label('Date of the hike'), html.Br(),
|
345 |
-
dcc.DatePickerSingle(id='calendar-date',
|
346 |
-
placeholder='Select the date of your hike',
|
347 |
-
display_format='Do MMMM YYYY',
|
348 |
-
min_date_allowed=date.today(),
|
349 |
-
max_date_allowed=date.today() + timedelta(days=7),
|
350 |
-
initial_visible_month=date.today(),
|
351 |
-
date=date.today()), dcc.Store(id='store-date')], width={'size': 'auto'}),
|
352 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}),
|
353 |
-
dbc.Col([html.Div([html.Label('Start time'), html.Br(), html.Br(),
|
354 |
-
html.Div([dcc.Dropdown(hours, placeholder=hour, value=hour, style=picker_style, id='dropdown-hour'),
|
355 |
-
dcc.Store(id='store-hour'),
|
356 |
-
html.Span(':'),
|
357 |
-
dcc.Dropdown(minutes, placeholder=minute, value=minute, style=picker_style, id='dropdown-minute'),
|
358 |
-
dcc.Store(id='store-minute')],
|
359 |
-
style={'border': '1px solid goldenrod',
|
360 |
-
'height': '34px',
|
361 |
-
'width': '76px',
|
362 |
-
'display': 'flex',
|
363 |
-
'align-items': 'center',
|
364 |
-
},
|
365 |
-
),
|
366 |
-
], style={'font-family': 'Sans'},
|
367 |
-
),
|
368 |
-
], width={'size': 'auto'}),
|
369 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}),
|
370 |
-
dbc.Col([dbc.Label('Average pace (km/h)'), html.Div(dcc.Slider(3, 6.5, 0.5, value=speed, id='slider-pace'), style={'width': '272px'}), dcc.Store(id='store-pace')], width={'size': 'auto'}),
|
371 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}),
|
372 |
-
dbc.Col([dbc.Label('Forecast frequency (km)'), html.Div(dcc.Slider(1, 5, 1, value=frequency, id='slider-freq'), style={'width': '170px'}), dcc.Store(id='store-freq')], width={'size': 'auto'}),
|
373 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}),
|
374 |
-
dbc.Col([html.Br(), html.Button('Forecast', id='submit-forecast', n_clicks=0,
|
375 |
-
style={'width': '86px', 'height': '36px', 'background-color': 'goldenrod', 'font-weight': 'bold', 'color': 'white'})],
|
376 |
-
width={'size': 'auto'}),
|
377 |
-
]),
|
378 |
-
], style={'font-size': 13, 'font-family': 'sans'}),
|
379 |
-
html.Div([html.Br(),
|
380 |
-
dbc.Row([dbc.Col(html.Div('Sunrise '), width={'size': 'auto', 'offset': 9}),
|
381 |
-
dbc.Col(html.Img(src=sunrise_icon, style={'height':'42px'}), width={'size': 'auto'}),
|
382 |
-
dbc.Col(html.Div(id='sunrise-time'), width={'size': 'auto'}),
|
383 |
-
dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 22})], width={'size': 'auto'}),
|
384 |
-
dbc.Col(html.Div('Sunset '), width={'size': 'auto', 'offset': 0}),
|
385 |
-
dbc.Col(html.Img(src=sunset_icon, style={'height':'42px'}), width={'size': 'auto'}),
|
386 |
-
dbc.Col(html.Div(id='sunset-time'), width={'size': 'auto'})]),
|
387 |
-
], style={'font-size': 13, 'font-family': 'sans'}),
|
388 |
-
html.Div(id='datatable-div'),
|
389 |
-
html.Div([dcc.Graph(id='base-figure', clear_on_unhover=True, style={'height': '90vh'})], id='base-figure-div'),
|
390 |
dcc.Tooltip(id='figure-tooltip'),
|
391 |
-
html.Div([dcc.Link('Freedom Luxembourg', href='https://www.freeletz.lu/freeletz/',
|
392 |
-
target='_blank', style={'color': 'goldenrod', 'font-size': 15, 'font-family': 'sans', 'text-decoration': 'none'}),
|
393 |
-
], style={'text-align': 'center'},),
|
394 |
-
html.Div([dcc.Link('Powered by Open Meteo', href='https://open-meteo.com/',
|
395 |
-
target='_blank', style={'color': 'darkslategray', 'font-size': 13, 'font-family': 'sans', 'text-decoration': 'none'}),
|
396 |
-
], style={'text-align': 'center'}),
|
397 |
dcc.Interval(
|
398 |
-
|
399 |
-
|
400 |
-
|
401 |
], id='layout-content')
|
402 |
|
403 |
-
layout = dmc.MantineProvider(layout)
|
404 |
-
|
405 |
return layout
|
406 |
|
407 |
-
|
408 |
-
|
409 |
-
# Callbacks
|
410 |
-
|
411 |
-
@callback(Output('store-gpx', 'data'),
|
412 |
-
Output('name-gpx', 'children'),
|
413 |
-
Input('upload-gpx', 'contents'),
|
414 |
-
State('upload-gpx', 'filename'))
|
415 |
-
def update_gpx(contents, filename):
|
416 |
-
if filename:
|
417 |
-
try:
|
418 |
-
igpx = filename
|
419 |
-
message = html.Div(['Upload your GPX track ', html.H6(igpx, style={'color': 'darkslategray', 'font-size': 12, 'font-weight': 'bold'})])
|
420 |
-
content_type, content_string = contents.split(',')
|
421 |
-
decoded = base64.b64decode(content_string)
|
422 |
-
gpx_parsed = gpxpy.parse(decoded)
|
423 |
-
# Convert to a dataframe one point at a time.
|
424 |
-
points = []
|
425 |
-
for track in gpx_parsed.tracks:
|
426 |
-
for segment in track.segments:
|
427 |
-
for p in segment.points:
|
428 |
-
points.append({
|
429 |
-
'latitude': p.latitude,
|
430 |
-
'longitude': p.longitude,
|
431 |
-
'altitude': p.elevation,
|
432 |
-
})
|
433 |
-
df_gpx = pd.DataFrame.from_records(points)
|
434 |
-
except Exception:
|
435 |
-
igpx = 'default_gpx.gpx'
|
436 |
-
message = html.Div(['Upload your GPX track ', html.H6('The GPX cannot be parsed. Please, upload another file.', style={'color': 'darkslategray', 'font-size': 12, 'font-weight': 'bold'})])
|
437 |
-
df_gpx = Converter(input_file = igpx).gpx_to_dataframe()
|
438 |
-
else:
|
439 |
-
igpx = 'default_gpx.gpx'
|
440 |
-
message = html.Div(['Upload your GPX track ', html.H6(igpx, style={'color': 'darkslategray', 'font-size': 12, 'font-weight': 'bold'})])
|
441 |
-
df_gpx = Converter(input_file = igpx).gpx_to_dataframe()
|
442 |
-
|
443 |
-
return df_gpx.to_dict('records'), message
|
444 |
-
|
445 |
-
@callback(Output('store-date', 'data'),
|
446 |
-
Input('calendar-date', 'date'))
|
447 |
-
def update_date(value):
|
448 |
-
if value:
|
449 |
-
cdate = value
|
450 |
-
else:
|
451 |
-
cdate = hdate
|
452 |
-
return cdate
|
453 |
-
|
454 |
-
@callback(Output('store-hour', 'data'),
|
455 |
-
Input('dropdown-hour', 'value'))
|
456 |
-
def update_hour(value):
|
457 |
-
if value:
|
458 |
-
hour = value
|
459 |
-
else:
|
460 |
-
hour = hour
|
461 |
-
return hour
|
462 |
-
|
463 |
-
@callback(Output('store-minute', 'data'),
|
464 |
-
Input('dropdown-minute', 'value'))
|
465 |
-
def update_minute(value):
|
466 |
-
if value:
|
467 |
-
minute = value
|
468 |
-
else:
|
469 |
-
minute = minute
|
470 |
-
return minute
|
471 |
-
|
472 |
-
@callback(Output('store-freq', 'data'),
|
473 |
-
Input('slider-freq', 'value'))
|
474 |
-
def update_freq(value):
|
475 |
-
if value:
|
476 |
-
frequency = value
|
477 |
-
else:
|
478 |
-
frequency = frequency
|
479 |
-
return frequency
|
480 |
-
|
481 |
-
@callback(Output('store-pace', 'data'),
|
482 |
-
Input('slider-pace', 'value'))
|
483 |
-
def update_pace(value):
|
484 |
-
if value:
|
485 |
-
speed = value
|
486 |
-
else:
|
487 |
-
speed = speed
|
488 |
-
return speed
|
489 |
-
|
490 |
-
@callback(Output('sunrise-time', 'children'),
|
491 |
-
Output('sunset-time', 'children'),
|
492 |
-
Output('datatable-div', 'children'),
|
493 |
-
Output('base-figure-div', 'children'),
|
494 |
-
Input('submit-forecast', 'n_clicks'),
|
495 |
-
State('store-gpx', 'data'),
|
496 |
-
State('store-date', 'data'),
|
497 |
-
State('store-hour', 'data'),
|
498 |
-
State('store-minute', 'data'),
|
499 |
-
State('store-freq', 'data'),
|
500 |
-
State('store-pace', 'data'),
|
501 |
-
prevent_initial_call=False)
|
502 |
-
def weather_forecast(n_clicks, gpx_json, cdate, h, m, freq, pace):
|
503 |
-
|
504 |
-
global df_wp
|
505 |
-
global hdate
|
506 |
-
global hour
|
507 |
-
global minute
|
508 |
-
global time
|
509 |
-
global frequency
|
510 |
-
global granularity
|
511 |
-
global speed
|
512 |
-
|
513 |
-
if cdate:
|
514 |
-
hdate = cdate
|
515 |
-
|
516 |
-
if h:
|
517 |
-
hour = h
|
518 |
-
|
519 |
-
if m:
|
520 |
-
minute = m
|
521 |
-
time = hour + ':' + minute
|
522 |
-
|
523 |
-
if freq:
|
524 |
-
frequency = freq
|
525 |
-
granularity = frequency * 1000
|
526 |
-
|
527 |
-
if pace:
|
528 |
-
speed = pace
|
529 |
-
|
530 |
-
if not gpx_json:
|
531 |
-
igpx = 'default_gpx.gpx'
|
532 |
-
df_gpx = Converter(input_file = igpx).gpx_to_dataframe()
|
533 |
-
gpx_json = df_gpx.to_dict('records')
|
534 |
-
|
535 |
-
if n_clicks >=0:
|
536 |
-
|
537 |
-
gpx_df = pd.DataFrame.from_records(gpx_json)
|
538 |
-
|
539 |
-
df_gpx, df_wp, dfs, sunrise, sunset, centre_lat, centre_lon = parse_gpx(gpx_df, hdate)
|
540 |
-
|
541 |
-
sunrise_div = html.Div([sunrise])
|
542 |
-
sunset_div = html.Div([sunset])
|
543 |
-
|
544 |
-
table_div = html.Div([dash_table.DataTable(id='datatable-display',
|
545 |
-
markdown_options = {'html': True},
|
546 |
-
columns=[{'name': i, 'id': i, 'deletable': False, 'selectable': False, 'presentation': 'markdown'} for i in dfs.columns],
|
547 |
-
data=dfs.to_dict('records'),
|
548 |
-
editable=False,
|
549 |
-
row_deletable=False,
|
550 |
-
style_as_list_view=True,
|
551 |
-
style_cell={'fontSize': '12px', 'text-align': 'center', 'margin-bottom':'0'},
|
552 |
-
css=[dict(selector= 'p', rule= 'margin: 0; text-align: center')],
|
553 |
-
style_header={'backgroundColor': 'goldenrod', 'color': 'white', 'fontWeight': 'bold'})
|
554 |
-
])
|
555 |
-
|
556 |
-
fig = plot_fig(df_gpx, df_wp, centre_lat, centre_lon)
|
557 |
-
|
558 |
-
figure_div = html.Div([dcc.Graph(id='base-figure', figure=fig, clear_on_unhover=True, style={'height': '90vh'})])
|
559 |
-
|
560 |
-
return sunrise_div, sunset_div, table_div, figure_div
|
561 |
|
562 |
@callback(Output('figure-tooltip', 'show'),
|
563 |
Output('figure-tooltip', 'bbox'),
|
@@ -572,7 +315,7 @@ def display_hover(hoverData):
|
|
572 |
bbox = pt['bbox']
|
573 |
num = pt['pointNumber']
|
574 |
|
575 |
-
df_row =
|
576 |
img_src = df_row['Weather']
|
577 |
txt_src = df_row['dist_read']
|
578 |
|
@@ -581,11 +324,15 @@ def display_hover(hoverData):
|
|
581 |
|
582 |
return True, bbox, children
|
583 |
|
584 |
-
@callback(Output('
|
585 |
[Input('interval-component', 'n_intervals')])
|
586 |
def refresh_layout(n):
|
587 |
-
|
588 |
-
|
|
|
|
|
|
|
589 |
|
590 |
if __name__ == '__main__':
|
591 |
app.run(debug=False, host='0.0.0.0', port=7860)
|
|
|
|
1 |
+
from datetime import datetime, timedelta
|
|
|
|
|
|
|
2 |
import pandas as pd
|
3 |
+
import numpy as np
|
4 |
+
import os
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
5 |
|
6 |
import srtm
|
7 |
elevation_data = srtm.get_data()
|
8 |
|
9 |
+
import json
|
10 |
+
import geopy
|
11 |
+
from geopy import distance
|
12 |
+
from beaufort_scale.beaufort_scale import beaufort_scale_kmh
|
13 |
+
|
14 |
+
import requests
|
15 |
import requests_cache
|
16 |
import openmeteo_requests
|
17 |
from retry_requests import retry
|
18 |
|
19 |
+
from dash import Dash, dcc, html, Input, Output, callback, no_update
|
20 |
+
from dash_extensions import Purify
|
21 |
+
import plotly.graph_objects as go
|
22 |
+
|
23 |
+
### UPDATE PEAK LIST ###
|
24 |
+
|
25 |
+
lat = 49.610755
|
26 |
+
lon = 6.13268
|
27 |
+
ele = 310
|
28 |
+
dist = 100
|
29 |
+
|
30 |
+
overpass_url = 'https://overpass.private.coffee/api/interpreter'
|
31 |
+
|
32 |
+
def add_ele(row):
|
33 |
+
if str(int(round(row['altitude'], 0))).isnumeric():
|
34 |
+
row['altitude'] = row['altitude']
|
35 |
+
else:
|
36 |
+
row['altitude'] = elevation_data.get_elevation(row['latitude'], row['longitude'], 0)
|
37 |
+
return row
|
38 |
+
|
39 |
+
def eukarney(lat1, lon1):
|
40 |
+
p1 = (lat1, lon1)
|
41 |
+
p2 = (lat, lon)
|
42 |
+
karney = distance.distance(p1, p2).m
|
43 |
+
return karney
|
44 |
+
|
45 |
+
def compute_bbox(lat, lon, dist):
|
46 |
+
bearings = [225, 45]
|
47 |
+
origin = geopy.Point(lat, lon)
|
48 |
+
l = []
|
49 |
+
|
50 |
+
for bearing in bearings:
|
51 |
+
destination = distance.distance(dist).destination(origin, bearing)
|
52 |
+
coords = destination.latitude, destination.longitude
|
53 |
+
l.extend(coords)
|
54 |
+
return l
|
55 |
+
|
56 |
+
bbox = compute_bbox(lat, lon, dist)
|
57 |
+
bbox = ','.join(str(x) for x in compute_bbox(lat, lon, dist))
|
58 |
+
|
59 |
+
peak_list = 'peak_list.csv'
|
60 |
+
|
61 |
+
def update_peaks():
|
62 |
+
|
63 |
+
overpass_query = '[out:json];(nwr[natural=peak](' + bbox + ');nwr[natural=hill](' + bbox + '););out body;'
|
64 |
+
|
65 |
+
response = requests.get(overpass_url, params={'data': overpass_query})
|
66 |
+
|
67 |
+
response = response.json()
|
68 |
|
69 |
+
peak_dict = {'name': [], 'latitude': [], 'longitude': [], 'altitude': []}
|
70 |
+
for e in response['elements']:
|
71 |
+
peak_dict['latitude'].append(float(e['lat']))
|
72 |
+
peak_dict['longitude'].append(float(e['lon']))
|
73 |
+
if 'name' in e['tags'].keys():
|
74 |
+
peak_dict['name'].append(e['tags']['name'])
|
75 |
+
else:
|
76 |
+
peak_dict['name'].append('Unnamed hill')
|
77 |
+
if 'ele' in e['tags'].keys():
|
78 |
+
peak_dict['altitude'].append(float(e['tags']['ele']))
|
79 |
+
else:
|
80 |
+
peak_dict['altitude'].append(elevation_data.get_elevation(e['lat'], e['lon'], 0))
|
81 |
|
|
|
|
|
|
|
|
|
|
|
82 |
|
83 |
+
df = pd.DataFrame.from_dict(peak_dict)
|
84 |
+
|
85 |
+
df = df.apply(lambda x: add_ele(x), axis=1)
|
86 |
+
|
87 |
+
df['distances'] = df.apply(lambda x: eukarney(x['latitude'], x['longitude']), axis=1).fillna(0)
|
88 |
+
|
89 |
+
df['altitude'] = df['altitude'].round(0).astype(int)
|
90 |
+
|
91 |
+
df.to_csv(peak_list, index=False)
|
92 |
+
|
93 |
+
return df
|
94 |
+
|
95 |
+
### WEATHER FORECAST ###
|
96 |
+
|
97 |
cache_session = requests_cache.CachedSession('.cache', expire_after = 3600)
|
98 |
retry_session = retry(cache_session, retries = 5, backoff_factor = 0.2)
|
99 |
openmeteo = openmeteo_requests.Client(session = retry_session)
|
|
|
102 |
url = 'https://api.open-meteo.com/v1/forecast'
|
103 |
params = {
|
104 |
'timezone': 'auto',
|
105 |
+
'hourly': ['temperature_2m', 'is_day', 'rain', 'weather_code', 'wind_speed_10m', 'snow_depth']
|
|
|
106 |
}
|
107 |
|
108 |
# Load the JSON files mapping weather codes to descriptions and icons
|
|
|
111 |
|
112 |
# Weather icons URL
|
113 |
icon_url = 'https://raw.githubusercontent.com/basmilius/weather-icons/refs/heads/dev/production/fill/svg/'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
114 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
115 |
def map_icons(df):
|
116 |
code = df['weather_code']
|
117 |
|
|
|
145 |
rain = 'No rain / No info'
|
146 |
return rain
|
147 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
148 |
# Obtain the weather forecast for each waypoint at each specific time
|
149 |
+
def get_weather(df):
|
|
|
|
|
|
|
|
|
150 |
|
151 |
+
params['latitude'] = df['latitude']
|
152 |
+
params['longitude'] = df['longitude']
|
153 |
+
params['elevation'] = df['altitude']
|
154 |
|
155 |
+
now = datetime.now()
|
|
|
156 |
|
157 |
+
start_period = (now - timedelta(seconds=3600)).strftime('%Y-%m-%dT%H:%M')
|
158 |
+
end_period = now.strftime('%Y-%m-%dT%H:%M')
|
159 |
|
160 |
+
params['start_hour'] = start_period
|
161 |
+
params['end_hour'] = end_period
|
|
|
|
|
|
|
|
|
|
|
|
|
162 |
|
163 |
responses = openmeteo.weather_api(url, params=params)
|
164 |
|
|
|
166 |
response = responses[0]
|
167 |
|
168 |
# Process hourly data. The order of variables needs to be the same as requested.
|
169 |
+
# currently = response.Current()
|
170 |
hourly = response.Hourly()
|
171 |
|
172 |
+
minutely_temperature_2m = hourly.Variables(0).ValuesAsNumpy()[0]
|
173 |
+
is_day = hourly.Variables(1).ValuesAsNumpy()[0]
|
174 |
+
rain = hourly.Variables(2).ValuesAsNumpy()[0]
|
175 |
+
weather_code = hourly.Variables(3).ValuesAsNumpy()[0]
|
176 |
+
minutely_wind_speed_10m = hourly.Variables(4).ValuesAsNumpy()[0]
|
177 |
+
snow_depth = hourly.Variables(5).ValuesAsNumpy()[0]
|
178 |
|
179 |
+
df['Temp (°C)'] = minutely_temperature_2m
|
180 |
+
df['weather_code'] = weather_code
|
181 |
+
df['is_day'] = is_day
|
182 |
|
183 |
v_rain_intensity = np.vectorize(rain_intensity)
|
184 |
+
df['Rain level'] = v_rain_intensity(rain)
|
185 |
|
186 |
v_beaufort_scale_kmh = np.vectorize(beaufort_scale_kmh)
|
187 |
|
188 |
+
df['Wind level'] = v_beaufort_scale_kmh(minutely_wind_speed_10m, language='en')
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
189 |
|
190 |
+
df['Rain (mm/h)'] = rain
|
191 |
+
df['Wind (km/h)'] = minutely_wind_speed_10m
|
192 |
|
193 |
+
df['Snow depth (cm)'] = (snow_depth * 100).round(1)
|
|
|
194 |
|
195 |
+
return df
|
196 |
|
|
|
|
|
|
|
197 |
|
198 |
+
def format_peaks():
|
199 |
|
200 |
+
if not os.path.isfile(peak_list):
|
201 |
+
update_peaks()
|
202 |
|
203 |
+
today = datetime.today()
|
204 |
+
modified_date = datetime.fromtimestamp(os.path.getmtime(peak_list))
|
205 |
+
peak_age = today - modified_date
|
206 |
|
207 |
+
if peak_age.days > 30:
|
208 |
+
update_peaks()
|
209 |
|
210 |
+
df = pd.read_csv(peak_list)
|
211 |
+
df = df[df['altitude']>=df['altitude'].quantile(3/4)].copy()
|
212 |
+
df = df.sort_values(by='distances',ascending=True).reset_index(drop=True)
|
213 |
+
df = df.head(600).copy()
|
214 |
|
215 |
+
df = df.apply(lambda x: get_weather(x), axis=1)
|
|
|
|
|
|
|
216 |
|
217 |
+
df['Temp (°C)'] = df['Temp (°C)'].round(0).astype(int).astype(str) + '°C'
|
218 |
+
df['Wind (km/h)'] = df['Wind (km/h)'].round(1).astype(str).replace('0.0', '')
|
219 |
+
df['Rain (mm/h)'] = df['Rain (mm/h)'].round(1).astype(str).replace('0.0', '')
|
220 |
+
df['distances'] = (df['distances'] / 1000).round(1).astype(str) + ' km'
|
221 |
+
df['Snow depth (cm)'] = df['Snow depth (cm)'].astype(str) + ' cm'
|
222 |
+
df['altitude'] = df['altitude'].astype(str) + ' m'
|
223 |
+
df['is_day'] = df['is_day'].astype(int)
|
224 |
|
225 |
+
df['weather_code'] = df['weather_code'].astype(int)
|
226 |
+
df = df.apply(map_icons, axis=1)
|
227 |
|
228 |
+
df['Rain level'] = df['Rain level'].astype(str)
|
229 |
+
df['Wind level'] = df['Wind level'].astype(str)
|
230 |
|
231 |
+
df = df.rename(columns={'distances': 'Distance (km)'})
|
|
|
|
|
|
|
232 |
|
233 |
+
df['dist_read'] = ('<p style="font-family:sans; font-size:12px;">' +
|
234 |
+
df['name'] + '<br>' +
|
235 |
+
df['altitude'] + ' | ' + df['Distance (km)'] + '<br><br>' +
|
236 |
+
'Snow: ' + df['Snow depth (cm)'] + '<br><br>' +
|
237 |
+
'<b>' + df['Weather outline'] + '</b><br><br>' +
|
238 |
+
df['Temp (°C)'] + '<br><br>' +
|
239 |
+
df['Rain level'] + '<br>' +
|
240 |
+
df['Wind level'])
|
241 |
|
242 |
+
df = df[(df['Snow depth (cm)'] != '0.0 cm') | (df['Weather outline'].str.lower().str.contains('snow'))].copy()
|
|
|
|
|
|
|
|
|
|
|
|
|
243 |
|
244 |
+
return df
|
245 |
|
246 |
+
def snow_color(row):
|
247 |
+
if row['Snow depth (cm)'] == '0.0 cm':
|
248 |
+
row['snow_colour'] = 'goldenrod'
|
249 |
+
else:
|
250 |
+
row['snow_colour'] = 'aqua'
|
251 |
+
return row
|
252 |
|
253 |
+
def plot_fig():
|
|
|
|
|
254 |
|
255 |
+
global df
|
256 |
|
257 |
+
lat_centre = 49.8464
|
258 |
+
lon_centre = 6.0992
|
259 |
|
260 |
+
df = format_peaks()
|
261 |
|
262 |
+
df['snow_colour'] = ''
|
263 |
|
264 |
+
df = df.apply(lambda row: snow_color(row), axis=1)
|
265 |
|
266 |
fig = go.Figure()
|
267 |
|
268 |
+
fig.add_trace(go.Scattermap(lon=df['longitude'],
|
269 |
+
lat=df['latitude'],
|
270 |
+
mode='markers', marker=dict(size=24, color=df['snow_colour'], opacity=0.8, symbol='circle'),
|
271 |
+
name='circles'))
|
272 |
|
273 |
+
fig.add_trace(go.Scattermap(lon=df['longitude'],
|
274 |
+
lat=df['latitude'],
|
275 |
+
mode='markers', marker=dict(size=8, opacity=1, symbol='mountain'),
|
276 |
+
name='peaks'))
|
|
|
|
|
277 |
|
278 |
fig.update_layout(map_style='open-street-map',
|
279 |
+
map=dict(center=dict(lat=lat_centre, lon=lon_centre), zoom=8))
|
280 |
|
281 |
+
fig.update_traces(showlegend=False, hoverinfo='none', hovertemplate=None, selector=({'name': 'circles'}))
|
282 |
+
fig.update_traces(showlegend=False, hoverinfo='none', hovertemplate=None, selector=({'name': 'peaks'}))
|
283 |
|
284 |
return fig
|
285 |
|
286 |
+
app = Dash(__name__)
|
|
|
|
|
|
|
|
|
|
|
287 |
server = app.server
|
288 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
289 |
def serve_layout():
|
290 |
|
291 |
layout = html.Div([
|
292 |
+
html.Div([dcc.Graph(id='base-figure', clear_on_unhover=True, style={'height': '99vh'})], id='base-figure-div'),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
293 |
dcc.Tooltip(id='figure-tooltip'),
|
|
|
|
|
|
|
|
|
|
|
|
|
294 |
dcc.Interval(
|
295 |
+
id='interval-component',
|
296 |
+
interval=60 * 60 * 1000,
|
297 |
+
n_intervals=0),
|
298 |
], id='layout-content')
|
299 |
|
|
|
|
|
300 |
return layout
|
301 |
|
302 |
+
layout_funct = serve_layout
|
303 |
+
app.layout = layout_funct
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
304 |
|
305 |
@callback(Output('figure-tooltip', 'show'),
|
306 |
Output('figure-tooltip', 'bbox'),
|
|
|
315 |
bbox = pt['bbox']
|
316 |
num = pt['pointNumber']
|
317 |
|
318 |
+
df_row = df.iloc[num].copy()
|
319 |
img_src = df_row['Weather']
|
320 |
txt_src = df_row['dist_read']
|
321 |
|
|
|
324 |
|
325 |
return True, bbox, children
|
326 |
|
327 |
+
@callback(Output('base-figure', 'figure'),
|
328 |
[Input('interval-component', 'n_intervals')])
|
329 |
def refresh_layout(n):
|
330 |
+
global fig
|
331 |
+
global layout_funct
|
332 |
+
fig = plot_fig()
|
333 |
+
layout_funct = serve_layout
|
334 |
+
return fig
|
335 |
|
336 |
if __name__ == '__main__':
|
337 |
app.run(debug=False, host='0.0.0.0', port=7860)
|
338 |
+
|