Major update. New Version using Strava .fit files with additional heart rate data usage/plot
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
gpx_files/*
|
gpx_files/*
|
||||||
!gpx_files/.keep
|
!gpx_files/.keep
|
||||||
|
fit_files/*
|
||||||
|
!fit_files/.keep
|
||||||
|
|||||||
827
fit_app.py
Normal file
827
fit_app.py
Normal file
@@ -0,0 +1,827 @@
|
|||||||
|
#!/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():
|
||||||
|
folder = './fit_files' # Ordnerpfad anpassen
|
||||||
|
if not os.path.exists(folder):
|
||||||
|
os.makedirs(folder)
|
||||||
|
|
||||||
|
files = [f for f in os.listdir(folder) if f.lower().endswith('.fit')]
|
||||||
|
|
||||||
|
# Datum extrahieren für Sortierung
|
||||||
|
def extract_date(filename):
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(filename[:10], '%d.%m.%Y') # Format DD.MM.YYYY
|
||||||
|
except ValueError:
|
||||||
|
try:
|
||||||
|
return datetime.datetime.strptime(filename[:10], '%Y-%m-%d') # Format YYYY-MM-DD
|
||||||
|
except ValueError:
|
||||||
|
return datetime.datetime.min # Ungültige -> ans Ende
|
||||||
|
|
||||||
|
files.sort(key=extract_date, reverse=True)
|
||||||
|
|
||||||
|
# Dropdown-Einträge bauen
|
||||||
|
if files:
|
||||||
|
return [{'label': f, 'value': os.path.join(folder, f)} for f in files]
|
||||||
|
else:
|
||||||
|
# Dummy-Eintrag, damit es nie crasht
|
||||||
|
return [{
|
||||||
|
'label': 'Keine FIT-Datei gefunden',
|
||||||
|
'value': 'NO_FILE'
|
||||||
|
}]
|
||||||
|
|
||||||
|
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_fit(file_path):
|
||||||
|
fit_file = FitFile(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)
|
||||||
|
|
||||||
|
# Erstelle DataFrame
|
||||||
|
df = pd.DataFrame(records)
|
||||||
|
|
||||||
|
# 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['heart_rate'] = heart_rate[:len(df)]
|
||||||
|
# ##############
|
||||||
|
|
||||||
|
# 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
|
||||||
|
|
||||||
|
|
||||||
|
# =============================================================================
|
||||||
|
# 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),
|
||||||
|
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 (220 - Alter, hier als Beispiel 190)
|
||||||
|
max_hr_estimated = 190 # Du kannst das anpassen
|
||||||
|
|
||||||
|
# Zone 1: Sehr leicht (50-60% HRmax)
|
||||||
|
zone1_lower = max_hr_estimated * 0.5
|
||||||
|
zone1_upper = max_hr_estimated * 0.6
|
||||||
|
|
||||||
|
# Zone 2: Leicht (60-70% HRmax)
|
||||||
|
zone2_upper = max_hr_estimated * 0.7
|
||||||
|
|
||||||
|
# Zone 3: Moderat (70-80% HRmax)
|
||||||
|
zone3_upper = max_hr_estimated * 0.8
|
||||||
|
|
||||||
|
# Zone 4: Hart (80-90% HRmax) #update: bis 100%
|
||||||
|
zone4_upper = max_hr_estimated * 1.0
|
||||||
|
|
||||||
|
# Füge Zonen-Bereiche als Hintergrundbereiche hinzu
|
||||||
|
fig.add_hrect(y0=zone1_lower, y1=zone1_upper,
|
||||||
|
fillcolor="green", opacity=0.1, line_width=0)
|
||||||
|
fig.add_hrect(y0=zone1_upper, y1=zone2_upper,
|
||||||
|
fillcolor="yellow", opacity=0.1, line_width=0)
|
||||||
|
fig.add_hrect(y0=zone2_upper, y1=zone3_upper,
|
||||||
|
fillcolor="orange", opacity=0.1, line_width=0)
|
||||||
|
fig.add_hrect(y0=zone3_upper, y1=zone4_upper,
|
||||||
|
fillcolor="red", opacity=0.1, line_width=0)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
#
|
||||||
0
fit_files/.keep
Normal file
0
fit_files/.keep
Normal file
@@ -21,6 +21,7 @@ import pandas as pd
|
|||||||
import numpy as np
|
import numpy as np
|
||||||
import plotly.express as px
|
import plotly.express as px
|
||||||
import plotly.graph_objects as go
|
import plotly.graph_objects as go
|
||||||
|
from scipy.interpolate import interp1d
|
||||||
import gpxpy
|
import gpxpy
|
||||||
|
|
||||||
|
|
||||||
@@ -214,43 +215,82 @@ def create_map_plot(df):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
def create_elevation_plot(df):
|
|
||||||
|
|
||||||
|
######################
|
||||||
|
# NEUE VERSION:
|
||||||
|
def create_elevation_plot(df, smooth_points=500):
|
||||||
|
# Originale Daten
|
||||||
x = df['time']
|
x = df['time']
|
||||||
y = df['rel_elev']
|
y = df['rel_elev']
|
||||||
n_layers = 36
|
|
||||||
base_color = (5, 158, 5) # Greenish
|
|
||||||
max_alpha = 0.25
|
|
||||||
traces = []
|
|
||||||
|
|
||||||
# Main elevation line
|
# Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten
|
||||||
traces.append(go.Scatter(
|
if len(y) >= 4: # Genug Punkte für cubic interpolation
|
||||||
x=x, y=y,
|
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',
|
mode='lines',
|
||||||
line=dict(color='lime', width=2),
|
line=dict(color='#1CAF50'), # Fill between color!
|
||||||
|
fill='tozeroy',
|
||||||
|
#fillcolor='rgba(226, 241, 248)',
|
||||||
|
hoverinfo='skip',
|
||||||
showlegend=False
|
showlegend=False
|
||||||
))
|
))
|
||||||
|
|
||||||
# Single gradient fill (above and below 0)
|
# Hauptlinie (geglättet)
|
||||||
for i in range(1, n_layers + 1):
|
fig.add_trace(go.Scatter(
|
||||||
alpha = max_alpha * (1 - i / n_layers)
|
x=x_smooth, y=y_smooth,
|
||||||
color = f'rgba({base_color[0]}, {base_color[1]}, {base_color[2]}, {alpha:.3f})'
|
mode='lines',
|
||||||
y_layer = y * (i / n_layers)
|
line=dict(color='#084C20', width=2), # Line color!
|
||||||
traces.append(go.Scatter(
|
name='Elevation',
|
||||||
x=x,
|
showlegend=False
|
||||||
y=y_layer,
|
))
|
||||||
mode='lines',
|
|
||||||
fill='tonexty',
|
|
||||||
line=dict(width=0),
|
|
||||||
fillcolor=color,
|
|
||||||
hoverinfo='skip',
|
|
||||||
showlegend=False
|
|
||||||
))
|
|
||||||
|
|
||||||
fig = go.Figure(data=traces)
|
# 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(
|
fig.update_layout(
|
||||||
title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16)),
|
title=dict(text='Höhenprofil relativ zum Startwert', font=dict(size=16, color='white')),
|
||||||
xaxis_title='Zeit',
|
xaxis_title='Zeit',
|
||||||
yaxis_title='Höhe relativ zum Start (m)',
|
yaxis_title='Höhe relativ zum Start (m)',
|
||||||
template='plotly_dark',
|
template='plotly_dark',
|
||||||
@@ -258,11 +298,13 @@ def create_elevation_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=500
|
height=400
|
||||||
)
|
)
|
||||||
|
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
||||||
# Compute mean velocity in km/s
|
# Compute mean velocity in km/s
|
||||||
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
|
vel_kmps_mean = df['cum_dist_km'].iloc[-1] / df['time_diff_sec'].iloc[-1]
|
||||||
@@ -282,7 +324,7 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
|||||||
template='plotly_dark',
|
template='plotly_dark',
|
||||||
)
|
)
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
title=dict(text='Abweichung von integrierter Durchschnittsgeschwindigkeit', font=dict(size=16)),
|
title=dict(text='Abweichung von integriertem Durchschnittstempo', font=dict(size=16)),
|
||||||
yaxis_title='Abweichung (km)',
|
yaxis_title='Abweichung (km)',
|
||||||
xaxis_title='Zeit',
|
xaxis_title='Zeit',
|
||||||
height=400,
|
height=400,
|
||||||
@@ -299,7 +341,7 @@ def create_deviation_plot(df): #Distanz-Zeit-Diagramm
|
|||||||
y0=0,
|
y0=0,
|
||||||
y1=0,
|
y1=0,
|
||||||
line=dict(color='gray', width=1, dash='dash'),
|
line=dict(color='gray', width=1, dash='dash'),
|
||||||
name='Durchschnittsgeschwindigkeit'
|
name='Durchschnittstempo'
|
||||||
)
|
)
|
||||||
return fig
|
return fig
|
||||||
|
|
||||||
@@ -316,7 +358,7 @@ def create_speed_plot(df):
|
|||||||
line=dict(color='royalblue')
|
line=dict(color='royalblue')
|
||||||
))
|
))
|
||||||
fig.update_layout(
|
fig.update_layout(
|
||||||
title=dict(text=f'Durchschnittsgeschwindigkeit über die Zeit (geglättet): {mean_speed_kmh:.2f} km/h', font=dict(size=16)),
|
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'),
|
xaxis=dict(title='Zeit', tickformat='%H:%M', type='date'),
|
||||||
yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'),
|
yaxis=dict(title='Geschwindigkeit (km/h)', rangemode='tozero'),
|
||||||
template='plotly_dark',
|
template='plotly_dark',
|
||||||
@@ -325,6 +367,16 @@ def create_speed_plot(df):
|
|||||||
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)
|
||||||
)
|
)
|
||||||
|
# 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
|
return fig
|
||||||
|
|
||||||
|
|
||||||
@@ -381,8 +433,49 @@ def create_pace_bars_plot(df):
|
|||||||
offset=0
|
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(
|
fig.update_layout(
|
||||||
title=dict(text='Pace (min/km) je Kilometer', font=dict(size=16)),
|
title=dict(text='Tempo (min/km) je Kilometer', font=dict(size=16)),
|
||||||
xaxis_title='Distanz (km)',
|
xaxis_title='Distanz (km)',
|
||||||
yaxis_title='Minuten pro km',
|
yaxis_title='Minuten pro km',
|
||||||
barmode='overlay',
|
barmode='overlay',
|
||||||
@@ -3,4 +3,6 @@ dash-bootstrap-components
|
|||||||
plotly
|
plotly
|
||||||
pandas
|
pandas
|
||||||
numpy
|
numpy
|
||||||
|
scipy
|
||||||
gpxpy
|
gpxpy
|
||||||
|
fitparse
|
||||||
|
|||||||
Reference in New Issue
Block a user