Visualising Strava Race Evaluation. Two New Graphs That Examine Runners on… | by Juan Hernanz | Aug, 2024

We’re prepared now to play with the information to create the visualisations.

Challenges:

To acquire the information wanted for the visuals my first instinct was: have a look at the cumulative distance column for each runner, determine when a lap distance was accomplished (1000, 2000, 3000, and so on.) by every of them and do the variations of timestamps.

That algorithm seems easy, and may work, however it had some limitations that I wanted to deal with:

  1. Precise lap distances are sometimes accomplished in between two knowledge factors registered. To be extra correct I needed to do interpolation of each place and time.
  2. Attributable to distinction within the precision of units, there may be misalignments throughout runners. The most common is when a runner’s lap notification beeps earlier than one other one even when they’ve been collectively the entire observe. To minimise this I made a decision to use the reference runner to set the place marks for each lap within the observe. The time distinction will likely be calculated when different runners cross these marks (regardless that their cumulative distance is forward or behind the lap). That is extra near the truth of the race: if somebody crosses a degree earlier than, they’re forward (regardless the cumulative distance of their system)
  3. With the earlier level comes one other downside: the latitude and longitude of a reference mark may by no means be precisely registered on the opposite runners’ knowledge. I used Nearest Neighbours to search out the closest datapoint by way of place.
  4. Lastly, Nearest Neighbours may deliver improper datapoints if the observe crosses the identical positions at totally different moments in time. So the inhabitants the place the Nearest Neighbours will search for one of the best match must be decreased to a smaller group of candidates. I outlined a window dimension of 20 datapoints across the goal distance (distance_cum).

Algorithm

With all of the earlier limitations in thoughts, the algorithm needs to be as follows:

1. Select the reference and a lap distance (default= 1km)

2. Utilizing the reference knowledge, determine the place and the second each lap was accomplished: the reference marks.

3. Go to different runner’s knowledge and determine the moments they crossed these place marks. Then calculate the distinction in time of each runners crossing the marks. Lastly the delta of this time distinction to characterize the evolution of the hole.

Code Instance

1. Select the reference and a lap distance (default= 1km)

  • Juan would be the reference (juan_df) on the examples.
  • The opposite runners will likely be Pedro (pedro_df ) and Jimena (jimena_df).
  • Lap distance will likely be 1000 metres

2. Create interpolate_laps(): operate that finds or interpolates the precise level for every accomplished lap and return it in a brand new dataframe. The inferpolation is completed with the operate: interpolate_value() that was additionally created.

## Operate: interpolate_value()

Enter:
- begin: The beginning worth.
- finish: The ending worth.
- fraction: A price between 0 and 1 that represents the place between
the beginning and finish values the place the interpolation ought to happen.
Return:
- The interpolated worth that lies between the begin and finish values
on the specified fraction.

def interpolate_value(begin, finish, fraction):
return begin + (finish - begin) * fraction
## Operate: interpolate_laps()

Enter:
- track_df: dataframe with observe knowledge.
- lap_distance: metres per lap (default 1000)
Return:
- track_laps: dataframe with lap metrics. As many rows as laps recognized.

def interpolate_laps(track_df , lap_distance = 1000):
#### 1. Initialise track_laps with the primary row of track_df
track_laps = track_df.loc[0][['latitude','longitude','elevation','date_time','distance_cum']].copy()

# Set distance_cum = 0
track_laps[['distance_cum']] = 0

# Transpose dataframe
track_laps = pd.DataFrame(track_laps)
track_laps = track_laps.transpose()

#### 2. Calculate number_of_laps = Whole Distance / lap_distance
number_of_laps = track_df['distance_cum'].max()//lap_distance

#### 3. For every lap i from 1 to number_of_laps:
for i in vary(1,int(number_of_laps+1),1):

# a. Calculate target_distance = i * lap_distance
target_distance = i*lap_distance

# b. Discover first_crossing_index the place track_df['distance_cum'] > target_distance
first_crossing_index = (track_df['distance_cum'] > target_distance).idxmax()

# c. If match is precisely the lap distance, copy that row
if (track_df.loc[first_crossing_index]['distance_cum'] == target_distance):
new_row = track_df.loc[first_crossing_index][['latitude','longitude','elevation','date_time','distance_cum']]

# Else: Create new_row with interpolated values, copy that row.
else:

fraction = (target_distance - track_df.loc[first_crossing_index-1, 'distance_cum']) / (track_df.loc[first_crossing_index, 'distance_cum'] - track_df.loc[first_crossing_index-1, 'distance_cum'])

# Create the brand new row
new_row = pd.Sequence({
'latitude': interpolate_value(track_df.loc[first_crossing_index-1, 'latitude'], track_df.loc[first_crossing_index, 'latitude'], fraction),
'longitude': interpolate_value(track_df.loc[first_crossing_index-1, 'longitude'], track_df.loc[first_crossing_index, 'longitude'], fraction),
'elevation': interpolate_value(track_df.loc[first_crossing_index-1, 'elevation'], track_df.loc[first_crossing_index, 'elevation'], fraction),
'date_time': track_df.loc[first_crossing_index-1, 'date_time'] + (track_df.loc[first_crossing_index, 'date_time'] - track_df.loc[first_crossing_index-1, 'date_time']) * fraction,
'distance_cum': target_distance
}, identify=f'lap_{i}')

# d. Add the brand new row to the dataframe that shops the laps
new_row_df = pd.DataFrame(new_row)
new_row_df = new_row_df.transpose()

track_laps = pd.concat([track_laps,new_row_df])

#### 4. Convert date_time to datetime format and take away timezone
track_laps['date_time'] = pd.to_datetime(track_laps['date_time'], format='%Y-%m-%d %H:%M:%S.%fpercentz')
track_laps['date_time'] = track_laps['date_time'].dt.tz_localize(None)

#### 5. Calculate seconds_diff between consecutive rows in track_laps
track_laps['seconds_diff'] = track_laps['date_time'].diff()

return track_laps

Making use of the interpolate operate to the reference dataframe will generate the next dataframe:

juan_laps = interpolate_laps(juan_df , lap_distance=1000)
Dataframe with the lap metrics on account of interpolation. Picture by Creator.

Be aware because it was a 10k race, 10 laps of 1000m has been recognized (see column distance_cum). The column seconds_diff has the time per lap. The remainder of the columns (latitude, longitude, elevation and date_time) mark the place and time for every lap of the reference as the results of interpolation.

3. To calculate the time gaps between the reference and the opposite runners I created the operate gap_to_reference()

## Helper Capabilities:
- get_seconds(): Convert timedelta to complete seconds
- format_timedelta(): Format timedelta as a string (e.g., "+01:23" or "-00:45")
# Convert timedelta to complete seconds
def get_seconds(td):
# Convert to complete seconds
total_seconds = td.total_seconds()

return total_seconds

# Format timedelta as a string (e.g., "+01:23" or "-00:45")
def format_timedelta(td):
# Convert to complete seconds
total_seconds = td.total_seconds()

# Decide signal
signal = '+' if total_seconds >= 0 else '-'

# Take absolute worth for calculation
total_seconds = abs(total_seconds)

# Calculate minutes and remaining seconds
minutes = int(total_seconds // 60)
seconds = int(total_seconds % 60)

# Format the string
return f"{signal}{minutes:02d}:{seconds:02d}"

## Operate: gap_to_reference()

Enter:
- laps_dict: dictionary containing the df_laps for all of the runnners' names
- df_dict: dictionary containing the track_df for all of the runnners' names
- reference_name: identify of the reference
Return:
- matches: processed knowledge with time variations.


def gap_to_reference(laps_dict, df_dict, reference_name):
#### 1. Get the reference's lap knowledge from laps_dict
matches = laps_dict[reference_name][['latitude','longitude','date_time','distance_cum']]

#### 2. For every racer (identify) and their knowledge (df) in df_dict:
for identify, df in df_dict.gadgets():

# If racer is the reference:
if identify == reference_name:

# Set time distinction to zero for all laps
for lap, row in matches.iterrows():
matches.loc[lap,f'seconds_to_reference_{reference_name}'] = 0

# If racer is just not the reference:
if identify != reference_name:

# a. For every lap discover the closest level in racer's knowledge primarily based on lat, lon.
for lap, row in matches.iterrows():

# Step 1: set the place and lap distance from the reference
target_coordinates = matches.loc[lap][['latitude', 'longitude']].values
target_distance = matches.loc[lap]['distance_cum']

# Step 2: discover the datapoint that will likely be within the centre of the window
first_crossing_index = (df_dict[name]['distance_cum'] > target_distance).idxmax()

# Step 3: choose the 20 candidate datapoints to search for the match
window_size = 20
window_sample = df_dict[name].loc[first_crossing_index-(window_size//2):first_crossing_index+(window_size//2)]
candidates = window_sample[['latitude', 'longitude']].values

# Step 4: get the closest match utilizing the coordinates
nn = NearestNeighbors(n_neighbors=1, metric='euclidean')
nn.match(candidates)
distance, indice = nn.kneighbors([target_coordinates])

nearest_timestamp = window_sample.iloc[indice.flatten()]['date_time'].values
nearest_distance_cum = window_sample.iloc[indice.flatten()]['distance_cum'].values
euclidean_distance = distance

matches.loc[lap,f'nearest_timestamp_{name}'] = nearest_timestamp[0]
matches.loc[lap,f'nearest_distance_cum_{name}'] = nearest_distance_cum[0]
matches.loc[lap,f'euclidean_distance_{name}'] = euclidean_distance

# b. Calculate time distinction between racer and reference at this level
matches[f'time_to_ref_{name}'] = matches[f'nearest_timestamp_{name}'] - matches['date_time']

# c. Retailer time distinction and different related knowledge
matches[f'time_to_ref_diff_{name}'] = matches[f'time_to_ref_{name}'].diff()
matches[f'time_to_ref_diff_{name}'] = matches[f'time_to_ref_diff_{name}'].fillna(pd.Timedelta(seconds=0))

# d. Format knowledge utilizing helper capabilities
matches[f'lap_difference_seconds_{name}'] = matches[f'time_to_ref_diff_{name}'].apply(get_seconds)
matches[f'lap_difference_formatted_{name}'] = matches[f'time_to_ref_diff_{name}'].apply(format_timedelta)

matches[f'seconds_to_reference_{name}'] = matches[f'time_to_ref_{name}'].apply(get_seconds)
matches[f'time_to_reference_formatted_{name}'] = matches[f'time_to_ref_{name}'].apply(format_timedelta)

#### 3. Return processed knowledge with time variations
return matches

Under the code to implement the logic and retailer outcomes on the dataframe matches_gap_to_reference:

# Lap distance
lap_distance = 1000

# Retailer the DataFrames in a dictionary
df_dict = {
'jimena': jimena_df,
'juan': juan_df,
'pedro': pedro_df,
}

# Retailer the Lap DataFrames in a dictionary
laps_dict = {
'jimena': interpolate_laps(jimena_df , lap_distance),
'juan': interpolate_laps(juan_df , lap_distance),
'pedro': interpolate_laps(pedro_df , lap_distance)
}

# Calculate gaps to reference
reference_name = 'juan'
matches_gap_to_reference = gap_to_reference(laps_dict, df_dict, reference_name)

The columns of the ensuing dataframe include the essential data that will likely be displayed on the graphs: