Updated the elevation related part and the elevation line plot. Also correction at the pace_bars_plot right tickz entries positioning bug.

This commit is contained in:
2026-05-07 15:19:37 +02:00
parent 9b329232c6
commit bbf99a59f2
2 changed files with 72 additions and 105 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 MiB

After

Width:  |  Height:  |  Size: 2.1 MiB

View File

@@ -239,12 +239,15 @@ def process_fit(file_path):
df['time_diff_sec'] = df['time_diff'].dt.total_seconds() df['time_diff_sec'] = df['time_diff'].dt.total_seconds()
df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0]) df['duration_hms'] = df['time_diff'].apply(lambda td: str(td).split('.')[0])
# Cumulative distance (km) # Cumulative distance (km) (vektorisiert)
distances = [0] lat_r = np.radians(df['lat'].values)
for i in range(1, len(df)): lon_r = np.radians(df['lon'].values)
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat']) dlat = np.diff(lat_r, prepend=lat_r[0])
distances.append(distances[-1] + d) dlon = np.diff(lon_r, prepend=lon_r[0])
df['cum_dist_km'] = distances a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2
a[0] = 0
step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))
df['cum_dist_km'] = np.cumsum(step_dist)
# Elevation handling # Elevation handling
if 'elev' in df.columns: if 'elev' in df.columns:
@@ -275,37 +278,27 @@ 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
# ############## # Zweiter Durchlauf nötig: nach dropna() hat df weniger Zeilen als records,
# UPDATE: Da NaN-Problem mit heart_rate, manuell nochmal neu einlesen und überschreiben: # safe_add_column_to_dataframe behandelt den Längenunterschied korrekt.
# save heart rate data into variable
heart_rate = [] heart_rate = []
for record in fit_file.get_messages("record"): for record in fit_file.get_messages("record"):
# Records can contain multiple pieces of data (ex: timestamp, latitude, longitude, etc)
for data in record: for data in record:
# Print the name and value of the data (and the units if it has any)
if data.name == 'heart_rate': if data.name == 'heart_rate':
heart_rate.append(data.value) heart_rate.append(data.value)
# Hier variable neu überschrieben:
df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate) df = safe_add_column_to_dataframe(df, 'heart_rate', heart_rate)
# ##############
# ######################################
# IF issues with heart_rate values, usw these DEBUG prints:
#print(heart_rate)
if 'heart_rate' in df.columns: if 'heart_rate' in df.columns:
df['heart_rate'] = pd.to_numeric(df['heart_rate'], errors='coerce') df['heart_rate'] = pd.to_numeric(df['heart_rate'], errors='coerce')
df['hr_smooth'] = df['heart_rate'].rolling(window=5, center=True).mean() #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") # Uncomment if needed - DEBUG purpose! df['hr_smooth'] = df['heart_rate'].rolling(window=2, center=True, min_periods=1).mean()
else: else:
print("Keine Heart Rate Daten gefunden!")
df['heart_rate'] = np.nan df['heart_rate'] = np.nan
df['hr_smooth'] = np.nan df['hr_smooth'] = np.nan
print(f"Verarbeitete FIT-Datei: {len(df)} Datenpunkte") print(f"Verarbeitete FIT-Datei: {len(df)} Datenpunkte")
print(f"Distanz: {df['cum_dist_km'].iloc[-1]:.2f} km") print(f"Distanz: {df['cum_dist_km'].iloc[-1]:.2f} km")
print(f"Dauer: {df['duration_hms'].iloc[-1]}") print(f"Dauer: {df['duration_hms'].iloc[-1]}")
# ######################################
return df return df
@@ -366,12 +359,15 @@ def process_gpx(file_path):
((secs % 3600) // 60).astype(int).astype(str).str.zfill(2) + ':' + \ ((secs % 3600) // 60).astype(int).astype(str).str.zfill(2) + ':' + \
(secs % 60).astype(int).astype(str).str.zfill(2) (secs % 60).astype(int).astype(str).str.zfill(2)
# Kumulative Distanz (gleiche Logik wie in deiner FIT-Funktion) # Cumulative distance (km) (vektorisiert)
distances = [0] lat_r = np.radians(df['lat'].values)
for i in range(1, len(df)): lon_r = np.radians(df['lon'].values)
d = haversine(df.loc[i-1, 'lon'], df.loc[i-1, 'lat'], df.loc[i, 'lon'], df.loc[i, 'lat']) dlat = np.diff(lat_r, prepend=lat_r[0])
distances.append(distances[-1] + d) dlon = np.diff(lon_r, prepend=lon_r[0])
df['cum_dist_km'] = distances a = np.sin(dlat/2)**2 + np.cos(lat_r) * np.cos(np.roll(lat_r,1)) * np.sin(dlon/2)**2
a[0] = 0
step_dist = 2 * 6371 * np.arcsin(np.sqrt(np.clip(a, 0, 1)))
df['cum_dist_km'] = np.cumsum(step_dist)
# Elevation (gleiche Logik wie in deiner FIT-Funktion) # Elevation (gleiche Logik wie in deiner FIT-Funktion)
df['elev'] = df['elev'].bfill() df['elev'] = df['elev'].bfill()
@@ -508,15 +504,12 @@ def calculate_calories_burned(df):
vo2max = 15.0 * (hr_max / hr_rest) vo2max = 15.0 * (hr_max / hr_rest)
cumulative = 0.0 dt = np.diff(ts, prepend=ts[0])
for i in range(1, len(ts)): mask = (dt > 0) & (dt <= 10)
dt = ts[i] - ts[i - 1] frac = np.clip((hr - hr_rest) / (hr_max - hr_rest), 0, None)
if dt <= 0 or dt > 10: met = np.clip((frac * vo2max) / 3.5, 1.0, 18.0)
continue
frac = max(0.0, (hr[i] - hr_rest) / (hr_max - hr_rest))
met = max(1.0, min((frac * vo2max) / 3.5, 18.0))
kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0 kcal_per_s = met * _WEIGHT_KG * 3.5 / 12000.0
cumulative += kcal_per_s * dt cumulative = float(np.sum(kcal_per_s[mask] * dt[mask]))
print(f"DEBUG calories: rows={len(df)}, hr_valid={df['heart_rate'].notna().sum()}, duration={df['time_diff_sec'].iloc[-1]:.0f}s, hr_mean={df['heart_rate'].mean():.1f}") print(f"DEBUG calories: rows={len(df)}, hr_valid={df['heart_rate'].notna().sum()}, duration={df['time_diff_sec'].iloc[-1]:.0f}s, hr_mean={df['heart_rate'].mean():.1f}")
return int(round(cumulative)) return int(round(cumulative))
@@ -1070,8 +1063,7 @@ def create_pixel_heatmap(dataframes,
if len(lats_r) < 2: if len(lats_r) < 2:
continue continue
xs, ys = to_px(lats_r, lons_r) xs, ys = to_px(lats_r, lons_r)
for x, y in zip(xs, ys): np.add.at(count_grid, (ys, xs), 1)
count_grid[y, x] += 1
max_count = max(count_grid.max(), 1) max_count = max(count_grid.max(), 1)
log_max = np.log1p(max_count) log_max = np.log1p(max_count)
@@ -1107,21 +1099,30 @@ def create_pixel_heatmap(dataframes,
# - Einzellauf-Modus: nur highlight_df, aber count_grid kam von allen # - Einzellauf-Modus: nur highlight_df, aber count_grid kam von allen
draw_frames = dataframes if (mode == 'city' or highlight_df is None) else [highlight_df] draw_frames = dataframes if (mode == 'city' or highlight_df is None) else [highlight_df]
for df in draw_frames: # ← draw_frames statt dataframes from matplotlib.collections import LineCollection
# (LineCollection - alle Segmente in einem Aufruf):
for df in draw_frames:
lats_r = df['lat'].dropna().values lats_r = df['lat'].dropna().values
lons_r = df['lon'].dropna().values lons_r = df['lon'].dropna().values
if len(lats_r) < 2: if len(lats_r) < 2:
continue continue
xs, ys = to_px(lats_r, lons_r) xs, ys = to_px(lats_r, lons_r)
for i in range(len(xs) - 1): # Segmente als Array: shape (N-1, 2, 2)
mid_x = np.clip((xs[i] + xs[i+1]) // 2, 0, img_width - 1) points = np.array([xs, ys]).T.reshape(-1, 1, 2)
mid_y = np.clip((ys[i] + ys[i+1]) // 2, 0, img_height - 1) segments = np.concatenate([points[:-1], points[1:]], axis=1)
norm_val = np.log1p(count_grid[mid_y, mid_x]) / log_max if log_max > 0 else 0
ax.plot([xs[i], xs[i+1]], [ys[i], ys[i+1]], # Farbe pro Segment aus count_grid
color=cmap(norm_val), linewidth=line_width, mid_xs = np.clip((xs[:-1] + xs[1:]) // 2, 0, img_width - 1)
solid_capstyle='round', solid_joinstyle='round') mid_ys = np.clip((ys[:-1] + ys[1:]) // 2, 0, img_height - 1)
counts = count_grid[mid_ys, mid_xs]
norm_vals = np.log1p(counts) / log_max if log_max > 0 else np.zeros_like(counts)
colors = cmap(norm_vals)
lc = LineCollection(segments, colors=colors, linewidths=line_width,
capstyle='round', joinstyle='round')
ax.add_collection(lc)
# Colorbar als Inset-Axes (verändert NICHT die Figure-Größe) # Colorbar als Inset-Axes (verändert NICHT die Figure-Größe)
#cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # # verticale Position: rechts #cbar_ax = fig_mpl.add_axes([0.88, 0.10, 0.025, 0.70]) # # verticale Position: rechts
@@ -1633,32 +1634,17 @@ def create_pixel_hr_map(df, img_width=900, img_height=900,
# ELEVATION-PLOT: # ELEVATION-PLOT:
# ----------------------------------------------------------------------------- # -----------------------------------------------------------------------------
def create_elevation_plot(df, smooth_points=500): def create_elevation_plot(df, smooth_points=500):
# Originale Daten
x = df['time'] x = df['time']
y = df['rel_elev'] y = df['rel_elev']
# Einfache Glättung: nur Y-Werte glätten, X-Werte beibehalten y_smooth = (
if len(y) >= 4: # Genug Punkte für cubic interpolation pd.Series(y.values)
y_numeric = y.to_numpy() .interpolate(method='linear', limit=10, limit_direction='both') # kurze Lücken schließen
# Nur gültige Y-Punkte für Interpolation .rolling(window=15, center=True, min_periods=1)
mask = ~np.isnan(y_numeric) .mean()
if np.sum(mask) >= 4: # Genug gültige Punkte .values
# 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 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() fig = go.Figure()
@@ -1957,12 +1943,12 @@ def create_heart_rate_plot(df):
fig = go.Figure() fig = go.Figure()
# Heart Rate Linie (geglättet) # Heart Rate Linie (geglättet)
mask = df['heart_rate'].isna()
fig.add_trace(go.Scatter( fig.add_trace(go.Scatter(
x=df['time'][~mask], x=df['time'][~mask],
y=df['hr_smooth'][~mask], y=df['heart_rate'][~mask],
mode='lines', mode='lines',
#name='Geglättete Herzfrequenz', line=dict(color='#ff2c48', width=1.5), # etwas dünner für gezackte Linie
line=dict(color='#ff2c48', width=2),
showlegend=False, showlegend=False,
hovertemplate=( hovertemplate=(
"Zeit: %{x}<br>" + "Zeit: %{x}<br>" +
@@ -2232,7 +2218,7 @@ def create_pace_bars_plot(df, formatted_pace=None):
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
fig.add_trace(go.Bar( fig.add_trace(go.Bar(
x=pace_vals, x=pace_vals,
y=y_labels, y=list(range(len(y_labels))), # ← 0,1,2,3... statt Strings y=y_labels,
orientation='h', orientation='h',
marker=dict( marker=dict(
color=bar_colors, color=bar_colors,
@@ -2296,7 +2282,7 @@ def create_pace_bars_plot(df, formatted_pace=None):
if right_text: if right_text:
fig.add_annotation( fig.add_annotation(
x=x_max, x=x_max,
y=km_lbl, y=i, # ← numerischer Index statt km_lbl String
text=right_text, text=right_text,
showarrow=False, showarrow=False,
xanchor='right', xanchor='right',
@@ -2329,13 +2315,16 @@ def create_pace_bars_plot(df, formatted_pace=None):
yaxis=dict( yaxis=dict(
title='', title='',
autorange='reversed', # km 1 oben, letzter km unten (wie Strava) autorange='reversed', # km 1 oben, letzter km unten (wie Strava)
tickmode='array',
tickvals=list(range(len(y_labels))), # ← 0,1,2...
ticktext=y_labels, # ← "1","2",...,"0.4"
tickfont=dict(size=11, color='white'), tickfont=dict(size=11, color='white'),
showgrid=False, showgrid=False,
zeroline=False, zeroline=False,
), ),
template='plotly_dark', template='plotly_dark',
height=plot_height, height=plot_height,
margin=dict(l=50, r=80, t=45, b=45), margin=dict(l=40, r=40, t=45, b=45),
plot_bgcolor='#111111', plot_bgcolor='#111111',
paper_bgcolor='#1e1e1e', paper_bgcolor='#1e1e1e',
font=dict(color='white'), font=dict(color='white'),
@@ -2567,7 +2556,7 @@ def update_all_plots(json_data):
Output('heatmap-city-info', 'children'), Output('heatmap-city-info', 'children'),
Input('file-dropdown', 'value'), Input('file-dropdown', 'value'),
Input('heatmap-mode-toggle', 'value'), Input('heatmap-mode-toggle', 'value'),
prevent_initial_call=True #prevent_initial_call=True # Sonst beim Start der App kein Renderprozess
) )
def update_pixel_heatmap(selected_file, toggle_value): def update_pixel_heatmap(selected_file, toggle_value):
""" """
@@ -2578,11 +2567,11 @@ def update_pixel_heatmap(selected_file, toggle_value):
""" """
import os import os
if not selected_file or selected_file in ['NO_FILES', 'NO_FOLDER', 'ERROR']: if not selected_file or selected_file in ['NO_FILES', 'NO_FOLDER', 'ERROR', None]:
fig = go.Figure() empty = go.Figure()
fig.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'), empty.update_layout(paper_bgcolor='#1e1e1e', font=dict(color='white'),
title=dict(text='Keine Datei ausgewählt')) title=dict(text='Datei wählen...', font=dict(color='white')))
return fig, '' return empty, ''
city_code = extract_city_code(selected_file) city_code = extract_city_code(selected_file)
city_info_text = f'Erkannte Region: {city_code}' if city_code else 'Kein Stadtcode erkannt' city_info_text = f'Erkannte Region: {city_code}' if city_code else 'Kein Stadtcode erkannt'
@@ -2631,28 +2620,6 @@ def update_pixel_heatmap(selected_file, toggle_value):
return fig, city_info_text return fig, city_info_text
# Lade alle Läufe dieser Stadt
all_options = list_files()
city_dfs, city_paths = load_runs_for_city(city_code, all_options)
if not city_dfs:
# Fallback: nur aktuelle Datei
try:
df_single = process_selected_file(selected_file)
city_dfs = [df_single]
except Exception:
city_dfs = []
fig = create_pixel_heatmap(
dataframes=city_dfs,
mode='city',
city_code=city_code,
n_city_runs=len(city_dfs),
)
city_info_text = f'Region {city_code} · {len(city_dfs)} Läufe geladen'
return fig, city_info_text
# Callback 3: Export SVG # Callback 3: Export SVG