height_profile / app.py
Lode Nachtergaele
bug in label
238c27d
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)