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¶

  1. Which holds appear most often?
  2. Which regions of the board are used most heavily?
  3. How different are hand and foot usage patterns?
  4. 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¶

  1. Setup and Imports
  2. Some Useful Functions
  3. Holds Heatmaps
  4. Some other hold stats
  5. Conclusion

Setup and Imports¶

In [1]:
"""
==================================
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)
In [2]:
"""
==================================
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.

In [3]:
len(df_climbs)
Out[3]:
43440

So we have about 43k climbs, but this has some repetition to due to angle.

In [4]:
len(df_climbs['frames'].unique())
Out[4]:
26209

Great, so 26k unique different routes to analyze. Let's see what our df_placements DataFrame looks like.

In [5]:
# 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)
Out[5]:
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.

In [6]:
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
In [7]:
"""
==================================
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
In [8]:
df_placements['default_role_type'] = df_placements['default_role_id'].map(role_type_map)

df_placements
Out[8]:
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.

In [9]:
"""
==================================
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]
In [10]:
### 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.

In [11]:
"""
==================================
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
In [12]:
### 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.

In [13]:
"""
==================================
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
In [14]:
df_climbs.iloc[10000]['frames']
Out[14]:
'p344r5p348r8p352r5p362r6p366r8p367r8p369r6p371r6p372r7p379r8p382r6p386r8p388r8p403r8p603r7p615r6p617r6'
In [15]:
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()
No description has been provided for this image

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?¶

In [16]:
"""
==================================
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()
No description has been provided for this image

Let's see how the whole board looks.

In [17]:
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()
No description has been provided for this image

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.

In [18]:
"""
==================================
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
In [19]:
# 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()
No description has been provided for this image
In [20]:
# 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')
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image
No description has been provided for this image

Some other hold stats¶

Default holds¶

Each hold has a default role. Let's see the default roles.

In [21]:
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()
No description has been provided for this image

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.

In [22]:
df_placements.groupby(['default_role_id']).size().reset_index(name='usage_count')
Out[22]:
default_role_id usage_count
0 5 75
1 6 353
2 7 17
3 8 53

Plastic vs. Wood¶

In [23]:
"""
==================================
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.
In [24]:
"""
==================================
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
In [25]:
"""
==================================
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()
No description has been provided for this image
In [26]:
"""
==================================
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
No description has been provided for this image

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.