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_id10, and has two sets: wood and plastic. These haveset_id12 and 13 respectively. - the
framefeature of a climb determines the climb: it looks something likep3r4p29r2p59r1p65r2p75r3p89r2p157r4p158r4. A substringpXrYtells us the placement (placement_id=X) and the role (whether it is a start, finish, foot, or middle hold) comes from theplacement_role_id=Y. The role will also tell us which color to use if we plot our climb against the board. - the
holestable will tell us whichplacement_idgoes where on the (x,y) coordinate system. It also tells us the ID of its mirror image, which let's us unravel theplacement_idof 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¶
Setup and Imports¶
"""
==================================
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)
"""
==================================
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.
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
# 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'
# 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'])
}
get_role_type(7)
'finish'
## Boundary conditions
x_min, x_max = -68, 68
y_min, y_max = 0, 144
Hold Usage DataFrame¶
"""
==================================
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.
"""
==================================
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¶
"""
==================================
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¶
"""
==================================
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¶
"""
==================================
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¶
"""
==================================
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¶
"""
==================================
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 |
"""
==================================
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¶
"""
==================================
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
"""
==================================
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
"""
==================================
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
"""
==================================
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
"""
==================================
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¶
"""
==================================
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)")
"""
==================================
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°:
Hand at 40°:
Hand at 45°:
Hand at 50°:
Foot at 30°:
Foot at 40°:
Foot at 45°:
Foot at 50°:
Conclusion¶
"""
==================================
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)
"""
==================================
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:¶
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_difficultyis the Bayesian-smoothed overall scoreoverall_difficulty_rawis retained only as a reference column
- One row per
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
- One row per (
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