Mountain Project Logo

Feature Request: Average Grade / Difficulty Score in Tick Breakdown & Pitches by Year

Original Post
Hunter Creed · · Unknown Hometown · Joined Oct 2013 · Points: 602

I built a working demo of this on my own tick list so you can see exactly what it'd look like, not just describe it: https://claude.ai/code/artifact/b93e6783-e2c6-4611-89cc-970cd084d251

Example:


The idea: add a calculated average difficulty score to personal profiles, based on ticked routes. Not just a flat average either -- a route you onsight-led counts more than one you top-roped, and (optionally) a route from this season counts more than one from a decade ago. Simple example: 5.7 + 5.9 doesn't have to just be "5.8 avg" -- it can weight for how it was climbed and when.


Where it'd slot in naturally:

Tick Breakdown -- same four windows you already have (Last 90 Days / Last Year / Last 5 Years / All Time), each showing an average grade alongside the existing pitch/day counts.

Pitches by Year -- same idea, plotted as a trend across the x-axis, so climbers can see their average grade progress year over year instead of just volume.

Why it's useful: it gives climbers a real way to track progression and set goals, and it'd make Partner Finder matches more accurate than self-reported grades, which everyone knows run optimistic.


I put together a small, dependency-free JS reference implementation showing the math end-to-end (grade parsing, the weighting, and both the Tick Breakdown and Pitches-by-Year groupings) -- pasted below if anyone wants to see it work or build on it directly. [insert into JS or your AI model of choice]

/**

 * Average Climbing Grade — reference implementation

 *

 Proof-of-concept for a Mountain Project feature request: a weighted average difficulty score per climber, computable over any span of ticks (all time, last 5 years, last year, last 90 days — matching the existing "Tick Breakdown" windows — or grouped by year, matching "Pitches by Year").

 *

 Why not a plain average? A plain mean treats a top-roped 5.7 from a decade ago the same as an onsight lead last week. This weights each tick by (a) how it was climbed and (b) optionally, how recently — before averaging.

 *

No dependencies. Runs as-is in Node or a browser console. YDS (5.x) grades only — ice (WI), aid (A/C), and boulder (V) grades live on different scales and would need their own averageGrade() pass rather than being mixed into this one./

// ---- 1. Grade <-> numeric scale ------------------------------------------

// 5.0-5.9 map straight to 0-9 (+/- shifts by 0.3). 5.10 and up move to a

// continuous integer scale so a/b/c/d sort correctly across number grades:

// 5.10a=10, 5.10b=11, 5.10c=12, 5.10d=13, 5.11a=14 ...

const LETTER_OFFSET = { a: 0, b: 1, c: 2, d: 3 };

function gradeToNumber(grade) {

  const g = grade.trim();

  if (g.includes('/')) {

    // slash grades ("5.10a/b") average their two endpoints

    const [first, ...rest] = g.split('/');

    const base = first.match(/^5\.(\d+)/)[1];

    const vals = [first, ...rest.map(p => (/^[a-d][+-]?$/.test(p) ? `5.${base}${p}` : p))]

      .map(gradeToNumber)

      .filter(v => v !== null);

    return vals.length ? vals.reduce((a, b) => a + b, 0) / vals.length : null;

  }

  const m = g.match(/^5\.(\d+)([+-]?)([a-d]?)$/);

  if (!m) return null; // not a YDS grade

  const [, numStr, mod, letter] = m;

  const num = parseInt(numStr, 10);

  if (num <= 9) {

    return num + (mod === '+' ? 0.3 : mod === '-' ? -0.3 : 0);

  }

  const base = 10 + (num - 10) * 4;

  if (letter) return base + LETTER_OFFSET[letter];

  return base + (mod === '+' ? 3 : mod === '-' ? 0 : 1.5); // bare "5.10" -> midpoint

}

function numberToGrade(v) {

  if (v <= 9.5) {

    const whole = Math.round(v);

    const frac = v - whole;

    return frac > 0.15 ? `5.${whole}+` : frac < -0.15 ? `5.${whole}-` : `5.${whole}`;

  }

  const rem = v - 10;

  const num = 10 + Math.floor(rem / 4);

  const letter = ['a', 'b', 'c', 'd'][Math.min(3, Math.max(0, Math.round(rem % 4)))];

  return `5.${num}${letter}`;

}

// ---- 2. Weights ------------------------------------------------------------

// Style: how the route was climbed. A clean lead demonstrates the grade;

// a top-rope or an attempt demonstrates less (or nothing) of it.

const STYLE_WEIGHT = {

  onsight: 1.0, flash: 1.0, redpoint: 1.0, pinkpoint: 1.0, lead: 1.0, solo: 1.0,

  'fell/hung': 0.7,   // worked/rehearsed, not clean, but completed

  tr: 0.6,            // top-rope — didn't lead it

  follow: 0.5,        // seconding a multi-pitch

  attempt: 0, dnf: 0, // excluded — route wasn't finished

};

// Recency: optional exponential decay so a current tick counts more than

// an old one. Off by default — turn it on for a single "your current

// grade" headline number; leave it off when you're already bucketing by

// time window (Tick Breakdown) or by year (Pitches by Year), since the

// bucket itself is doing the recency filtering.

function recencyWeight(tickDate, referenceDate = new Date(), halfLifeYears = 3) {

  const yearsAgo = (referenceDate - tickDate) / (1000 * 60 * 60 * 24 * 365.25);

  return Math.pow(0.5, yearsAgo / halfLifeYears);

}

// ---- 3. Core equation -------------------------------------------------------

//   D = Σ(wᵢ · gᵢ) / Σ(wᵢ),   wᵢ = styleWeight(i) · recencyWeight(i)

//

// tick shape: { grade: '5.10a', style: 'onsight', date: '2024-06-01' }

function averageGrade(ticks, { useRecency = false, halfLifeYears = 3, referenceDate = new Date() } = {}) {

  let num = 0, den = 0, counted = 0;

  for (const t of ticks) {

    const g = gradeToNumber(t.grade);

    if (g === null) continue; // not a YDS grade — skip (ice/boulder need their own pass)

    const s = STYLE_WEIGHT[(t.style || '').toLowerCase()] ?? 0.5;

    if (s === 0) continue;    // attempt / DNF

    const r = useRecency ? recencyWeight(new Date(t.date), referenceDate, halfLifeYears) : 1;

    const w = s * r;

    num += g * w;

    den += w;

    counted++;

  }

  if (den === 0) return null;

  return { numeric: +(num / den).toFixed(2), grade: numberToGrade(num / den), ticksCounted: counted, ticksTotal: ticks.length };

}

// ---- 4. "Tick Breakdown" x-axis: bucket by MP's existing windows -----------

function averageGradeByWindow(ticks, referenceDate = new Date()) {

  const DAY = 1000 * 60 * 60 * 24;

  const windows = {

    'Last 90 Days': 90 * DAY,

    'Last Year': 365 * DAY,

    'Last 5 Years': 5 * 365 * DAY,

    'All Time': Infinity,

  };

  const out = {};

  for (const [label, span] of Object.entries(windows)) {

    const subset = ticks.filter(t => referenceDate - new Date(t.date) <= span);

    out[label] = averageGrade(subset);

  }

  return out;

}

// ---- 5. "Pitches by Year" x-axis: same idea, grouped by calendar year ------

function averageGradeByYear(ticks) {

  const byYear = {};

  for (const t of ticks) {

    const y = new Date(t.date).getFullYear();

    (byYear[y] ??= []).push(t);

  }

  const out = {};

  for (const [year, subset] of Object.entries(byYear).sort()) {

    out[year] = averageGrade(subset);

  }

  return out;

}

// ---- Example -----------------------------------------------------------

const sampleTicks = [

  { grade: '5.7',   style: 'onsight',  date: '2022-04-04' },

  { grade: '5.9',   style: 'lead',     date: '2023-05-16' },

  { grade: '5.10a', style: 'redpoint', date: '2025-06-01' },

  { grade: '5.8',   style: 'tr',       date: '2021-01-10' },

  { grade: '5.9',   style: 'attempt',  date: '2025-08-02' }, // excluded — not finished

];

console.log('Overall (unweighted for recency):', averageGrade(sampleTicks));

// -> { numeric: 8.13, grade: '5.8', ticksCounted: 5 }

console.log('Overall, current-form headline (recency-weighted):', averageGrade(sampleTicks, { useRecency: true }));

console.log('Tick Breakdown:', averageGradeByWindow(sampleTicks));

console.log('Pitches by Year:', averageGradeByYear(sampleTicks));

module.exports = { gradeToNumber, numberToGrade, averageGrade, averageGradeByWindow, averageGradeByYear };

]

Happy to answer questions or adjust the approach -- just wanted to make this concrete instead of hand-wavy. Would be great to see added to the site!

-Hunter

Guideline #1: Don't be a jerk.

Discuss MountainProject.com
Post a Reply to "Feature Request: Average Grade / Difficulty Sco…"

Log In to Reply
Welcome

Join the Community! It's FREE

Already have an account? Login to close this notice.