Major updates: analyse .fit and .gpx files now with jogging_dashboard_***_app.py. Additionally, created a WEB and a GUI version of the tool.

This commit is contained in:
2025-09-27 23:47:26 +02:00
parent b5122418c6
commit 45f7659312
5 changed files with 438 additions and 644 deletions

4
.gitignore vendored
View File

@@ -2,5 +2,5 @@ gpx_files/*
!gpx_files/.keep !gpx_files/.keep
fit_files/* fit_files/*
!fit_files/.keep !fit_files/.keep
fit_app_build-exe-gui.py __pycache__/*
fit_app_build_EXE_gui_file.txt !__pycache__/.keep

0
__pycache__/.keep Normal file
View File

View File

@@ -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]}<br>" +
"Distance (km): %{customdata[1]:.2f}<br>" +
"Elapsed Time: %{customdata[2]}<extra></extra>"
),
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]}<br>" +
# "Distance (km): %{customdata[0]:.2f}<br>" +
# "Elevation: %{customdata[1]}<extra></extra>" +
# "Elapsed Time: %{customdata[2]}<extra></extra>"
# ),
# 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)
#

View File

@@ -26,69 +26,107 @@ import gpxpy
from fitparse import FitFile from fitparse import FitFile
# === Helper Functions === # === 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 # Definiere Ordner und Dateierweiterungen
if not os.path.exists(folder): folders_config = [
print(f"Ordner {folder} existiert nicht!") {'folder': './fit_files', 'extensions': ['.fit'], 'type': 'FIT'},
return [{'label': 'Ordner nicht gefunden', 'value': 'NO_FOLDER'}] {'folder': './gpx_files', 'extensions': ['.gpx'], 'type': 'GPX'}
]
# Hole alle .fit Files all_file_options = []
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'}]
def extract_date(filename): for config in folders_config:
"""Extrahiert Datum aus Filename für Sortierung""" 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: try:
# Versuche verschiedene Datumsformate all_files = os.listdir(folder)
return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') files = [f for f in all_files
except ValueError: if any(f.lower().endswith(ext) for ext in extensions)]
try: except Exception as e:
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') print(f"Fehler beim Lesen des Ordners {folder}: {e}")
except ValueError: continue
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
# Sortiere Files nach Datum (neueste zuerst) # Erstelle Optionen für diesen Ordner
files.sort(key=extract_date, reverse=True)
# Erstelle Dropdown-Optionen
if files:
options = []
for f in files: for f in files:
file_path = os.path.join(folder, f) 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: try:
size_mb = os.path.getsize(file_path) / (1024 * 1024) size_mb = os.path.getsize(file_path) / (1024 * 1024)
mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path)) mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
#label = f"[{file_type}] {f}"
label = f"{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: except:
label = f #label = f"[{file_type}] {f}"
label = f"{f}"
options.append({ all_file_options.append({
'label': label, 'label': label,
'value': file_path 'value': file_path,
'date': file_date,
'type': file_type
}) })
return options
else: # Sortiere alle Files nach Datum (neueste zuerst)
return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}] 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): 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 a = sin(dlat/2)**2 + cos(radians(lat1)) * cos(radians(lat2)) * sin(dlon/2)**2
return 2 * R * asin(sqrt(a)) return 2 * R * asin(sqrt(a))
########
# FIT
########
def process_fit(file_path): def process_fit(file_path):
""" """
Verarbeitet eine FIT-Datei und erstellt einen DataFrame 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) df['speed_kmh_smooth'] = df['speed_kmh'].rolling(window=10, win_type="gaussian", center=True).mean(std=2)
# Heart rate handling (NEU!) # Heart rate handling (NEU!)
# ############## # ##############
# UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben: # 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): 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', plot_bgcolor='#111111',
font=dict(color='white'), font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40), margin=dict(l=40, r=40, t=50, b=40),
height=400 height=400,
uirevision='constant', # Avoiding not needed Re-renderings
) )
return fig return fig
@@ -672,7 +817,8 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
paper_bgcolor='#1e1e1e', paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111', plot_bgcolor='#111111',
font=dict(color='white', size=14), 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 # Add horizontal reference line at y=0
fig.add_shape( fig.add_shape(
@@ -706,7 +852,8 @@ def create_speed_plot(df):
paper_bgcolor='#1e1e1e', paper_bgcolor='#1e1e1e',
plot_bgcolor='#111111', plot_bgcolor='#111111',
font=dict(color='white'), 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 # Add horizontal reference line at y=mean_speed_kmh
fig.add_shape( fig.add_shape(
@@ -849,7 +996,8 @@ def create_heart_rate_plot(df):
plot_bgcolor='#111111', plot_bgcolor='#111111',
font=dict(color='white'), font=dict(color='white'),
margin=dict(l=40, r=40, t=50, b=40), margin=dict(l=40, r=40, t=50, b=40),
height=400 height=400,
uirevision='constant', # Avoiding not needed Re-renderings
) )
return fig 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), margin=dict(l=40, r=40, t=30, b=40),
plot_bgcolor='#111111', plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e', paper_bgcolor='#1e1e1e',
font=dict(color='white') font=dict(color='white'),
uirevision='constant', # Avoiding not needed Re-renderings
) )
return fig return fig
@@ -980,19 +1129,23 @@ def create_pace_bars_plot(df, formatted_pace=None):
# === App Setup === # === App Setup ===
app = dash.Dash(__name__, external_stylesheets=[dbc.themes.SLATE]) app = dash.Dash(__name__,
app.title = "FIT Dashboard" suppress_callback_exceptions=True, # Weniger Validierung
compress=True, # Gzip-Kompression
external_stylesheets=[dbc.themes.SLATE],
title = "Jogging Dashboard"
)
app.layout = html.Div([ app.layout = html.Div([
html.H1("Running Dashboard", style={'textAlign': 'center'}), html.H1("Jogging Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'), dcc.Store(id='stored-df'),
html.Div([ html.Div([
html.Label("FIT-Datei wählen:", style={'color': 'white'}), html.Label("Datei wählen:", style={'color': 'white'}),
dcc.Dropdown( dcc.Dropdown(
id='fit-file-dropdown', id='file-dropdown',
options=list_fit_files(), options=list_files(),
value=list_fit_files()[0]['value'], # immer gültig value=list_files()[0]['value'], # immer gültig
clearable=False, clearable=False,
style={'width': '300px', 'color': 'black'} style={'width': '300px', 'color': 'black'}
) )
@@ -1012,11 +1165,10 @@ app.layout = html.Div([
# Callback 1: Load GPX File and Store as JSON # Callback 1: Load GPX File and Store as JSON
@app.callback( @app.callback(
Output('stored-df', 'data'), Output('stored-df', 'data'),
Input('fit-file-dropdown', 'value') Input('file-dropdown', 'value')
) )
def load_fit_data(path): def load_data(selected_file): # Dateipfad der ausgewählten Datei
df = process_fit(path) df = process_selected_file(selected_file) # Verarbeitet diese Datei
return df.to_json(date_format='iso', orient='split') return df.to_json(date_format='iso', orient='split')
# Callback 2: Update All (static) Plots # Callback 2: Update All (static) Plots
@@ -1068,7 +1220,11 @@ def highlight_map(hoverData, fig_map, json_data):
# === Run Server === # === Run Server ===
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True, port=8051) app.run(debug=True,
port=8051,
threaded=True,
processes=1
)
# NOTE: # NOTE:

View File

@@ -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()