mirix commited on
Commit
683ee80
·
verified ·
1 Parent(s): dc51410

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +435 -181
app.py CHANGED
@@ -1,99 +1,49 @@
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,7 +52,8 @@ 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,7 +62,33 @@ with open('weather_icons_custom.json', 'r') as file:
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,20 +122,44 @@ def rain_intensity(precipt):
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,143 +167,398 @@ def get_weather(df):
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
- fig = plot_fig()
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  def serve_layout():
292
 
293
  layout = html.Div([
294
- html.Div([dcc.Graph(id='base-figure', figure=fig, clear_on_unhover=True, style={'height': '99vh'})], id='base-figure-div'),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
295
  dcc.Tooltip(id='figure-tooltip'),
 
 
 
 
 
 
296
  dcc.Interval(
297
- id='interval-component',
298
- interval=60 * 60 * 1000,
299
- n_intervals=0),
300
  ], id='layout-content')
301
 
 
 
302
  return layout
303
 
304
  app.layout = serve_layout
305
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
  @callback(Output('figure-tooltip', 'show'),
307
  Output('figure-tooltip', 'bbox'),
308
  Output('figure-tooltip', 'children'),
@@ -316,7 +572,7 @@ def display_hover(hoverData):
316
  bbox = pt['bbox']
317
  num = pt['pointNumber']
318
 
319
- df_row = df.iloc[num].copy()
320
  img_src = df_row['Weather']
321
  txt_src = df_row['dist_read']
322
 
@@ -325,13 +581,11 @@ def display_hover(hoverData):
325
 
326
  return True, bbox, children
327
 
328
- @callback(Output('base-figure', 'figure'),
329
  [Input('interval-component', 'n_intervals')])
330
  def refresh_layout(n):
331
- global fig
332
- fig = plot_fig()
333
- return fig
334
 
335
  if __name__ == '__main__':
336
  app.run(debug=False, host='0.0.0.0', port=7860)
337
-
 
1
+ import io
 
 
 
 
 
 
 
2
  import json
3
+ import pytz
4
+ import numpy as np
5
+ import pandas as pd
6
+ pd.options.mode.chained_assignment = None
7
  from geopy import distance
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
+ ### VARIABLES ###
33
 
34
+ hdate_object = date.today()
35
+ hour = '10'
36
+ minute = '30'
37
+ speed = 4.0
38
+ frequency = 2
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
+ # Setup the Open Meteo API client with cache and retry on error
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
  url = 'https://api.open-meteo.com/v1/forecast'
53
  params = {
54
  'timezone': 'auto',
55
+ 'minutely_15': ['temperature_2m', 'rain', 'wind_speed_10m', 'weather_code', 'is_day'],
56
+ 'hourly': ['rain'],
57
  }
58
 
59
  # Load the JSON files mapping weather codes to descriptions and icons
 
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
  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(df_wp):
142
+
143
+ params['latitude'] = df_wp['latitude']
144
+ params['longitude'] = df_wp['longitude']
145
+ params['elevation'] = df_wp['altitude']
146
 
147
+ start_dt = datetime.strptime(hdate + 'T' + time, '%Y-%m-%dT%H:%M')
 
 
148
 
149
+ delta_dt = start_dt + timedelta(seconds=df_wp['seconds'])
150
+ delta_read = delta_dt.strftime('%Y-%m-%dT%H:%M')
151
 
152
+ start_period = (delta_dt - timedelta(seconds=1800)).strftime('%Y-%m-%dT%H:%M')
153
+ end_period = (delta_dt + timedelta(seconds=1800)).strftime('%Y-%m-%dT%H:%M')
154
 
155
+ time_read = delta_dt.strftime('%H:%M')
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
  response = responses[0]
168
 
169
  # Process hourly data. The order of variables needs to be the same as requested.
170
+ minutely = response.Minutely15()
171
  hourly = response.Hourly()
172
 
173
+ minutely_temperature_2m = minutely.Variables(0).ValuesAsNumpy()[0]
174
+ rain = hourly.Variables(0).ValuesAsNumpy()[0]
175
+ minutely_wind_speed_10m = minutely.Variables(2).ValuesAsNumpy()[0]
176
+ weather_code = minutely.Variables(3).ValuesAsNumpy()[0]
177
+ is_day = minutely.Variables(4).ValuesAsNumpy()[0]
 
178
 
179
+ df_wp['Temp (°C)'] = minutely_temperature_2m
180
+ df_wp['weather_code'] = weather_code
181
+ df_wp['is_day'] = is_day
182
 
183
  v_rain_intensity = np.vectorize(rain_intensity)
184
+ df_wp['Rain level'] = v_rain_intensity(rain)
185
 
186
  v_beaufort_scale_kmh = np.vectorize(beaufort_scale_kmh)
187
 
188
+ df_wp['Wind level'] = v_beaufort_scale_kmh(minutely_wind_speed_10m, language='en')
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
+ df_gpx = df_gpx.apply(lambda x: add_ele(x), axis=1)
 
 
206
 
207
+ centre_lat = (df_gpx['latitude'].max() + df_gpx['latitude'].min()) / 2
208
+ centre_lon = (df_gpx['longitude'].max() + df_gpx['longitude'].min()) / 2
209
 
210
+ # Create shifted columns in order to facilitate distance calculation
 
 
 
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
+ # Apply the distance function to the dataframe
 
 
 
 
 
 
217
 
218
+ df_gpx['distances'] = df_gpx.apply(lambda x: eukarney(x['latitude'], x['longitude'], x['altitude'], x['lat_shift'], x['lon_shift'], x['alt_shift']), axis=1).fillna(0)
219
+ df_gpx['distance'] = df_gpx['distances'].cumsum().round(decimals = 0).astype(int)
220
 
221
+ df_gpx = df_gpx.drop(columns=['lat_shift', 'lon_shift', 'alt_shift', 'distances']).copy()
 
222
 
223
+ start = df_gpx['distance'].min()
224
+ finish = df_gpx['distance'].max()
225
 
226
+ dist_rang = list(range(start, finish, granularity))
227
+ dist_rang.append(finish)
 
 
 
 
 
 
228
 
229
+ way_list = []
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
+ df_wp = pd.DataFrame(way_list)
235
 
236
+ df_wp['seconds'] = df_wp['distance'].apply(lambda x: int(round(x / (speed * (5/18)), 0)))
237
+
238
+ df_wp = df_wp.apply(lambda x: get_weather(x), axis=1)
239
+
240
+ df_wp['Temp (°C)'] = df_wp['Temp (°C)'].round(0).astype(int).astype(str) + '°C'
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
+ df_wp['Rain level'] = df_wp['Rain level'].astype(str)
246
+ df_wp['Wind level'] = df_wp['Wind level'].astype(str)
247
+
248
+ df_wp['dist_read'] = ('<p style="font-family:sans; font-size:12px;"><b>' +
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
+ df_wp = df_wp.reset_index(drop=True)
257
 
258
+ df_wp['Waypoint'] = df_wp.index
259
 
260
+ dfs = df_wp[['Waypoint', 'Time', 'Weather', 'Weather outline', 'Temp (°C)', 'Rain (mm/h)', 'Rain level', 'Wind (km/h)', 'Wind level']].copy()
 
261
 
262
+ dfs['Wind (km/h)'] = dfs['Wind (km/h)'].round(1).astype(str).replace('0.0', '')
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
+ dfs['Weather'] = '<img style="float: right; padding: 0; margin: -6px; display: block;" width=48px; src=' + dfs['Weather'] + '>'
267
 
268
+ return df_gpx, df_wp, dfs, sunrise, sunset, centre_lat, centre_lon
269
+
270
+ ### PLOTS ###
271
+
272
+ # Plot map
273
+
274
+ def plot_fig(df_gpx, df_wp, centre_lat, centre_lon):
275
 
276
  fig = go.Figure()
277
 
278
+ fig.add_trace(go.Scattermap(lon=df_gpx['longitude'],
279
+ lat=df_gpx['latitude'],
280
+ mode='lines', line=dict(width=4, color='firebrick'),
281
+ name='gpx_trace'))
282
 
283
+ fig.add_trace(go.Scattermap(lon=df_wp['longitude'],
284
+ lat=df_wp['latitude'],
285
+ mode='markers+text', marker=dict(size=24, color='firebrick', opacity=0.8, symbol='circle'),
286
+ textfont=dict(color='white', weight='bold'),
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=centre_lat, lon=centre_lon), zoom=12))
292
 
293
+ fig.update_traces(showlegend=False, hoverinfo='none', hovertemplate=None, selector=({'name': 'wp_trace'}))
294
+ fig.update_traces(showlegend=False, hoverinfo='skip', hovertemplate=None, selector=({'name': 'gpx_trace'}))
295
 
296
  return fig
297
 
298
+ ### DASH APP ###
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.Link('The Weather for Hikers', href='.',
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
+ id='interval-component',
399
+ interval=6 * 60 * 60 * 1000,
400
+ n_intervals=0),
401
  ], id='layout-content')
402
 
403
+ layout = dmc.MantineProvider(layout)
404
+
405
  return layout
406
 
407
  app.layout = serve_layout
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'),
564
  Output('figure-tooltip', 'children'),
 
572
  bbox = pt['bbox']
573
  num = pt['pointNumber']
574
 
575
+ df_row = df_wp.iloc[num].copy()
576
  img_src = df_row['Weather']
577
  txt_src = df_row['dist_read']
578
 
 
581
 
582
  return True, bbox, children
583
 
584
+ @callback(Output('layout-content', 'children'),
585
  [Input('interval-component', 'n_intervals')])
586
  def refresh_layout(n):
587
+ layout = serve_layout()
588
+ return layout
 
589
 
590
  if __name__ == '__main__':
591
  app.run(debug=False, host='0.0.0.0', port=7860)