Spaces:
Sleeping
Sleeping
import json | |
import sys | |
from pathlib import Path | |
from typing import List | |
from urllib.request import pathname2url | |
from xml.dom import minidom | |
from folium.plugins import BeautifyIcon | |
from folium.features import DivIcon | |
# import folium.plugins as plugins | |
import numpy as np | |
import pandas as pd | |
from scipy.signal import find_peaks | |
import streamlit as st | |
import folium | |
from streamlit_folium import st_folium | |
import altair as alt | |
from io import StringIO | |
import branca | |
def get_gpx(uploaded_file): | |
data = StringIO(uploaded_file.getvalue().decode("utf-8")) | |
xmldoc = minidom.parse(data) | |
track = xmldoc.getElementsByTagName("trkpt") | |
elevation = xmldoc.getElementsByTagName("ele") | |
n_track = len(track) | |
# Parsing GPX elements | |
lon_list = [] | |
lat_list = [] | |
h_list = [] | |
for s in range(n_track): | |
lon, lat = ( | |
track[s].attributes["lon"].value, | |
track[s].attributes["lat"].value, | |
) | |
elev = elevation[s].firstChild.nodeValue | |
lon_list.append(float(lon)) | |
lat_list.append(float(lat)) | |
h_list.append(float(elev)) | |
# Calculate average latitude and longitude | |
ave_lat = sum(lat_list) / len(lat_list) | |
ave_lon = sum(lon_list) / len(lon_list) | |
return ave_lat, ave_lon, lon_list, lat_list, h_list | |
# From https://tomaugspurger.net/posts/modern-4-performance/ | |
def gcd_vec(lat1, lng1, lat2, lng2): | |
""" | |
Calculate great circle distance. | |
http://www.johndcook.com/blog/python_longitude_latitude/ | |
Parameters | |
---------- | |
lat1, lng1, lat2, lng2: float or array of float | |
Returns | |
------- | |
distance: | |
distance from ``(lat1, lng1)`` to ``(lat2, lng2)`` in kilometers. | |
""" | |
# python2 users will have to use ascii identifiers | |
ϕ1 = np.deg2rad(90 - lat1) | |
ϕ2 = np.deg2rad(90 - lat2) | |
θ1 = np.deg2rad(lng1) | |
θ2 = np.deg2rad(lng2) | |
cos = np.sin(ϕ1) * np.sin(ϕ2) * np.cos(θ1 - θ2) + np.cos(ϕ1) * np.cos(ϕ2) | |
arc = np.arccos(cos) | |
return arc * 6373 | |
CATEGORY_TO_COLOR = { | |
5: "#68bd44", | |
4: "#68bd44", | |
3: "#fbaa1c", | |
2: "#f15822", | |
1: "#ed2125", | |
0: "#800000", | |
} | |
def climb_category(climb_score): | |
"""Determine category of the climb based on the climb score as defined by Garmin""" | |
if climb_score < 1_500: | |
return 5 # Not categorised | |
elif climb_score < 8_000: | |
return 4 | |
elif climb_score < 16_000: | |
return 3 | |
elif climb_score < 32000: | |
return 2 | |
elif climb_score < 64000: | |
return 1 | |
else: | |
return 0 # Hors categorie | |
def grade_to_color(grade): | |
"""Determine the color of the climb based on its grade according to Garmin""" | |
if grade < 3: | |
return "lightgrey" | |
elif grade < 6: | |
return CATEGORY_TO_COLOR[3] | |
elif grade < 9: | |
return CATEGORY_TO_COLOR[2] | |
elif grade < 12: | |
return CATEGORY_TO_COLOR[1] | |
else: | |
return CATEGORY_TO_COLOR[0] | |
def find_climbs(df: pd.DataFrame) -> pd.DataFrame: | |
"""Detect all valleys and peaks. Filter out climbs and | |
add meta data (lenght, meters climbed, average grade, climb_score, ...) | |
""" | |
peaks, _ = find_peaks(df["smoothed_elevation"]) | |
df_peaks = df.iloc[peaks, :].assign(base=0).assign(kind="peak") | |
valleys, _ = find_peaks(df["smoothed_elevation"].max() - df["smoothed_elevation"]) | |
df_valleys = df.iloc[valleys, :].assign(base=0).assign(kind="valley") | |
df_elevation = pd.concat([df_valleys, df_peaks], axis=0).sort_values( | |
by="distance_from_start" | |
) | |
# Climbscore acoording to Garmin: | |
# https://s3.eu-central-1.amazonaws.com/download.navigation-professionell.de/ | |
# Garmin/Manuals/Understanding+ClimbPro+on+the+Edge.pdf | |
df_peaks_filtered = ( | |
pd.concat( | |
[df_elevation, df_elevation.shift(1).bfill().add_prefix("prev_")], | |
axis=1, | |
) | |
.query("(kind=='peak') & (prev_kind=='valley')") | |
.assign( | |
length=lambda df_: df_["distance_from_start"] | |
- df_["prev_distance_from_start"] | |
) | |
.assign(total_ascent=lambda df_: df_["elev"] - df_["prev_elev"]) | |
.assign(grade=lambda df_: (df_["total_ascent"] / df_["length"]) * 100) | |
.assign(climb_score=lambda df_: df_["length"] * df_["grade"]) | |
.assign(hill_category=lambda df_: df_["climb_score"].map(climb_category)) | |
.query("climb_score >= 1_500") | |
.assign(max_elevation=df["elev"].max().round(-1) + 10) | |
) | |
# Garmin rules | |
# df_peaks_filtered = df_peaks_meta.query( | |
# "(climb_score >= 1_500) & (length >= 0.5) & (grade >= 3_000)" | |
# ) | |
return df_peaks_filtered | |
def generate_height_profile_json(df: pd.DataFrame) -> str: | |
"""Generate a height profile of the ride in Altair. | |
Returns a string with json. | |
""" | |
df_distance = ( | |
df.assign(lon_1=lambda df_: df["lon"].shift(1)) | |
.assign(lat_1=lambda df_: df["lat"].shift(1)) | |
.drop(columns=["elev"]) | |
)[["lat", "lon", "lat_1", "lon_1"]] | |
df["distance"] = pd.Series( | |
[gcd_vec(*x) for x in df_distance.itertuples(index=False)], | |
index=df_distance.index, | |
).fillna(0) | |
total_distance = df["distance"].sum() | |
total_distance_round = np.round(total_distance) | |
df["distance_from_start"] = df["distance"].cumsum() | |
df["smoothed_elevation"] = df["elev"].rolling(10).mean().bfill() | |
df["grade"] = ( | |
0.1 | |
* (df["elev"] - df["elev"].shift(1).bfill()) | |
/ (df["distance_from_start"] - df["distance_from_start"].shift(1).bfill()) | |
) | |
df["smoothed_grade"] = df["grade"].rolling(10).mean() | |
df["smoothed_grade"] = df["smoothed_grade"].bfill() | |
df["smoothed_grade_color"] = df["smoothed_grade"].map(grade_to_color) | |
# df["grade_color"] = df["grade"].map(grade_to_color) | |
elevation = ( | |
alt.Chart( | |
df[ | |
[ | |
"distance_from_start", | |
"smoothed_elevation", | |
"smoothed_grade_color", | |
"grade", | |
] | |
] | |
) | |
.mark_bar() | |
.encode( | |
x=alt.X("distance_from_start") | |
.axis( | |
grid=False, | |
tickCount=10, | |
labelExpr="datum.label + ' km'", | |
title=None, | |
) | |
.scale(domain=(0, total_distance_round)), | |
y=alt.Y("smoothed_elevation").axis( | |
domain=False, | |
ticks=False, | |
tickCount=5, | |
labelExpr="datum.label + ' m'", | |
title=None, | |
), | |
color=alt.Color("smoothed_grade_color").scale(None), | |
tooltip=[ | |
alt.Tooltip( | |
"distance_from_start:Q", title="Distance (km)", format=".2f" | |
), | |
alt.Tooltip("smoothed_elevation:Q", title="Elevation (m)", format="d"), | |
alt.Tooltip("grade_percent:Q", title="Grade (%)", format=".0%"), | |
], | |
) | |
.transform_calculate( | |
grade_percent="datum.grade/100", | |
) | |
) | |
max_elevation = df["elev"].max().round(-1) | |
# elevation = ( | |
# alt.Chart(df) | |
# .mark_area( | |
# color=alt.Gradient( | |
# gradient="linear", | |
# stops=[ | |
# alt.GradientStop(color="lightgrey", offset=0), | |
# alt.GradientStop(color="darkgrey", offset=1), | |
# ], | |
# x1=1, | |
# x2=1, | |
# y1=1, | |
# y2=0, | |
# ), | |
# line={"color": "darkgreen"}, | |
# ) | |
# .encode( | |
# x=alt.X( | |
# "distance_from_start", | |
# axis=alt.Axis( | |
# domain=False, | |
# ticks=False, | |
# tickCount=10, | |
# labelExpr="datum.label + ' km'", | |
# ), | |
# scale=alt.Scale(domain=(0, total_distance_round)), | |
# ), | |
# y=alt.Y( | |
# "elev", | |
# axis=alt.Axis( | |
# domain=False, | |
# ticks=False, | |
# tickCount=5, | |
# labelExpr="datum.label + ' m'", | |
# ), | |
# scale=alt.Scale(domain=(0, max_elevation)), | |
# ), | |
# ) | |
# ) | |
df_peaks_filtered = find_climbs(df) | |
line_peaks = ( | |
alt.Chart(df_peaks_filtered[["distance_from_start", "elev", "max_elevation"]]) | |
.mark_rule(color="red") | |
.encode( | |
x=alt.X("distance_from_start:Q").scale(domain=(0, total_distance_round)), | |
y="elev", | |
y2="max_elevation", | |
) | |
) | |
# line_peaks = ( | |
# alt.Chart(df_peaks_filtered[["distance_from_start", "elev", "max_elevation"]]) | |
# .mark_rule(color="red") | |
# .encode( | |
# x=alt.X( | |
# "distance_from_start:Q", | |
# scale=alt.Scale(domain=(0, total_distance_round)), | |
# ), | |
# y="elev", | |
# y2="max_elevation", | |
# ) | |
# ) | |
df_annot = ( | |
df_peaks_filtered.reset_index(drop=True) | |
.assign(number=lambda df_: df_.index + 1) | |
.assign(circle_pos=lambda df_: df_["max_elevation"] + 20)[ | |
[ | |
"distance_from_start", | |
"max_elevation", | |
"circle_pos", | |
"number", | |
"length", | |
"total_ascent", | |
"grade", | |
"climb_score", | |
"prev_distance_from_start", | |
] | |
] | |
) | |
# annotation = ( | |
# alt.Chart(df_annot) | |
# .mark_text(align="center", baseline="bottom", fontSize=16, dy=-10) | |
# .encode( | |
# x=alt.X("distance_from_start:Q").scale(domain=(0, total_distance_round)), | |
# y="max_elevation", | |
# text="number", | |
# ) | |
# ) | |
annotation = ( | |
alt.Chart(df_annot) | |
.mark_text(align="center", baseline="bottom", fontSize=16, dy=-10) | |
.encode( | |
x=alt.X( | |
"distance_from_start:Q", | |
scale=alt.Scale(domain=(0, total_distance_round)), | |
), | |
y="max_elevation", | |
text="number", | |
tooltip=[ | |
alt.Tooltip( | |
"prev_distance_from_start:Q", title="Starts at (km)", format=".2f" | |
), | |
alt.Tooltip("total_ascent:Q", title="Total ascent (m)", format="d"), | |
alt.Tooltip("length:Q", title="Length (km)", format=".2f"), | |
alt.Tooltip("grade_percent:Q", title="Average Grade", format=".0%"), | |
alt.Tooltip("climb_score:Q", title="Climb score", format="d"), | |
], | |
) | |
.transform_calculate( | |
grade_percent="datum.grade/(100*1000)", | |
# total_ascent_int="Math.round(datum.total_ascent)", | |
) | |
) | |
chart = ( | |
(elevation + line_peaks + annotation) | |
.properties(width="container") | |
.configure_view( | |
strokeWidth=0, | |
) | |
) | |
return chart, df_peaks_filtered | |
def generate_climb_profile(df_hill: pd.DataFrame, title: str): | |
climb_profile = ( | |
alt.Chart( | |
df_hill, | |
title=alt.Title( | |
title, | |
anchor="start", | |
), | |
) | |
.mark_area() | |
.encode( | |
x=alt.X("distance_from_start") | |
.axis(grid=False, tickCount=10, labelExpr="datum.label + ' m'", title=None) | |
.scale(domain=(0, df_hill["distance_from_start"].max())), | |
y=alt.Y("elev").axis( | |
domain=False, | |
ticks=False, | |
tickCount=5, | |
labelExpr="datum.label + ' m'", | |
title=None, | |
), | |
color=alt.Color("color_grade").scale(None), | |
tooltip=[ | |
alt.Tooltip("distance_from_start:Q", title="Distance (m)", format="d"), | |
alt.Tooltip("elev:Q", title="Elevation (m)", format="d"), | |
alt.Tooltip("grade_percent:Q", title="Grade (%)", format=".0%"), | |
], | |
) | |
.transform_calculate( | |
grade_percent="datum.grade/100", | |
) | |
) | |
return climb_profile | |
gpx_file = st.file_uploader("Upload gpx file", type=["gpx"]) | |
if gpx_file is not None: | |
ave_lat, ave_lon, lon_list, lat_list, h_list = get_gpx(gpx_file) | |
df = pd.DataFrame({"lon": lon_list, "lat": lat_list, "elev": h_list}) | |
route_map = folium.Map(location=[ave_lat, ave_lon], zoom_start=12, height=400) | |
folium.PolyLine( | |
list(zip(lat_list, lon_list)), color="red", weight=2.5, opacity=1 | |
).add_to(route_map) | |
chart, df_peaks = generate_height_profile_json(df) | |
for index, row in df_peaks.reset_index(drop=True).iterrows(): | |
icon = BeautifyIcon( | |
icon="arrow-down", | |
icon_shape="marker", | |
number=str(index + 1), | |
border_color="red", | |
background_color="white", | |
) | |
icon_div = DivIcon( | |
icon_size=(150, 36), | |
icon_anchor=(7, 20), | |
html=f"<div style='font-size: 18pt; color : black'>{index+1}</div>", | |
) | |
length = ( | |
f"{row['length']:.1f} km" | |
if row["length"] >= 1 | |
else f"{row['length']*1000:.0f} m" | |
) | |
popup_text = f"""Climb {index+1}<br> | |
Lenght: {length}<br> | |
Avg. grade: {row['grade']/1000:.1f}%<br> | |
Total ascend: {int(row['total_ascent'])}m | |
""" | |
popup = folium.Popup(popup_text, min_width=100, max_width=200) | |
folium.Marker( | |
[row["lat"], row["lon"]], | |
popup=popup, | |
icon=icon_div, | |
).add_to(route_map) | |
df_hill = ( | |
df[ | |
df["distance_from_start"].between( | |
row["prev_distance_from_start"], | |
row["distance_from_start"], | |
) | |
] | |
.assign( | |
distance_from_start=lambda df_: ( | |
df_["distance_from_start"] - row["prev_distance_from_start"] | |
) | |
* 1_000 | |
) | |
.assign(color_grade=lambda df_: df_["grade"].map(grade_to_color)) | |
) | |
# df_hill_resample = df_hill.groupby((df_hill["distance_from_start"]*1000).round(-2)).agg({"elev":"mean", "grade":"mean"}).reset_index() | |
# df_hill_resample["color_grade"] = df_resampled["grade"].map(grade_to_color) | |
title = f"Climb {index+1}: {row['length']:.2f}km {(row['grade']/100_000):.2%} {int(row['total_ascent']):d}hm" | |
climb_profile = generate_climb_profile(df_hill, title) | |
climb_profile_json = json.loads(climb_profile.to_json()) | |
vega = folium.features.VegaLite( | |
climb_profile_json, | |
width=200, | |
height=200, | |
) | |
circle = folium.CircleMarker( | |
radius=15, | |
location=[row["lat"], row["lon"]], | |
# tooltip = label, | |
color="crimson", | |
fill=True, | |
) | |
# popup = folium.Popup() | |
# vega.add_to(popup) | |
# popup.add_to(circle) | |
circle.add_to(route_map) | |
# circle_marker = folium.CircleMarker( | |
# [row["lat"], row["lon"]], | |
# radius=15, | |
# popup=folium.Popup(max_width=400).add_child( | |
# folium.VegaLite(climb_profile_json, width=400, height=400) | |
# ), | |
# ) | |
st.table( | |
df_peaks[ | |
["length", "total_ascent", "grade", "climb_score", "hill_category"] | |
].reset_index(drop=True) | |
) | |
st_data = st_folium(route_map, height=600, width=850) | |
st.altair_chart(chart, use_container_width=True) | |
for index, row in df_peaks.reset_index(drop=True).iterrows(): | |
df_hill = ( | |
df[ | |
df["distance_from_start"].between( | |
row["prev_distance_from_start"], | |
row["distance_from_start"], | |
) | |
] | |
.assign( | |
distance_from_start=lambda df_: ( | |
df_["distance_from_start"] - row["prev_distance_from_start"] | |
) | |
* 1_000 | |
) | |
.assign(color_grade=lambda df_: df_["grade"].map(grade_to_color)) | |
) | |
df_new_index = pd.DataFrame( | |
index=pd.Index(np.arange(0, df_hill["distance_from_start"].max(), 10)) | |
) | |
df_hill_resample = pd.concat( | |
[df_hill.set_index("distance_from_start"), df_new_index], axis=0 | |
).sort_index() | |
df_hill_resample = df_hill_resample[["elev", "grade"]].interpolate() | |
df_hill_resample["color_grade"] = df_hill_resample["grade"].map(grade_to_color) | |
df_hill_resample = ( | |
df_hill_resample.reset_index() | |
.rename(columns={"index": "distance_from_start"}) | |
.sort_values(by="distance_from_start") | |
) | |
max_grade = df_hill_resample["grade"].rolling(10).mean().max() / 100 | |
title = f"""Climb {index+1}, length:{row['length']:.2f}km | |
Avg. grade: {(row['grade']/100_000):.2%} | |
Max. grade: {max_grade:.2%} | |
Total ascent: {int(row['total_ascent']):d}hm""" | |
climb_profile = generate_climb_profile(df_hill_resample, title) | |
st.altair_chart(climb_profile, use_container_width=True) | |