From 45f7659312627d7738fd924a3f37ca03724cc8f4 Mon Sep 17 00:00:00 2001 From: Marcel Weschke Date: Sat, 27 Sep 2025 23:47:26 +0200 Subject: [PATCH] Major updates: analyse .fit and .gpx files now with jogging_dashboard_***_app.py. Additionally, created a WEB and a GUI version of the tool. --- .gitignore | 4 +- __pycache__/.keep | 0 gpx_app.py | 575 ------------------ ...app.py => jogging_dashboard_browser_app.py | 290 +++++++-- jogging_dashboard_gui_app.py | 213 +++++++ 5 files changed, 438 insertions(+), 644 deletions(-) create mode 100644 __pycache__/.keep delete mode 100644 gpx_app.py rename fit_app.py => jogging_dashboard_browser_app.py (80%) create mode 100644 jogging_dashboard_gui_app.py diff --git a/.gitignore b/.gitignore index 36c4bae..9c58235 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,5 @@ gpx_files/* !gpx_files/.keep fit_files/* !fit_files/.keep -fit_app_build-exe-gui.py -fit_app_build_EXE_gui_file.txt +__pycache__/* +!__pycache__/.keep diff --git a/__pycache__/.keep b/__pycache__/.keep new file mode 100644 index 0000000..e69de29 diff --git a/gpx_app.py b/gpx_app.py deleted file mode 100644 index dfe4c4f..0000000 --- a/gpx_app.py +++ /dev/null @@ -1,575 +0,0 @@ -#!/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 -from scipy.interpolate import interp1d -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')] - files = [f for f in os.listdir(folder) if f.endswith('.gpx')] - - # Extract date from the start of the filename and sort descending - def extract_date(filename): - try: - return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') - except ValueError: - return datetime.datetime.min # Put files without a valid date at the end - - files.sort(key=extract_date, reverse=True) - - return [{'label': f, 'value': os.path.join(folder, f)} for f in files] - - - -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() - df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0]) - # 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_map( - # df, - # lat='lat', - # lon='lon', - # hover_name='time', - # hover_data={ - # 'cum_dist_km': ':.2f', - # 'duration_hms': True, - # 'lat': False, - # 'lon': False, - # 'time': False - # }, - # labels={ - # 'cum_dist_km': 'Distance (km) ', - # 'duration_hms': 'Elapsed Time ' - # }, - # zoom=13, - # height=800 - # ) - fig = px.line_map( - df, - lat='lat', - lon='lon', - zoom=13, - height=800 - ) - - fig.update_traces( - hovertemplate=( - #"Time: %{customdata[0]}
" + - "Distance (km): %{customdata[1]:.2f}
" + - "Elapsed Time: %{customdata[2]}" - ), - customdata=df[['time', 'cum_dist_km', 'duration_hms']] - ) - # Define map style and the line ontop - fig.update_layout(map_style="open-street-map") - # The built-in plotly.js styles are: carto-darkmatter, carto-positron, open-street-map, stamen-terrain, stamen-toner, stamen-watercolor, white-bg - # The built-in Mapbox styles are: basic, streets, outdoors, light, dark, satellite, satellite-streets - fig.update_traces(line=dict(color="#f54269", width=3)) - - # Start / Stop marker - start = df.iloc[0] - end = df.iloc[-1] - fig.add_trace(go.Scattermap( - 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.Scattermap( - 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 - - - - -###################### -# NEUE VERSION: -def create_elevation_plot(df, smooth_points=500): - # Originale Daten - x = df['time'] - y = df['rel_elev'] - - # Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten - if len(y) >= 4: # Genug Punkte für cubic interpolation - y_numeric = y.to_numpy() - - # Nur gültige Y-Punkte für Interpolation - mask = ~np.isnan(y_numeric) - - if np.sum(mask) >= 4: # Genug gültige Punkte - # Index-basierte Interpolation für Y-Werte - valid_indices = np.where(mask)[0] - valid_y = y_numeric[mask] - - # Interpolation über die Indizes - f = interp1d(valid_indices, valid_y, kind='cubic', - bounds_error=False, fill_value='extrapolate') - - # Neue Y-Werte für alle ursprünglichen X-Positionen - all_indices = np.arange(len(y)) - y_smooth = f(all_indices) - - # Originale X-Werte beibehalten - x_smooth = x - else: - # Fallback: originale Daten - x_smooth, y_smooth = x, y - else: - # Zu wenige Punkte: originale Daten verwenden - x_smooth, y_smooth = x, y - - fig = go.Figure() - - # Fläche unter der Kurve (mit geglätteten Daten) - fig.add_trace(go.Scatter( - x=x_smooth, y=y_smooth, - mode='lines', - line=dict(color='#1CAF50'), # Fill between color! - fill='tozeroy', - #fillcolor='rgba(226, 241, 248)', - hoverinfo='skip', - showlegend=False - )) - - # Hauptlinie (geglättet) - fig.add_trace(go.Scatter( - x=x_smooth, y=y_smooth, - mode='lines', - line=dict(color='#084C20', width=2), # Line color! - name='Elevation', - showlegend=False - )) - - # SUPDER IDEE, ABER GEHT NICHT WEGE NEUEN smoothed POINTS! GEHT NUR BEI X - #fig.update_traces( - # hovertemplate=( - # #"Time: %{customdata[0]}
" + - # "Distance (km): %{customdata[0]:.2f}
" + - # "Elevation: %{customdata[1]}" + - # "Elapsed Time: %{customdata[2]}" - # ), - # customdata=df[['cum_dist_km','elev', 'time']] - # - - # Layout im Dark Theme - fig.update_layout( - title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')), - 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=40, t=50, b=40), - height=400 - ) - - 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', - labels={ - 'time_loc': 'Zeit', - 'del_dist_km_qmean': 'Δ Strecke (km)' - }, - template='plotly_dark', - ) - fig.update_layout( - title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)), - 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='Durchschnittstempo' - ) - 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=dict(text=f'Tempo über die Zeit (geglättet) - Durchschnittstempo: {mean_speed_kmh:.2f} km/h', font=dict(size=16)), - 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) - ) - # Add horizontal reference line at y=mean_speed_kmh - fig.add_shape( - type='line', - x0=df['time_loc'].iloc[0], - x1=df['time_loc'].iloc[-1], - y0=mean_speed_kmh, - y1=mean_speed_kmh, - line=dict(color='gray', width=1, dash='dash'), - name='Durchschnittstempo' - ) - 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', - textposition='inside', - marker_color='#125595', - name='Pace pro km', - offset=0 - )) - - - - - # ######### - # Add horizontal reference line - X-Werte für gesamte Breite - # Calculate average pace - total_distance_km = df['cum_dist_km'].iloc[-1] - total_seconds = df['time_diff_sec'].iloc[-1] - - # Average pace (min/km) - KORRIGIERT - if total_distance_km > 0: - pace_sec_per_km = total_seconds / total_distance_km - pace_min_per_km = pace_sec_per_km / 60 # Konvertiere zu Minuten pro km - else: - pace_min_per_km = 0 - - fig.add_shape( - type='line', - x0=0, # Start bei 0 - x1=total_distance_km, # Ende bei maximaler Distanz - y0=pace_min_per_km, - y1=pace_min_per_km, - line=dict(color='gray', width=1, dash='dash'), - ) - - ## Optional: Text-Annotation für die durchschnittliche Pace - #fig.add_annotation( - # x=total_distance_km * 0.8, # Position bei 80% der Distanz - # y=pace_min_per_km, - # text=f"Ø {pace_min_per_km:.1f} min/km", - # showarrow=True, - # arrowhead=2, - # arrowcolor="gray", - # bgcolor="rgba(255,0,0,0.1)", - # bordercolor="gray", - # font=dict(color="white") - #) - - - - - fig.update_layout( - title=dict(text='Tempo (min/km) je Kilometer', font=dict(size=16)), - 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=40, t=30, 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 === -# Callback 1: Load GPX File and Store as JSON -@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') - -# Callback 2: Update All Plots -@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(io.StringIO(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) -# diff --git a/fit_app.py b/jogging_dashboard_browser_app.py similarity index 80% rename from fit_app.py rename to jogging_dashboard_browser_app.py index 5a18cc7..8a9f2f2 100644 --- a/fit_app.py +++ b/jogging_dashboard_browser_app.py @@ -26,69 +26,107 @@ import gpxpy from fitparse import FitFile # === Helper Functions === -def list_fit_files(): +def list_files(): """ - Listet alle .fit Files im Verzeichnis auf und sortiert sie nach Datum + Listet alle .fit Files aus fit_files/ und .gpx Files aus gpx_files/ auf + und sortiert sie nach Datum (neueste zuerst) """ - folder = './fit_files' - # Prüfe ob Ordner existiert - if not os.path.exists(folder): - print(f"Ordner {folder} existiert nicht!") - return [{'label': 'Ordner nicht gefunden', 'value': 'NO_FOLDER'}] + # Definiere Ordner und Dateierweiterungen + folders_config = [ + {'folder': './fit_files', 'extensions': ['.fit'], 'type': 'FIT'}, + {'folder': './gpx_files', 'extensions': ['.gpx'], 'type': 'GPX'} + ] - # Hole alle .fit Files - try: - all_files = os.listdir(folder) - files = [f for f in all_files if f.lower().endswith('.fit')] - except Exception as e: - print(f"Fehler beim Lesen des Ordners: {e}") - return [{'label': 'Fehler beim Lesen', 'value': 'ERROR'}] + all_file_options = [] - def extract_date(filename): - """Extrahiert Datum aus Filename für Sortierung""" + for config in folders_config: + folder = config['folder'] + extensions = config['extensions'] + file_type = config['type'] + + # Prüfe ob Ordner existiert + if not os.path.exists(folder): + print(f"Ordner {folder} existiert nicht!") + continue + + # Hole alle Files mit den entsprechenden Erweiterungen try: - # Versuche verschiedene Datumsformate - return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') - except ValueError: - try: - return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') - except ValueError: - try: - # Versuche auch andere Formate - return datetime.datetime.strptime(filename[:8], '%Y%m%d') - except ValueError: - # Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum - try: - file_path = os.path.join(folder, filename) - return datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) - except: - return datetime.datetime.min + all_files = os.listdir(folder) + files = [f for f in all_files + if any(f.lower().endswith(ext) for ext in extensions)] + except Exception as e: + print(f"Fehler beim Lesen des Ordners {folder}: {e}") + continue - # Sortiere Files nach Datum (neueste zuerst) - files.sort(key=extract_date, reverse=True) - - # Erstelle Dropdown-Optionen - if files: - options = [] + # Erstelle Optionen für diesen Ordner for f in files: file_path = os.path.join(folder, f) - # Zeige auch Dateigröße und Änderungsdatum an + + # Extrahiere Datum für Sortierung + file_date = extract_date_from_file(f, file_path) + + # Erstelle Label mit Dateityp-Info try: size_mb = os.path.getsize(file_path) / (1024 * 1024) mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) + #label = f"[{file_type}] {f}" label = f"{f}" - #label = f"{f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')}\n)" # For debugging purpose + # Optional: Erweiterte Info (auskommentiert für sauberere Ansicht) + # label = f"[{file_type}] {f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')})" except: - label = f + #label = f"[{file_type}] {f}" + label = f"{f}" - options.append({ + all_file_options.append({ 'label': label, - 'value': file_path + 'value': file_path, + 'date': file_date, + 'type': file_type }) - return options - else: - return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}] + + # Sortiere alle Files nach Datum (neueste zuerst) + all_file_options.sort(key=lambda x: x['date'], reverse=True) + + # Entferne 'date' und 'type' aus den finalen Optionen (nur für Sortierung gebraucht) + final_options = [{'label': opt['label'], 'value': opt['value']} + for opt in all_file_options] + + # Fallback wenn keine Files gefunden + if not final_options: + return [{'label': 'Keine .fit oder .gpx Dateien gefunden', 'value': 'NO_FILES'}] + + return final_options + +def extract_date_from_file(filename, file_path): + """Extrahiert Datum aus Filename für Sortierung""" + try: + # Versuche verschiedene Datumsformate im Dateinamen + # Format: dd.mm.yyyy + return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') + except ValueError: + try: + # Format: yyyy-mm-dd + return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') + except ValueError: + try: + # Format: yyyymmdd + return datetime.datetime.strptime(filename[:8], '%Y%m%d') + except ValueError: + try: + # Format: yyyy_mm_dd + return datetime.datetime.strptime(filename[:10], '%Y_%m_%d') + except ValueError: + try: + # Format: dd-mm-yyyy + return datetime.datetime.strptime(filename[:10], '%d-%m-%Y') + except ValueError: + # Wenn kein Datum erkennbar, nutze Datei-Änderungsdatum + try: + return datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) + except: + return datetime.datetime.min + def haversine(lon1, lat1, lon2, lat2): """ @@ -100,6 +138,10 @@ def haversine(lon1, lat1, lon2, lat2): a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2 return 2 * R * asin(sqrt(a)) + +######## +# FIT +######## def process_fit(file_path): """ Verarbeitet eine FIT-Datei und erstellt einen DataFrame @@ -222,10 +264,6 @@ def process_fit(file_path): df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2) - - - - # Heart rate handling (NEU!) # ############## # UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben: @@ -266,6 +304,112 @@ def process_fit(file_path): +######## +# GPX +######## +def process_gpx(file_path): + """ + Verarbeitet GPX-Dateien und gibt DataFrame im gleichen Format wie process_fit() zurück + """ + import gpxpy + import gpxpy.gpx + + try: + with open(file_path, 'r', encoding='utf-8') as gpx_file: + gpx = gpxpy.parse(gpx_file) + + print(f"Verarbeite GPX-Datei: {file_path}") + + # Sammle GPS-Punkte aus allen Tracks/Segments + points_data = [] + for track in gpx.tracks: + for segment in track.segments: + for point in segment.points: + points_data.append({ + 'time': point.time, + 'lat': point.latitude, + 'lon': point.longitude, + 'elev': point.elevation if point.elevation else 0, + 'heart_rate': None # GPX hat normalerweise keine HR-Daten + }) + + if not points_data: + print("Keine GPS-Daten in GPX-Datei gefunden") + return pd.DataFrame() + + # Erstelle DataFrame + df = pd.DataFrame(points_data) + print(f"GPX DataFrame erstellt mit {len(df)} Zeilen") + + # Sortiere nach Zeit + df = df.sort_values('time').reset_index(drop=True) + + # Zeit-Verarbeitung (wie in deiner FIT-Funktion) + df['time'] = pd.to_datetime(df['time']) + df['time_loc'] = df['time'].dt.tz_localize(None) + df['time_diff'] = df['time'] - df['time'].iloc[0] + df['time_diff_sec'] = df['time_diff'].dt.total_seconds() + df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0]) + + # Kumulative Distanz (gleiche Logik wie in deiner FIT-Funktion) + 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 (gleiche Logik wie in deiner FIT-Funktion) + df['elev'] = df['elev'].bfill() + df['delta_elev'] = df['elev'].diff().fillna(0) + df['rel_elev'] = df['elev'] - df['elev'].iloc[0] + + # Speed-Berechnung (gleiche Logik wie dein Fallback) + 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) + + # Velocity (wie in deiner FIT-Funktion) + df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec']) + + # Smoothed speed (wie in deiner FIT-Funktion) + df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2) + + # Heart rate (GPX hat keine, also NaN wie dein Fallback) + df['heart_rate'] = np.nan + df['hr_smooth'] = np.nan + + print(f"Verarbeitete GPX-Datei: {len(df)} Datenpunkte") + print(f"Distanz: {df['cum_dist_km'].iloc[-1]:.2f} km") + print(f"Dauer: {df['duration_hms'].iloc[-1]}") + + return df + + except Exception as e: + print(f"Fehler beim Verarbeiten der GPX-Datei {file_path}: {str(e)}") + return pd.DataFrame() + +# NEUE UNIVERSELLE WRAPPER-FUNKTION (nutzt deine bestehenden Funktionen!) +def process_selected_file(file_path): + """ + Universelle Funktion die automatisch FIT oder GPX verarbeitet + """ + if not file_path or file_path in ['NO_FILES', 'NO_FOLDER', 'ERROR']: + return pd.DataFrame() + + # Bestimme Dateityp + if file_path.lower().endswith('.fit'): + # NUTZT DEINE ORIGINALE FUNKTION! + return process_fit(file_path) + elif file_path.lower().endswith('.gpx'): + # Nutzt die neue GPX-Funktion + return process_gpx(file_path) + else: + print(f"Unbekannter Dateityp: {file_path}") + return pd.DataFrame() + + + def safe_add_column_to_dataframe(df, column_name, values): """ @@ -553,7 +697,8 @@ def create_elevation_plot(df, smooth_points=500): plot_bgcolor='#111111', font=dict(color='white'), margin=dict(l=40, r=40, t=50, b=40), - height=400 + height=400, + uirevision='constant', # Avoiding not needed Re-renderings ) return fig @@ -672,7 +817,8 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm paper_bgcolor='#1e1e1e', plot_bgcolor='#111111', font=dict(color='white', size=14), - margin=dict(l=40, r=40, t=50, b=40) + margin=dict(l=40, r=40, t=50, b=40), + uirevision='constant', # Avoiding not needed Re-renderings ) # Add horizontal reference line at y=0 fig.add_shape( @@ -706,7 +852,8 @@ def create_speed_plot(df): paper_bgcolor='#1e1e1e', plot_bgcolor='#111111', font=dict(color='white'), - margin=dict(l=40, r=40, t=40, b=40) + margin=dict(l=40, r=40, t=40, b=40), + uirevision='constant', # Avoiding not needed Re-renderings ) # Add horizontal reference line at y=mean_speed_kmh fig.add_shape( @@ -849,7 +996,8 @@ def create_heart_rate_plot(df): plot_bgcolor='#111111', font=dict(color='white'), margin=dict(l=40, r=40, t=50, b=40), - height=400 + height=400, + uirevision='constant', # Avoiding not needed Re-renderings ) return fig @@ -971,7 +1119,8 @@ def create_pace_bars_plot(df, formatted_pace=None): margin=dict(l=40, r=40, t=30, b=40), plot_bgcolor='#111111', paper_bgcolor='#1e1e1e', - font=dict(color='white') + font=dict(color='white'), + uirevision='constant', # Avoiding not needed Re-renderings ) return fig @@ -980,19 +1129,23 @@ def create_pace_bars_plot(df, formatted_pace=None): # === App Setup === -app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE]) -app.title = "FIT Dashboard" +app = dash.Dash(__name__, + suppress_callback_exceptions=True, # Weniger Validierung + compress=True, # Gzip-Kompression + external_stylesheets=[dbc.themes.SLATE], + title = "Jogging Dashboard" +) app.layout = html.Div([ - html.H1("Running Dashboard", style={'textAlign': 'center'}), + html.H1("Jogging Dashboard", style={'textAlign': 'center'}), dcc.Store(id='stored-df'), html.Div([ - html.Label("FIT-Datei wählen:", style={'color': 'white'}), + html.Label("Datei wählen:", style={'color': 'white'}), dcc.Dropdown( - id='fit-file-dropdown', - options=list_fit_files(), - value=list_fit_files()[0]['value'], # immer gültig + id='file-dropdown', + options=list_files(), + value=list_files()[0]['value'], # immer gültig clearable=False, style={'width': '300px', 'color': 'black'} ) @@ -1012,11 +1165,10 @@ app.layout = html.Div([ # Callback 1: Load GPX File and Store as JSON @app.callback( Output('stored-df', 'data'), - Input('fit-file-dropdown', 'value') + Input('file-dropdown', 'value') ) -def load_fit_data(path): - df = process_fit(path) - +def load_data(selected_file): # Dateipfad der ausgewählten Datei + df = process_selected_file(selected_file) # Verarbeitet diese Datei return df.to_json(date_format='iso', orient='split') # Callback 2: Update All (static) Plots @@ -1068,7 +1220,11 @@ def highlight_map(hoverData, fig_map, json_data): # === Run Server === if __name__ == '__main__': - app.run(debug=True, port=8051) + app.run(debug=True, + port=8051, + threaded=True, + processes=1 + ) # NOTE: diff --git a/jogging_dashboard_gui_app.py b/jogging_dashboard_gui_app.py new file mode 100644 index 0000000..3a0174b --- /dev/null +++ b/jogging_dashboard_gui_app.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" + +@author: Marcel Weschke +@email: marcel.weschke@directbox.de +""" +# %% Load libraries +import os +import sys +import threading +import time +from PyQt6.QtWidgets import QApplication, QMainWindow, QVBoxLayout, QWidget, QSplashScreen, QLabel +from PyQt6.QtWebEngineWidgets import QWebEngineView +from PyQt6.QtCore import QUrl, QTimer, Qt +from PyQt6.QtGui import QPixmap + +# Performance-Optimierungen für Qt WebEngine +os.environ.update({ + # Hardware-Beschleunigung forcieren + "QTWEBENGINE_CHROMIUM_FLAGS": ( + "--ignore-gpu-blocklist " + "--enable-gpu-rasterization " + "--enable-zero-copy " + "--disable-logging " + "--no-sandbox " + "--disable-dev-shm-usage " + "--disable-extensions " + "--disable-plugins " + "--disable-background-timer-throttling " + "--disable-backgrounding-occluded-windows " + "--disable-renderer-backgrounding " + "--disable-features=TranslateUI " + "--aggressive-cache-discard " + "--memory-pressure-off" + ), + # Logging reduzieren + "QT_LOGGING_RULES": "qt.webenginecontext.debug=false", + "QTWEBENGINE_DISABLE_SANDBOX": "1", + # Cache-Optimierungen + "QTWEBENGINE_DISABLE_GPU_THREAD": "0" +}) + +# Importiere deine Dash-App +from jogging_dashboard_browser_app import app + +class DashThread(threading.Thread): + """Optimierter Dash-Thread mit besserer Kontrolle""" + def __init__(self): + super().__init__(daemon=True) + self.dash_ready = False + + def run(self): + try: + # Dash mit Performance-Optimierungen starten + app.run( + debug=False, + port=8051, + use_reloader=False, + host='127.0.0.1', + threaded=True, # Threading für bessere Performance + processes=1 # Single process für Desktop + ) + except Exception as e: + print(f"Dash-Server Fehler: {e}") + + def wait_for_dash(self, timeout=10): + """Warte bis Dash-Server bereit ist""" + import requests + start_time = time.time() + + while time.time() - start_time < timeout: + try: + response = requests.get('http://127.0.0.1:8051/', timeout=2) + if response.status_code == 200: + self.dash_ready = True + return True + except: + pass + time.sleep(0.5) + + return False + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Jogging Dashboard - Desktop") + self.setGeometry(100, 100, 1400, 900) # Größere Standardgröße + + # Performance: Lade Seite erst wenn Dash bereit ist + self.browser = None + self.setup_ui() + + def setup_ui(self): + """UI-Setup ohne sofortiges Laden der Seite""" + central_widget = QWidget() + layout = QVBoxLayout() + + # Loading-Label während Dash startet + self.loading_label = QLabel("🚀 Dashboard wird geladen...") + self.loading_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + self.loading_label.setStyleSheet(""" + QLabel { + font-size: 18px; + color: #333; + background: #f0f0f0; + padding: 20px; + border-radius: 10px; + } + """) + + layout.addWidget(self.loading_label) + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + def load_dashboard(self): + """Lade Dashboard nachdem Dash bereit ist""" + # Browser-Widget erstellen + self.browser = QWebEngineView() + + # Performance-Einstellungen für WebEngineView + settings = self.browser.settings() + settings.setAttribute(settings.WebAttribute.PluginsEnabled, False) + settings.setAttribute(settings.WebAttribute.JavascriptEnabled, True) + settings.setAttribute(settings.WebAttribute.LocalStorageEnabled, True) + + # Seite laden + self.browser.setUrl(QUrl("http://127.0.0.1:8051")) + + # Layout aktualisieren + central_widget = QWidget() + layout = QVBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) # Kein Rand für maximale Größe + layout.addWidget(self.browser) + central_widget.setLayout(layout) + self.setCentralWidget(central_widget) + + print("✅ Dashboard geladen!") + +class SplashScreen(QSplashScreen): + """Splash Screen für bessere UX während des Startens""" + def __init__(self): + # Einfacher Text-Splash (du kannst ein Logo hinzufügen) + pixmap = QPixmap(400, 200) + pixmap.fill(Qt.GlobalColor.white) + super().__init__(pixmap) + + # Text hinzufügen + self.setStyleSheet(""" + QSplashScreen { + background-color: #2c3e50; + color: white; + font-size: 16px; + } + """) + + def showMessage(self, message): + super().showMessage(message, Qt.AlignmentFlag.AlignCenter, Qt.GlobalColor.white) + +def main(): + print("🚀 Starte Jogging Dashboard...") + + # Qt Application mit Performance-Flags + app_qt = QApplication(sys.argv) + #app_qt.setAttribute(Qt.ApplicationAttribute.AA_EnableHighDpiScaling, True) # Not working yet + #app_qt.setAttribute(Qt.ApplicationAttribute.AA_UseHighDpiPixmaps, True) # Not working yet + + # Splash Screen anzeigen + splash = SplashScreen() + splash.show() + splash.showMessage("Initialisiere Dashboard...") + app_qt.processEvents() + + # Dash-Server starten + dash_thread = DashThread() + dash_thread.start() + + splash.showMessage("Starte Web-Server...") + app_qt.processEvents() + + # Auf Dash warten + if dash_thread.wait_for_dash(timeout=15): + splash.showMessage("Dashboard bereit!") + app_qt.processEvents() + + # Hauptfenster erstellen und laden + window = MainWindow() + + # Kurz warten für bessere UX + time.sleep(0.5) + + # Dashboard laden + window.load_dashboard() + + # Splash schließen und Hauptfenster anzeigen + splash.close() + window.show() + + print("✅ Dashboard erfolgreich gestartet!") + + else: + splash.showMessage("❌ Fehler beim Starten!") + app_qt.processEvents() + time.sleep(2) + splash.close() + print("❌ Dashboard konnte nicht gestartet werden!") + sys.exit(1) + + # Event-Loop starten + sys.exit(app_qt.exec()) + +if __name__ == "__main__": + main()