Tension Board 2: Hold Difficulty Analysis¶

We continue on with our hold analysis, except we will solely be interested in computing the difficulty of each hold.

Recall some of the following findings.

  • TB2 Mirror has has layout_id 10, and has two sets: wood and plastic. These have set_id 12 and 13 respectively.
  • the frame feature of a climb determines the climb: it looks something like p3r4p29r2p59r1p65r2p75r3p89r2p157r4p158r4. A substring pXrY tells us the placement (placement_id=X) and the role (whether it is a start, finish, foot, or middle hold) comes from the placement_role_id=Y. The role will also tell us which color to use if we plot our climb against the board.
  • the holes table will tell us which placement_id goes where on the (x,y) coordinate system. It also tells us the ID of its mirror image, which let's us unravel the placement_id of its mirror image.

Output¶

The final products are hold-level difficulty scores saved to CSV files. These scores encode, for each placement, the average difficulty of climbs that use that hold. The scores are computed per-angle, per-role, and also aggregated. A Bayesian smoothing step shrinks noisy estimates for rarely-used holds toward the global mean, and mirror averaging stabilizes scores across symmetric left-right hold pairs.

Notebook Structure¶

  1. Setup and Imports
  2. Hold Usage DataFrame
  3. Difficulty Score
  4. Visualization
  5. Conclusion

Setup and Imports¶

In [1]:
"""
==================================
Setup and Imports
==================================
"""


# 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 os

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]:
"""
==================================
Query our data from the DB
==================================

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,
    p_mirror.id AS mirror_placement_id
FROM placements p
JOIN holes h ON p.hole_id = h.id
JOIN sets s ON p.set_id = s.id
LEFT JOIN holes h_mirror ON h.mirrored_hole_id = h_mirror.id
LEFT JOIN placements p_mirror ON p_mirror.hole_id = h_mirror.id AND p_mirror.layout_id = p.layout_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)

# Save placements csv in data (for other things later on)
df_placements.to_csv('../data/placements.csv')

We've added a column for the mirror of a hold. Let's take a look at df_placements.

In [3]:
display(df_placements)
placement_id x y default_role_id set_id set_name mirror_placement_id
0 672 -64 4 8 13 Plastic 794
1 673 -64 20 8 13 Plastic 795
2 419 -64 28 5 12 Wood 537
3 420 -64 36 5 12 Wood 538
4 421 -64 44 5 12 Wood 539
... ... ... ... ... ... ... ...
493 798 64 108 6 13 Plastic 676
494 545 64 116 6 12 Wood 427
495 799 64 124 6 13 Plastic 677
496 800 64 132 6 13 Plastic 678
497 801 64 140 7 13 Plastic 679

498 rows × 7 columns

In [4]:
# Role definitions
ROLE_DEFINITIONS = {
    'start': 5,
    'middle': 6,
    'finish': 7,
    'foot': 8
}

HAND_ROLES = ['start', 'middle', 'finish']
FOOT_ROLES = ['foot']
ROLE_TYPES = ['start', 'middle', 'finish', 'hand', 'foot']

MATERIAL_PALETTE = {'Wood': '#8B4513', 'Plastic': '#4169E1'}

def get_role_type(role_id):
    """Map role_id to role_type string."""
    for role_type, rid in ROLE_DEFINITIONS.items():
        if role_id == rid:
            return role_type
    return 'unknown'
In [5]:
# Placement Data
# Build placement_coordinates dict
placement_coordinates = {
    row['placement_id']: (row['x'], row['y'])
    for _, row in df_placements.iterrows()
}

# Build mirror mapping
placement_to_mirror = {
    row['placement_id']: int(row['mirror_placement_id'])
    for _, row in df_placements.iterrows()
    if pd.notna(row['mirror_placement_id'])
}
In [6]:
get_role_type(7)
Out[6]:
'finish'
In [7]:
## Boundary conditions
x_min, x_max = -68, 68
y_min, y_max = 0, 144

Hold Usage DataFrame¶

In [8]:
"""
==================================
Hold Usage DataFrame
==================================

Explodes climb frames into individual hold usages.
"""

records = []

for _, row in df_climbs.iterrows():
    frames = row['frames']
    if not isinstance(frames, str):
        continue
    
    matches = re.findall(r'p(\d+)r(\d+)', frames)
    
    for p_str, r_str in matches:
        role_type = get_role_type(int(r_str))
        records.append({
            'placement_id': int(p_str),
            'role_id': int(r_str),
            'role_type': role_type,
            'is_hand': role_type in HAND_ROLES,
            'is_foot': role_type in FOOT_ROLES,
            'difficulty': row['display_difficulty'],
            'angle': row['angle'],
            'climb_uuid': row['uuid']
        })

df_hold_usage = pd.DataFrame(records)

print(f"Built hold usage DataFrame: {len(df_hold_usage):,} records")
print(f"Unique placements: {df_hold_usage['placement_id'].nunique():,}")
print(f"Unique angles: {sorted(df_hold_usage['angle'].unique())}")

print("\nRecords by role type:")
display(df_hold_usage['role_type'].value_counts().to_frame('count'))

print(f"\nHand usages: {df_hold_usage['is_hand'].sum():,}")
print(f"Foot usages: {df_hold_usage['is_foot'].sum():,}")
Built hold usage DataFrame: 475,273 records
Unique placements: 498
Unique angles: [np.int64(0), np.int64(5), np.int64(10), np.int64(15), np.int64(20), np.int64(25), np.int64(30), np.int64(35), np.int64(40), np.int64(45), np.int64(50), np.int64(55), np.int64(60), np.int64(65)]

Records by role type:
count
role_type
middle 206858
foot 155362
start 66693
finish 46360
Hand usages: 319,911
Foot usages: 155,362

Difficulty Score¶

Bayesian Smoothing of Hold Difficulty¶

Raw hold difficulty estimates can be unstable for rarely used holds. To reduce noise, we apply Bayesian smoothing, shrinking hold-level averages toward the global mean difficulty. Frequently used holds remain close to their empirical means, while sparse holds are pulled more strongly toward the overall average.

In [9]:
"""
==================================
Bayesian Smoothing
==================================
"""

SMOOTHING_M = 20

def bayesian_smooth(mean_col, count_col, global_mean, m=SMOOTHING_M):
    """
    Bayesian smoothing toward the global mean.
    """
    return (count_col * mean_col + m * global_mean) / (count_col + m)

GLOBAL_DIFFICULTY_MEAN = df_hold_usage['difficulty'].mean()
print(f"Global difficulty mean: {GLOBAL_DIFFICULTY_MEAN:.3f}")
Global difficulty mean: 19.439

Raw Difficulty Score¶

In [10]:
"""
==================================
Raw difficulty score (averged & smoothed)
==================================


Average difficulty of all climbs that use this hold, plus a Bayesian-smoothed
version that is more stable for low-usage holds.
"""

raw_scores = df_hold_usage.groupby('placement_id').agg(
    raw_difficulty=('difficulty', 'mean'),
    usage_count=('climb_uuid', 'count'),
    climbs_count=('climb_uuid', 'nunique')
)

raw_scores['raw_difficulty_smoothed'] = bayesian_smooth(
    raw_scores['raw_difficulty'],
    raw_scores['usage_count'],
    GLOBAL_DIFFICULTY_MEAN
)

raw_scores = raw_scores.round(2)

print("### Top 10 Hardest Holds (Raw)\n")
display(raw_scores.sort_values('raw_difficulty', ascending=False).head(10))

print("\n### Top 10 Easiest Holds (Raw)\n")
display(raw_scores.sort_values('raw_difficulty', ascending=True).head(10))

print("\n### Example of Raw vs Smoothed Difficulty\n")
display(raw_scores[['raw_difficulty', 'raw_difficulty_smoothed', 'usage_count']].head(10))
### Top 10 Hardest Holds (Raw)

raw_difficulty usage_count climbs_count raw_difficulty_smoothed
placement_id
800 24.31 156 111 23.76
785 23.99 85 72 23.12
678 23.78 129 112 23.20
789 23.67 193 150 23.28
763 23.58 325 245 23.34
524 23.58 127 95 23.02
514 23.56 187 147 23.16
765 23.49 248 192 23.19
671 23.45 136 109 22.93
624 23.42 220 156 23.09
### Top 10 Easiest Holds (Raw)

raw_difficulty usage_count climbs_count raw_difficulty_smoothed
placement_id
462 15.25 2497 1011 15.28
344 15.27 2481 1041 15.31
382 15.46 1429 641 15.51
308 15.95 2819 1247 15.98
362 15.96 1887 873 16.00
372 16.03 1884 748 16.06
480 16.09 2006 903 16.12
500 16.18 1346 627 16.23
490 16.25 2045 783 16.28
561 16.35 2519 1196 16.37
### Example of Raw vs Smoothed Difficulty

raw_difficulty raw_difficulty_smoothed usage_count
placement_id
304 18.07 18.08 2849
305 16.84 16.85 4069
306 19.37 19.38 1285
307 16.37 16.40 2020
308 15.95 15.98 2819
309 16.77 16.80 1638
310 19.04 19.04 2174
311 21.62 21.59 1558
312 20.08 20.07 1056
313 20.49 20.47 1051

Per-Angle Difficulty Score¶

In [11]:
"""
==================================
Per-Angle Difficulty Score
==================================

Computes difficulty score per angle, then aggregates with weighting.
Uses Bayesian-smoothed per-angle difficulty throughout.
"""

# Calculate per-angle scores
angle_scores = df_hold_usage.groupby(['placement_id', 'angle']).agg(
    avg_difficulty=('difficulty', 'mean'),
    usage_count=('climb_uuid', 'count')
).reset_index()

# Apply Bayesian smoothing
angle_scores['avg_difficulty_smoothed'] = bayesian_smooth(
    angle_scores['avg_difficulty'],
    angle_scores['usage_count'],
    GLOBAL_DIFFICULTY_MEAN
)

# Pivot to see angles side-by-side
angle_pivot = angle_scores.pivot_table(
    index='placement_id',
    columns='angle',
    values='avg_difficulty_smoothed',
    aggfunc='mean'
)
angle_pivot.columns = [f'diff_{int(col)}deg' for col in angle_pivot.columns]

# Calculate weighted average using the smoothed per-angle values
weighted_scores = []

for pid in angle_scores['placement_id'].unique():
    df_pid = angle_scores[angle_scores['placement_id'] == pid].copy()

    total_count = df_pid['usage_count'].sum()
    weighted_diff = (
        df_pid['avg_difficulty_smoothed'] * df_pid['usage_count']
    ).sum() / total_count

    weighted_scores.append({
        'placement_id': pid,
        'angle_weighted_difficulty': weighted_diff,
        'angles_used': len(df_pid),
        'min_angle': int(df_pid['angle'].min()),
        'max_angle': int(df_pid['angle'].max()),
        'angle_range': int(df_pid['angle'].max() - df_pid['angle'].min())
    })

df_angle_scores = pd.DataFrame(weighted_scores).set_index('placement_id')

print("### Per-Angle Difficulty Analysis (Sample)\n")
display(angle_pivot.join(df_angle_scores).head(15))

print(f"\nAngles used per hold:")
print(df_angle_scores['angles_used'].describe())
### Per-Angle Difficulty Analysis (Sample)

diff_0deg diff_5deg diff_10deg diff_15deg diff_20deg diff_25deg diff_30deg diff_35deg diff_40deg diff_45deg diff_50deg diff_55deg diff_60deg diff_65deg angle_weighted_difficulty angles_used min_angle max_angle angle_range
placement_id
304 14.358313 15.379967 15.437664 14.993775 15.850295 16.527804 17.635693 17.677584 19.684152 19.417567 17.745105 20.527522 19.074385 18.479846 18.272045 14 0 65 65
305 13.824616 14.968359 15.341517 14.622970 15.019134 15.380159 16.615851 16.468835 18.144765 17.809557 16.420702 19.316732 18.639216 19.712892 17.037214 14 0 65 65
306 17.003751 16.962131 17.543101 16.864094 16.685296 17.743945 18.545905 18.943467 20.342349 20.741804 19.836485 19.757155 20.151057 19.004074 19.561578 14 0 65 65
307 14.831166 15.781233 15.434490 14.507607 14.584466 14.951095 16.012358 16.364832 17.924284 17.818614 16.288553 18.271960 18.867846 18.242536 16.790522 14 0 65 65
308 14.425710 15.144440 14.853565 14.127614 14.312753 14.809036 15.672556 15.634699 17.087853 17.141442 15.996081 18.898774 19.514074 19.187932 16.275611 14 0 65 65
309 15.004230 16.765644 15.807055 15.194230 15.145516 15.512663 16.663910 16.497540 18.303862 17.861202 17.118220 19.433386 19.185767 19.132030 17.192749 14 0 65 65
310 16.304230 16.529814 16.234484 15.744708 16.570616 17.140874 18.129187 18.932218 20.329788 20.344834 19.488945 20.780951 20.207156 19.516201 19.181602 14 0 65 65
311 17.035678 18.081880 17.255258 17.356511 17.985233 19.114605 20.076222 20.631045 22.512118 23.174483 23.149385 22.373451 19.724657 19.511854 21.543274 14 0 65 65
312 17.963782 18.225573 17.948158 17.410170 17.125092 18.117806 18.570877 19.364461 20.993642 21.053398 20.859238 20.962119 19.990114 19.631419 20.155470 14 0 65 65
313 18.139857 18.201573 18.014995 17.861152 17.421236 18.117845 19.043472 19.417304 21.346952 21.666439 21.689836 20.374740 19.524275 19.444211 20.529345 14 0 65 65
314 17.897573 19.020921 17.747237 17.991578 18.009954 18.654573 19.050584 19.822298 21.168691 21.706622 21.447345 20.686023 19.893935 NaN 20.507950 13 0 60 60
315 19.001029 19.171483 18.531616 18.736023 19.041098 19.869117 20.074604 21.028343 22.580264 23.036636 22.408137 20.499175 19.584411 19.893935 21.802208 14 0 65 65
316 16.676020 17.507135 17.353101 16.577980 16.431615 17.215636 17.168416 16.812934 18.933639 19.079067 17.256427 20.970546 19.395910 19.108221 18.036443 14 0 65 65
317 16.334601 18.084241 16.793476 17.288569 17.428920 17.626920 19.305687 19.422889 21.043804 20.959964 19.690978 20.853590 19.295988 19.058981 19.716329 14 0 65 65
318 16.128414 17.344858 16.515378 15.921409 16.419418 17.770121 18.374653 18.677883 20.553229 20.178609 20.228082 20.133268 20.307124 18.703026 19.286570 14 0 65 65
Angles used per hold:
count    498.000000
mean      13.124498
std        1.344398
min        7.000000
25%       13.000000
50%       14.000000
75%       14.000000
max       14.000000
Name: angles_used, dtype: float64

Per-Role Difficulty Score¶

In [12]:
"""
==================================
Per-Role Difficulty Score
==================================

Individual roles (start, middle, finish, foot) AND aggregate (hand).
All exported difficulty values are Bayesian-smoothed.
"""

# Individual role scores
role_scores = df_hold_usage.groupby(['placement_id', 'role_type']).agg(
    avg_difficulty=('difficulty', 'mean'),
    usage_count=('climb_uuid', 'count')
).reset_index()

# Apply Bayesian smoothing
role_scores['avg_difficulty_smoothed'] = bayesian_smooth(
    role_scores['avg_difficulty'],
    role_scores['usage_count'],
    GLOBAL_DIFFICULTY_MEAN
)

# Pivot for individual roles
role_pivot = role_scores.pivot_table(
    index='placement_id',
    columns='role_type',
    values='avg_difficulty_smoothed',
    aggfunc='mean'
)
role_pivot.columns = [f'diff_as_{col}' for col in role_pivot.columns]

# Usage counts per individual role
role_counts = role_scores.pivot_table(
    index='placement_id',
    columns='role_type',
    values='usage_count',
    aggfunc='sum',
    fill_value=0
)
role_counts.columns = [f'uses_as_{col}' for col in role_counts.columns]

# Aggregate hand difficulty
hand_usage = df_hold_usage[df_hold_usage['is_hand']].groupby('placement_id').agg(
    diff_as_hand_raw=('difficulty', 'mean'),
    uses_as_hand=('climb_uuid', 'count')
)

hand_usage['diff_as_hand'] = bayesian_smooth(
    hand_usage['diff_as_hand_raw'],
    hand_usage['uses_as_hand'],
    GLOBAL_DIFFICULTY_MEAN
)

hand_usage = hand_usage[['diff_as_hand', 'uses_as_hand']]

# Combine role tables
df_role_analysis = role_pivot.join(role_counts).join(hand_usage).round(2)

cols_order = [
    'diff_as_start', 'uses_as_start',
    'diff_as_middle', 'uses_as_middle',
    'diff_as_finish', 'uses_as_finish',
    'diff_as_hand', 'uses_as_hand',
    'diff_as_foot', 'uses_as_foot'
]
cols_order = [c for c in cols_order if c in df_role_analysis.columns]
df_role_analysis = df_role_analysis[cols_order]

print("### Role-Specific Difficulty Scores (Sample)\n")
display(df_role_analysis.head(15))

print("\n### Holds Used as Both Hand and Foot\n")
dual_use = df_role_analysis[
    df_role_analysis['diff_as_hand'].notna() &
    df_role_analysis['diff_as_foot'].notna()
].copy()

if len(dual_use) > 0:
    dual_use['hand_minus_foot'] = dual_use['diff_as_hand'] - dual_use['diff_as_foot']
    display(
        dual_use[['diff_as_hand', 'diff_as_foot', 'hand_minus_foot']]
        .sort_values('hand_minus_foot', ascending=False)
        .head(15)
    )
### Role-Specific Difficulty Scores (Sample)

diff_as_start uses_as_start diff_as_middle uses_as_middle diff_as_finish uses_as_finish diff_as_hand uses_as_hand diff_as_foot uses_as_foot
placement_id
304 21.39 23 19.74 1 NaN 0 21.49 24 18.03 2825
305 17.09 3110 16.11 254 16.25 25 16.97 3389 16.35 680
306 19.48 623 19.50 589 19.27 1 19.49 1213 17.91 72
307 18.72 53 16.45 1677 17.08 12 16.49 1742 16.08 278
308 17.91 34 16.08 2613 13.63 116 15.95 2763 18.11 56
309 NaN 0 17.20 1086 16.12 550 16.80 1636 19.31 2
310 NaN 0 19.51 1 NaN 0 19.51 1 19.04 2173
311 21.00 40 21.97 1308 19.27 1 21.96 1349 19.18 209
312 20.32 14 20.10 988 19.47 4 20.11 1006 19.22 50
313 19.91 4 20.47 1033 19.38 2 20.47 1039 19.87 12
314 NaN 0 20.50 814 19.54 7 20.49 821 19.78 8
315 NaN 0 22.16 575 20.02 21 22.11 596 19.22 2
316 NaN 0 17.54 624 17.01 31 17.44 655 19.13 2
317 19.95 8 19.51 1 19.56 1 20.06 10 19.45 739
318 21.54 16 19.61 1 NaN 0 21.58 17 19.02 1384
### Holds Used as Both Hand and Foot

diff_as_hand diff_as_foot hand_minus_foot
placement_id
540 21.50 16.57 4.93
493 23.15 18.23 4.92
437 22.22 17.81 4.41
375 23.44 19.12 4.32
505 22.46 18.17 4.29
716 21.29 17.01 4.28
403 20.48 16.44 4.04
336 21.80 17.85 3.95
484 20.88 16.94 3.94
678 23.25 19.42 3.83
671 22.98 19.27 3.71
454 21.87 18.17 3.70
387 22.69 19.02 3.67
765 23.19 19.58 3.61
549 20.35 16.75 3.60

Per-Role Per-Angle Difficulty Score¶

In [13]:
"""
==================================
Per-Role Per-Angle Difficulty Score
==================================


Granular scores: placement_id × role_type × angle
Includes both individual roles AND aggregate hand.
All downstream tables use the smoothed difficulty values.
"""

# Individual roles per angle
role_angle_scores = df_hold_usage.groupby(['placement_id', 'role_type', 'angle']).agg(
    avg_difficulty=('difficulty', 'mean'),
    usage_count=('climb_uuid', 'count')
).reset_index()

role_angle_scores['avg_difficulty_smoothed'] = bayesian_smooth(
    role_angle_scores['avg_difficulty'],
    role_angle_scores['usage_count'],
    GLOBAL_DIFFICULTY_MEAN
)

# Aggregate hand per angle
hand_angle_scores = df_hold_usage[df_hold_usage['is_hand']].groupby(['placement_id', 'angle']).agg(
    avg_difficulty=('difficulty', 'mean'),
    usage_count=('climb_uuid', 'count')
).reset_index()

hand_angle_scores['avg_difficulty_smoothed'] = bayesian_smooth(
    hand_angle_scores['avg_difficulty'],
    hand_angle_scores['usage_count'],
    GLOBAL_DIFFICULTY_MEAN
)
hand_angle_scores['role_type'] = 'hand'

# Combine all
df_role_angle = pd.concat([role_angle_scores, hand_angle_scores], ignore_index=True)

print(f"Total role-angle records: {len(df_role_angle):,}")
print("\nBreakdown by role_type:")
display(df_role_angle.groupby('role_type').size().to_frame('count'))

print("\n### Per-Role Per-Angle Difficulty Scores (Sample)\n")
display(df_role_angle.head(20))
Total role-angle records: 19,330

Breakdown by role_type:
count
role_type
finish 1843
foot 4457
hand 5606
middle 4836
start 2588
### Per-Role Per-Angle Difficulty Scores (Sample)

placement_id role_type angle avg_difficulty usage_count avg_difficulty_smoothed
0 304 foot 0 11.684461 38 14.358313
1 304 foot 5 12.373548 27 15.379967
2 304 foot 10 13.770594 48 15.437664
3 304 foot 15 13.983581 88 14.993775
4 304 foot 20 15.404871 189 15.790877
5 304 foot 25 16.192658 196 16.493211
6 304 foot 30 17.536083 362 17.635693
7 304 foot 35 17.568185 273 17.695861
8 304 foot 40 19.613719 953 19.610120
9 304 foot 45 19.385296 421 19.387715
10 304 foot 50 17.548184 172 17.745105
11 304 foot 55 21.208078 32 20.527522
12 304 foot 60 18.645859 17 19.074385
13 304 foot 65 16.349211 9 18.479846
14 304 middle 40 25.666700 1 19.735206
15 304 start 20 22.059550 2 19.676897
16 304 start 25 24.000000 1 19.655840
17 304 start 35 15.000000 2 19.035120
18 304 start 40 23.812325 16 21.382495
19 304 start 45 26.000000 2 20.035120

Creating Tables¶

In [14]:
"""
==================================
Role-Specific Tables
==================================

Tables for: start, middle, finish, hand, foot
Each with per-angle columns and overall average.
Uses Bayesian-smoothed role-angle difficulty values.
"""

angles = sorted(df_hold_usage['angle'].unique())
role_tables = {}

for role in ROLE_TYPES:
    df_role = df_role_angle[df_role_angle['role_type'] == role].copy()

    if df_role.empty:
        print(f"No data for role: {role}")
        continue

    pivot = df_role.pivot_table(
        index='placement_id',
        columns='angle',
        values='avg_difficulty_smoothed',
        aggfunc='mean'
    )
    pivot.columns = [f'{role}_diff_{int(col)}deg' for col in pivot.columns]
    pivot[f'{role}_overall_avg'] = pivot.mean(axis=1).round(2)

    usage_pivot = df_role.pivot_table(
        index='placement_id',
        columns='angle',
        values='usage_count',
        aggfunc='sum',
        fill_value=0
    )
    usage_pivot.columns = [f'{role}_uses_{int(col)}deg' for col in usage_pivot.columns]
    pivot[f'{role}_total_uses'] = usage_pivot.sum(axis=1).astype(int)

    role_tables[role] = pivot.join(usage_pivot)

    print(f"\n### {role.upper()} Difficulty by Angle\n")
    display(role_tables[role].head(8))
### START Difficulty by Angle

start_diff_0deg start_diff_5deg start_diff_10deg start_diff_15deg start_diff_20deg start_diff_25deg start_diff_30deg start_diff_35deg start_diff_40deg start_diff_45deg start_diff_50deg start_diff_55deg start_diff_60deg start_diff_65deg start_overall_avg start_total_uses start_uses_0deg start_uses_5deg start_uses_10deg start_uses_15deg start_uses_20deg start_uses_25deg start_uses_30deg start_uses_35deg start_uses_40deg start_uses_45deg start_uses_50deg start_uses_55deg start_uses_60deg start_uses_65deg
placement_id
304 NaN NaN NaN NaN 19.676897 19.655840 NaN 19.035120 21.382495 20.035120 NaN NaN NaN NaN 19.96 23 0 0 0 0 2 1 0 2 16 2 0 0 0 0
305 14.947292 16.297979 15.864334 14.876030 15.159396 15.462498 16.816769 16.677078 18.359877 18.064990 16.678709 19.446097 18.645955 19.791053 16.93 3110 27 17 34 83 179 210 337 327 1059 511 266 30 19 11
306 18.041483 18.528867 18.346265 17.934554 17.420282 18.448938 18.430936 18.922249 20.251273 20.738748 19.603760 19.805110 20.053946 19.322997 18.99 623 7 3 5 10 28 34 73 42 259 113 36 4 7 2
307 NaN NaN NaN NaN 18.620735 19.097242 19.004414 19.115526 19.375025 19.779794 18.717167 19.548697 NaN NaN 19.16 53 0 0 0 0 4 2 4 4 26 9 3 1 0 0
308 19.071483 19.370125 19.370125 19.370125 19.370125 19.346316 18.955806 19.077071 18.726291 18.750943 19.171483 NaN 19.323128 NaN 19.16 34 2 1 1 1 1 1 4 3 11 4 2 0 3 0
311 NaN NaN NaN NaN 19.274887 19.370125 19.323860 19.555332 20.885460 20.677633 19.584411 NaN NaN NaN 19.81 40 0 0 0 0 1 1 4 3 22 8 1 0 0 0
312 NaN NaN NaN NaN NaN NaN 19.560602 NaN 20.063308 19.740526 19.560602 NaN NaN NaN 19.73 14 0 0 0 0 0 0 1 0 8 4 1 0 0 0
313 NaN NaN NaN NaN NaN NaN NaN NaN 19.729245 NaN 19.655840 NaN NaN NaN 19.69 4 0 0 0 0 0 0 0 0 3 0 1 0 0 0
### MIDDLE Difficulty by Angle

middle_diff_0deg middle_diff_5deg middle_diff_10deg middle_diff_15deg middle_diff_20deg middle_diff_25deg middle_diff_30deg middle_diff_35deg middle_diff_40deg middle_diff_45deg middle_diff_50deg middle_diff_55deg middle_diff_60deg middle_diff_65deg middle_overall_avg middle_total_uses middle_uses_0deg middle_uses_5deg middle_uses_10deg middle_uses_15deg middle_uses_20deg middle_uses_25deg middle_uses_30deg middle_uses_35deg middle_uses_40deg middle_uses_45deg middle_uses_50deg middle_uses_55deg middle_uses_60deg middle_uses_65deg
placement_id
304 NaN NaN NaN NaN NaN NaN NaN NaN 19.735206 NaN NaN NaN NaN NaN 19.74 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0
305 16.276514 17.298947 17.633520 16.760686 17.318863 18.199786 17.387201 17.965818 17.629325 17.696301 18.702794 19.035120 NaN NaN 17.66 254 12 6 12 20 28 20 37 21 68 22 6 2 0 0
306 18.294462 17.710486 18.812249 18.171598 17.360195 18.035659 18.880869 19.275986 20.333968 20.586812 20.160629 19.705754 19.947914 19.164028 19.03 589 3 6 3 8 31 30 76 62 238 85 30 10 4 3
307 15.026041 16.103842 15.715346 14.816145 14.732889 15.136868 16.081527 16.466949 17.990633 17.824323 16.527698 18.400043 18.969601 18.587747 16.60 1677 22 14 22 49 116 128 165 188 578 255 117 13 6 4
308 14.663612 15.347282 15.022850 14.220563 14.450224 14.956042 15.744466 15.833684 17.154599 17.257273 16.064613 18.898774 19.605567 19.187932 16.31 2613 34 23 35 73 150 161 257 266 846 430 273 29 27 9
309 16.287049 17.332198 16.430065 15.637004 15.694585 16.206502 16.636376 17.190268 18.619260 18.522922 17.165366 19.295004 NaN NaN 17.08 1086 13 7 19 36 57 64 123 102 426 145 81 13 0 0
310 NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.512983 NaN NaN NaN NaN 19.51 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0
311 17.261868 18.511255 17.577625 17.369305 18.348869 19.438541 20.271631 20.883244 22.830996 23.386122 23.723661 22.265804 19.782193 19.703459 20.10 1308 10 6 13 24 56 64 177 106 554 216 63 14 4 1
### FINISH Difficulty by Angle

finish_diff_0deg finish_diff_5deg finish_diff_10deg finish_diff_15deg finish_diff_20deg finish_diff_25deg finish_diff_30deg finish_diff_35deg finish_diff_40deg finish_diff_45deg finish_diff_50deg finish_diff_55deg finish_diff_60deg finish_diff_65deg finish_overall_avg finish_total_uses finish_uses_0deg finish_uses_5deg finish_uses_10deg finish_uses_15deg finish_uses_20deg finish_uses_25deg finish_uses_30deg finish_uses_35deg finish_uses_40deg finish_uses_45deg finish_uses_50deg finish_uses_55deg finish_uses_60deg finish_uses_65deg
placement_id
305 19.170125 18.989173 NaN 18.989173 19.001078 18.553026 19.057847 18.170905 18.532669 18.546081 19.227268 NaN NaN NaN 18.82 25 1 1 0 1 1 4 2 5 5 4 1 0 0 0
306 NaN NaN NaN 19.274887 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.27 1 0 0 0 1 0 0 0 0 0 0 0 0 0 0
307 NaN 18.989173 NaN 18.671483 NaN 19.132030 18.844211 19.084411 18.850984 19.050270 NaN NaN NaN NaN 18.95 12 0 1 0 2 0 1 2 1 3 2 0 0 0 0
308 19.005044 18.555332 18.298967 17.912256 16.518560 16.736968 16.913246 15.710252 16.408560 16.553961 18.079647 NaN 19.060602 NaN 17.48 116 1 3 3 4 12 10 11 17 27 23 4 0 1 0
309 16.367613 18.424897 17.473749 16.863470 15.816039 15.810265 17.296163 16.212085 17.514139 17.301676 18.214182 19.65584 19.185767 19.13203 17.52 550 14 3 9 16 45 48 74 70 129 119 18 1 3 1
311 NaN NaN NaN NaN NaN NaN NaN NaN 19.274887 NaN NaN NaN NaN NaN 19.27 1 0 0 0 0 0 0 0 0 1 0 0 0 0 0
312 NaN NaN NaN NaN NaN NaN NaN NaN 19.466222 NaN NaN NaN NaN NaN 19.47 4 0 0 0 0 0 0 0 0 4 0 0 0 0 0
313 NaN NaN NaN NaN NaN NaN NaN NaN 19.376029 NaN NaN NaN NaN NaN 19.38 2 0 0 0 0 0 0 0 0 2 0 0 0 0 0
### HAND Difficulty by Angle

hand_diff_0deg hand_diff_5deg hand_diff_10deg hand_diff_15deg hand_diff_20deg hand_diff_25deg hand_diff_30deg hand_diff_35deg hand_diff_40deg hand_diff_45deg hand_diff_50deg hand_diff_55deg hand_diff_60deg hand_diff_65deg hand_overall_avg hand_total_uses hand_uses_0deg hand_uses_5deg hand_uses_10deg hand_uses_15deg hand_uses_20deg hand_uses_25deg hand_uses_30deg hand_uses_35deg hand_uses_40deg hand_uses_45deg hand_uses_50deg hand_uses_55deg hand_uses_60deg hand_uses_65deg
placement_id
304 NaN NaN NaN NaN 19.676897 19.655840 NaN 19.035120 21.498285 20.035120 NaN NaN NaN NaN 19.98 24 0 0 0 0 2 1 0 2 17 2 0 0 0 0
305 14.139976 15.318755 15.639000 14.708758 15.217116 15.559423 16.758546 16.622713 18.270364 17.959273 16.664200 19.275093 18.645955 19.791053 16.76 3389 40 24 46 104 208 234 376 353 1132 537 273 32 19 11
306 17.304001 17.167722 17.948776 17.283814 16.870517 17.967297 18.567265 19.072884 20.323977 20.784844 19.965922 19.933019 20.368833 19.084237 18.76 1213 10 9 8 19 59 64 149 104 497 198 66 14 11 5
307 15.026041 15.929447 15.715346 14.708648 14.727128 15.129955 16.063957 16.465378 18.031383 17.891131 16.471549 18.498572 18.969601 18.587747 16.59 1742 22 15 22 51 120 131 171 193 607 266 120 14 6 4
308 14.613480 15.232620 14.853565 14.098304 14.286117 14.780625 15.626444 15.591308 17.068645 17.086101 16.003580 18.898774 19.384724 19.187932 16.19 2763 37 27 39 78 163 172 272 286 884 457 279 29 31 9
309 15.004230 16.765644 15.807055 15.194230 15.145516 15.512663 16.663910 16.497540 18.304390 17.860713 17.118220 19.433386 19.185767 19.132030 16.97 1636 27 10 28 52 102 112 197 172 555 264 99 14 3 1
310 NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.512983 NaN NaN NaN NaN 19.51 1 0 0 0 0 0 0 0 0 0 1 0 0 0 0
311 17.261868 18.511255 17.577625 17.369305 18.318364 19.421617 20.241349 20.870455 22.796329 23.398876 23.709093 22.265804 19.782193 19.703459 20.09 1349 10 6 13 24 57 65 181 109 577 224 64 14 4 1
### FOOT Difficulty by Angle

foot_diff_0deg foot_diff_5deg foot_diff_10deg foot_diff_15deg foot_diff_20deg foot_diff_25deg foot_diff_30deg foot_diff_35deg foot_diff_40deg foot_diff_45deg foot_diff_50deg foot_diff_55deg foot_diff_60deg foot_diff_65deg foot_overall_avg foot_total_uses foot_uses_0deg foot_uses_5deg foot_uses_10deg foot_uses_15deg foot_uses_20deg foot_uses_25deg foot_uses_30deg foot_uses_35deg foot_uses_40deg foot_uses_45deg foot_uses_50deg foot_uses_55deg foot_uses_60deg foot_uses_65deg
placement_id
304 14.358313 15.379967 15.437664 14.993775 15.790877 16.493211 17.635693 17.695861 19.610120 19.387715 17.745105 20.527522 19.074385 18.479846 17.33 2825 38 27 48 88 189 196 362 273 953 421 172 32 17 9
305 17.043884 17.708657 17.566815 16.877590 15.752185 15.994583 16.615302 16.491297 17.673288 17.427524 16.270205 19.481133 19.200840 19.383385 17.39 680 9 7 8 18 39 38 74 69 232 113 53 8 8 4
306 18.807847 19.036792 18.749997 18.391123 18.370056 18.349233 19.020728 18.724023 20.155918 19.323953 19.006541 19.169068 19.151078 19.322506 18.97 72 2 1 2 3 4 5 10 8 26 5 3 1 1 1
307 17.940964 18.870347 18.165965 17.416108 16.726426 16.693951 16.943629 17.217167 17.582950 18.019654 17.200313 18.951467 19.285468 18.953301 17.85 278 7 2 5 9 16 16 43 27 88 38 21 3 1 2
308 18.157193 19.036792 NaN 19.322506 19.194211 18.730905 19.087409 19.519758 18.551021 19.923794 19.023756 NaN 19.745347 NaN 19.12 56 4 1 0 1 2 5 6 3 24 6 2 0 2 0
309 NaN NaN NaN NaN NaN NaN NaN NaN 19.370125 19.370125 NaN NaN NaN NaN 19.37 2 0 0 0 0 0 0 0 0 1 1 0 0 0 0
310 16.304230 16.529814 16.234484 15.744708 16.570616 17.140874 18.129187 18.932218 20.329788 20.343101 19.488945 20.780951 20.207156 19.516201 18.30 2173 21 19 34 64 139 135 250 220 783 358 124 14 9 3
311 19.001078 18.807847 18.631481 18.936490 18.077582 18.520198 19.098722 19.327896 20.200681 19.937756 19.108368 20.080268 19.386497 19.262392 19.17 209 1 2 4 6 17 13 27 22 80 20 10 4 1 2
In [15]:
"""
==================================
Combined Table for Modelling
==================================

Build a single placement-level table used downstream in feature
engineering. The smoothed overall difficulty is exposed under the simple
name `overall_difficulty`, while the raw version is retained as
`overall_difficulty_raw` for reference.
"""

# Start with placement info
df_model_features = df_placements[['placement_id', 'x', 'y', 'set_name', 'default_role_id']].copy()
df_model_features = df_model_features.set_index('placement_id')
df_model_features = df_model_features.rename(columns={
    'set_name': 'material',
    'default_role_id': 'default_role'
})

# Add raw + smoothed overall scores
df_model_features = df_model_features.join(
    raw_scores[['raw_difficulty', 'raw_difficulty_smoothed', 'usage_count', 'climbs_count']],
    how='left'
)

# Add angle scores
df_model_features = df_model_features.join(
    df_angle_scores[['angle_weighted_difficulty', 'angles_used', 'min_angle', 'max_angle', 'angle_range']],
    how='left'
)

# Add per-role tables
for role in ROLE_TYPES:
    if role in role_tables:
        df_model_features = df_model_features.join(role_tables[role], how='left')

# Add aggregate hand / foot scores if missing
extra_role_cols = [c for c in ['diff_as_hand', 'uses_as_hand', 'diff_as_foot', 'uses_as_foot'] if c in df_role_analysis.columns]
missing_extra_cols = [c for c in extra_role_cols if c not in df_model_features.columns]
if missing_extra_cols:
    df_model_features = df_model_features.join(df_role_analysis[missing_extra_cols], how='left')

# Rename for clarity
df_model_features = df_model_features.rename(columns={
    'raw_difficulty': 'overall_difficulty_raw',
    'raw_difficulty_smoothed': 'overall_difficulty'
})

print("### Combined Model Features Table (Before Mirror)\n")
display(df_model_features.head(10))
print(f"\nShape: {df_model_features.shape}")
### Combined Model Features Table (Before Mirror)

x y material default_role overall_difficulty_raw overall_difficulty usage_count climbs_count angle_weighted_difficulty angles_used min_angle max_angle angle_range start_diff_0deg start_diff_5deg start_diff_10deg start_diff_15deg start_diff_20deg start_diff_25deg start_diff_30deg start_diff_35deg start_diff_40deg start_diff_45deg start_diff_50deg start_diff_55deg start_diff_60deg start_diff_65deg start_overall_avg start_total_uses start_uses_0deg start_uses_5deg start_uses_10deg start_uses_15deg start_uses_20deg start_uses_25deg start_uses_30deg start_uses_35deg start_uses_40deg start_uses_45deg start_uses_50deg start_uses_55deg start_uses_60deg start_uses_65deg middle_diff_0deg middle_diff_5deg middle_diff_10deg middle_diff_15deg middle_diff_20deg middle_diff_25deg middle_diff_30deg middle_diff_35deg middle_diff_40deg middle_diff_45deg middle_diff_50deg middle_diff_55deg middle_diff_60deg middle_diff_65deg middle_overall_avg middle_total_uses middle_uses_0deg middle_uses_5deg middle_uses_10deg middle_uses_15deg middle_uses_20deg middle_uses_25deg middle_uses_30deg middle_uses_35deg middle_uses_40deg middle_uses_45deg middle_uses_50deg middle_uses_55deg middle_uses_60deg middle_uses_65deg finish_diff_0deg finish_diff_5deg finish_diff_10deg finish_diff_15deg finish_diff_20deg finish_diff_25deg finish_diff_30deg finish_diff_35deg finish_diff_40deg finish_diff_45deg finish_diff_50deg finish_diff_55deg finish_diff_60deg finish_diff_65deg finish_overall_avg finish_total_uses finish_uses_0deg finish_uses_5deg finish_uses_10deg finish_uses_15deg finish_uses_20deg finish_uses_25deg finish_uses_30deg finish_uses_35deg finish_uses_40deg finish_uses_45deg finish_uses_50deg finish_uses_55deg finish_uses_60deg finish_uses_65deg hand_diff_0deg hand_diff_5deg hand_diff_10deg hand_diff_15deg hand_diff_20deg hand_diff_25deg hand_diff_30deg hand_diff_35deg hand_diff_40deg hand_diff_45deg hand_diff_50deg hand_diff_55deg hand_diff_60deg hand_diff_65deg hand_overall_avg hand_total_uses hand_uses_0deg hand_uses_5deg hand_uses_10deg hand_uses_15deg hand_uses_20deg hand_uses_25deg hand_uses_30deg hand_uses_35deg hand_uses_40deg hand_uses_45deg hand_uses_50deg hand_uses_55deg hand_uses_60deg hand_uses_65deg foot_diff_0deg foot_diff_5deg foot_diff_10deg foot_diff_15deg foot_diff_20deg foot_diff_25deg foot_diff_30deg foot_diff_35deg foot_diff_40deg foot_diff_45deg foot_diff_50deg foot_diff_55deg foot_diff_60deg foot_diff_65deg foot_overall_avg foot_total_uses foot_uses_0deg foot_uses_5deg foot_uses_10deg foot_uses_15deg foot_uses_20deg foot_uses_25deg foot_uses_30deg foot_uses_35deg foot_uses_40deg foot_uses_45deg foot_uses_50deg foot_uses_55deg foot_uses_60deg foot_uses_65deg diff_as_hand uses_as_hand diff_as_foot uses_as_foot
placement_id
672 -64 4 Plastic 8 21.38 21.33 711 519 21.205284 13 0 65 65 19.132030 NaN NaN NaN 19.322506 NaN NaN NaN 19.642288 NaN NaN NaN NaN NaN 19.37 5.0 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 3.0 0.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN 19.274887 NaN NaN NaN NaN NaN 19.27 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.132030 NaN NaN NaN 19.322506 NaN NaN NaN 19.490526 NaN NaN NaN NaN NaN 19.32 6 1 0 0 0 1 0 0 0 4 0 0 0 0 0 19.098810 NaN 18.876361 18.402547 17.942421 18.637490 19.736641 19.602446 22.081567 22.675847 22.000175 20.700905 19.893935 19.571483 19.94 705.0 3.0 0.0 5.0 15.0 40.0 35.0 82.0 41.0 334.0 119.0 23.0 5.0 1.0 2.0 19.15 6 21.35 705
673 -64 20 Plastic 8 21.80 21.68 368 260 21.373192 14 0 65 65 NaN NaN NaN NaN NaN NaN NaN NaN 19.307847 NaN NaN NaN NaN NaN 19.31 2.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 2.0 0.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.307847 NaN NaN NaN NaN NaN 19.31 2 0 0 0 0 0 0 0 0 2 0 0 0 0 0 18.989173 19.322506 19.041320 18.457627 18.013649 19.208262 18.890967 19.415287 22.126208 22.848312 21.737351 21.500780 20.337941 20.103301 20.00 366.0 1.0 1.0 2.0 7.0 13.0 13.0 31.0 24.0 174.0 66.0 20.0 9.0 3.0 2.0 19.31 2 21.70 366
419 -64 28 Wood 5 19.57 19.57 307 205 19.859530 12 0 55 55 NaN NaN NaN 19.084411 19.560602 NaN 19.393935 19.274887 21.062118 20.365526 20.019970 19.703459 NaN NaN 19.81 21.0 0.0 0.0 0.0 1.0 1.0 0.0 1.0 1.0 10.0 4.0 2.0 1.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.084411 19.560602 NaN 19.393935 19.274887 21.062118 20.365526 20.019970 19.703459 NaN NaN 19.81 21 0 0 0 1 1 0 1 1 10 4 2 1 0 0 19.369829 19.036792 18.157193 17.901905 17.619754 18.250644 18.556759 18.061686 20.297069 20.880055 20.185680 19.668811 NaN NaN 19.00 286.0 2.0 1.0 4.0 5.0 12.0 15.0 30.0 29.0 125.0 48.0 13.0 2.0 0.0 0.0 21.39 21 19.31 286
420 -64 36 Wood 5 19.81 19.80 633 426 19.968225 14 0 65 65 17.265653 19.132030 NaN 18.385486 18.397978 18.292421 18.343273 18.985815 20.958325 21.609065 20.922051 19.798697 NaN NaN 19.28 229.0 7.0 1.0 0.0 6.0 10.0 10.0 23.0 16.0 106.0 39.0 10.0 1.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN 19.560602 NaN NaN NaN NaN NaN 19.56 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN 19.608221 NaN 19.989665 NaN NaN NaN NaN NaN 19.80 3.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 0.0 2.0 0.0 0.0 0.0 0.0 0.0 17.265653 19.132030 NaN 18.385486 18.397978 18.292421 18.449108 18.985815 21.036813 21.609065 20.922051 19.798697 NaN NaN 19.30 233 7 1 0 6 10 10 24 16 109 39 10 1 0 0 18.989173 18.989173 19.167744 19.296483 17.779218 17.974195 18.541636 18.074791 19.925870 20.890366 20.609196 20.259737 19.772723 19.110879 19.24 400.0 1.0 1.0 1.0 2.0 15.0 14.0 39.0 30.0 175.0 82.0 27.0 8.0 3.0 2.0 20.10 233 19.59 400
421 -64 44 Wood 5 21.38 21.21 212 154 20.917513 11 5 60 55 NaN NaN NaN 19.077071 19.535120 19.671483 19.722024 NaN 21.875629 20.256333 NaN NaN NaN NaN 20.02 31.0 0.0 0.0 0.0 3.0 2.0 2.0 6.0 0.0 16.0 2.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN 19.132030 19.560602 NaN NaN NaN NaN NaN 19.35 2.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 1.0 1.0 0.0 0.0 0.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.077071 19.535120 19.671483 19.722024 19.132030 21.878990 20.256333 NaN NaN NaN NaN 19.90 33 0 0 0 3 2 2 6 1 17 2 0 0 0 0 NaN 19.751078 NaN 18.716938 18.880905 18.713801 18.653357 19.314287 21.418174 21.168850 20.655065 20.834724 19.608221 NaN 19.79 179.0 0.0 1.0 0.0 2.0 5.0 7.0 14.0 16.0 90.0 29.0 8.0 6.0 1.0 0.0 21.48 33 20.96 179
422 -64 52 Wood 6 20.01 19.96 216 143 20.158814 13 0 60 60 NaN 19.036792 18.609680 18.860509 18.250730 19.239975 19.414981 20.043451 22.388100 22.301668 20.121501 19.965363 NaN NaN 19.84 135.0 0.0 1.0 3.0 7.0 11.0 11.0 16.0 6.0 57.0 20.0 2.0 1.0 0.0 0.0 NaN NaN 19.385997 18.816201 18.921483 NaN 19.466938 NaN 19.782193 19.370125 NaN NaN NaN NaN 19.29 13.0 0.0 0.0 1.0 3.0 2.0 0.0 2.0 0.0 4.0 1.0 0.0 0.0 0.0 0.0 NaN NaN NaN 19.052663 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.05 1.0 0.0 0.0 0.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 NaN 19.036792 18.598164 18.211840 17.977959 19.239975 19.432614 20.043451 22.344244 22.196750 20.121501 19.965363 NaN NaN 19.74 149 0 1 4 11 13 11 18 6 61 21 2 1 0 0 19.132030 NaN 18.989665 18.989173 18.250905 18.604241 19.259737 17.962421 19.131406 18.991772 19.881419 19.370125 19.322506 NaN 18.99 67.0 1.0 0.0 2.0 1.0 5.0 5.0 8.0 10.0 19.0 11.0 3.0 1.0 1.0 0.0 21.03 149 17.77 67
674 -64 60 Plastic 6 20.07 20.04 412 293 20.121001 14 0 65 65 19.084411 NaN 19.417744 NaN 18.563443 18.593820 18.759553 18.585275 19.929094 20.690445 20.417505 19.644211 19.591554 NaN 19.39 111.0 1.0 0.0 1.0 0.0 4.0 6.0 7.0 7.0 59.0 16.0 7.0 2.0 1.0 0.0 18.580574 NaN NaN 18.671483 18.728436 19.080574 18.511468 18.702794 19.382826 19.676429 19.772723 19.608221 NaN NaN 19.07 79.0 2.0 0.0 0.0 2.0 6.0 2.0 10.0 6.0 31.0 16.0 3.0 1.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 18.294462 NaN 19.417744 18.671483 18.122978 18.372833 18.191333 18.160680 19.764999 20.469901 20.575754 19.790114 19.591554 NaN 19.12 190 3 0 1 2 10 8 17 13 90 32 10 3 1 0 NaN 19.751078 19.091562 18.638905 18.570924 18.717376 18.649286 19.100035 21.125720 21.119430 20.966501 20.748113 20.077071 19.720549 19.71 222.0 0.0 1.0 3.0 5.0 6.0 4.0 13.0 15.0 112.0 37.0 15.0 5.0 3.0 3.0 19.12 190 20.79 222
423 -64 68 Wood 6 19.97 19.93 273 199 20.026671 13 0 60 60 NaN NaN NaN 19.068179 18.770905 19.130905 19.684489 19.473060 20.518601 20.070905 19.608221 NaN 20.033593 NaN 19.60 84.0 0.0 0.0 0.0 2.0 5.0 5.0 14.0 7.0 42.0 5.0 1.0 0.0 3.0 0.0 19.055840 NaN 19.274887 NaN 18.429853 18.559436 18.452945 18.896801 20.321690 20.549587 20.186023 19.751078 19.941554 NaN 19.40 155.0 1.0 0.0 1.0 0.0 7.0 6.0 14.0 12.0 80.0 25.0 7.0 1.0 1.0 0.0 NaN NaN 19.138525 19.147902 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 19.14 2.0 0.0 0.0 1.0 1.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 19.055840 NaN 18.995865 18.818836 18.065814 18.453075 18.914586 19.017888 20.532040 20.754629 20.286523 19.751078 20.448860 NaN 19.42 241 1 0 2 3 12 11 28 19 122 30 8 1 4 0 NaN 19.227268 19.227268 19.084411 18.944211 19.274887 NaN 19.313443 20.305259 19.990905 19.703459 NaN NaN NaN 19.45 32.0 0.0 1.0 1.0 1.0 2.0 1.0 0.0 4.0 16.0 5.0 1.0 0.0 0.0 0.0 19.93 241 19.76 32
424 -64 76 Wood 6 20.37 20.24 123 95 20.252982 13 0 60 60 NaN NaN NaN NaN NaN NaN NaN NaN 20.033593 NaN NaN NaN NaN NaN 20.03 3.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 0.0 3.0 0.0 0.0 0.0 0.0 0.0 18.807847 NaN NaN NaN 19.077071 19.685767 19.219693 19.294462 20.938510 20.768665 20.250984 19.703459 NaN NaN 19.75 50.0 2.0 0.0 0.0 0.0 3.0 3.0 4.0 3.0 23.0 8.0 3.0 1.0 0.0 0.0 NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN NaN 18.807847 NaN NaN NaN 19.077071 19.685767 19.219693 19.294462 21.138172 20.768665 20.250984 19.703459 NaN NaN 19.77 53 2 0 0 0 3 3 4 3 26 8 3 1 0 0 NaN 18.989173 19.282825 18.853301 18.777573 18.865475 19.028925 18.750838 20.187008 20.396640 19.807847 19.941554 19.330444 NaN 19.35 70.0 0.0 1.0 1.0 2.0 5.0 3.0 5.0 7.0 34.0 8.0 2.0 1.0 1.0 0.0 21.01 53 19.43 70
425 -64 84 Wood 6 19.07 19.10 273 183 19.431048 14 0 65 65 19.274887 19.322506 19.322506 19.370125 18.968375 19.417744 19.171483 19.216938 19.206847 19.676764 19.536792 NaN 19.433616 NaN 19.33 38.0 1.0 1.0 1.0 1.0 3.0 1.0 2.0 2.0 17.0 7.0 1.0 0.0 1.0 0.0 18.989173 NaN NaN 18.863633 18.657193 18.490905 18.742313 18.799901 19.347335 18.998315 19.782193 19.772723 19.262392 19.465363 19.10 108.0 1.0 0.0 0.0 2.0 4.0 5.0 6.0 4.0 60.0 16.0 4.0 3.0 2.0 1.0 NaN NaN NaN NaN 18.671483 19.065363 19.249432 18.086468 18.741461 18.690526 NaN NaN NaN NaN 18.75 19.0 0.0 0.0 0.0 0.0 2.0 1.0 3.0 4.0 5.0 4.0 0.0 0.0 0.0 0.0 18.853301 19.322506 19.322506 18.826084 17.836987 18.254542 18.524659 17.683341 19.112073 18.856156 19.850905 19.772723 19.265475 19.465363 18.92 165 2 1 1 3 9 7 11 10 82 27 5 3 3 1 NaN NaN 19.296483 19.103301 18.938997 19.279971 18.769048 19.297573 20.975078 20.921057 19.740526 19.512983 20.269989 NaN 19.65 108.0 0.0 0.0 2.0 2.0 6.0 3.0 14.0 5.0 48.0 19.0 4.0 1.0 4.0 0.0 18.16 165 20.51 108
Shape: (498, 167)

Taking the Mirror Score into Account¶

In [16]:
"""
==================================
Mirror average function
==================================

Mirror averaging is a simple way to stabilize difficulty estimates under
left-right board symmetry. For each mirror pair:
- if both holds have a value, we average them
- if only one side has a value, we copy it to the missing mirror hold
- metadata and usage counts are left unchanged
"""

def average_with_mirror(df, columns, placement_to_mirror):
    df_result = df.copy()
    processed = set()

    for placement_id in df_result.index:
        if placement_id in processed:
            continue

        mirror_id = placement_to_mirror.get(placement_id)

        if mirror_id and mirror_id in df_result.index:
            for col in columns:
                if col not in df_result.columns:
                    continue

                val1 = df_result.loc[placement_id, col]
                val2 = df_result.loc[mirror_id, col]

                if pd.notna(val1) and pd.notna(val2):
                    avg_val = (val1 + val2) / 2
                    df_result.loc[placement_id, col] = avg_val
                    df_result.loc[mirror_id, col] = avg_val
                elif pd.isna(val1) and pd.notna(val2):
                    df_result.loc[placement_id, col] = val2
                elif pd.notna(val1) and pd.isna(val2):
                    df_result.loc[mirror_id, col] = val1

            processed.add(mirror_id)

        processed.add(placement_id)

    return df_result
In [17]:
"""
==================================
Apply mirror to all difficulty coluns
==================================

Averages mirror pairs for:
- overall difficulty
- angle-weighted difficulty
- per-role overall averages
- per-role per-angle difficulties
"""

overall_cols = [c for c in ['overall_difficulty', 'angle_weighted_difficulty'] if c in df_model_features.columns]
role_avg_cols = [c for c in df_model_features.columns if c.endswith('_overall_avg')]
angle_diff_cols = [c for c in df_model_features.columns if '_diff_' in c and c.endswith('deg')]

all_difficulty_cols = sorted(set(overall_cols + role_avg_cols + angle_diff_cols))

missing_before = df_model_features[all_difficulty_cols].isna().sum().sum()
print(f"Missing values before mirror: {missing_before}")
print(f"Columns affected: {len(all_difficulty_cols)}")

df_model_features = average_with_mirror(df_model_features, all_difficulty_cols, placement_to_mirror)

missing_after = df_model_features[all_difficulty_cols].isna().sum().sum()
print(f"Missing values after mirror: {missing_after}")
print(f"Reduced by: {missing_before - missing_after}")
Missing values before mirror: 15839
Columns affected: 77
Missing values after mirror: 13521
Reduced by: 2318
In [18]:
"""
==================================
Rebuild detailed role-angle table from model features
==================================

Rebuild df_role_angle from the mirror-filled placement-level table so
saved exports and later visualizations reflect the final mirrored values.
"""

records = []

angle_cols = [c for c in df_model_features.columns if '_diff_' in c and c.endswith('deg')]

for col in angle_cols:
    parts = col.split('_')
    role = parts[0]
    angle = int(parts[2].replace('deg', ''))

    for placement_id, val in df_model_features[col].dropna().items():
        records.append({
            'placement_id': placement_id,
            'role_type': role,
            'angle': angle,
            'avg_difficulty_smoothed': val,
            'usage_count': 0
        })

df_role_angle = pd.DataFrame(records)

print(f"Rebuilt role-angle table: {len(df_role_angle)} records")

print("\n### Verify Hand @ 50° Coverage\n")
hand_50 = df_role_angle[(df_role_angle['role_type'] == 'hand') & (df_role_angle['angle'] == 50)]
print(f"Records for Hand @ 50°: {len(hand_50)}")

if placement_to_mirror:
    sample_pid = list(placement_to_mirror.keys())[0]
    sample_mirror = placement_to_mirror[sample_pid]

    print(f"\nSample mirror pair {sample_pid} <-> {sample_mirror}:")
    for role in ['hand', 'foot']:
        for angle in [40, 50]:
            check = df_role_angle[
                (df_role_angle['role_type'] == role) &
                (df_role_angle['angle'] == angle) &
                (df_role_angle['placement_id'].isin([sample_pid, sample_mirror]))
            ]
            if len(check) == 2:
                vals = check['avg_difficulty_smoothed'].values
                print(f"  {role} @ {angle}°: {vals[0]:.2f} <-> {vals[1]:.2f}")
Rebuilt role-angle table: 21548 records

### Verify Hand @ 50° Coverage

Records for Hand @ 50°: 444

Sample mirror pair 672 <-> 794:
  hand @ 40°: 19.44 <-> 19.44
  foot @ 40°: 22.22 <-> 22.22
  foot @ 50°: 22.31 <-> 22.31
In [19]:
"""
==================================
Verify mirror symmetry
==================================
"""

print("### Mirror Pair Verification\n")

sample_pairs = list(placement_to_mirror.items())[:5]

for pid, mirror_pid in sample_pairs:
    if pid not in df_model_features.index or mirror_pid not in df_model_features.index:
        continue
    
    print(f"Placement {pid} ↔ {mirror_pid}:")
    
    for col in ['overall_difficulty', 'hand_overall_avg', 'foot_overall_avg']:
        if col in df_model_features.columns:
            val1 = df_model_features.loc[pid, col]
            val2 = df_model_features.loc[mirror_pid, col]
            if pd.notna(val1) and pd.notna(val2):
                match = "okay" if abs(val1 - val2) < 0.01 else "x"
                print(f"  {col}: {val1:.2f} ↔ {val2:.2f} {match}")
    print()

# Count matching pairs
matching_pairs = 0
total_pairs = 0

for pid, mirror_pid in placement_to_mirror.items():
    if pid in df_model_features.index and mirror_pid in df_model_features.index:
        total_pairs += 1
        val1 = df_model_features.loc[pid, 'overall_difficulty']
        val2 = df_model_features.loc[mirror_pid, 'overall_difficulty']
        if pd.notna(val1) and pd.notna(val2) and abs(val1 - val2) < 0.01:
            matching_pairs += 1

print(f"Mirror pairs with matching values: {matching_pairs}/{total_pairs}")
### Mirror Pair Verification

Placement 672 ↔ 794:
  overall_difficulty: 21.46 ↔ 21.46 okay
  hand_overall_avg: 19.35 ↔ 19.35 okay
  foot_overall_avg: 19.95 ↔ 19.95 okay

Placement 673 ↔ 795:
  overall_difficulty: 21.53 ↔ 21.53 okay
  hand_overall_avg: 19.38 ↔ 19.38 okay
  foot_overall_avg: 19.91 ↔ 19.91 okay

Placement 419 ↔ 537:
  overall_difficulty: 19.95 ↔ 19.95 okay
  hand_overall_avg: 19.88 ↔ 19.88 okay
  foot_overall_avg: 19.13 ↔ 19.13 okay

Placement 420 ↔ 538:
  overall_difficulty: 19.88 ↔ 19.88 okay
  hand_overall_avg: 19.30 ↔ 19.30 okay
  foot_overall_avg: 19.23 ↔ 19.23 okay

Placement 421 ↔ 539:
  overall_difficulty: 20.55 ↔ 20.55 okay
  hand_overall_avg: 19.95 ↔ 19.95 okay
  foot_overall_avg: 19.48 ↔ 19.48 okay

Mirror pairs with matching values: 498/498
In [20]:
"""
==================================
Mirror Coverage Summary
==================================
"""

print("### Difficulty Column Coverage (After Mirror)\n")

scenarios = [
    ("Overall (all usages)", "overall_difficulty"),
    ("Angle-weighted", "angle_weighted_difficulty"),
    ("Hand (all angles)", "hand_overall_avg"),
    ("Foot (all angles)", "foot_overall_avg"),
    ("Hand @ 40°", "hand_diff_40deg"),
    ("Hand @ 50°", "hand_diff_50deg"),
    ("Foot @ 40°", "foot_diff_40deg"),
    ("Start @ 40°", "start_diff_40deg"),
    ("Finish @ 40°", "finish_diff_40deg"),
]

for name, col in scenarios:
    if col in df_model_features.columns:
        non_null = df_model_features[col].notna().sum()
        total = len(df_model_features)
        pct = non_null / total * 100
        print(f"{name:25s}: {non_null:3d}/{total} ({pct:5.1f}%)")
### Difficulty Column Coverage (After Mirror)

Overall (all usages)     : 498/498 (100.0%)
Angle-weighted           : 498/498 (100.0%)
Hand (all angles)        : 498/498 (100.0%)
Foot (all angles)        : 496/498 ( 99.6%)
Hand @ 40°               : 498/498 (100.0%)
Hand @ 50°               : 444/498 ( 89.2%)
Foot @ 40°               : 480/498 ( 96.4%)
Start @ 40°              : 390/498 ( 78.3%)
Finish @ 40°             : 326/498 ( 65.5%)

Visualization¶

In [21]:
"""
==================================
Visualization: difficulty heatmaps
==================================
"""

os.makedirs('../images/03_hold_difficulty', exist_ok=True)

def plot_difficulty_heatmap(score_column='overall_difficulty', title_suffix="", save=True):
    """Plot hold difficulty scores on the board."""
    
    fig, ax = plt.subplots(figsize=(16, 14))
    ax.imshow(board_img, extent=[x_min, x_max, y_min, y_max], aspect='auto')
    
    df_plot = df_model_features[df_model_features['x'].notna()].copy()
    
    if score_column not in df_plot.columns:
        print(f"Column '{score_column}' not found")
        plt.close()
        return
    
    df_plot = df_plot[df_plot[score_column].notna()]
    
    if df_plot.empty:
        print(f"No data for '{score_column}'")
        plt.close()
        return
    
    max_usage = df_plot['usage_count'].max()
    size_scale = 20 + 150 * (df_plot['usage_count'] / max_usage)
    
    scatter = ax.scatter(
        df_plot['x'],
        df_plot['y'],
        c=df_plot[score_column],
        s=size_scale,
        cmap='coolwarm',
        alpha=0.85,
        edgecolors='black',
        linewidths=0.5
    )
    
    ax.set_xlabel('X Position (inches)', fontsize=12)
    ax.set_ylabel('Y Position (inches)', fontsize=12)
    ax.set_title(f'Hold Difficulty: {score_column} {title_suffix}', fontsize=14)
    
    cbar = plt.colorbar(scatter, ax=ax, shrink=0.5)
    cbar.set_label('Difficulty')
    
    plt.tight_layout()
    
    if save:
        safe_name = score_column.replace('/', '_')
        plt.savefig(f'../images/03_hold_difficulty/difficulty_heatmap_{safe_name}.png', dpi=150, bbox_inches='tight')
    
    plt.show()


# Plot main scores
plot_difficulty_heatmap('overall_difficulty', "(Raw Average)")
plot_difficulty_heatmap('angle_weighted_difficulty', "(Angle-Weighted)")

# Plot role scores
plot_difficulty_heatmap('hand_overall_avg', "(Hand)")
plot_difficulty_heatmap('foot_overall_avg', "(Foot)")
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
In [22]:
"""
==================================
Visualization: per-role per-angle heatmaps
==================================
"""

def plot_role_angle_heatmap(role_type='hand', angle=40):
    """Plot difficulty scores for a specific role and angle."""
        
    df_role = df_role_angle[
        (df_role_angle['role_type'] == role_type) & 
        (df_role_angle['angle'] == angle)
    ].copy()
    
    if df_role.empty:
        print(f"No data for {role_type} at {angle}°")
        return
    
    df_role['x'] = df_role['placement_id'].map(lambda p: placement_coordinates.get(p, (None, None))[0])
    df_role['y'] = df_role['placement_id'].map(lambda p: placement_coordinates.get(p, (None, None))[1])
    df_role = df_role.dropna(subset=['x', 'y'])
    
    fig, ax = plt.subplots(figsize=(16, 14))
    ax.imshow(board_img, extent=[x_min, x_max, y_min, y_max], aspect='auto')
    
    scatter = ax.scatter(
        df_role['x'],
        df_role['y'],
        c=df_role['avg_difficulty_smoothed'],
        s=100,
        cmap='coolwarm',
        alpha=0.85,
        edgecolors='black',
        linewidths=0.5
    )
    
    ax.set_xlabel('X Position (inches)', fontsize=12)
    ax.set_ylabel('Y Position (inches)', fontsize=12)
    ax.set_title(f'{role_type.capitalize()} Hold Difficulty at {angle}°', fontsize=14)
    
    cbar = plt.colorbar(scatter, ax=ax, shrink=0.5)
    cbar.set_label('Difficulty')
    
    plt.tight_layout()
    plt.savefig(f'../images/03_hold_difficulty/difficulty_{role_type}_{angle}deg.png', dpi=150, bbox_inches='tight')
    plt.show()


# Plot for common angles
common_angles = [30, 40, 45, 50]

for role in ['hand', 'foot']:
    for angle in common_angles:
        print(f"\n{role.capitalize()} at {angle}°:")
        plot_role_angle_heatmap(role, angle)
Hand at 30°:
No description has been provided for this image
Hand at 40°:
No description has been provided for this image
Hand at 45°:
No description has been provided for this image
Hand at 50°:
No description has been provided for this image
Foot at 30°:
No description has been provided for this image
Foot at 40°:
No description has been provided for this image
Foot at 45°:
No description has been provided for this image
Foot at 50°:
No description has been provided for this image

Conclusion¶

In [23]:
"""
==================================
Summary Statistics
==================================
"""

# Material comparison
print("### Difficulty by Material\n")
material_diff = df_model_features.groupby('material').agg(
    count=('overall_difficulty', 'count'),
    avg_difficulty=('overall_difficulty', 'mean'),
    median_difficulty=('overall_difficulty', 'median'),
    avg_hand=('hand_overall_avg', 'mean'),
    avg_foot=('foot_overall_avg', 'mean'),
    avg_usage=('usage_count', 'mean')
).round(2)

display(material_diff)

# Default role comparison
print("\n### Difficulty by Default Role\n")
role_diff = df_model_features.groupby('default_role').agg(
    count=('overall_difficulty', 'count'),
    avg_difficulty=('overall_difficulty', 'mean'),
    avg_hand=('hand_overall_avg', 'mean'),
    avg_foot=('foot_overall_avg', 'mean'),
    avg_usage=('usage_count', 'mean')
).round(2)

display(role_diff)

# Correlation
if 'hand_overall_avg' in df_model_features.columns and 'foot_overall_avg' in df_model_features.columns:
    valid = df_model_features.dropna(subset=['hand_overall_avg', 'foot_overall_avg'])
    if len(valid) > 0:
        corr = valid['hand_overall_avg'].corr(valid['foot_overall_avg'])
        print(f"\nCorrelation (hand vs foot difficulty): {corr:.3f}")
        print(f"(based on {len(valid)} holds used as both)")
### Difficulty by Material

count avg_difficulty median_difficulty avg_hand avg_foot avg_usage
material
Plastic 256 20.49 20.74 19.46 19.41 1035.02
Wood 242 19.88 20.01 19.37 19.12 869.04
### Difficulty by Default Role

count avg_difficulty avg_hand avg_foot avg_usage
default_role
5 75 20.26 19.64 19.04 1057.64
6 353 20.23 19.34 19.37 816.30
7 17 20.44 19.25 19.56 2052.41
8 53 19.76 19.66 18.84 1375.55
Correlation (hand vs foot difficulty): 0.263
(based on 496 holds used as both)
In [24]:
"""
==================================
Save to  files
==================================
"""

import os
os.makedirs('../data/03_hold_difficulty', exist_ok=True)

# Main features table
df_model_features.to_csv('../data/03_hold_difficulty/hold_difficulty_scores.csv')

# Full pivot for modeling
pivot_value_col = 'avg_difficulty_smoothed' if 'avg_difficulty_smoothed' in df_role_angle.columns else 'avg_difficulty'

pivot_full = df_role_angle.pivot_table(
    index='placement_id',
    columns=['role_type', 'angle'],
    values=pivot_value_col,
    aggfunc='mean'
)
pivot_full.columns = [f'diff_{role}_{int(angle)}deg' for role, angle in pivot_full.columns]
pivot_full.to_csv('../data/03_hold_difficulty/hold_role_angle_difficulty_scores.csv')

# Per-role tables
for role in ROLE_TYPES:
    if role in role_tables:
        role_tables[role].to_csv(f'../data/03_hold_difficulty/hold_{role}_difficulty_by_angle.csv')

# Detailed records
df_role_angle.to_csv('../data/03_hold_difficulty/hold_role_angle_detailed.csv', index=False)

print("Saved files to ../data/03_hold_difficulty/:")
print("  - hold_difficulty_scores.csv (main table)")
print("  - hold_role_angle_difficulty_scores.csv (full pivot)")
for role in ROLE_TYPES:
    if role in role_tables:
        print(f"  - hold_{role}_difficulty_by_angle.csv")
print("  - hold_role_angle_detailed.csv (detailed records)")
Saved files to ../data/03_hold_difficulty/:
  - hold_difficulty_scores.csv (main table)
  - hold_role_angle_difficulty_scores.csv (full pivot)
  - hold_start_difficulty_by_angle.csv
  - hold_middle_difficulty_by_angle.csv
  - hold_finish_difficulty_by_angle.csv
  - hold_hand_difficulty_by_angle.csv
  - hold_foot_difficulty_by_angle.csv
  - hold_role_angle_detailed.csv (detailed records)

Tables produced:¶

  1. df_model_features - Main feature table for downstream modeling

    • One row per placement_id
    • Includes metadata, overall scores, angle-level summaries, and role-specific scores
    • overall_difficulty is the Bayesian-smoothed overall score
    • overall_difficulty_raw is retained only as a reference column
  2. df_role_angle - Detailed records for visualization / export

    • One row per (placement_id, role_type, angle) combination
    • Rebuilt after mirror-averaging so plots and exports reflect the final mirrored values
  3. role_tables[role] - Per-role tables

    • start, middle, finish, hand, foot
    • each with per-angle columns, overall averages, and usage counts

Mirror logic:¶

  • difficulty-like columns are mirrored across symmetric hold pairs
  • if both mirror holds have values, their scores are averaged
  • if only one side has a value, that value is copied to the missing mirror side
  • usage counts and metadata are left unchanged