import io import json import pytz import numpy as np import pandas as pd pd.options.mode.chained_assignment = None from geopy import distance import plotly.graph_objects as go import base64 import gpxpy from gpx_converter import Converter from sunrisesunset import SunriseSunset from datetime import datetime, date, timedelta from beaufort_scale.beaufort_scale import beaufort_scale_kmh from timezonefinder import TimezoneFinder tf = TimezoneFinder() from dash import Dash, dcc, html, dash_table, Input, Output, State, no_update, callback, _dash_renderer import dash_bootstrap_components as dbc import dash_mantine_components as dmc _dash_renderer._set_react_version('18.2.0') from dash_extensions import Purify import srtm elevation_data = srtm.get_data() import requests_cache import openmeteo_requests from retry_requests import retry ### VARIABLES ### hdate_object = date.today() hour = '10' minute = '30' speed = 4.0 frequency = 2 # Variables to become widgets igpx = 'default_gpx.gpx' hdate = hdate_object.strftime('%Y-%m-%d') time = hour + ':' + minute granularity = frequency * 1000 # Setup the Open Meteo API client with cache and retry on error cache_session = requests_cache.CachedSession('.cache', expire_after = 3600) retry_session = retry(cache_session, retries = 5, backoff_factor = 0.2) openmeteo = openmeteo_requests.Client(session = retry_session) # Open Meteo weather forecast API url = 'https://api.open-meteo.com/v1/forecast' params = { 'timezone': 'auto', 'minutely_15': ['temperature_2m', 'rain', 'wind_speed_10m', 'weather_code', 'is_day'], 'hourly': ['rain'], } # Load the JSON files mapping weather codes to descriptions and icons with open('weather_icons_custom.json', 'r') as file: icons = json.load(file) # Weather icons URL icon_url = 'https://raw.githubusercontent.com/basmilius/weather-icons/refs/heads/dev/production/fill/svg/' sunrise_icon = icon_url + 'sunrise.svg' sunset_icon = icon_url + 'sunset.svg' ### FUNCTIONS ### # Sunrise sunset def sunrise_sunset(lat_start, lon_start, lat_end, lon_end, hdate): tz = tf.timezone_at(lng=lon_start, lat=lat_start) zone = pytz.timezone(tz) day = datetime.strptime(hdate, '%Y-%m-%d') dt = day.astimezone(zone) rs_start = SunriseSunset(dt, lat=lat_start, lon=lon_start, zenith='official') rise_time = rs_start.sun_rise_set[0] rs_end = SunriseSunset(dt, lat=lat_end, lon=lon_end, zenith='official') set_time = rs_end.sun_rise_set[1] sunrise = rise_time.strftime('%H:%M') sunset = set_time.strftime('%H:%M') return sunrise, sunset # Map weather codes to descriptions and icons def map_icons(df): code = df['weather_code'] if df['is_day'] == 1: icon = icons[str(code)]['day']['icon'] description = icons[str(code)]['day']['description'] elif df['is_day'] == 0: icon = icons[str(code)]['night']['icon'] description = icons[str(code)]['night']['description'] df['Weather'] = icon_url + icon df['Weather outline'] = description return df # Quantitative pluviometry to natural language def rain_intensity(precipt): if precipt >= 50: rain = 'Extreme rain' elif 50 < precipt <= 16: rain = 'Very heavy rain' elif 4 <= precipt < 16: rain = 'Heavy rain' elif 1 <= precipt < 4: rain = 'Moderate rain' elif 0.25 <= precipt < 1: rain = 'Light rain' elif 0 < precipt < 0.25: rain = 'Light drizzle' else: rain = 'No rain / No info' return rain # Function to add elevation def add_ele(row): if pd.isnull(row['altitude']): row['altitude'] = elevation_data.get_elevation(row['latitude'], row['longitude'], 0) else: row['altitude'] = row['altitude'] return row # Compute distances using the Karney algorith with Euclidian altitude correction def eukarney(lat1, lon1, alt1, lat2, lon2, alt2): p1 = (lat1, lon1) p2 = (lat2, lon2) karney = distance.distance(p1, p2).m return np.sqrt(karney**2 + (alt2 - alt1)**2) # Obtain the weather forecast for each waypoint at each specific time def get_weather(df_wp): params['latitude'] = df_wp['latitude'] params['longitude'] = df_wp['longitude'] params['elevation'] = df_wp['altitude'] start_dt = datetime.strptime(hdate + 'T' + time, '%Y-%m-%dT%H:%M') delta_dt = start_dt + timedelta(seconds=df_wp['seconds']) delta_read = delta_dt.strftime('%Y-%m-%dT%H:%M') start_period = (delta_dt - timedelta(seconds=1800)).strftime('%Y-%m-%dT%H:%M') end_period = (delta_dt + timedelta(seconds=1800)).strftime('%Y-%m-%dT%H:%M') time_read = delta_dt.strftime('%H:%M') df_wp['Time'] = time_read params['start_minutely_15'] = delta_read params['end_minutely_15'] = delta_read params['start_hour'] = delta_read params['end_hour'] = delta_read responses = openmeteo.weather_api(url, params=params) # Process first location. Add a for-loop for multiple locations or weather models response = responses[0] # Process hourly data. The order of variables needs to be the same as requested. minutely = response.Minutely15() hourly = response.Hourly() minutely_temperature_2m = minutely.Variables(0).ValuesAsNumpy()[0] rain = hourly.Variables(0).ValuesAsNumpy()[0] minutely_wind_speed_10m = minutely.Variables(2).ValuesAsNumpy()[0] weather_code = minutely.Variables(3).ValuesAsNumpy()[0] is_day = minutely.Variables(4).ValuesAsNumpy()[0] df_wp['Temp (°C)'] = minutely_temperature_2m df_wp['weather_code'] = weather_code df_wp['is_day'] = is_day v_rain_intensity = np.vectorize(rain_intensity) df_wp['Rain level'] = v_rain_intensity(rain) v_beaufort_scale_kmh = np.vectorize(beaufort_scale_kmh) df_wp['Wind level'] = v_beaufort_scale_kmh(minutely_wind_speed_10m, language='en') df_wp['Rain (mm/h)'] = rain.round(1) df_wp['Wind (km/h)'] = minutely_wind_speed_10m.round(1) return df_wp # Parse the GPX track def parse_gpx(df_gpx, hdate): # Sunrise sunset lat_start, lon_start = df_gpx[['latitude', 'longitude']].head(1).values.flatten().tolist() lat_end, lon_end = df_gpx[['latitude', 'longitude']].tail(1).values.flatten().tolist() sunrise, sunset = sunrise_sunset(lat_start, lon_start, lat_end, lon_end, hdate) df_gpx = df_gpx.apply(lambda x: add_ele(x), axis=1) centre_lat = (df_gpx['latitude'].max() + df_gpx['latitude'].min()) / 2 centre_lon = (df_gpx['longitude'].max() + df_gpx['longitude'].min()) / 2 # Create shifted columns in order to facilitate distance calculation df_gpx['lat_shift'] = df_gpx['latitude'].shift(periods=-1).fillna(df_gpx['latitude']) df_gpx['lon_shift'] = df_gpx['longitude'].shift(periods=-1).fillna(df_gpx['longitude']) df_gpx['alt_shift'] = df_gpx['altitude'].shift(periods=-1).fillna(df_gpx['altitude']) # Apply the distance function to the dataframe 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) df_gpx['distance'] = df_gpx['distances'].cumsum().round(decimals = 0).astype(int) df_gpx = df_gpx.drop(columns=['lat_shift', 'lon_shift', 'alt_shift', 'distances']).copy() start = df_gpx['distance'].min() finish = df_gpx['distance'].max() dist_rang = list(range(start, finish, granularity)) dist_rang.append(finish) way_list = [] for waypoint in dist_rang: gpx_dict = df_gpx.iloc[(df_gpx.distance - waypoint).abs().argsort()[:1]].to_dict('records')[0] way_list.append(gpx_dict) df_wp = pd.DataFrame(way_list) df_wp['seconds'] = df_wp['distance'].apply(lambda x: int(round(x / (speed * (5/18)), 0))) df_wp = df_wp.apply(lambda x: get_weather(x), axis=1) df_wp['Temp (°C)'] = df_wp['Temp (°C)'].round(0).astype(int).astype(str) + '°C' df_wp['is_day'] = df_wp['is_day'].astype(int) df_wp['weather_code'] = df_wp['weather_code'].astype(int) df_wp = df_wp.apply(map_icons, axis=1) df_wp['Rain level'] = df_wp['Rain level'].astype(str) df_wp['Wind level'] = df_wp['Wind level'].astype(str) df_wp['dist_read'] = ('

' + df_wp['Weather outline'] + '

' + df_wp['Temp (°C)'] + '

' + df_wp['Rain level'] + '
' + df_wp['Wind level'] + '

' + df_wp['Time'] + '

' + 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

') df_wp = df_wp.reset_index(drop=True) df_wp['Waypoint'] = df_wp.index dfs = df_wp[['Waypoint', 'Time', 'Weather', 'Weather outline', 'Temp (°C)', 'Rain (mm/h)', 'Rain level', 'Wind (km/h)', 'Wind level']].copy() dfs['Wind (km/h)'] = dfs['Wind (km/h)'].round(1).astype(str).replace('0.0', '') dfs['Rain (mm/h)'] = dfs['Rain (mm/h)'].round(1).astype(str).replace('0.0', '') dfs['Temp (°C)'] = dfs['Temp (°C)'].str.replace('C', '') dfs['Weather'] = '' return df_gpx, df_wp, dfs, sunrise, sunset, centre_lat, centre_lon ### PLOTS ### # Plot map def plot_fig(df_gpx, df_wp, centre_lat, centre_lon): fig = go.Figure() fig.add_trace(go.Scattermap(lon=df_gpx['longitude'], lat=df_gpx['latitude'], mode='lines', line=dict(width=4, color='firebrick'), name='gpx_trace')) fig.add_trace(go.Scattermap(lon=df_wp['longitude'], lat=df_wp['latitude'], mode='markers+text', marker=dict(size=24, color='firebrick', opacity=0.8, symbol='circle'), textfont=dict(color='white', weight='bold'), text=df_wp.index.astype(str), name='wp_trace')) fig.update_layout(map_style='open-street-map', map=dict(center=dict(lat=centre_lat, lon=centre_lon), zoom=12)) fig.update_traces(showlegend=False, hoverinfo='none', hovertemplate=None, selector=({'name': 'wp_trace'})) fig.update_traces(showlegend=False, hoverinfo='skip', hovertemplate=None, selector=({'name': 'gpx_trace'})) return fig ### DASH APP ### external_stylesheets = [dbc.themes.BOOTSTRAP, dmc.styles.ALL] app = Dash(__name__, external_stylesheets=external_stylesheets) server = app.server # Layout hours = [str(n).zfill(2) for n in range(0, 24)] minutes = [str(n).zfill(2) for n in range(0, 60, 5)] picker_style = { 'display': 'inline-block', 'width': '35px', 'height': '32px', 'cursor': 'pointer', 'border': 'none', } def serve_layout(): layout = html.Div([ html.Div([dcc.Link('The Weather for Hikers', href='.', style={'color': 'darkslategray', 'font-size': 18, 'font-family': 'sans', 'font-weight': 'bold', 'text-decoration': 'none'}), ]), html.Div([dcc.Link('Freedom Luxembourg', href='https://www.freeletz.lu/freeletz/', target='_blank', style={'color': 'goldenrod', 'font-size': 14, 'font-family': 'sans', 'text-decoration': 'none'}), ]), html.Div([html.Br(), dbc.Row([ dbc.Col([dcc.Upload(id='upload-gpx', children=html.Div(id='name-gpx'), accept='.gpx, .GPX', max_size=10000000, min_size=100, style={ 'width': '174px', 'height': '48px', 'lineWidth': '174px', 'lineHeight': '48px', 'borderWidth': '2px', 'borderStyle': 'solid', 'borderColor': 'goldenrod', 'textAlign': 'center', }, ), dcc.Store(id='store-gpx')], width={'size': 'auto', 'offset': 1}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}), dbc.Col([dbc.Label('Date of the hike'), html.Br(), dcc.DatePickerSingle(id='calendar-date', placeholder='Select the date of your hike', display_format='Do MMMM YYYY', min_date_allowed=date.today(), max_date_allowed=date.today() + timedelta(days=7), initial_visible_month=date.today(), date=date.today()), dcc.Store(id='store-date')], width={'size': 'auto'}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}), dbc.Col([html.Div([html.Label('Start time'), html.Br(), html.Br(), html.Div([dcc.Dropdown(hours, placeholder=hour, value=hour, style=picker_style, id='dropdown-hour'), dcc.Store(id='store-hour'), html.Span(':'), dcc.Dropdown(minutes, placeholder=minute, value=minute, style=picker_style, id='dropdown-minute'), dcc.Store(id='store-minute')], style={'border': '1px solid goldenrod', 'height': '34px', 'width': '76px', 'display': 'flex', 'align-items': 'center', }, ), ], style={'font-family': 'Sans'}, ), ], width={'size': 'auto'}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}), 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'}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}), 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'}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 82})], width={'size': 'auto'}), dbc.Col([html.Br(), html.Button('Forecast', id='submit-forecast', n_clicks=0, style={'width': '86px', 'height': '36px', 'background-color': 'goldenrod', 'font-weight': 'bold', 'color': 'white'})], width={'size': 'auto'}), ]), ], style={'font-size': 13, 'font-family': 'sans'}), html.Div([html.Br(), dbc.Row([dbc.Col(html.Div('Sunrise '), width={'size': 'auto', 'offset': 9}), dbc.Col(html.Img(src=sunrise_icon, style={'height':'42px'}), width={'size': 'auto'}), dbc.Col(html.Div(id='sunrise-time'), width={'size': 'auto'}), dbc.Col([dmc.Divider(orientation='vertical', size=2, color='goldenrod', style={'height': 22})], width={'size': 'auto'}), dbc.Col(html.Div('Sunset '), width={'size': 'auto', 'offset': 0}), dbc.Col(html.Img(src=sunset_icon, style={'height':'42px'}), width={'size': 'auto'}), dbc.Col(html.Div(id='sunset-time'), width={'size': 'auto'})]), ], style={'font-size': 13, 'font-family': 'sans'}), html.Div(id='datatable-div'), html.Div([dcc.Graph(id='base-figure', clear_on_unhover=True, style={'height': '90vh'})], id='base-figure-div'), dcc.Tooltip(id='figure-tooltip'), html.Div([dcc.Link('Freedom Luxembourg', href='https://www.freeletz.lu/freeletz/', target='_blank', style={'color': 'goldenrod', 'font-size': 15, 'font-family': 'sans', 'text-decoration': 'none'}), ], style={'text-align': 'center'},), html.Div([dcc.Link('Powered by Open Meteo', href='https://open-meteo.com/', target='_blank', style={'color': 'darkslategray', 'font-size': 13, 'font-family': 'sans', 'text-decoration': 'none'}), ], style={'text-align': 'center'}), dcc.Interval( id='interval-component', interval=6 * 60 * 60 * 1000, n_intervals=0), ], id='layout-content') layout = dmc.MantineProvider(layout) return layout app.layout = serve_layout # Callbacks @callback(Output('store-gpx', 'data'), Output('name-gpx', 'children'), Input('upload-gpx', 'contents'), State('upload-gpx', 'filename')) def update_gpx(contents, filename): if filename: try: igpx = filename message = html.Div(['Upload your GPX track ', html.H6(igpx, style={'color': 'darkslategray', 'font-size': 12, 'font-weight': 'bold'})]) content_type, content_string = contents.split(',') decoded = base64.b64decode(content_string) gpx_parsed = gpxpy.parse(decoded) # Convert to a dataframe one point at a time. points = [] for track in gpx_parsed.tracks: for segment in track.segments: for p in segment.points: points.append({ 'latitude': p.latitude, 'longitude': p.longitude, 'altitude': p.elevation, }) df_gpx = pd.DataFrame.from_records(points) except Exception: igpx = 'default_gpx.gpx' 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'})]) df_gpx = Converter(input_file = igpx).gpx_to_dataframe() else: igpx = 'default_gpx.gpx' message = html.Div(['Upload your GPX track ', html.H6(igpx, style={'color': 'darkslategray', 'font-size': 12, 'font-weight': 'bold'})]) df_gpx = Converter(input_file = igpx).gpx_to_dataframe() return df_gpx.to_dict('records'), message @callback(Output('store-date', 'data'), Input('calendar-date', 'date')) def update_date(value): if value: cdate = value else: cdate = hdate return cdate @callback(Output('store-hour', 'data'), Input('dropdown-hour', 'value')) def update_hour(value): if value: hour = value else: hour = hour return hour @callback(Output('store-minute', 'data'), Input('dropdown-minute', 'value')) def update_minute(value): if value: minute = value else: minute = minute return minute @callback(Output('store-freq', 'data'), Input('slider-freq', 'value')) def update_freq(value): if value: frequency = value else: frequency = frequency return frequency @callback(Output('store-pace', 'data'), Input('slider-pace', 'value')) def update_pace(value): if value: speed = value else: speed = speed return speed @callback(Output('sunrise-time', 'children'), Output('sunset-time', 'children'), Output('datatable-div', 'children'), Output('base-figure-div', 'children'), Input('submit-forecast', 'n_clicks'), State('store-gpx', 'data'), State('store-date', 'data'), State('store-hour', 'data'), State('store-minute', 'data'), State('store-freq', 'data'), State('store-pace', 'data'), prevent_initial_call=False) def weather_forecast(n_clicks, gpx_json, cdate, h, m, freq, pace): global df_wp global hdate global hour global minute global time global frequency global granularity global speed if cdate: hdate = cdate if h: hour = h if m: minute = m time = hour + ':' + minute if freq: frequency = freq granularity = frequency * 1000 if pace: speed = pace if not gpx_json: igpx = 'default_gpx.gpx' df_gpx = Converter(input_file = igpx).gpx_to_dataframe() gpx_json = df_gpx.to_dict('records') if n_clicks >=0: gpx_df = pd.DataFrame.from_records(gpx_json) df_gpx, df_wp, dfs, sunrise, sunset, centre_lat, centre_lon = parse_gpx(gpx_df, hdate) sunrise_div = html.Div([sunrise]) sunset_div = html.Div([sunset]) table_div = html.Div([dash_table.DataTable(id='datatable-display', markdown_options = {'html': True}, columns=[{'name': i, 'id': i, 'deletable': False, 'selectable': False, 'presentation': 'markdown'} for i in dfs.columns], data=dfs.to_dict('records'), editable=False, row_deletable=False, style_as_list_view=True, style_cell={'fontSize': '12px', 'text-align': 'center', 'margin-bottom':'0'}, css=[dict(selector= 'p', rule= 'margin: 0; text-align: center')], style_header={'backgroundColor': 'goldenrod', 'color': 'white', 'fontWeight': 'bold'}) ]) fig = plot_fig(df_gpx, df_wp, centre_lat, centre_lon) figure_div = html.Div([dcc.Graph(id='base-figure', figure=fig, clear_on_unhover=True, style={'height': '90vh'})]) return sunrise_div, sunset_div, table_div, figure_div @callback(Output('figure-tooltip', 'show'), Output('figure-tooltip', 'bbox'), Output('figure-tooltip', 'children'), Input('base-figure', 'hoverData')) def display_hover(hoverData): if hoverData is None: return False, no_update, no_update pt = hoverData['points'][0] bbox = pt['bbox'] num = pt['pointNumber'] df_row = df_wp.iloc[num].copy() img_src = df_row['Weather'] txt_src = df_row['dist_read'] children = [html.Div([html.Img(src=img_src, style={'width': '100%'}), Purify(txt_src),], style={'width': '96px', 'white-space': 'normal'})] return True, bbox, children @callback(Output('layout-content', 'children'), [Input('interval-component', 'n_intervals')]) def refresh_layout(n): layout = serve_layout() return layout if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=7860)