From c4c759943da084d57df02ec6c2458c3ccbacbf55 Mon Sep 17 00:00:00 2001 From: Marcel Weschke Date: Wed, 30 Jul 2025 11:57:31 +0200 Subject: [PATCH] added main python app. --- app.py | 430 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 430 insertions(+) create mode 100644 app.py diff --git a/app.py b/app.py new file mode 100644 index 0000000..d72af7a --- /dev/null +++ b/app.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Created on Thu Jul 30th 2025 + +@author: Marcel Weschke +@email: marcel.weschke@directbox.de +""" +# %% Load libraries +import os +import base64 +import io +import datetime +from math import radians, sin, cos, sqrt, asin + +import dash +from dash import dcc, html, Input, Output, Dash +import dash_bootstrap_components as dbc + +import pandas as pd +import numpy as np +import plotly.express as px +import plotly.graph_objects as go +import gpxpy + + +# === Helper Functions === +def list_gpx_files(): + folder = './gpx_files' + return [{'label': f, 'value': os.path.join(folder, f)} for f in os.listdir(folder) if f.endswith('.gpx')] + + +def haversine(lon1, lat1, lon2, lat2): + R = 6371 + dlon = radians(lon2 - lon1) + dlat = radians(lat2 - lat1) + a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 + return 2 * R * asin(sqrt(a)) + + +def process_gpx(file_path): + with open(file_path, 'r') as gpx_file: + gpx = gpxpy.parse(gpx_file) + + points = gpx.tracks[0].segments[0].points + df = pd.DataFrame([{ + 'lat': p.latitude, + 'lon': p.longitude, + 'elev': p.elevation, + 'time': p.time + } for p in points]) + + # Basic cleanup + df['time'] = pd.to_datetime(df['time']) + df['time_loc'] = df['time'].dt.tz_localize(None) + df['time_diff'] = df['time'] - df['time'][0] + df['time_diff_sec'] = df['time_diff'].dt.total_seconds() + # Cumulative distance (km) + distances = [0] + for i in range(1, len(df)): + d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat']) + distances.append(distances[-1] + d) + df['cum_dist_km'] = distances + # Elevation and elevation change + df['elev'] = df['elev'].bfill() + df['delta_elev'] = df['elev'].diff().fillna(0) + df['rel_elev'] = df['elev'] - df['elev'].iloc[0] + # Velocity (used in pace and speed) + df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec']) + # Speed calculation (km/h) via distance and time diffs + df['delta_t'] = df['time'].diff().dt.total_seconds() + df['delta_d'] = df['cum_dist_km'].diff() + df['speed_kmh'] = (df['delta_d'] / df['delta_t']) * 3600 + df['speed_kmh'] = df['speed_kmh'].replace([np.inf, -np.inf], np.nan) + # Smoothed speed (Gaussian rolling) + df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2) + + return df + + +# ============================================================================= +# INFO BANNER +# ============================================================================= +def create_info_banner(df): + # Total distance in km + total_distance_km = df['cum_dist_km'].iloc[-1] + + # Total time as timedelta + total_seconds = df['time_diff_sec'].iloc[-1] + hours, remainder = divmod(int(total_seconds), 3600) + minutes, seconds = divmod(remainder, 60) + formatted_total_time = f"{hours:02d}:{minutes:02d}:{seconds:02d}" + + # Average pace (min/km) + if total_distance_km > 0: + pace_sec_per_km = total_seconds / total_distance_km + pace_min = int(pace_sec_per_km // 60) + pace_sec = int(pace_sec_per_km % 60) + formatted_pace = f"{pace_min}:{pace_sec:02d} min/km" + else: + formatted_pace = "N/A" + + # Build the info banner layout + info_banner = html.Div([ + html.Div([ + html.H4("Total Distance", style={'margin-bottom': '5px'}), + html.H2(f"{total_distance_km:.2f} km") + ], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}), + + html.Div([ + html.H4("Total Time", style={'margin-bottom': '5px'}), + html.H2(formatted_total_time) + ], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}), + + html.Div([ + html.H4("Average Pace", style={'margin-bottom': '5px'}), + html.H2(formatted_pace) + ], style={'width': '30%', 'display': 'inline-block', 'textAlign': 'center'}), + ], style={ + 'display': 'flex', + 'justifyContent': 'space-around', + 'backgroundColor': '#1e1e1e', + 'color': 'white', + 'padding': '20px', + 'marginBottom': '5px', + 'borderRadius': '10px', + 'width': '100%', + #'maxWidth': '1200px', + 'margin': 'auto' + }) + + return info_banner + + +# ============================================================================= +# START OF THE PLOTS +# ============================================================================= +def create_map_plot(df): + fig = px.line_mapbox(df, lat='lat', lon='lon', hover_name='time', + zoom=13, height=800) + fig.update_layout(mapbox_style="open-street-map") + fig.update_traces(line=dict(color="#f54269", width=3)) + + # Start / Stop marker + start = df.iloc[0] + end = df.iloc[-1] + fig.add_trace(go.Scattermapbox( + lat=[start['lat']], lon=[start['lon']], mode='markers+text', + marker=dict(size=12, color='#fca062'), text=['Start'], name='Start', textposition='bottom left' + )) + fig.add_trace(go.Scattermapbox( + lat=[end['lat']], lon=[end['lon']], mode='markers+text', + marker=dict(size=12, color='#b9fc62'), text=['Stop'], name='Stop', textposition='bottom left' + )) + fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white')) + fig.update_layout( + legend=dict( + orientation='h', # horizontal layout + yanchor='top', + y=-0.01, # move legend below the map + xanchor='center', + x=0.5, + font=dict(color='white') + ) +) + return fig + + +def create_elevation_plot(df): + x = df['time'] + y = df['rel_elev'] + n_layers = 36 + base_color = (5, 158, 5) # Greenish + max_alpha = 0.25 + traces = [] + + # Main elevation line + traces.append(go.Scatter( + x=x, y=y, + mode='lines', + line=dict(color='lime', width=2), + showlegend=False + )) + + # Single gradient fill (above and below 0) + for i in range(1, n_layers + 1): + alpha = max_alpha * (1 - i / n_layers) + color = f'rgba({base_color[0]}, {base_color[1]}, {base_color[2]}, {alpha:.3f})' + y_layer = y * (i / n_layers) + traces.append(go.Scatter( + x=x, + y=y_layer, + mode='lines', + fill='tonexty', + line=dict(width=0), + fillcolor=color, + hoverinfo='skip', + showlegend=False + )) + + fig = go.Figure(data=traces) + fig.update_layout( + title='Höhenprofil relativ zum Startwert', + xaxis_title='Zeit', + yaxis_title='Höhe relativ zum Start (m)', + template='plotly_dark', + paper_bgcolor='#1e1e1e', + plot_bgcolor='#111111', + font=dict(color='white'), + margin=dict(l=40, r=20, t=50, b=40), + height=500 + ) + return fig + + +def create_deviation_plot(df): #Distanz-Zeit-Diagramm + # Compute mean velocity in km/s + vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1] + # Expected cumulative distance assuming constant mean velocity + df['cum_dist_km_qmean'] = df['time_diff_sec'] * vel_kmps_mean + # Deviation from mean velocity distance + df['del_dist_km_qmean'] = df['cum_dist_km'] - df['cum_dist_km_qmean'] + # Plot the deviation + fig = px.line( + df, + x='time_loc', + y='del_dist_km_qmean', + title='Abweichung von integrierter Durchschnittsgeschwindigkeit', + labels={ + 'time_loc': 'Zeit', + 'del_dist_km_qmean': 'Δ Strecke (km)' + }, + template='plotly_dark', + ) + fig.update_layout( + yaxis_title='Abweichung (km)', + xaxis_title='Zeit', + height=400, + paper_bgcolor='#1e1e1e', + plot_bgcolor='#111111', + font=dict(color='white', size=14), + margin=dict(l=40, r=40, t=50, b=40) + ) + # Add horizontal reference line at y=0 + fig.add_shape( + type='line', + x0=df['time_loc'].iloc[0], + x1=df['time_loc'].iloc[-1], + y0=0, + y1=0, + line=dict(color='gray', width=1, dash='dash'), + name='Durchschnittsgeschwindigkeit' + ) + return fig + + +def create_speed_plot(df): + mask = df['speed_kmh_smooth'].isna() + mean_speed_kmh = df['speed_kmh'].mean() + fig = go.Figure() + fig.add_trace(go.Scatter( + x=df['time'][~mask], + y=df['speed_kmh_smooth'][~mask], + mode='lines', + name='Geglättete Geschwindigkeit', + line=dict(color='royalblue') + )) + fig.update_layout( + title=f'Geschwindigkeit über die Zeit (geglättet) (∅: {mean_speed_kmh:.2f} km/h)', + xaxis=dict(title='Zeit', tickformat='%H:%M', type='date'), + yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'), + template='plotly_dark', + paper_bgcolor='#1e1e1e', + plot_bgcolor='#111111', + font=dict(color='white'), + margin=dict(l=40, r=40, t=40, b=40) + ) + return fig + + + +def create_pace_bars_plot(df): + # Ensure time column is datetime + if not pd.api.types.is_datetime64_any_dtype(df['time']): + df['time'] = pd.to_datetime(df['time'], errors='coerce') + + # Assign km segments + df['km'] = df['cum_dist_km'].astype(int) + + # Time in seconds from start + df['time_sec'] = (df['time'] - df['time'].iloc[0]).dt.total_seconds() + + # Step 3: Compute pace manually per km group + df['km_start'] = np.nan + df['segment_len'] = np.nan + df['pace_min_per_km'] = np.nan + + for km_val, group in df.groupby('km'): + dist_start = group['cum_dist_km'].iloc[0] + dist_end = group['cum_dist_km'].iloc[-1] + segment_len = dist_end - dist_start + + time_start = group['time_sec'].iloc[0] + time_end = group['time_sec'].iloc[-1] + elapsed_time_sec = time_end - time_start + + if segment_len > 0: + pace_min_per_km = (elapsed_time_sec / 60) / segment_len + else: + pace_min_per_km = np.nan + + df.loc[group.index, 'km_start'] = km_val + df.loc[group.index, 'segment_len'] = segment_len + df.loc[group.index, 'pace_min_per_km'] = pace_min_per_km + + # Clean types + df['km_start'] = df['km_start'].astype(int) + df['segment_len'] = df['segment_len'].astype(float) + df['pace_min_per_km'] = pd.to_numeric(df['pace_min_per_km'], errors='coerce') + + # Step 4: Create Plotly bar chart + fig = go.Figure() + fig.add_trace(go.Bar( + x=df['km_start'], + y=df['pace_min_per_km'], + width=df['segment_len'], + text=[f"{v:.1f} min/km" if pd.notnull(v) else "" for v in df['pace_min_per_km']], + textposition='outside', + marker_color='dodgerblue', + name='Pace pro km', + offset=0 + )) + + fig.update_layout( + title='Pace (min/km) je Kilometer', + xaxis_title='Distanz (km)', + yaxis_title='Minuten pro km', + barmode='overlay', + bargap=0, + bargroupgap=0, + xaxis=dict( + type='linear', + range=[0, df['cum_dist_km'].iloc[-1]], + tickmode='linear', + dtick=1, + showgrid=True + ), + template='plotly_dark', + height=400, + margin=dict(l=40, r=20, t=20, b=40), + plot_bgcolor='#111111', + paper_bgcolor='#1e1e1e', + font=dict(color='white') + ) + return fig + + + + + +# === App Setup === +app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE]) +app.title = "GPX Dashboard" + +app.layout = html.Div([ + html.H1("Running Dashboard", style={'textAlign': 'center'}), + dcc.Store(id='stored-df'), + + html.Div([ + html.Label("GPX-Datei wählen:", style={'color': 'white'}), + dcc.Dropdown( + id='gpx-file-dropdown', + options=list_gpx_files(), + value=list_gpx_files()[0]['value'], + clearable=False, + style={'width': '300px', 'color': 'black'} + ) + ], style={'padding': '20px', 'backgroundColor': '#1e1e1e'}), + + html.Div(id='info-banner'), + dcc.Graph(id='fig-map'), + dcc.Graph(id='fig-elevation'), + dcc.Graph(id='fig_deviation'), + dcc.Graph(id='fig_speed'), + dcc.Graph(id='fig_pace_bars') +]) + + +# === Callbacks === +@app.callback( + Output('stored-df', 'data'), + Input('gpx-file-dropdown', 'value') +) +def load_gpx_data(path): + df = process_gpx(path) + return df.to_json(date_format='iso', orient='split') + + +@app.callback( + Output('info-banner', 'children'), + Output('fig-map', 'figure'), + Output('fig-elevation', 'figure'), + Output('fig_deviation', 'figure'), + Output('fig_speed', 'figure'), + Output('fig_pace_bars', 'figure'), + Input('stored-df', 'data') +) +def update_all_plots(json_data): + df = pd.read_json(json_data, orient='split') + + info = create_info_banner(df) + fig_map = create_map_plot(df) + fig_elev = create_elevation_plot(df) + fig_dev = create_deviation_plot(df) + fig_speed = create_speed_plot(df) + fig_pace = create_pace_bars_plot(df) + + return info, fig_map, fig_elev, fig_dev, fig_speed, fig_pace + + +# === Run Server === +if __name__ == '__main__': + app.run(debug=True, port=8051) + + +# NOTE: +# Zusammenhang zwischen Pace und Geschwindigkeit +# - Pace = Minuten pro Kilometer (z. B. 5:40/km) +# - Geschwindigkeit = Kilometer pro Stunde (z. B. 10.71 km/h) +#