Overview
I've been training for a while now for a series of Hyrox races. I did a mixed doubles race over the holidays, my first solo race last month, and I'm currently working up for another solo in the mid-year. While I've been doing the usual training and preparation, I wanted to take a deeper look at my performance compared to others to see if I can find any insights to improve my time in my next race. If this is something you've been interested in doing, then hopefully this post can provide some insights into how you can do the same with a little bit of Python.
Fetching the Race Results
Before we can begin to analyse anything, we need to get the data. Hyrox has a public leaderboard where you can view race results, but it doesn’t offer any way to export the data other than printing the page. Fortunately, there’s an unofficial Python API available called the pyrox-client that we can use to export the data in a more usable format. Use the following boilerplate, configure the parameters to get the data for your desired race, and run the script.
import os
import pyrox
import pandas as pd
from rich.console import Console
from rich.table import Table
class HyroxRaceInsights:
"""Analyses Hyrox race data and generates performance insights."""
ALL_EVENTS: dict[str, str] = {
"run1_time": "Run 1",
"skiErg_time": "SkiErg",
# ... remaining events
}
def fetch_data(self) -> None:
"""Fetch race data from Pyrox API."""
client = pyrox.PyroxClient()
race = client.get_race(
season=self.season,
location=self.location,
gender=self.gender,
division=self.division,
)
self.race_records = race.to_dict(orient="records")
self._find_athlete()
self._calculate_averages()
# Load configuration from environment variables
season = int(os.environ.get("HYROX_SEASON", "8"))
division = os.environ.get("HYROX_DIVISION", "open")
gender = os.environ.get("HYROX_GENDER", "male")
location = os.environ.get("HYROX_LOCATION", "Stockholm")
athlete = os.environ.get("HYROX_ATHLETE", "Smith, John")
analyzer = HyroxRaceInsights(season=season, location=location, division=division, gender=gender, athlete_name=athlete)
analyzer.fetch_data()
Now that we have the data, we can begin to visualise it, let's get started! I want to see where I fall within the average for each event and also where my biggest opportunities are. First, I wrote some code to calculate the event averages.
def _calculate_averages(self) -> None:
"""Calculate field average times for each event."""
self.averages = {
field: sum(times) / len(times)
for field in self.ALL_EVENTS
if (times := [r[field] for r in self.race_records if r.get(field) is not None])
}
I needed to find my own record in the data set for comparison. I broke this into a utility method called _find_athlete which locates it by name:
def _find_athlete(self) -> None:
"""Locate athlete record in race data."""
for record in self.race_records:
if str(record.get("name", "")).lower() == self.athlete_name.lower():
self.athlete_record = record
break
I could then identify where I fell within those averages by comparing my time at each station against the field average. I arranged it into a table that makes it easy to understand using the Rich library.
def _display_athlete_comparison_table(self) -> None:
"""Show athlete times vs field averages."""
table = Table(title=f"Athlete Comparison - {self.athlete_name}")
table.add_column("Event", style="cyan")
table.add_column("Your Time", style="yellow")
table.add_column("Field Avg", style="magenta")
table.add_column("Delta", style="white")
for field, event_name in self.ALL_EVENTS.items():
athlete_time = self.athlete_record.get(field)
avg_time = self.averages.get(field)
if athlete_time and avg_time:
delta = athlete_time - avg_time
color = self._delta_color(delta)
table.add_row(
event_name,
f"{athlete_time:.2f}m",
f"{avg_time:.2f}m",
f"[{color}]{delta:+.2f}m[/{color}]",
)
To get the biggest opportunities, I calculated how far I had strayed from the average in the other direction. In the rare case that anything is ever well above the average, it is calculated relative to whatever is closest to the average (I'll get there one day).
def _display_opportunities(self) -> None:
"""Show top 3 events where athlete lost the most time."""
# Filter events where we lost time and sort by biggest loss first
opportunities = sorted(
[(n, d, p) for n, d, p in self.heatmap_data if d > 0],
key=lambda x: x[1],
reverse=True,
)
if opportunities:
max_delta = opportunities[0][1]
for i, (event_name, delta_sec, delta_pct) in enumerate(
opportunities[: self.TOP_N_OPPORTUNITIES], 1
):
bar = self._make_bar((delta_sec / max_delta) * 25)
self.console.print(
f" {i}. {event_name:20} [red]{bar}[/red] "
f"[red bold]+{delta_sec:.0f}s[/red bold]"
)
I also wanted to see a heatmap that shows where I gained or lost time relative to the field average so I know where to focus my training.
def _display_heatmap(self) -> None:
"""Show per-event delta vs field average."""
self.heatmap_data = self._compute_event_deltas()
# Print each event sorted by delta, with a colored symbol and bar
for event_name, delta_sec, delta_pct in sorted(self.heatmap_data, key=lambda x: x[1]):
color = self._delta_color(delta_sec)
symbol = "✓" if delta_sec < 0 else "✗"
bar = self._make_bar(abs(delta_sec) / 10)
self.console.print(
f" {event_name:20} [{color}]{symbol}[/{color}] "
f"{bar} {delta_sec:+6.0f}s"
)
As a result I get some pretty charts that show me where I'm doing well and where I need to focus my efforts.
If you wanted to take this one step further, you could instead export the data and build an entire app around it using something like ApexCharts or D3 to create more interactive visualisations. You could even build a dashboard that tracks your performance over time across multiple races. Lots of interesting possibilities here as the data set is pretty rich. I highly recommend checking out the pyrox-client documentation to see what other data you can pull from the API to enrich your analysis.
Heart Rate Analysis
If you recorded your heart rate during your Hyrox race, you can use that to further enrich the data set to see where your heart rate spiked and how it correlates with your performance. This can be especially useful for endurance events like Hyrox, where pacing is important. I updated my script to read a heart_rate.csv file I generated from my Apple Watch export and built some additional visualisations to show how my heart rate changed throughout the event.
def _load_heart_rate_data(self) -> None:
"""Load heart rate data from CSV if it exists."""
heart_rate_file = "heart_rate.csv"
if not os.path.exists(heart_rate_file):
return
try:
df = pd.read_csv(heart_rate_file)
# Validate required columns
if not all(col in df.columns for col in self.HR_REQUIRED_COLUMNS):
return
self.heart_rate_data = df
except (pd.errors.ParserError, OSError) as e:
self.console.print(
f"[yellow]Warning:[/yellow] Could not load heart rate data: {e}"
)
I know I always start my watch when the race begins, so I wrote code to trim the end of the reading, as 9 times out of 10 I forget to turn it off right away when I cross the finish line. The following method maps heart rate readings to each station using cumulative time, and cuts off any data beyond the total race duration.
def _display_heart_rate_per_station(self) -> None:
"""Show average heart rate per event."""
event_hr_data = self._hr_per_event(self._hr_avg_values, self._hr_max_values)
max_avg_hr = max(hr for _, hr, _ in event_hr_data)
for event_name, avg_hr, max_hr in sorted(
event_hr_data, key=lambda x: x[1], reverse=True
):
bar = self._make_bar((avg_hr / max_avg_hr) * 30)
self.console.print(
f" {event_name:20} {bar} [yellow]{avg_hr:.0f}[/yellow] bpm"
)
With data mapped per station, I could then identify the peak exertion moments where my heart rate spiked the most. In other words, where I suffered.
def _display_heart_rate_spikes(self) -> None:
"""Show where heart rate spiked the most."""
spike_data = self._hr_per_event(self._hr_avg_values, self._hr_max_values)
top_spikes = sorted(spike_data, key=lambda x: x[2], reverse=True)[:3]
max_peak_hr = max(hr for _, hr, _ in spike_data)
for i, (event_name, _, peak_hr) in enumerate(top_spikes, 1):
bar = self._make_bar((peak_hr / max_peak_hr) * 30)
self.console.print(
f" {i}. {event_name:20} [red]{bar}[/red] Peak: {peak_hr:.0f} bpm"
)
As a result you get some additive insights that can help you figure out where you might be pushing a bit too hard, or opportunities where you can push even harder.
Conclusion
If you'd like to give my script a try you can find it on GitHub. Big thanks to the maintainers of the pyrox-client library for making it easy to access the data used here.
Hopefully, this article gives you some ideas for analysing your own performance. The key is to find a way to visualise the data that makes it easy to understand and draw insights from. Whether it’s a simple bar chart or a more complex visualisation, the important thing is to use the data to inform your training and help you improve. Good luck with your next race!




Top comments (0)