Rytterne Backyard 2025
2025-05-04
Jag sprang mitt första lopp igår. Det var en så kallad backyard här i Rytterne, som går ut på att man springer ett varv på 6,7 km - och så gör man om det varje heltimme så länge man mäktar med. Mitt mål var att klara tre varv, men latmasken bet tag så hårt i mina knän i början på sista varvet, så jag var tvungen att gå (läs: halta) de sista fyra kilometrarna. Hur som helst var det oerhört kul och idag har jag praktiserat vila framför datorn och analyserat data från loppet.
Min modesta utrustning
Jag använde mitt Polar H10-pulsband och mobilen med appen Polar Flow medan jag sprang. Strax innan första varvet började tryckte jag på start och sedan tryckte jag på stopp när jag kommit i mål efter varv tre. Så inga pauser mellan varven utan mobilen registrerade kontinuerligt. Appen är bra men det är lite svårt att se statistik per varv. Så idag laddade jag ned TCX-filen (en XML-fil som Garmin tagit fram för att standardisera överföring av träningsdata) från mitt konto på Polars hemsida och läste in den i en Pandas-dataframe och lekte lite med bland annat matplotlib.
Mjukvara
Pandas är ett mycket kompetent bibliotek som används för att analysera data. Utöver det laddade jag ned tcxreader från PyPI och läste in polar-filen till en dataframe så här:
from tcxreader.tcxreader import TCXReader, TCXExercise
import pandas as pd
import numpy as np
= 'Peter_Edström_2025-05-03_09-58-30.TCX' # filen från flow.polar.com
fil
= TCXReader()
tcx_reader
= tcx_reader.read(fil) data: TCXExercise
I data
har jag nu ett TCXExercise-objekt, där jag dels
har metadata för löppasset samt alla datapunkter, så kallade
trackpoints.
data.start_time, data.end_time# (datetime.datetime(2025, 5, 3, 7, 58, 31, 673000),
# datetime.datetime(2025, 5, 3, 11, 14, 52, 673000))
len(data.trackpoints)
# 11758
En trackpoint innehåller sensordata per ögonblick. Till exempel innehåller punkt nummer 150 följande data:
= data.trackpoints[150]
tp
tp.time, tp.distance, tp.hr_value, tp.latitude, tp.longitude, tp.tpx_ext
# (datetime.datetime(2025, 5, 3, 8, 1, 1, 673000),
# 150.9854736328125,
# 136,
# 59.49238718,
# 16.34682158,
# {})
Jag noterade att tiden anges i UTC utan dst, så jag började med att skriva en liten hjälpfunktion som gör om tiden till lokaltid och sedan tar bort tidszonen helt och hållet (jag skulle precis lika gärna ha kunnat plussa på 2h istället, men på det här sättet kan jag återanvända koden om jag, mot förmodan, kommer springa under vintertid, eller utomlands). Därefter läste jag in allt till en dataframe.
def localize_time(t):
= pd.to_datetime(t)
t if t.tzinfo is None:
= t.tz_localize("UTC")
t return t.tz_convert("Europe/Stockholm").tz_localize(None)
= pd.DataFrame([{
df "time": localize_time(tp.time),
"distance": tp.distance,
"hr_value": tp.hr_value,
# "latitude": tp.latitude, # jag behöver inte gps-koordinaterna men
# "longitude": tp.longitude # vill visa att de finns här
for tp in data.trackpoints]) }
Nu ser df
ut så här:
- | time | distance | hr_value |
---|---|---|---|
0 | 2025-05-03 09:58:31.673 | NaN | 103 |
1 | 2025-05-03 09:58:32.673 | NaN | 103 |
2 | 2025-05-03 09:58:33.673 | 0.108122 | 102 |
3 | 2025-05-03 09:58:34.673 | 0.555316 | 102 |
4 | 2025-05-03 09:58:35.673 | 1.120294 | 92 |
… | … | … | … |
11753 | 2025-05-03 13:14:48.673 | 21598.427734 | 117 |
11754 | 2025-05-03 13:14:49.673 | 21598.427734 | 116 |
11755 | 2025-05-03 13:14:50.673 | 21598.917969 | 116 |
11756 | 2025-05-03 13:14:51.673 | 21598.917969 | 113 |
11757 | 2025-05-03 13:14:52.673 | 21599.347656 | 112 |
Jag insåg att tiden kan snyggas till mer. Jag behöver inga millisekunder Dessutom saknar jag en kolumn för tempo och hastighet. Så jag började med att skapa dessa.
# Runda av tiden till sekund
"time"] = df["time"].dt.floor("s")
df[
# Sortera time-kolumnen
= df.sort_values("time").reset_index(drop=True)
df
# Beräkna tidsskillnad i sekunder mellan varje punkt
"delta_time"] = df["time"].diff().dt.total_seconds()
df[
# Beräkna distansskillnad i meter mellan varje punkt
"delta_dist"] = df["distance"].diff()
df[
# Beräkna tempo i min/km
"tempo"] = (df["delta_time"] / (df["delta_dist"] / 1000)) / 60
df[
# Beräkna hastighet i km/h
"speed_kmh"] = (df["delta_dist"] / df["delta_time"]) * 3.6
df[
# Ta bort tillfälliga kolumner
=['delta_time', 'delta_dist'], inplace=True)
df.drop(columns
# Passa på och sätt time som indexkolumn
'time', inplace=True) df.set_index(
Nu ser det ut så här, om jag till exempel kollar en kvart in på första varvet:
'2025-05-03 10:15':] df.loc[
time | distance | hr_value | tempo | speed_kmh |
---|---|---|---|---|
2025-05-03 10:15:00 | 2175.016113 | 165 | 8.221928 | 7.297559 |
2025-05-03 10:15:01 | 2178.069336 | 164 | 5.458713 | 10.991602 |
2025-05-03 10:15:02 | 2180.704102 | 164 | 6.325673 | 9.485156 |
2025-05-03 10:15:03 | 2183.270996 | 164 | 6.492930 | 9.240820 |
2025-05-03 10:15:04 | 2186.426758 | 163 | 5.281345 | 11.360742 |
… | … | … | … | … |
2025-05-03 13:14:48 | 21598.427734 | 117 | 609.523810 | 0.098437 |
2025-05-03 13:14:49 | 21598.427734 | 116 | inf | 0.000000 |
2025-05-03 13:14:50 | 21598.917969 | 116 | 33.997344 | 1.764844 |
2025-05-03 13:14:51 | 21598.917969 | 113 | inf | 0.000000 |
2025-05-03 13:14:52 | 21599.347656 | 112 | 38.787879 | 1.546875 |
Som kan anas är distance-kolumnen total distans och jag vill gärna ha
relativ distans per varv. Så jag lade till en kolumn
varvdistans
och räknade ut vilka datapunkter som gäller för
respektive varv, genom att utgå från att varv n
börjar
klockan 10:00 + n - 1
. Sedan bestämde jag ett varv slutar
när 6700 meter avverkats plus att hastigheten sjunkigt under 1 km/h.
'varv'] = 0
df['varvdistans'] = 0.0
df[
= pd.to_datetime('2025-05-03 10:00:00')
starttid0
# Fixa tre varv
for i in range(0,3):
# Ta reda på när varvet slutade och sätt varv till varvnummer för alla
# datapunkter från från starttid till då.
# Räkna även ut relativ distans
= starttid0 + pd.Timedelta(hours=i)
starttid
= df.loc[starttid,'distance']
startdistans = df.loc[
index 'distance'] > startdistans + 6700 ) & ( df['speed_kmh'] < 1 )
( df[0]
].index[
# Skriv varv
'varv'] = 1 + i
df.loc[starttid:index,
# Skriv relativ distans
'varvdistans'] = df.loc[
df.loc[starttid:index, 'distance'] - startdistans starttid:index,
Nu kan jag titta enbart på varv 2 till exempel:
'varv'] == 2] df.loc[df[
time | distance | hr_value | tempo | speed_kmh | varv | varvdistans |
---|---|---|---|---|---|---|
2025-05-03 11:00:00 | 7211.465332 | 110 | inf | 0.000000 | 2 | 0.000000 |
2025-05-03 11:00:01 | 7211.465332 | 111 | inf | 0.000000 | 2 | 0.000000 |
2025-05-03 11:00:02 | 7211.465332 | 111 | inf | 0.000000 | 2 | 0.000000 |
2025-05-03 11:00:03 | 7212.661621 | 110 | 13.931973 | 4.306641 | 2 | 1.196289 |
2025-05-03 11:00:04 | 7213.101074 | 109 | 37.925926 | 1.582031 | 2 | 1.635742 |
… | … | … | … | … | … | … |
2025-05-03 11:50:26 | 14118.394531 | 178 | 20.686869 | 2.900391 | 2 | 6906.929199 |
2025-05-03 11:50:27 | 14119.183594 | 178 | 21.122112 | 2.840625 | 2 | 6907.718262 |
2025-05-03 11:50:28 | 14120.509766 | 178 | 12.567501 | 4.774219 | 2 | 6909.044434 |
2025-05-03 11:50:29 | 14120.994141 | 178 | 34.408602 | 1.743750 | 2 | 6909.528809 |
2025-05-03 11:50:30 | 14121.131836 | 177 | 121.040189 | 0.495703 | 2 | 6909.666504 |
Notera att varvet startar på 0 m och slutar på 6909, vilket är längre än de utlovade 6,7 km. Men det kan bero på min gps också.
När man väl kommit hit kan man börja titta på lite roliga saker. Genom att importera matplotlib kan man rita lite kurvor.
import matplotlib.pyplot as plt
Till exempel kan vi plotta pulsen gentemot relativ distans över alla varv:
=(12, 6))
plt.figure(figsize
for varv in range(1, 4): # Varv 1 till 3
= df[df['varv'] == varv].sort_values(by='varvdistans')
df_varv
plt.plot('varvdistans'],
df_varv['hr_value'],
df_varv[=f'Varv {varv}'
label
)
'Relativ distans (m)')
plt.xlabel('HR (bpm)')
plt.ylabel('Jämförelse puls mellan varv 1–3')
plt.title(
plt.legend()True)
plt.grid(
plt.tight_layout()"rytterne-backyard-2025-puls.png", dpi=300, bbox_inches="tight") plt.savefig(
Pulsen är lite
högre under andra varvet.
Man kan även jämföra tempo:
=(12, 6))
plt.figure(figsize
for varv in range(1, 4): # Varv 1 till 3
# Jag kör ett glidande medelvärde (se nedan) för att minska hackigheten
# men fular till det lite på varv 3 eftersom det är extra hackigt...
= 75 if varv == 4 else 20
win = df[df['varv'] == varv].sort_values(by='varvdistans')
df_varv
plt.plot('varvdistans'],
df_varv[# Här kommer det glidande medelvärdet...
'tempo'].rolling(window=win).mean(),
df_varv[=f'Varv {varv}',
label=0.9
linewidth
)
'Relativ distans (m)')
plt.xlabel('Tempo [min/km]')
plt.ylabel('Jämförelse tempo mellan varv 1–3')
plt.title(
plt.legend()17, 4)
plt.ylim(True)
plt.grid(
plt.tight_layout()"rytterne-backyard-2025-tempo.png", dpi=150, bbox_inches="tight") plt.savefig(
Tempot under
sista varvet är all-over-the-place…
Här ser man tydligt jag promenerade/haltade stora delar av varv tre (gröna kurvan).
Till sist kan vi plotta puls mot tempo och se hur variabla värden är. Jag inbillar mig att “molnet” bör vara så tight som möjligt under långa lopp utan så stora höjdskillnader.
# Glidande medelvärden
"tempo_smooth"] = df["tempo"].rolling(window=10, center=True).mean()
df["hr_smooth"] = df["hr_value"].rolling(window=10, center=True).mean()
df[
# Plotta för varje varv
for varv in sorted(df["varv"].unique()):
if varv == 0:
continue
= df[df["varv"] == varv]
df_varv
# Filtrera bort orimliga värden
= (
mask "tempo_smooth"].notna() &
df_varv["hr_smooth"].notna() &
df_varv["tempo_smooth"] < 40) &
(df_varv["tempo_smooth"] > 3)
(df_varv[
)= df_varv[mask]
df_clean
= df_clean["tempo_smooth"]
x = df_clean["hr_smooth"]
y
# Median är trevligare än medelvärde
= x.median()
median_tempo = y.median()
median_hr
# Plotta lite
=(8, 5))
plt.figure(figsize=0.4, s=10, color="#deb3ff", label="Data")
plt.scatter(x, y, alpha="black", marker="x", s=100, label="Median")
plt.scatter(median_tempo, median_hr, color
# Lägg till textetiketter för medianvärden
= f"{median_tempo:.1f} min/km\n {median_hr:.0f} bpm"
label + 0.2, median_hr - 1.5, label, fontsize=10, color="black", ha="left")
plt.text(median_tempo
"Tempo (min/km)")
plt.xlabel("Puls (bpm)")
plt.ylabel(f"Varv {varv}: Puls mot Tempo")
plt.title(
plt.legend()True)
plt.grid(110, 180)
plt.ylim(5, 15)
plt.xlim(
plt.tight_layout()f"rytterne-backyard-2025-scatter-varv{varv:.0f}.png", dpi=150, bbox_inches="tight") plt.savefig(
Hyfsat
tight, men lite hög puls kanske.
Och,
tja, så här bör det inte se ut.
Tillbaka