Tension Board 2 Mirror: Hold Usage Analysis¶
This notebook shifts attention from whole climbs to individual holds. The objective is to understand which holds are being used, how often they are used, and how their use changes by role, material, and location on the board.
As I climb on a TB2 Mirror and it interests me, I restrict the analysis to the Tension Board 2 Mirror. Restricting to one board also makes later feature engineering cleaner: the same physical placement has the same geometric meaning throughout the notebook series.
Main questions¶
- Which holds appear most often?
- Which regions of the board are used most heavily?
- How different are hand and foot usage patterns?
- Are plastic or wood holds favoured over the other?
The outputs here will feed directly into the next notebook, where hold usage is turned into hold-difficulty features.
Notebook Structure¶
Setup and Imports¶
"""
==================================
Setup
==================================
"""
# Imports
import pandas as pd
import matplotlib.pyplot as plt
import seaborn as sns
import numpy as np
import matplotlib.patches as mpatches
import sqlite3
import re
from collections import defaultdict
from PIL import Image
# Set some display options
pd.set_option('display.max_columns', None)
pd.set_option('display.max_rows', 100)
# Set style
palette=['steelblue', 'coral', 'seagreen'] #(for multi-bar graphs)
# Set board image for some visual analysis
board_img = Image.open('../images/tb2_board_12x12_composite.png')
# Connect to the database
DB_PATH="../data/tb2.db"
conn = sqlite3.connect(DB_PATH)
"""
==================================
Loading the data
==================================
"""
# This time we restrict to where `layout_id=10` for the TB2 Mirror
# Query climbs data
climbs_query = """
SELECT
c.uuid,
c.name AS climb_name,
c.setter_username,
c.layout_id AS layout_id,
c.description,
c.is_nomatch,
c.is_listed,
l.name AS layout_name,
p.name AS board_name,
c.frames,
cs.angle,
cs.display_difficulty,
dg.boulder_name AS boulder_grade,
cs.ascensionist_count,
cs.quality_average,
cs.fa_at
FROM climbs c
JOIN layouts l ON c.layout_id = l.id
JOIN products p ON l.product_id = p.id
JOIN climb_stats cs ON c.uuid = cs.climb_uuid
JOIN difficulty_grades dg ON ROUND(cs.display_difficulty) = dg.difficulty
WHERE cs.display_difficulty IS NOT NULL AND c.is_listed=1 AND c.layout_id=10
"""
# Query information about placements (and their mirrors)
placements_query = """
SELECT
p.id AS placement_id,
h.x,
h.y,
p.default_placement_role_id AS default_role_id,
p.set_id AS set_id,
s.name AS set_name
FROM placements p
JOIN holes h ON p.hole_id = h.id
JOIN sets s ON p.set_id = s.id
WHERE p.layout_id = 10
"""
# Load it into a DataFrame
df_climbs = pd.read_sql_query(climbs_query, conn)
df_placements = pd.read_sql_query(placements_query, conn)
First let's see how many climbs we're working with.
len(df_climbs)
43440
So we have about 43k climbs, but this has some repetition to due to angle.
len(df_climbs['frames'].unique())
26209
Great, so 26k unique different routes to analyze. Let's see what our df_placements DataFrame looks like.
# Our climbs DataFrame will look the same as in 01, although we are just restricting to the TB2 Mirror.
# Let's see what our placements DataFrame looks like.
df_placements.head(10)
| placement_id | x | y | default_role_id | set_id | set_name | |
|---|---|---|---|---|---|---|
| 0 | 672 | -64 | 4 | 8 | 13 | Plastic |
| 1 | 673 | -64 | 20 | 8 | 13 | Plastic |
| 2 | 419 | -64 | 28 | 5 | 12 | Wood |
| 3 | 420 | -64 | 36 | 5 | 12 | Wood |
| 4 | 421 | -64 | 44 | 5 | 12 | Wood |
| 5 | 422 | -64 | 52 | 6 | 12 | Wood |
| 6 | 674 | -64 | 60 | 6 | 13 | Plastic |
| 7 | 423 | -64 | 68 | 6 | 12 | Wood |
| 8 | 424 | -64 | 76 | 6 | 12 | Wood |
| 9 | 425 | -64 | 84 | 6 | 12 | Wood |
Now let's set our board boundaries and some basical mapping rules. First let's take a look at the placement_roles table in the databse.
led_color_query = """
SELECT * FROM placement_roles ORDER BY id;
"""
df_placement_roles = pd.read_sql_query(led_color_query, conn)
display(df_placement_roles)
| id | product_id | position | name | full_name | led_color | screen_color | |
|---|---|---|---|---|---|---|---|
| 0 | 1 | 4 | 1 | start | Start | 00FF00 | 00DD00 |
| 1 | 2 | 4 | 2 | middle | Middle | 0000FF | 0066FF |
| 2 | 3 | 4 | 3 | finish | Finish | FF0000 | FF0000 |
| 3 | 4 | 4 | 4 | foot | Foot Only | FF00FF | FF00FF |
| 4 | 5 | 5 | 1 | start | Start | 00FF00 | 00DD00 |
| 5 | 6 | 5 | 2 | middle | Middle | 0000FF | 0066FF |
| 6 | 7 | 5 | 3 | finish | Finish | FF0000 | FF0000 |
| 7 | 8 | 5 | 4 | foot | Foot Only | FF00FF | FF00FF |
"""
==================================
Mappings and more setup
==================================
"""
role_names = df_placement_roles.name[4:].tolist()
role_colors = df_placement_roles.led_color[4:].tolist()
sets = df_placements['set_name'].unique().tolist()
# Placement coordinates pX -> (x,y)
placement_coordinates = dict(zip(df_placements['placement_id'],
zip(df_placements['x'], df_placements['y'])))
# Placement set: is it wood or plastic?
placement_sets = dict(zip(df_placements['placement_id'], df_placements['set_name']))
# Role map name. Takes rY to foot/start/finish/middle
role_name_map = {i+5: name for i, name in enumerate(role_names)}
# Role map color. Takes rY to the appropriate color for the LED
role_color_map = {i+5: f"#{led_color}" for i, led_color in enumerate(role_colors)}
# Figure out whether a hold is a hand or a foot
role_type_map = {5: 'hand', 6: 'hand', 7: 'hand', 8: 'foot'}
## Boundary conditions
x_min, x_max = -68, 68
y_min, y_max = 0, 144
df_placements['default_role_type'] = df_placements['default_role_id'].map(role_type_map)
df_placements
| placement_id | x | y | default_role_id | set_id | set_name | default_role_type | |
|---|---|---|---|---|---|---|---|
| 0 | 672 | -64 | 4 | 8 | 13 | Plastic | foot |
| 1 | 673 | -64 | 20 | 8 | 13 | Plastic | foot |
| 2 | 419 | -64 | 28 | 5 | 12 | Wood | hand |
| 3 | 420 | -64 | 36 | 5 | 12 | Wood | hand |
| 4 | 421 | -64 | 44 | 5 | 12 | Wood | hand |
| ... | ... | ... | ... | ... | ... | ... | ... |
| 493 | 798 | 64 | 108 | 6 | 13 | Plastic | hand |
| 494 | 545 | 64 | 116 | 6 | 12 | Wood | hand |
| 495 | 799 | 64 | 124 | 6 | 13 | Plastic | hand |
| 496 | 800 | 64 | 132 | 6 | 13 | Plastic | hand |
| 497 | 801 | 64 | 140 | 7 | 13 | Plastic | hand |
498 rows × 7 columns
Some useful functions¶
Here we create some useful functions.
Extract Placements and Roles¶
First, we will want a quicker way to look at the placements in the frames. We'll do this by just quickly making a list of the placements in the frame.
"""
==================================
Create a list of placements from a climb
==================================
"""
# Parse the placements
def parse_frames_p(frames):
"""Returns a list of the placement ID's"""
if not frames:
return []
# Find all 'p<number>' patterns
matches = re.findall(r'p(\d+)', frames)
return [int(m) for m in matches]
# Parse the placements, together with role
def parse_frames_pr(frames):
"""Returns a list of tuples containing the placement ID and the role ID"""
if not frames:
return defaultdict()
# Find all 'p<number>' patterns
matches = re.findall(r'p(\d+)r(\d+)', frames)
return [(int(p), int(r)) for p,r in matches]
### Tests
# Let's just take one row from out data frame
test_climb = df_climbs.iloc[0]
# Isolate the frames feature
test_frames = test_climb['frames']
display(parse_frames_p(test_frames))
display(parse_frames_pr(test_frames))
[466, 477, 480, 485, 490, 492, 498, 500, 504, 507, 509, 514]
[(466, 8), (477, 6), (480, 6), (485, 8), (490, 6), (492, 7), (498, 5), (500, 6), (504, 8), (507, 6), (509, 6), (514, 8)]
Creating a per-climb DataFrame¶
Second, we create a dataframe for any specific climb. This will just be useful if we want to map a specific climb to the board.
"""
==================================
Create a dataframe from a climb
==================================
"""
def climb_to_DataFrame(frames):
"""Extract placement IDs, roles, and coordinates from frames string efficiently.
Parameters: a `frames` string. This is a string of the form px_1ry_1px_2ry_2...
- The px_i will tell you that we are dealing with a hold with placement ID x_i.
- The ry_i will tell you that we are dealing with a hold with role ID y_i."""
if not frames or not isinstance(frames, str):
return pd.DataFrame(columns=['placement_id', 'role_id', 'role_name', 'x', 'y', 'set_name'])
# Parse all at once
matches = re.findall(r'p(\d+)r(\d+)', frames)
if not matches:
return pd.DataFrame(columns=['placement_id', 'role_id', 'role_name', 'x', 'y', 'set_name'])
# Convert to numpy arrays
placements = np.array([int(m[0]) for m in matches])
roles = np.array([int(m[1]) for m in matches])
# Create DataFrame
df = pd.DataFrame({
'placement_id': placements,
'role_id': roles
})
# Map role names & colors
df['role_name'] = df['role_id'].map(role_name_map)
df['role_kind'] = df['role_id'].map(role_type_map)
df['led_color'] = df['role_id'].map(role_color_map)
df['set_name'] = df['placement_id'].map(placement_sets)
# Map coordinates (using get with default for safety)
df['(x,y)'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan)))
df['x'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan))[0])
df['y'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan))[1])
# Map set name
df['set_name'] = df['placement_id'].apply(lambda p: placement_sets.get(p, (np.nan, np.nan)))
return df
### Tests
# Let's just take one row from out data frame
test_climb = df_climbs.iloc[10000]
# Isolate the frames feature
test_frames = test_climb['frames']
print(test_climb['climb_name'])
display(climb_to_DataFrame(test_frames))
Ooo La La
| placement_id | role_id | role_name | role_kind | led_color | set_name | (x,y) | x | y | |
|---|---|---|---|---|---|---|---|---|---|
| 0 | 344 | 5 | start | hand | #00FF00 | Wood | (-20, 64) | -20 | 64 |
| 1 | 348 | 8 | foot | foot | #FF00FF | Wood | (-24, 4) | -24 | 4 |
| 2 | 352 | 5 | start | hand | #00FF00 | Wood | (-24, 52) | -24 | 52 |
| 3 | 362 | 6 | middle | hand | #0000FF | Wood | (-28, 96) | -28 | 96 |
| 4 | 366 | 8 | foot | foot | #FF00FF | Wood | (-32, 20) | -32 | 20 |
| 5 | 367 | 8 | foot | foot | #FF00FF | Wood | (-32, 28) | -32 | 28 |
| 6 | 369 | 6 | middle | hand | #0000FF | Wood | (-32, 68) | -32 | 68 |
| 7 | 371 | 6 | middle | hand | #0000FF | Wood | (-32, 108) | -32 | 108 |
| 8 | 372 | 7 | finish | hand | #FF0000 | Wood | (-32, 116) | -32 | 116 |
| 9 | 379 | 8 | foot | foot | #FF00FF | Wood | (-40, 4) | -40 | 4 |
| 10 | 382 | 6 | middle | hand | #0000FF | Wood | (-40, 76) | -40 | 76 |
| 11 | 386 | 8 | foot | foot | #FF00FF | Wood | (-44, 16) | -44 | 16 |
| 12 | 388 | 8 | foot | foot | #FF00FF | Wood | (-44, 56) | -44 | 56 |
| 13 | 403 | 8 | foot | foot | #FF00FF | Wood | (-52, 32) | -52 | 32 |
| 14 | 603 | 7 | finish | hand | #FF0000 | Plastic | (-24, 116) | -24 | 116 |
| 15 | 615 | 6 | middle | hand | #0000FF | Plastic | (-32, 76) | -32 | 76 |
| 16 | 617 | 6 | middle | hand | #0000FF | Plastic | (-32, 92) | -32 | 92 |
Visualizing the climb from the data¶
We'll use our climb_to_DataFrame function and then just map the data using a scatter plot overlayed on the image of the TB2.
"""
==================================
Mapping frames to the boad
==================================
"""
def map_climb(climb_name, climb_frames):
# Create figure
fig,ax = plt.subplots(figsize=(16,14))
# Show board image as background
ax.imshow(board_img, extent=[x_min,x_max, y_min, y_max], aspect='auto')
df = climb_to_DataFrame(climb_frames)
# Create heatmap using scatter (only at hold positions)
scatter = ax.scatter(
df['x'],
df['y'],
c=df['led_color'],
alpha=0.85,
edgecolors='black',
linewidths=0.5
)
# Labels
ax.set_title(climb_name)
return fig, ax
df_climbs.iloc[10000]['frames']
'p344r5p348r8p352r5p362r6p366r8p367r8p369r6p371r6p372r7p379r8p382r6p386r8p388r8p403r8p603r7p615r6p617r6'
test_climb = df_climbs.iloc[10000]
map_climb(test_climb['climb_name'], test_climb['frames'])
plt.savefig('../images/02_hold_stats/Ooo_La_La.png', dpi=150, bbox_inches='tight')
plt.show()
One can also use the above function to figure out what hold is what by doing something like map_climb('', p370r6). We'll do one better, and display the placement ID on the hold.
Which placement ID corresponds to which hold?¶
"""
==================================
Mapping placement IDs to the board
==================================
"""
def map_hold_labels(placements):
"""
Map a climb with placement IDs displayed as text labels instead of colors.
"""
# Make our placements into string
placements = [str(p) for p in placements]
# Make an associated frame
frames = 'p'
frames += 'r7p'.join(placements)
frames += 'r7'
# Create figure
fig, ax = plt.subplots(figsize=(16, 14))
# Make a dataframe
df = climb_to_DataFrame(frames)
# Show board image as background
ax.imshow(board_img, extent=[x_min, x_max, y_min, y_max], aspect='auto')
# Plot text labels instead of scatter points
for _, row in df.iterrows():
ax.text(
row['x'],
row['y'],
str(row['placement_id']), # The text to display
ha='center', # Horizontal alignment
va='center', # Vertical alignment
fontsize=10,
fontweight='bold',
color='white',
bbox=dict(
boxstyle='circle,pad=0.3', # Circle background
alpha=0.2,
edgecolor='white',
linewidth=1
)
)
# Labels
ax.set_title('', fontsize=16)
return fig, ax
def map_single_hold_label(placement, role=6):
"""
Map a single hold with its placement ID displayed.
"""
frame_string = f"p{placement}r{role}"
return map_hold_labels(f"Placement {placement}", frame_string)
# Test it
fig, ax = map_hold_labels([369, 420])
fig.savefig('../images/02_hold_stats/placements_369_420.png', dpi=150, bbox_inches='tight')
plt.show()
Let's see how the whole board looks.
placements = df_placements['placement_id'].tolist()
fig, ax = map_hold_labels(placements)
fig.savefig('../images/02_hold_stats/all_placement_ids.png', dpi=150, bbox_inches='tight')
plt.show()
Holds Heatmaps¶
Here we produce a heatmap of the holds on the Tension Board 2 Mirror. We will, under some restrictions if necessary, count how many times a hold appears in the list of unique climbs. We then use this information to create a heat map (subject to those restrictions -- e.g., grade, grade range, hold type, etc.).
We use the usage_count (or hand_usage_count/foot_usage_count/start_usage_count) to create a heatmap overlayed on an image of the TB2 12x12, potentially with a specified grade.
"""
==================================
Holds heatmap
==================================
"""
def plot_heatmap(boulder_name=None, grade_range=None, hold_type='all', df_source=df_climbs, board_image=board_img, title_suffix=""):
"""
Plots a hold usage heatmap for a specific grade, grade range, and hold type.
Parameters:
-----------
boulder_name : str, optional
Specific boulder name to filter (e.g., '6b/V4', '7a/V6').
grade_range : tuple, optional
Range of numeric difficulties (e.g., (16, 18) for V3-V4).
hold_type : str, optional
Type of hold to visualize. Options: 'all', 'hand', 'foot', 'start', 'middle', 'finish'.
df_source : DataFrame
The source dataframe containing climb data.
board_image : array
The board image to overlay.
title_suffix : str
Extra text for the title.
"""
# 1. Define Role Mappings (TB2: 5-8)
role_map = {
'all': {5, 6, 7, 8},
'hand': {5, 6, 7},
'foot': {8},
'start': {5},
'middle': {6},
'finish': {7}
}
if hold_type not in role_map:
print(f"Invalid hold_type '{hold_type}'. Use: {list(role_map.keys())}")
return
allowed_roles = role_map[hold_type]
# 2. Filter Data by Grade
if boulder_name:
df_filtered = df_source[df_source['boulder_grade'] == boulder_name]
grade_label = boulder_name
elif grade_range:
min_diff, max_diff = grade_range
df_filtered = df_source[
(df_source['display_difficulty'] >= min_diff) &
(df_source['display_difficulty'] <= max_diff)
]
# Create readable label from boulder_name range
min_name = df_filtered.groupby('display_difficulty')['boulder_grade'].first().get(min_diff, f"V{min_diff-10}")
max_name = df_filtered.groupby('display_difficulty')['boulder_grade'].first().get(max_diff, f"V{max_diff-10}")
grade_label = f"{min_name} to {max_name}"
else:
df_filtered = df_source
grade_label = "All Grades"
if df_filtered.empty:
print(f"No climbs found for: {boulder_name or grade_range}")
return
# 3. Count Placement Usage (Filtered by Role)
placement_counts = {}
for frames in df_filtered['frames'].dropna().unique():
matches = re.findall(r'p(\d+)r(\d+)', frames)
for p_str, r_str in matches:
p_id = int(p_str)
r_id = int(r_str)
if r_id in allowed_roles:
placement_counts[p_id] = placement_counts.get(p_id, 0) + 1
# 4. Prepare Data for Plotting
plot_data = []
for pid, count in placement_counts.items():
if pid in placement_coordinates:
x, y = placement_coordinates[pid]
plot_data.append({'x': x, 'y': y, 'count': count})
if not plot_data:
print(f"No placements found for hold_type '{hold_type}' in this grade range.")
return
df_plot = pd.DataFrame(plot_data)
# 5. Plot
fig, ax = plt.subplots(figsize=(16, 14))
ax.imshow(board_image, extent=[x_min, x_max, y_min, y_max], aspect='auto')
max_count = df_plot['count'].max()
size_scale = 20 + 200 * (df_plot['count'] / max_count if max_count > 0 else 0)
scatter = ax.scatter(
df_plot['x'],
df_plot['y'],
c=df_plot['count'],
s=size_scale,
cmap='plasma',
alpha=0.8,
edgecolors='black',
linewidths=0.5
)
ax.set_xlabel('X Position (inches)')
ax.set_ylabel('Y Position (inches)')
title = f"{hold_type.capitalize()} Hold Usage - {grade_label} {title_suffix}".strip()
ax.set_title(title, fontsize=16)
cbar = plt.colorbar(scatter, ax=ax, shrink=0.5)
cbar.set_label('Usage Count')
return fig, ax
# All grade, all holds
fig, ax = plot_heatmap()
fig.savefig('../images/02_hold_stats/all_holds_all_grades_heatmap.png', dpi=150, bbox_inches='tight')
plt.show()
# Specific boulder_name
fig, ax = plot_heatmap(boulder_name='6b/V4')
fig.savefig('../images/02_hold_stats/all_holds_6b_V4_heatmap.png', dpi=150, bbox_inches='tight')
fig, ax = plot_heatmap(boulder_name='7a/V6')
fig.savefig('../images/02_hold_stats/all_holds_7a_V6_heatmap.png', dpi=150, bbox_inches='tight')
# Specific boulder_name + hold type
fig, ax = plot_heatmap(boulder_name='6b/V4', hold_type='start')
fig.savefig('../images/02_hold_stats/start_holds_6b_V4_heatmap.png', dpi=150, bbox_inches='tight')
fig, ax = plot_heatmap(boulder_name='7a+/V7', hold_type='foot')
fig.savefig('../images/02_hold_stats/foot_holds_7a+_V7_heatmap.png', dpi=150, bbox_inches='tight')
# Grade range (still works, now shows boulder_name in label)
fig, ax = plot_heatmap(grade_range=(18, 20), hold_type='hand')
fig.savefig('../images/02_hold_stats/hand_holds_18-20_heatmap.png', dpi=150, bbox_inches='tight')
# All grades, specific hold type
fig, ax = plot_heatmap(hold_type='finish')
fig.savefig('../images/02_hold_stats/finish_holds_all_grades_heatmap.png', dpi=150, bbox_inches='tight')
Some other hold stats¶
Default holds¶
Each hold has a default role. Let's see the default roles.
holds_string = ''.join([f"p{pid}r{rid}" for pid, rid in zip(df_placements['placement_id'], df_placements['default_role_id'])])
fig, ax = map_climb('Default Roles', holds_string)
fig.savefig('../images/02_hold_stats/default_holds.png')
plt.show()
All of the green, blue, or red holds are hand holds, and the bottom purple holds are foot holds by default. Let's do a count of how many of each we have.
df_placements.groupby(['default_role_id']).size().reset_index(name='usage_count')
| default_role_id | usage_count | |
|---|---|---|
| 0 | 5 | 75 |
| 1 | 6 | 353 |
| 2 | 7 | 17 |
| 3 | 8 | 53 |
Plastic vs. Wood¶
"""
==================================
Plastic vs wood analysis
==================================
Using df_placements['set_name'] to determine material.
"""
# Ensure we have usage counts in df_placements (recalculating to be safe)
from collections import defaultdict
# Initialize counters
placement_usage = defaultdict(int)
hand_usage = defaultdict(int)
foot_usage = defaultdict(int)
start_usage = defaultdict(int)
finish_usage = defaultdict(int)
# Roles: Hand (0,1,2,5,6,7), Foot (3,8), Start (0,5), Finish (2,7)
hand_roles = {0, 1, 2, 5, 6, 7}
foot_roles = {3, 8}
start_roles = {0, 5}
finish_roles = {2, 7}
# Iterate over unique frames
unique_frames = df_climbs['frames'].dropna().unique()
for frames in unique_frames:
matches = re.findall(r'p(\d+)r(\d+)', frames)
for p_str, r_str in matches:
p_id = int(p_str)
r_id = int(r_str)
placement_usage[p_id] += 1
if r_id in hand_roles:
hand_usage[p_id] += 1
if r_id in foot_roles:
foot_usage[p_id] += 1
if r_id in start_roles:
start_usage[p_id] += 1
if r_id in finish_roles:
finish_usage[p_id] += 1
# Map back to df_placements
df_placements['usage_count'] = df_placements['placement_id'].map(placement_usage).fillna(0).astype(int)
df_placements['hand_usage_count'] = df_placements['placement_id'].map(hand_usage).fillna(0).astype(int)
df_placements['foot_usage_count'] = df_placements['placement_id'].map(foot_usage).fillna(0).astype(int)
df_placements['start_usage_count'] = df_placements['placement_id'].map(start_usage).fillna(0).astype(int)
df_placements['finish_usage_count'] = df_placements['placement_id'].map(finish_usage).fillna(0).astype(int)
print("Usage counts updated in df_placements.")
Usage counts updated in df_placements.
"""
==================================
Aggregate use by material
==================================
"""
# Group by set_name (Plastic vs Wood)
material_stats = df_placements.groupby('set_name').agg(
total_holds=('placement_id', 'count'),
total_usage=('usage_count', 'sum'),
avg_usage_per_hold=('usage_count', 'mean'),
total_hand_usage=('hand_usage_count', 'sum'),
total_foot_usage=('foot_usage_count', 'sum'),
total_start_usage=('start_usage_count', 'sum'),
total_finish_usage=('finish_usage_count', 'sum')
).round(2)
# Calculate usage percentage (relative to total usage of all holds)
total_all_usage = material_stats['total_usage'].sum()
material_stats['pct_of_total_usage'] = (material_stats['total_usage'] / total_all_usage * 100).round(2)
print("### Usage Statistics by Material\n")
display(material_stats)
### Usage Statistics by Material
| total_holds | total_usage | avg_usage_per_hold | total_hand_usage | total_foot_usage | total_start_usage | total_finish_usage | pct_of_total_usage | |
|---|---|---|---|---|---|---|---|---|
| set_name | ||||||||
| Plastic | 256 | 158233 | 618.10 | 110779 | 47454 | 21626 | 18623 | 57.07 |
| Wood | 242 | 119006 | 491.76 | 79706 | 39300 | 18796 | 9283 | 42.93 |
"""
==================================
Visualizing plastic vs wood
==================================
"""
palette = {'Wood': '#8B4513', 'Plastic': '#4169E1'}
materials = ['Wood', 'Plastic']
fig, axes = plt.subplots(2, 3, figsize=(18, 10))
# Total Holds on Board
ax = axes[0, 0]
sns.barplot(
data=material_stats.reset_index(),
x='set_name',
y='total_holds',
hue='set_name',
order=materials,
hue_order=materials,
palette=palette,
legend=False,
ax=ax
)
ax.set_title('Total Holds on Board', fontsize=12)
ax.set_xlabel('')
ax.set_ylabel('Count')
ax.bar_label(ax.containers[0], fontsize=10)
# Total Usage Count
ax = axes[0, 1]
sns.barplot(
data=material_stats.reset_index(),
x='set_name',
y='total_usage',
hue='set_name',
order=materials,
hue_order=materials,
palette=palette,
legend=False,
ax=ax
)
ax.set_title('Total Usage Count', fontsize=12)
ax.set_xlabel('')
ax.set_ylabel('Total Usages')
ax.bar_label(ax.containers[0], fontsize=10)
# Average Usage Per Hold
ax = axes[0, 2]
sns.barplot(
data=material_stats.reset_index(),
x='set_name',
y='avg_usage_per_hold',
hue='set_name',
order=materials,
hue_order=materials,
palette=palette,
legend=False,
ax=ax
)
ax.set_title('Avg Usage Per Hold', fontsize=12)
ax.set_xlabel('')
ax.set_ylabel('Avg Usages')
ax.bar_label(ax.containers[0], fontsize=10)
# Hand Usage
ax = axes[1, 0]
sns.barplot(
data=material_stats.reset_index(),
x='set_name',
y='total_hand_usage',
hue='set_name',
order=materials,
hue_order=materials,
palette=palette,
legend=False,
ax=ax
)
ax.set_title('Total Hand Usage', fontsize=12)
ax.set_xlabel('')
ax.set_ylabel('Count')
ax.bar_label(ax.containers[0], fontsize=10)
# Foot Usage
ax = axes[1, 1]
sns.barplot(
data=material_stats.reset_index(),
x='set_name',
y='total_foot_usage',
hue='set_name',
order=materials,
hue_order=materials,
palette=palette,
legend=False,
ax=ax
)
ax.set_title('Total Foot Usage', fontsize=12)
ax.set_xlabel('')
ax.set_ylabel('Count')
ax.bar_label(ax.containers[0], fontsize=10)
# Start vs Finish Usage
ax = axes[1, 2]
df_start_finish = material_stats[['total_start_usage', 'total_finish_usage']].reset_index().melt(
id_vars='set_name',
var_name='Usage Type',
value_name='Count'
)
sns.barplot(
data=df_start_finish,
x='set_name',
y='Count',
hue='Usage Type',
order=materials,
palette=['#32CD32', '#FF4500'],
ax=ax
)
ax.set_title('Start vs Finish Usage', fontsize=12)
ax.set_xlabel('')
ax.legend(title='')
plt.suptitle('Plastic vs Wood Hold Usage Analysis', fontsize=16, y=1.02)
plt.tight_layout()
plt.savefig('../images/02_hold_stats/plastic_vs_wood_holds.png', dpi=150, bbox_inches='tight')
plt.show()
"""
==================================
Normalized usage (per hold)
==================================
"""
# Calculate hand/foot usage per hold
df_placements['hand_per_hold'] = df_placements['hand_usage_count']
df_placements['foot_per_hold'] = df_placements['foot_usage_count']
normalized_stats = df_placements.groupby('set_name').agg(
avg_hand_per_hold=('hand_usage_count', 'mean'),
avg_foot_per_hold=('foot_usage_count', 'mean'),
avg_start_per_hold=('start_usage_count', 'mean'),
avg_finish_per_hold=('finish_usage_count', 'mean')
).round(2)
print("### Normalized Usage (Average per Hold)\n")
display(normalized_stats)
# Plot normalized
fig, ax = plt.subplots(figsize=(10, 6))
normalized_stats_plot = normalized_stats.reset_index().melt(
id_vars='set_name',
var_name='Usage Type',
value_name='Avg per Hold'
)
sns.barplot(
data=normalized_stats_plot,
x='Usage Type',
y='Avg per Hold',
hue='set_name',
palette=palette,
ax=ax
)
ax.set_title('Normalized Usage: Wood vs Plastic (Avg per Hold)', fontsize=14)
ax.set_xlabel('')
ax.legend(title='Material')
ax.tick_params(axis='x', rotation=15)
plt.tight_layout()
plt.savefig('../images/02_hold_stats/plastic_vs_wood_normalized.png', dpi=150, bbox_inches='tight')
plt.show()
### Normalized Usage (Average per Hold)
| avg_hand_per_hold | avg_foot_per_hold | avg_start_per_hold | avg_finish_per_hold | |
|---|---|---|---|---|
| set_name | ||||
| Plastic | 432.73 | 185.37 | 84.48 | 72.75 |
| Wood | 329.36 | 162.40 | 77.67 | 38.36 |
So there are more plastic holds, and it seems as though the plastic holds are used far more than the wood on average.
Conclusion¶
The main result of this notebook is a geometric picture of the board: some holds and regions appear far more often than others, and hand/foot usage is not distributed uniformly across the wall.
That matters for modelling. A hold is not only a location; it is also part of an empirical usage pattern. In the next notebook I build on this by assigning difficulty information to individual holds, first globally and then by role and angle.