{ "cells": [ { "cell_type": "markdown", "id": "f40e80c8", "metadata": {}, "source": [ "# Kilter Board: Hold Usage Analysis\n", "\n", "This notebook shifts attention from whole climbs to individual holds. The objective is to understand **which holds are being used**, **how often they are used**, and **how their use changes by role, material, and location on the board**.\n", "\n", "As I climb on a TB2 Mirror and it interests me, I restrict the analysis to the **Tension Board 2 Mirror**. Restricting to one board also makes later feature engineering cleaner: the same physical placement has the same geometric meaning throughout the notebook series.\n", "\n", "## Main questions\n", "\n", "1. Which holds appear most often?\n", "2. Which regions of the board are used most heavily?\n", "3. How different are hand and foot usage patterns?\n", "4. Are plastic or wood holds favoured over the other?\n", "\n", "The outputs here will feed directly into the next notebook, where hold usage is turned into hold-difficulty features.\n", "\n", "## Notebook Structure\n", "1. [Setup and Imports](#setup-and-imports)\n", "2. [Some Useful Functions](#some-useful-functions)\n", "3. [Holds Heatmaps](#holds-heatmaps)\n", "4. [Some other hold stats](#some-other-hold-stats)\n", "5. [Conclusion](#conclusion)" ] }, { "cell_type": "markdown", "id": "135284a6", "metadata": {}, "source": [ "## Setup and Imports" ] }, { "cell_type": "code", "execution_count": null, "id": "94e7a456", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Setup\n", "==================================\n", "\"\"\"\n", "# Imports\n", "import pandas as pd\n", "import matplotlib.pyplot as plt\n", "import seaborn as sns\n", "import numpy as np\n", "import matplotlib.patches as mpatches\n", "\n", "import sqlite3\n", "\n", "import re\n", "from collections import defaultdict\n", "\n", "from PIL import Image\n", "\n", "# Set some display options\n", "pd.set_option('display.max_columns', None)\n", "pd.set_option('display.max_rows', 100)\n", "\n", "# Set style\n", "palette=['steelblue', 'coral', 'seagreen'] #(for multi-bar graphs)\n", "\n", "# Set board image for some visual analysis\n", "board_img = Image.open('../images/kilter-original-16x12_compose.png')\n", "\n", "# Connect to the database\n", "DB_PATH=\"../data/kilter.db\"\n", "conn = sqlite3.connect(DB_PATH)" ] }, { "cell_type": "code", "execution_count": null, "id": "63352d8e", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Loading the data\n", "==================================\n", "\n", "This time we restrict to where `layout_id=1` for the Kilter Board Original.\n", "We also put the date past 2016 as to not get that test climb from 2006.\n", "\n", "There are also some phantom placements with y-value > 156. We'll filter those out in our query.\n", "\"\"\"\n", "\n", "\n", "# Query climbs data\n", "climbs_query = \"\"\"\n", "SELECT\n", " c.uuid,\n", " c.name AS climb_name,\n", " c.setter_username,\n", " c.layout_id AS layout_id,\n", " c.description,\n", " c.is_nomatch,\n", " c.is_listed,\n", " l.name AS layout_name,\n", " p.name AS board_name,\n", " c.frames,\n", " cs.angle,\n", " cs.display_difficulty,\n", " dg.boulder_name AS boulder_grade,\n", " cs.ascensionist_count,\n", " cs.quality_average,\n", " cs.fa_at\n", " \n", "FROM climbs c\n", "JOIN layouts l ON c.layout_id = l.id\n", "JOIN products p ON l.product_id = p.id\n", "JOIN climb_stats cs ON c.uuid = cs.climb_uuid\n", "JOIN difficulty_grades dg ON ROUND(cs.display_difficulty) = dg.difficulty\n", "WHERE cs.display_difficulty IS NOT NULL AND c.is_listed=1 AND c.layout_id=1 AND cs.fa_at > '2016-01-01'\n", "\"\"\"\n", "\n", "# Query information about placements (and their mirrors)\n", "placements_query = \"\"\"\n", "SELECT\n", " p.id AS placement_id,\n", " h.x,\n", " h.y,\n", " p.default_placement_role_id AS default_role_id,\n", " p.set_id AS set_id,\n", " s.name AS set_name\n", "FROM placements p\n", "JOIN holes h ON p.hole_id = h.id\n", "JOIN sets s ON p.set_id = s.id\n", "WHERE p.layout_id = 1 AND y <=156\n", "\"\"\"\n", "\n", "# Load it into a DataFrame\n", "df_climbs = pd.read_sql_query(climbs_query, conn)\n", "df_placements = pd.read_sql_query(placements_query, conn)" ] }, { "cell_type": "markdown", "id": "670aa500", "metadata": {}, "source": [ "First let's see how many climbs we're working with." ] }, { "cell_type": "code", "execution_count": null, "id": "26a6ec05", "metadata": {}, "outputs": [], "source": [ "len(df_climbs)" ] }, { "cell_type": "markdown", "id": "bbf913c2", "metadata": {}, "source": [ "So we have about 287k climbs, but this has some repetition to due to angle." ] }, { "cell_type": "code", "execution_count": null, "id": "cc682905", "metadata": {}, "outputs": [], "source": [ "len(df_climbs['frames'].unique())" ] }, { "cell_type": "markdown", "id": "467097aa", "metadata": {}, "source": [ "Great, so 173k unique different routes to analyze. Let's see what our `df_placements` DataFrame looks like." ] }, { "cell_type": "code", "execution_count": null, "id": "e97eaddd", "metadata": {}, "outputs": [], "source": [ "# Our climbs DataFrame will look the same as in 01, although we are just restricting to the TB2 Mirror.\n", "# Let's see what our placements DataFrame looks like.\n", "\n", "df_placements.head(10)" ] }, { "cell_type": "markdown", "id": "11beff1c", "metadata": {}, "source": [ "Now let's set our board boundaries and some basical mapping rules. First let's take a look at the `placement_roles` table in the databse." ] }, { "cell_type": "code", "execution_count": null, "id": "e7320eac", "metadata": {}, "outputs": [], "source": [ "led_color_query = \"\"\"\n", "SELECT * FROM placement_roles ORDER BY id;\n", "\"\"\"\n", "\n", "df_placement_roles = pd.read_sql_query(led_color_query, conn)\n", "\n", "display(df_placement_roles)" ] }, { "cell_type": "code", "execution_count": null, "id": "9d3eb97b", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Mappings and more setup\n", "==================================\n", "\"\"\"\n", "\n", "role_names = df_placement_roles.name[4:].tolist()\n", "role_colors = df_placement_roles.led_color[4:].tolist()\n", "sets = df_placements['set_name'].unique().tolist()\n", "\n", "# Placement coordinates pX -> (x,y)\n", "placement_coordinates = dict(zip(df_placements['placement_id'],\n", " zip(df_placements['x'], df_placements['y'])))\n", "\n", "# Placement set: is it wood or plastic?\n", "placement_sets = dict(zip(df_placements['placement_id'], df_placements['set_name']))\n", "\n", "\n", "# Role map name. Takes rY to foot/start/finish/middle\n", "role_name_map = {i+12: name for i, name in enumerate(role_names)}\n", "\n", "# Role map color. Takes rY to the appropriate color for the LED\n", "role_color_map = {i+12: f\"#{led_color}\" for i, led_color in enumerate(role_colors)}\n", "\n", "# Figure out whether a hold is a hand or a foot\n", "role_type_map = {12: 'hand', 13: 'hand', 14: 'hand', 15: 'foot'}\n", "\n", "\"\"\"\n", "SELECT * FROM product_sizes WHERE id=28;\n", "id|product_id|edge_left|edge_right|edge_bottom|edge_top|name |description|image_filename |position|is_listed|\n", "--+----------+---------+----------+-----------+--------+-------+-----------+--------------------+--------+---------+\n", "28| 1| -24| 168| 0| 156|16 x 12|Super Wide |product_sizes/28.png| 5| 1|\n", "\"\"\"\n", "x_min, x_max = -24, 168 # add 4 inches to the end of each side for width\n", "y_min, y_max = 0, 156 # add 4 inches to the end of each side for height" ] }, { "cell_type": "code", "execution_count": null, "id": "96620735", "metadata": {}, "outputs": [], "source": [ "df_placements['default_role_type'] = df_placements['default_role_id'].map(role_type_map)\n", "\n", "df_placements" ] }, { "cell_type": "markdown", "id": "54d35d91", "metadata": {}, "source": [ "---" ] }, { "cell_type": "markdown", "id": "fa735a35", "metadata": {}, "source": [ "# Some useful functions\n", "\n", "Here we create some useful functions. \n" ] }, { "cell_type": "markdown", "id": "b1876286", "metadata": {}, "source": [ "## Extract Placements and Roles\n", "\n", "First, we will want a quicker way to look at the placements in the frames. We'll do this by just quickly making a list of the placements in the frame." ] }, { "cell_type": "code", "execution_count": null, "id": "0c6f3d87", "metadata": {}, "outputs": [], "source": [ "\n", "\"\"\"\n", "==================================\n", "Create a list of placements from a climb\n", "==================================\n", "\"\"\"\n", "\n", "# Parse the placements\n", "def parse_frames_p(frames):\n", " \"\"\"Returns a list of the placement ID's\"\"\"\n", " if not frames:\n", " return []\n", " \n", " # Find all 'p' patterns\n", " matches = re.findall(r'p(\\d+)', frames)\n", " return [int(m) for m in matches]\n", "\n", "# Parse the placements, together with role\n", "def parse_frames_pr(frames):\n", " \"\"\"Returns a list of tuples containing the placement ID and the role ID\"\"\"\n", " if not frames:\n", " return defaultdict()\n", " \n", " # Find all 'p' patterns\n", " matches = re.findall(r'p(\\d+)r(\\d+)', frames)\n", " return [(int(p), int(r)) for p,r in matches]\n" ] }, { "cell_type": "code", "execution_count": null, "id": "18f99b4b", "metadata": {}, "outputs": [], "source": [ "### Tests\n", "# Let's just take one row from out data frame\n", "test_climb = df_climbs.iloc[0]\n", "\n", "\n", "\n", "# Isolate the frames feature\n", "test_frames = test_climb['frames']\n", "\n", "display(parse_frames_p(test_frames))\n", "\n", "display(parse_frames_pr(test_frames))" ] }, { "cell_type": "markdown", "id": "1585ff55", "metadata": {}, "source": [ "\n", "## Creating a per-climb DataFrame\n", "Second, we create a dataframe for any specific climb. This will just be useful if we want to map a specific climb to the board." ] }, { "cell_type": "code", "execution_count": null, "id": "fef2d333", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Create a dataframe from a climb\n", "==================================\n", "\"\"\"\n", "\n", "def climb_to_DataFrame(frames):\n", " \"\"\"Extract placement IDs, roles, and coordinates from frames string efficiently.\n", " \n", " Parameters: a `frames` string. This is a string of the form px_1ry_1px_2ry_2...\n", " - The px_i will tell you that we are dealing with a hold with placement ID x_i.\n", " - The ry_i will tell you that we are dealing with a hold with role ID y_i.\"\"\"\n", " if not frames or not isinstance(frames, str):\n", " return pd.DataFrame(columns=['placement_id', 'role_id', 'role_name', 'x', 'y', 'set_name'])\n", " \n", " # Parse all at once\n", " matches = re.findall(r'p(\\d+)r(\\d+)', frames)\n", " \n", " if not matches:\n", " return pd.DataFrame(columns=['placement_id', 'role_id', 'role_name', 'x', 'y', 'set_name'])\n", " \n", " # Convert to numpy arrays\n", " placements = np.array([int(m[0]) for m in matches])\n", " roles = np.array([int(m[1]) for m in matches])\n", " \n", " # Create DataFrame\n", " df = pd.DataFrame({\n", " 'placement_id': placements,\n", " 'role_id': roles\n", " })\n", " \n", " # Map role names & colors\n", " df['role_name'] = df['role_id'].map(role_name_map)\n", " df['role_kind'] = df['role_id'].map(role_type_map)\n", " df['led_color'] = df['role_id'].map(role_color_map)\n", " df['set_name'] = df['placement_id'].map(placement_sets)\n", " \n", " # Map coordinates (using get with default for safety)\n", " df['(x,y)'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan)))\n", " df['x'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan))[0])\n", " df['y'] = df['placement_id'].apply(lambda p: placement_coordinates.get(p, (np.nan, np.nan))[1])\n", "\n", " # Map set name\n", " df['set_name'] = df['placement_id'].apply(lambda p: placement_sets.get(p, (np.nan, np.nan)))\n", " \n", " return df\n" ] }, { "cell_type": "code", "execution_count": null, "id": "f6b6fb92", "metadata": {}, "outputs": [], "source": [ "### Tests\n", "# Let's just take one row from out data frame\n", "test_climb = df_climbs.iloc[10000]\n", "\n", "# Isolate the frames feature\n", "test_frames = test_climb['frames']\n", "\n", "print(test_climb['climb_name'])\n", "display(climb_to_DataFrame(test_frames))" ] }, { "cell_type": "markdown", "id": "155d841e", "metadata": {}, "source": [ "## Visualizing the climb from the data" ] }, { "cell_type": "markdown", "id": "9f7af9b4", "metadata": {}, "source": [ "\n", "We'll use our `climb_to_DataFrame` function and then just map the data using a scatter plot overlayed on the image of the TB2." ] }, { "cell_type": "code", "execution_count": null, "id": "899291e9", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Mapping frames to the boad\n", "==================================\n", "\"\"\"\n", "\n", "def map_climb(climb_name, climb_frames):\n", " # Create figure\n", " fig,ax = plt.subplots(figsize=(17,12))\n", "\n", " # Show board image as background\n", " ax.imshow(board_img, extent=[x_min,x_max, y_min, y_max], aspect='equal')\n", "\n", " df = climb_to_DataFrame(climb_frames)\n", "\n", " # Create heatmap using scatter (only at hold positions)\n", " scatter = ax.scatter(\n", " df['x'],\n", " df['y'],\n", " c=df['led_color'],\n", " alpha=0.85,\n", " edgecolors='black',\n", " linewidths=0.5\n", " )\n", "\n", " # Labels\n", " ax.set_title(climb_name)\n", "\n", "\n", " return fig, ax" ] }, { "cell_type": "code", "execution_count": null, "id": "be1ff408", "metadata": {}, "outputs": [], "source": [ "df_climbs.iloc[10000]['frames']" ] }, { "cell_type": "code", "execution_count": null, "id": "7fb09aea", "metadata": {}, "outputs": [], "source": [ "test_climb = df_climbs.iloc[10000]\n", "\n", "map_climb(test_climb['climb_name'], test_climb['frames'])\n", "\n", "plt.savefig('../images/02_hold_stats/Anna_Got_Me_Clickin', dpi=150, bbox_inches='tight')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "bd96b297", "metadata": {}, "source": [ "One can also use the above function to figure out what hold is what by doing something like `map_climb('', p370r6)`. We'll do one better, and display the placement ID on the hold." ] }, { "cell_type": "markdown", "id": "dffbf453", "metadata": {}, "source": [ "## Which placement ID corresponds to which hold?" ] }, { "cell_type": "code", "execution_count": null, "id": "5c2675be", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Mapping placement IDs to the board\n", "==================================\n", "\"\"\"\n", "\n", "def map_hold_labels(placements):\n", " \"\"\"\n", " Map a climb with placement IDs displayed as text labels instead of colors.\n", " \"\"\"\n", " # Make our placements into string\n", " placements = [str(p) for p in placements]\n", " \n", " # Make an associated frame\n", " frames = 'p'\n", " frames += 'r15p'.join(placements)\n", " frames += 'r15'\n", "\n", " # Create figure\n", " fig, ax = plt.subplots(figsize=(17, 12))\n", "\n", " # Make a dataframe\n", " df = climb_to_DataFrame(frames)\n", "\n", " # Show board image as background\n", " ax.imshow(board_img, extent=[x_min, x_max, y_min, y_max], aspect='auto')\n", " \n", " # Plot text labels instead of scatter points\n", " for _, row in df.iterrows():\n", " ax.text(\n", " row['x'],\n", " row['y'],\n", " str(row['placement_id']), # The text to display\n", " ha='center', # Horizontal alignment\n", " va='center', # Vertical alignment\n", " fontsize=5,\n", " fontweight='bold',\n", " color='white',\n", " bbox=dict(\n", " boxstyle='circle,pad=0.1', # Circle background\n", " alpha=0.2,\n", " edgecolor='white',\n", " linewidth=1\n", " )\n", " )\n", "\n", " # Labels\n", " ax.set_title('', fontsize=16)\n", "\n", " return fig, ax\n", "\n", "\n", "def map_single_hold_label(placement, role=6):\n", " \"\"\"\n", " Map a single hold with its placement ID displayed.\n", " \"\"\"\n", " frame_string = f\"p{placement}r{role}\"\n", " return map_hold_labels(f\"Placement {placement}\", frame_string)\n", "\n", "\n", "# Test it\n", "fig, ax = map_hold_labels([1175, 1180, 1200])\n", "fig.savefig('../images/02_hold_stats/placements_1175_1180_1200.png', dpi=150, bbox_inches='tight')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "d5117d01", "metadata": {}, "source": [ "Let's see how the whole board looks. " ] }, { "cell_type": "code", "execution_count": null, "id": "4b0d7fe6", "metadata": {}, "outputs": [], "source": [ "placements = df_placements['placement_id'].tolist()\n", "\n", "\n", "fig, ax = map_hold_labels(placements)\n", "fig.savefig('../images/02_hold_stats/all_placement_ids.png', dpi=150, bbox_inches='tight')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "cf63036d", "metadata": {}, "source": [ "---" ] }, { "cell_type": "markdown", "id": "49827fce", "metadata": {}, "source": [ "# Holds Heatmaps" ] }, { "cell_type": "markdown", "id": "6c2f2fc7", "metadata": {}, "source": [ "Here we produce a heatmap of the holds on the Tension Board 2 Mirror. We will, under some restrictions if necessary, count how many times a hold appears in the list of unique climbs. We then use this information to create a heat map (subject to those restrictions -- e.g., grade, grade range, hold type, etc.)." ] }, { "cell_type": "markdown", "id": "a32575cf", "metadata": {}, "source": [ "We use the usage_count (or hand_usage_count/foot_usage_count/start_usage_count) to create a heatmap overlayed on an image of the TB2 12x12, potentially with a specified grade." ] }, { "cell_type": "code", "execution_count": null, "id": "4722ab15", "metadata": {}, "outputs": [], "source": [ "\"\"\"\n", "==================================\n", "Holds heatmap\n", "==================================\n", "\"\"\"\n", "\n", "def plot_heatmap(boulder_name=None, grade_range=None, hold_type='all', df_source=df_climbs, board_image=board_img, title_suffix=\"\"):\n", " \"\"\"\n", " Plots a hold usage heatmap for a specific grade, grade range, and hold type.\n", " \n", " Parameters:\n", " -----------\n", " boulder_name : str, optional\n", " Specific boulder name to filter (e.g., '6b/V4', '7a/V6').\n", " grade_range : tuple, optional\n", " Range of numeric difficulties (e.g., (16, 18) for V3-V4).\n", " hold_type : str, optional\n", " Type of hold to visualize. Options: 'all', 'hand', 'foot', 'start', 'middle', 'finish'.\n", " df_source : DataFrame\n", " The source dataframe containing climb data.\n", " board_image : array\n", " The board image to overlay.\n", " title_suffix : str\n", " Extra text for the title.\n", " \"\"\"\n", " \n", " # 1. Define Role Mappings (TB2: 5-8)\n", " role_map = {\n", " 'all': {12, 13, 14, 15},\n", " 'hand': {12, 13, 14},\n", " 'foot': {15},\n", " 'start': {12},\n", " 'middle': {13},\n", " 'finish': {14}\n", " }\n", " \n", " if hold_type not in role_map:\n", " print(f\"Invalid hold_type '{hold_type}'. Use: {list(role_map.keys())}\")\n", " return\n", " \n", " allowed_roles = role_map[hold_type]\n", " \n", " # 2. Filter Data by Grade\n", " if boulder_name:\n", " df_filtered = df_source[df_source['boulder_grade'] == boulder_name]\n", " grade_label = boulder_name\n", " elif grade_range:\n", " min_diff, max_diff = grade_range\n", " df_filtered = df_source[\n", " (df_source['display_difficulty'] >= min_diff) & \n", " (df_source['display_difficulty'] <= max_diff)\n", " ]\n", " # Create readable label from boulder_name range\n", " min_name = df_filtered.groupby('display_difficulty')['boulder_grade'].first().get(min_diff, f\"V{min_diff-10}\")\n", " max_name = df_filtered.groupby('display_difficulty')['boulder_grade'].first().get(max_diff, f\"V{max_diff-10}\")\n", " grade_label = f\"{min_name} to {max_name}\"\n", " else:\n", " df_filtered = df_source\n", " grade_label = \"All Grades\"\n", "\n", " if df_filtered.empty:\n", " print(f\"No climbs found for: {boulder_name or grade_range}\")\n", " return\n", "\n", " # 3. Count Placement Usage (Filtered by Role)\n", " placement_counts = {}\n", " \n", " for frames in df_filtered['frames'].dropna().unique():\n", " matches = re.findall(r'p(\\d+)r(\\d+)', frames)\n", " \n", " for p_str, r_str in matches:\n", " p_id = int(p_str)\n", " r_id = int(r_str)\n", " \n", " if r_id in allowed_roles:\n", " placement_counts[p_id] = placement_counts.get(p_id, 0) + 1\n", " \n", " # 4. Prepare Data for Plotting\n", " plot_data = []\n", " for pid, count in placement_counts.items():\n", " if pid in placement_coordinates:\n", " x, y = placement_coordinates[pid]\n", " plot_data.append({'x': x, 'y': y, 'count': count})\n", " \n", " if not plot_data:\n", " print(f\"No placements found for hold_type '{hold_type}' in this grade range.\")\n", " return\n", " \n", " df_plot = pd.DataFrame(plot_data)\n", " \n", " # 5. Plot\n", " fig, ax = plt.subplots(figsize=(18, 14))\n", " \n", " ax.imshow(board_image, extent=[x_min, x_max, y_min, y_max], aspect='auto')\n", " \n", " max_count = df_plot['count'].max()\n", " size_scale = 20 + 200 * (df_plot['count'] / max_count if max_count > 0 else 0)\n", " \n", " scatter = ax.scatter(\n", " df_plot['x'],\n", " df_plot['y'],\n", " c=df_plot['count'],\n", " s=size_scale,\n", " cmap='plasma',\n", " alpha=0.8,\n", " edgecolors='black',\n", " linewidths=0.5\n", " )\n", " \n", " ax.set_xlabel('X Position (inches)')\n", " ax.set_ylabel('Y Position (inches)')\n", " \n", " title = f\"{hold_type.capitalize()} Hold Usage - {grade_label} {title_suffix}\".strip()\n", " ax.set_title(title, fontsize=16)\n", " \n", " cbar = plt.colorbar(scatter, ax=ax, shrink=0.5)\n", " cbar.set_label('Usage Count')\n", " \n", " \n", " return fig, ax" ] }, { "cell_type": "code", "execution_count": null, "id": "bfd88ce5", "metadata": {}, "outputs": [], "source": [ "# All grade, all holds\n", "fig, ax = plot_heatmap()\n", "fig.savefig('../images/02_hold_stats/all_holds_all_grades_heatmap.png', dpi=150, bbox_inches='tight')\n", "plt.show()\n" ] }, { "cell_type": "markdown", "id": "86be9207", "metadata": {}, "source": [ "There is a distinct lack of usage on holds that are on the left and right edges. This is because the 16ft x 12ft Kilter Board is quite rare. While the middle portion of the climb encompasses all other boards. " ] }, { "cell_type": "code", "execution_count": null, "id": "c32a1dda", "metadata": {}, "outputs": [], "source": [ "\n", "# Specific boulder_name\n", "fig, ax = plot_heatmap(boulder_name='6b/V4')\n", "fig.savefig('../images/02_hold_stats/all_holds_6b_V4_heatmap.png', dpi=150, bbox_inches='tight')\n", "\n", "fig, ax = plot_heatmap(boulder_name='7a/V6')\n", "fig.savefig('../images/02_hold_stats/all_holds_7a_V6_heatmap.png', dpi=150, bbox_inches='tight')\n", "\n", "# Specific boulder_name + hold type\n", "fig, ax = plot_heatmap(boulder_name='6b/V4', hold_type='start')\n", "fig.savefig('../images/02_hold_stats/start_holds_6b_V4_heatmap.png', dpi=150, bbox_inches='tight')\n", "\n", "fig, ax = plot_heatmap(boulder_name='7a+/V7', hold_type='foot')\n", "fig.savefig('../images/02_hold_stats/foot_holds_7a+_V7_heatmap.png', dpi=150, bbox_inches='tight')\n", "\n", "\n", "# Grade range (still works, now shows boulder_name in label)\n", "fig, ax = plot_heatmap(grade_range=(18, 20), hold_type='hand')\n", "fig.savefig('../images/02_hold_stats/hand_holds_18-20_heatmap.png', dpi=150, bbox_inches='tight')\n", "\n", "# All grades, specific hold type\n", "fig, ax = plot_heatmap(hold_type='finish')\n", "fig.savefig('../images/02_hold_stats/finish_holds_all_grades_heatmap.png', dpi=150, bbox_inches='tight')\n" ] }, { "cell_type": "markdown", "id": "ec96087b", "metadata": {}, "source": [ "---" ] }, { "cell_type": "markdown", "id": "c68eb1fe", "metadata": {}, "source": [ "# Default holds\n", "\n", "Each hold has a default role. Let's see the default roles." ] }, { "cell_type": "code", "execution_count": null, "id": "7a43767d", "metadata": {}, "outputs": [], "source": [ "holds_string = ''.join([f\"p{pid}r{rid}\" for pid, rid in zip(df_placements['placement_id'], df_placements['default_role_id'])])\n", "\n", "\n", "fig, ax = map_climb('Default Roles', holds_string)\n", "\n", "fig.savefig('../images/02_hold_stats/default_holds.png')\n", "plt.show()" ] }, { "cell_type": "markdown", "id": "f1495cd5", "metadata": {}, "source": [ "All of the green, blue, or pink holds are hand holds, and the orange holds are foot holds by default. Let's do a count of how many of each we have. " ] }, { "cell_type": "code", "execution_count": null, "id": "87cd49ab", "metadata": {}, "outputs": [], "source": [ "df_placements.groupby(['default_role_id']).size().reset_index(name='usage_count')" ] }, { "cell_type": "markdown", "id": "a5960bdb", "metadata": {}, "source": [ "---" ] }, { "cell_type": "markdown", "id": "a95141b1", "metadata": {}, "source": [ "# Conclusion\n", "\n", "The main result of this notebook is a geometric picture of the board: some holds and regions appear far more often than others, and hand/foot usage is not distributed uniformly across the wall.\n", "\n", "That matters for modelling. A hold is not only a location; it is also part of an empirical usage pattern. In the next notebook I build on this by assigning **difficulty information** to individual holds, first globally and then by role and angle." ] } ], "metadata": { "kernelspec": { "display_name": "Python 3", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.14.3" } }, "nbformat": 4, "nbformat_minor": 5 }