import json import pytz import numpy as np import pandas as pd from geopy import distance import plotly.graph_objects as go from gpx_converter import Converter from sunrisesunset import SunriseSunset from datetime import datetime, 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, no_update, callback import dash_bootstrap_components as dbc from dash_extensions import Purify import srtm elevation_data = srtm.get_data() import requests_cache import openmeteo_requests from retry_requests import retry ### VARIABLES ### # Variables to become widgets igpx = 'default_gpx.gpx' date = '2024-12-22' time = '10:15' speed = 4.0 granularity = 2000 # 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, date): tz = tf.timezone_at(lng=lon_start, lat=lat_start) zone = pytz.timezone(tz) day = datetime.strptime(date, '%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['longitude'] start_dt = datetime.strptime(date + '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(igpx): global centre_lat global centre_lon global sunrise global sunset df_gpx = Converter(input_file = igpx).gpx_to_dataframe() # 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, date) 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']) 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] df_gpx, df_wp, dfs = parse_gpx(igpx) ### PLOTS ### # Plot map 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'})) ### DASH APP ### external_stylesheets = [dbc.themes.BOOTSTRAP] app = Dash(__name__, external_stylesheets=external_stylesheets) # Callbacks @callback(Output('graph-tooltip', 'show'), Output('graph-tooltip', 'bbox'), Output('graph-tooltip', 'children'), Input('graph-basic-2', '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] 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': '128px', 'white-space': 'normal'})] return True, bbox, children # Layout app.layout = html.Div([ html.Div([dcc.Link('The Weather for Hikers', href='.', style={'color': 'darkslategray', 'font-size': 20, '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': 15, 'font-family': 'sans', 'text-decoration': 'none'}), ]), html.Div([html.Br(), dbc.Row([dbc.Col(html.Div('Sunrise '), width={'size': 'auto', 'offset': 4}), dbc.Col(html.Img(src=sunrise_icon, style={'height':'42px'}), width={'size': 'auto'}), dbc.Col(html.Div(sunrise), width={'size': 'auto'}), dbc.Col(html.Div('Sunset '), width={'size': 'auto', 'offset': 1}), dbc.Col(html.Img(src=sunset_icon, style={'height':'42px'}), width={'size': 'auto'}), dbc.Col(html.Div(sunset), width={'size': 'auto'})]), ], style={'font-size': 13, 'font-family': 'sans'}), html.Div([dash_table.DataTable( id='datatable-interactivity', 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'}), dcc.Graph(id='graph-basic-2', figure=fig, clear_on_unhover=True, style={'height': '90vh'}), dcc.Tooltip(id='graph-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': 'black', 'font-size': 13, 'font-family': 'sans', 'text-decoration': 'none'}), ], style={'text-align': 'center'},) ]) # Light up server if __name__ == '__main__': app.run(debug=False, host='0.0.0.0', port=7860)