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

fil = 'Peter_Edström_2025-05-03_09-58-30.TCX' # filen från flow.polar.com

tcx_reader = TCXReader()

data: TCXExercise = tcx_reader.read(fil)

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:

tp = data.trackpoints[150]
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):
    t = pd.to_datetime(t)
    if t.tzinfo is None:
        t = t.tz_localize("UTC")
    return t.tz_convert("Europe/Stockholm").tz_localize(None)

df = pd.DataFrame([{
    "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
df["time"] = df["time"].dt.floor("s")

# Sortera time-kolumnen
df = df.sort_values("time").reset_index(drop=True)

# Beräkna tidsskillnad i sekunder mellan varje punkt
df["delta_time"] = df["time"].diff().dt.total_seconds()

# Beräkna distansskillnad i meter mellan varje punkt
df["delta_dist"] = df["distance"].diff()

# Beräkna tempo i min/km
df["tempo"] = (df["delta_time"] / (df["delta_dist"] / 1000)) / 60

# Beräkna hastighet i km/h
df["speed_kmh"] = (df["delta_dist"] / df["delta_time"]) * 3.6

# Ta bort tillfälliga kolumner
df.drop(columns=['delta_time', 'delta_dist'], inplace=True)

# Passa på och sätt time som indexkolumn
df.set_index('time', inplace=True)

Nu ser det ut så här, om jag till exempel kollar en kvart in på första varvet:

df.loc['2025-05-03 10:15':]
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.

df['varv'] = 0
df['varvdistans'] = 0.0

starttid0 = pd.to_datetime('2025-05-03 10:00:00')

# 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

    starttid = starttid0 + pd.Timedelta(hours=i)

    startdistans = df.loc[starttid,'distance']
    index = df.loc[
        ( df['distance'] > startdistans + 6700 ) & ( df['speed_kmh'] < 1 )
    ].index[0]
    
    # Skriv varv
    df.loc[starttid:index, 'varv'] = 1 + i

    # Skriv relativ distans
    df.loc[starttid:index, 'varvdistans'] = df.loc[
        starttid:index, 'distance'] - startdistans

Nu kan jag titta enbart på varv 2 till exempel:

df.loc[df['varv'] == 2]
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:

plt.figure(figsize=(12, 6))

for varv in range(1, 4):  # Varv 1 till 3
    df_varv = df[df['varv'] == varv].sort_values(by='varvdistans')
    plt.plot(
        df_varv['varvdistans'],
        df_varv['hr_value'],
        label=f'Varv {varv}'
    )

plt.xlabel('Relativ distans (m)')
plt.ylabel('HR (bpm)')
plt.title('Jämförelse puls mellan varv 1–3')
plt.legend()
plt.grid(True)
plt.tight_layout()
plt.savefig("rytterne-backyard-2025-puls.png", dpi=300, bbox_inches="tight")

Pulsen är lite högre under andra varvet.

Man kan även jämföra tempo:

plt.figure(figsize=(12, 6))

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...
    win = 75 if varv == 4 else 20
    df_varv = df[df['varv'] == varv].sort_values(by='varvdistans')
    plt.plot(
        df_varv['varvdistans'],
        # Här kommer det glidande medelvärdet...
        df_varv['tempo'].rolling(window=win).mean(),
        label=f'Varv {varv}',
        linewidth=0.9
    )

plt.xlabel('Relativ distans (m)')
plt.ylabel('Tempo [min/km]')
plt.title('Jämförelse tempo mellan varv 1–3')
plt.legend()
plt.ylim(17, 4)
plt.grid(True)
plt.tight_layout()
plt.savefig("rytterne-backyard-2025-tempo.png", dpi=150, bbox_inches="tight")

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
df["tempo_smooth"] = df["tempo"].rolling(window=10, center=True).mean()
df["hr_smooth"] = df["hr_value"].rolling(window=10, center=True).mean()

# Plotta för varje varv
for varv in sorted(df["varv"].unique()):
    if varv == 0:
        continue

    df_varv = df[df["varv"] == varv]

    # Filtrera bort orimliga värden
    mask = (
        df_varv["tempo_smooth"].notna() &
        df_varv["hr_smooth"].notna() &
        (df_varv["tempo_smooth"] < 40) &
        (df_varv["tempo_smooth"] > 3)
    )
    df_clean = df_varv[mask]

    x = df_clean["tempo_smooth"]
    y = df_clean["hr_smooth"]

    # Median är trevligare än medelvärde
    median_tempo = x.median()
    median_hr = y.median()

    # Plotta lite
    plt.figure(figsize=(8, 5))
    plt.scatter(x, y, alpha=0.4, s=10, color="#deb3ff", label="Data")
    plt.scatter(median_tempo, median_hr, color="black", marker="x", s=100, label="Median")

    # Lägg till textetiketter för medianvärden
    label = f"{median_tempo:.1f} min/km\n {median_hr:.0f} bpm"
    plt.text(median_tempo + 0.2, median_hr - 1.5, label, fontsize=10, color="black", ha="left")
    
    plt.xlabel("Tempo (min/km)")
    plt.ylabel("Puls (bpm)")
    plt.title(f"Varv {varv}: Puls mot Tempo")
    plt.legend()
    plt.grid(True)
    plt.ylim(110, 180)
    plt.xlim(5, 15)
    plt.tight_layout()
    plt.savefig(f"rytterne-backyard-2025-scatter-varv{varv:.0f}.png", dpi=150, bbox_inches="tight")

Hyfsat tight, men lite hög puls kanske.

Lite rörigare här.

Och, tja, så här bör det inte se ut.


Tillbaka