Files
jogging-dashboard/fit_app.py

922 lines
30 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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
from fitparse import FitFile
# === Helper Functions ===
def list_fit_files():
"""
Listet alle .fit Files im Verzeichnis auf und sortiert sie nach Datum
"""
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'}]
# 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'}]
def extract_date(filename):
"""Extrahiert Datum aus Filename für Sortierung"""
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
# Sortiere Files nach Datum (neueste zuerst)
files.sort(key=extract_date, reverse=True)
# Erstelle Dropdown-Optionen
if files:
options = []
for f in files:
file_path = os.path.join(folder, f)
# Zeige auch Dateigröße und Änderungsdatum an
try:
size_mb = os.path.getsize(file_path) / (1024 * 1024)
mod_time = datetime.datetime.fromtimestamp(os.path.getmtime(file_path))
label = f"{f}"
#label = f"{f} ({size_mb:.1f}MB - {mod_time.strftime('%d.%m.%Y %H:%M')}\n)" # For debugging purpose
except:
label = f
options.append({
'label': label,
'value': file_path
})
return options
else:
return [{'label': 'Keine .fit Dateien gefunden', 'value': 'NO_FILE'}]
def haversine(lon1, lat1, lon2, lat2):
"""
Berechnet die Entfernung zwischen zwei GPS-Koordinaten in km
"""
R = 6371 # Erdradius in km
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_fit(file_path):
"""
Verarbeitet eine FIT-Datei und erstellt einen DataFrame
"""
if file_path in ['NO_FILE', 'NO_FOLDER', 'ERROR']:
print(f"Ungültiger Dateipfad: {file_path}")
return pd.DataFrame()
if not os.path.exists(file_path):
print(f"Datei nicht gefunden: {file_path}")
return pd.DataFrame()
try:
fit_file = FitFile(file_path)
print(f"Verarbeite FIT-Datei: {file_path}")
# Sammle alle record-Daten
records = []
for record in fit_file.get_messages("record"):
record_data = {}
for data in record:
# Sammle alle verfügbaren Datenfelder
record_data[data.name] = data.value
records.append(record_data)
if not records:
print("Keine Aufzeichnungsdaten in der FIT-Datei gefunden")
return pd.DataFrame()
# Erstelle DataFrame
df = pd.DataFrame(records)
print(f"DataFrame erstellt mit {len(df)} Zeilen und Spalten: {list(df.columns)}")
# Debugging: Schaue welche Spalten verfügbar sind
print(f"Verfügbare Spalten: {df.columns.tolist()}")
# Suche nach Heart Rate in verschiedenen Formaten
possible_hr_cols = [col for col in df.columns if 'heart' in col.lower() or 'hr' in col.lower()]
print(f"Mögliche Heart Rate Spalten: {possible_hr_cols}")
# Standard-Spaltennamen für verschiedene FIT-Formate
lat_cols = ['position_lat', 'lat', 'latitude']
lon_cols = ['position_long', 'lon', 'longitude']
elev_cols = ['altitude', 'elev', 'elevation', 'enhanced_altitude']
time_cols = ['timestamp', 'time']
hr_cols = ['heart_rate', 'hr'] + possible_hr_cols
speed_cols = ['speed', 'enhanced_speed']
dist_cols = ['distance', 'total_distance']
# Finde die richtigen Spaltennamen
lat_col = next((col for col in lat_cols if col in df.columns), None)
lon_col = next((col for col in lon_cols if col in df.columns), None)
elev_col = next((col for col in elev_cols if col in df.columns), None)
time_col = next((col for col in time_cols if col in df.columns), None)
hr_col = next((col for col in hr_cols if col in df.columns), None)
speed_col = next((col for col in speed_cols if col in df.columns), None)
# Prüfe ob wichtige Daten vorhanden sind
if not lat_col or not lon_col or not time_col:
raise ValueError(f"Wichtige Daten fehlen! Lat: {lat_col}, Lon: {lon_col}, Time: {time_col}")
# Benenne Spalten einheitlich um
df = df.rename(columns={
lat_col: 'lat',
lon_col: 'lon',
elev_col: 'elev' if elev_col else None,
time_col: 'time',
hr_col: 'heart_rate' if hr_col else None,
speed_col: 'speed_ms' if speed_col else None
})
# FIT lat/lon sind oft in semicircles - konvertiere zu Grad
if df['lat'].max() > 180: # Semicircles detection
df['lat'] = df['lat'] * (180 / 2**31)
df['lon'] = df['lon'] * (180 / 2**31)
# Entferne Zeilen ohne GPS-Daten
df = df.dropna(subset=['lat', 'lon', 'time']).reset_index(drop=True)
# 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'].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])
# 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 handling
if 'elev' in df.columns:
df['elev'] = df['elev'].bfill()
df['delta_elev'] = df['elev'].diff().fillna(0)
df['rel_elev'] = df['elev'] - df['elev'].iloc[0]
else:
# Fallback wenn keine Elevation vorhanden
df['elev'] = 0
df['delta_elev'] = 0
df['rel_elev'] = 0
# Speed calculation
if 'speed_ms' in df.columns:
# Konvertiere m/s zu km/h
df['speed_kmh'] = df['speed_ms'] * 3.6
else:
# Fallback: Berechne Speed aus GPS-Daten
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 (used in pace calculations)
df['vel_kmps'] = np.gradient(df['cum_dist_km'], df['time_diff_sec'])
# Smoothed speed (Gaussian rolling)
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:
# save heart rate data into variable
heart_rate = []
for record in fit_file.get_messages("record"):
# Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc)
for data in record:
# Print the name and value of the data (and the units if it has any)
if data.name == 'heart_rate':
heart_rate.append(data.value)
# Hier variable neu überschrieben:
df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate)
# ##############
# MY DEBUG:
#print(heart_rate)
if 'heart_rate' in df.columns:
df['heart_rate'] = pd.to_numeric(df['heart_rate'], errors='coerce')
df['hr_smooth'] = df['heart_rate'].rolling(window=5, center=True).mean()
print(f"Heart rate range: {df['heart_rate'].min():.0f} - {df['heart_rate'].max():.0f} bpm")
else:
print("Keine Heart Rate Daten gefunden!")
df['heart_rate'] = np.nan
df['hr_smooth'] = np.nan
print(f"Verarbeitete FIT-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 FIT-Datei {file_path}: {str(e)}")
return pd.DataFrame()
def safe_add_column_to_dataframe(df, column_name, values):
"""
Fügt eine Spalte sicher zu einem DataFrame hinzu, auch wenn die Längen nicht übereinstimmen
"""
if df.empty:
return df
df_len = len(df)
values_len = len(values) if hasattr(values, '__len__') else 0
if values_len == df_len:
# Perfekt - gleiche Länge
df[column_name] = values
elif values_len > df_len:
# Zu viele Werte - kürze sie
print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Kürze Werte.")
df[column_name] = values[:df_len]
elif values_len < df_len:
# Zu wenige Werte - fülle mit NaN auf
print(f"WARNUNG: {column_name} hat {values_len} Werte, DataFrame hat {df_len} Zeilen. Fülle mit NaN auf.")
extended_values = list(values) + [None] * (df_len - values_len)
df[column_name] = extended_values
else:
# Keine Werte - fülle mit NaN
print(f"WARNUNG: Keine Werte für {column_name}. Fülle mit NaN.")
df[column_name] = [None] * df_len
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[0]:.2f}<br>" +
"Speed (Km/h): %{customdata[1]:.2f}<br>" +
"Heart Rate (bpm): %{customdata[2]}<br>" +
"Elapsed Time: %{customdata[3]}<extra></extra>"
),
#customdata=df[['time', 'cum_dist_km', 'duration_hms']]
customdata=df[['cum_dist_km', 'speed_kmh', 'heart_rate', '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
# heart_rate Plot NEW !!!
def create_heart_rate_plot(df):
# Maske für gültige Heart Rate Daten
mask = df['hr_smooth'].isna()
# Durchschnittliche Heart Rate berechnen (nur gültige Werte)
valid_hr = df['heart_rate'].dropna()
if len(valid_hr) > 0:
mean_hr = valid_hr.mean()
min_hr = valid_hr.min()
max_hr = valid_hr.max()
else:
mean_hr = 0
min_hr = 0
max_hr = 0
fig = go.Figure()
# Heart Rate Linie (geglättet)
fig.add_trace(go.Scatter(
x=df['time'][~mask],
y=df['hr_smooth'][~mask],
mode='lines',
#name='Geglättete Herzfrequenz',
line=dict(color='#E43D70', width=2),
showlegend=False,
hovertemplate=(
"Zeit: %{x}<br>" +
"Herzfrequenz: %{y:.0f} bpm<br>" +
"<extra></extra>"
)
))
# # Optional: Raw Heart Rate als dünnere, transparente Linie
# if not df['heart_rate'].isna().all():
# fig.add_trace(go.Scatter(
# x=df['time'],
# y=df['heart_rate'],
# mode='lines',
# name='Raw Herzfrequenz',
# line=dict(color='#E43D70', width=1, dash='dot'),
# opacity=0.3,
# showlegend=False,
# hoverinfo='skip'
# ))
# Durchschnittslinie
if mean_hr > 0:
fig.add_shape(
type='line',
x0=df['time_loc'].iloc[0],
x1=df['time_loc'].iloc[-1],
y0=mean_hr,
y1=mean_hr,
line=dict(color='gray', width=1, dash='dash'),
)
# Annotation für Durchschnittswert
fig.add_annotation(
x=df['time_loc'].iloc[int(len(df) * 0.8)], # Bei 80% der Zeit
y=mean_hr,
text=f"Ø {mean_hr:.0f} bpm",
showarrow=True,
arrowhead=2,
arrowcolor="gray",
bgcolor="rgba(128,128,128,0.1)",
bordercolor="gray",
font=dict(color="white", size=10)
)
# Heart Rate Zonen (optional)
if mean_hr > 0:
# Geschätzte maximale Herzfrequenz (Beispiel: 200 bpm)
# Heart Rate Zonen (optional)
# Geschätzte maximale Herzfrequenz
max_hr_estimated = 200 # oder z. B. 220 - alter
# Definiere feste HR-Zonen in BPM
zones = [
{"name": "Zone 1", "lower": 0, "upper": 124, "color": "#F4A4A3"},
{"name": "Zone 2", "lower": 124, "upper": 154, "color": "#EF7476"},
{"name": "Zone 3", "lower": 154, "upper": 169, "color": "#EA4748"},
{"name": "Zone 4", "lower": 169, "upper": 184, "color": "#E02628"},
{"name": "Zone 5", "lower": 184, "upper": max_hr_estimated, "color": "#B71316"},
]
# Zeichne Zonen als Hintergrund (horizontale Rechtecke)
for zone in zones:
fig.add_hrect(
y0=zone["lower"], y1=zone["upper"],
fillcolor=zone["color"],
opacity=0.1,
line_width=0,
annotation_text=zone["name"], # optional: Name der Zone einblenden
annotation_position="top left"
)
# Layout
title_text = f'Herzfrequenz über die Zeit (geglättete)'
if mean_hr > 0:
title_text += f' - Ø {mean_hr:.0f} bpm (Range: {min_hr:.0f}-{max_hr:.0f})'
fig.update_layout(
title=dict(text=title_text, font=dict(size=16, color='white')),
xaxis=dict(
title='Zeit',
tickformat='%H:%M',
type='date'
),
yaxis=dict(
title='Herzfrequenz (bpm)',
rangemode='tozero'
),
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_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
))
# #########
# 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 = "FIT Dashboard"
app.layout = html.Div([
html.H1("Running Dashboard", style={'textAlign': 'center'}),
dcc.Store(id='stored-df'),
html.Div([
html.Label("FIT-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
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_hr'),
dcc.Graph(id='fig_pace_bars')
])
# === Callbacks ===
# Callback 1: Load GPX File and Store as JSON
@app.callback(
Output('stored-df', 'data'),
Input('fit-file-dropdown', 'value')
)
def load_fit_data(path):
df = process_fit(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_hr', '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_hr = create_heart_rate_plot(df)
fig_pace = create_pace_bars_plot(df)
return info, fig_map, fig_elev, fig_dev, fig_speed, fig_hr, 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)
#