From f31bff4c1cfebdba98f10ee92ed975de1f83acd6 Mon Sep 17 00:00:00 2001 From: Stevie-Ray Hartog Date: Thu, 16 Oct 2025 21:39:00 +0200 Subject: [PATCH] Add IRCRA grading scale --- src/GradeParser.ts | 3 +- src/GradeScale.ts | 6 +- src/__tests__/GradeParser.ts | 51 +++++++++++++++- src/__tests__/scales/ircra.ts | 106 +++++++++++++++++++++++++++++++++ src/data/csvtojson.ts | 4 ++ src/data/research.csv | 109 ++++++++++++++++++++++++++++++++++ src/data/research.json | 1 + src/index.ts | 5 +- src/scales/index.ts | 11 +++- src/scales/ircra.ts | 62 +++++++++++++++++++ 10 files changed, 350 insertions(+), 8 deletions(-) create mode 100644 src/__tests__/scales/ircra.ts create mode 100644 src/data/research.csv create mode 100644 src/data/research.json create mode 100644 src/scales/ircra.ts diff --git a/src/GradeParser.ts b/src/GradeParser.ts index af2da6e..76a975b 100644 --- a/src/GradeParser.ts +++ b/src/GradeParser.ts @@ -57,7 +57,8 @@ export const convertGrade = ( return '' } const sameConversionGroup: boolean = fromScale.conversionGroup === toScale.conversionGroup - if (!sameConversionGroup) { + const isResearchConversion: boolean = fromScale.conversionGroup === 'research' || toScale.conversionGroup === 'research' + if (!sameConversionGroup && !isResearchConversion) { console.warn( `Scale: ${fromScale.displayName} doesn't support converting to Scale: ${toScale.displayName}` ) diff --git a/src/GradeScale.ts b/src/GradeScale.ts index 098b4a9..7ebe42c 100644 --- a/src/GradeScale.ts +++ b/src/GradeScale.ts @@ -26,7 +26,8 @@ export const GradeScales = { EWBANK: 'ewbank', SAXON: 'saxon', NORWEGIAN: 'norwegian', - BRAZILIAN_CRUX: 'brazilian_crux' + BRAZILIAN_CRUX: 'brazilian_crux', + IRCRA: 'ircra' } as const export type GradeScalesTypes = typeof GradeScales[keyof typeof GradeScales] @@ -35,7 +36,8 @@ export const ConversionGroups = { AID: 'aid', FREE: 'free', BOULDERING: 'bouldering', - ICE: 'ice' + ICE: 'ice', + RESEARCH: 'research' } as const export type ConversionGroupsTypes = typeof ConversionGroups[keyof typeof ConversionGroups] diff --git a/src/__tests__/GradeParser.ts b/src/__tests__/GradeParser.ts index 19d7761..ca9573f 100644 --- a/src/__tests__/GradeParser.ts +++ b/src/__tests__/GradeParser.ts @@ -1,6 +1,6 @@ import { getScoreForSort, convertGrade, getScale } from '../GradeParser' import { GradeScales } from '../GradeScale' -import { VScale, Font, YosemiteDecimal, French, Saxon, AI, WI } from '../scales' +import { VScale, Font, YosemiteDecimal, French, Saxon, AI, WI, IRCRA } from '../scales' describe('Grade Scales', () => { beforeAll(() => { @@ -322,4 +322,53 @@ describe('Grade Scales', () => { ) }) }) + + describe('IRCRA', () => { + test('32 > 1', () => { + expect(getScoreForSort('32', GradeScales.IRCRA)).toBeGreaterThan( + getScoreForSort('1', GradeScales.IRCRA) + ) + }) + + test('15 > 10', () => { + expect(getScoreForSort('15', GradeScales.IRCRA)).toBeGreaterThan( + getScoreForSort('10', GradeScales.IRCRA) + ) + }) + + test('25 > 20', () => { + expect(getScoreForSort('25', GradeScales.IRCRA)).toBeGreaterThan( + getScoreForSort('20', GradeScales.IRCRA) + ) + }) + + test('returns a GradeScale given the name', () => { + expect(getScale(GradeScales.IRCRA)).toEqual(IRCRA) + }) + + test('convert IRCRA to FONT', () => { + expect(convertGrade('14', GradeScales.IRCRA, GradeScales.FONT)).toEqual('5b+/5c') + }) + + test('convert IRCRA to VSCALE', () => { + expect(convertGrade('14', GradeScales.IRCRA, GradeScales.VSCALE)).toEqual('V2') + }) + + test('convert IRCRA to YDS', () => { + expect(convertGrade('15', GradeScales.IRCRA, GradeScales.YDS)).toEqual('5.10a') + }) + + test('convert IRCRA to French', () => { + expect(convertGrade('15', GradeScales.IRCRA, GradeScales.FRENCH)).toEqual('5c+/6a') + }) + + // Test reverse conversions + test('convert FONT to IRCRA', () => { + expect(convertGrade('6a', GradeScales.FONT, GradeScales.IRCRA)).toEqual('15/16') + }) + + test('convert VSCALE to IRCRA', () => { + expect(convertGrade('V2', GradeScales.VSCALE, GradeScales.IRCRA)).toEqual('13/14') + }) + }) }) diff --git a/src/__tests__/scales/ircra.ts b/src/__tests__/scales/ircra.ts new file mode 100644 index 0000000..8f10f26 --- /dev/null +++ b/src/__tests__/scales/ircra.ts @@ -0,0 +1,106 @@ +import { GradeBands } from '../../GradeBands' +import { IRCRA } from '../../scales' + +describe('IRCRA', () => { + describe('Get Score', () => { + test('32 > 1', () => { + const lowGrade = IRCRA.getScore('1') + const highGrade = IRCRA.getScore('32') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('15 > 10', () => { + const highGrade = IRCRA.getScore('15') + const lowGrade = IRCRA.getScore('10') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('25 > 20', () => { + const highGrade = IRCRA.getScore('25') + const lowGrade = IRCRA.getScore('20') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + }) + + describe('invalid grade format', () => { + jest.spyOn(console, 'warn').mockImplementation() + beforeEach(() => { + jest.clearAllMocks() + }) + test('invalid grade with letters', () => { + const invalidGrade = IRCRA.getScore('5a') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 5a for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + test('invalid grade with decimal', () => { + const invalidGrade = IRCRA.getScore('15.5') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 15.5 for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + test('invalid grade with plus', () => { + const invalidGrade = IRCRA.getScore('15+') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 15+ for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + test('invalid grade with minus', () => { + const invalidGrade = IRCRA.getScore('15-') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 15- for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + test('invalid grade with slash', () => { + const invalidGrade = IRCRA.getScore('15/16') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 15/16 for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + test('not IRCRA scale', () => { + const invalidGrade = IRCRA.getScore('v11') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: v11 for grade scale IRCRA') + expect(invalidGrade).toEqual(-1) + }) + }) + + describe('Get Grade', () => { + test('bottom of range', () => { + expect(IRCRA.getGrade(0)).toBe('1') + }) + + test('top of range', () => { + expect(IRCRA.getGrade(1000)).toBe('32') + }) + + test('single score provided', () => { + expect(IRCRA.getGrade(34)).toBe('9') + expect(IRCRA.getGrade(34.5)).toBe('9') + expect(IRCRA.getGrade(35)).toBe('9') + }) + test('range of scores provided', () => { + expect(IRCRA.getGrade([0.5, 2])).toBe('1') + expect(IRCRA.getGrade([8, 12])).toBe('2/4') + expect(IRCRA.getGrade([16, 17])).toBe('5') + }) + }) + + describe('Get Grade Band', () => { + test('gets Gradeband', () => { + expect(IRCRA.getGradeBand('1')).toEqual(GradeBands.BEGINNER) + expect(IRCRA.getGradeBand('32')).toEqual(GradeBands.EXPERT) + }) + }) + + describe('Is Type', () => { + test('valid IRCRA grades', () => { + expect(IRCRA.isType('1')).toBe(true) + expect(IRCRA.isType('15')).toBe(true) + expect(IRCRA.isType('32')).toBe(true) + }) + + test('invalid IRCRA grades', () => { + expect(IRCRA.isType('5a')).toBe(false) + expect(IRCRA.isType('15.5')).toBe(false) + expect(IRCRA.isType('15+')).toBe(false) + expect(IRCRA.isType('15-')).toBe(false) + expect(IRCRA.isType('15/16')).toBe(false) + expect(IRCRA.isType('v11')).toBe(false) + }) + }) +}) diff --git a/src/data/csvtojson.ts b/src/data/csvtojson.ts index 2ad5e68..681e76d 100644 --- a/src/data/csvtojson.ts +++ b/src/data/csvtojson.ts @@ -46,3 +46,7 @@ export const ICE_GRADE_TABLE: Promise = getData(CSV_PATH_ICE, JSON_P const CSV_PATH_AID = path.join(dataDir, 'aid.csv') const JSON_PATH_AID = path.join(dataDir, 'aid.json') export const AID_GRADE_TABLE: Promise = getData(CSV_PATH_AID, JSON_PATH_AID) + +const CSV_PATH_RESEARCH = path.join(dataDir, 'research.csv') +const JSON_PATH_RESEARCH = path.join(dataDir, 'research.json') +export const RESEARCH_GRADE_TABLE: Promise = getData(CSV_PATH_RESEARCH, JSON_PATH_RESEARCH) diff --git a/src/data/research.csv b/src/data/research.csv new file mode 100644 index 0000000..616d8f8 --- /dev/null +++ b/src/data/research.csv @@ -0,0 +1,109 @@ +score,ircra +0,1 +1,1 +2,1 +3,1 +4,1 +5,1 +6,2 +7,2 +8,2 +9,3 +10,3 +11,3 +12,4 +13,4 +14,4 +15,5 +16,5 +17,5 +18,6 +19,6 +20,6 +21,7 +22,7 +23,7 +24,8 +25,8 +26,8 +27,9 +28,9 +29,9 +30,9 +31,9 +32,9 +33,9 +34,9 +35,9 +36,10 +37,10 +38,10 +39,10 +40,10 +41,11 +42,11 +43,11 +44,11 +45,11 +46,12 +47,12 +48,12 +49,12 +50,12 +51,13 +52,13 +53,13 +54,13 +55,14 +56,14 +57,14 +58,15 +59,15 +60,15 +61,16 +62,16 +63,16 +64,17 +65,17 +66,17 +67,18 +68,18 +69,18 +70,19 +71,19 +72,20 +73,20 +74,20 +75,21 +76,21 +77,21 +78,22 +79,22 +80,23 +81,23 +82,23 +83,24 +84,24 +85,25 +86,25 +87,25 +88,26 +89,26 +90,27 +91,27 +92,28 +93,28 +94,29 +95,29 +96,30 +97,30 +98,31 +99,31 +100,32 +101,32 +102,32 +103,32 +104,32 +105,32 +106,32 +107,32 diff --git a/src/data/research.json b/src/data/research.json new file mode 100644 index 0000000..4aebb01 --- /dev/null +++ b/src/data/research.json @@ -0,0 +1 @@ +[{"score":0,"ircra":"1"},{"score":1,"ircra":"1"},{"score":2,"ircra":"1"},{"score":3,"ircra":"1"},{"score":4,"ircra":"1"},{"score":5,"ircra":"1"},{"score":6,"ircra":"2"},{"score":7,"ircra":"2"},{"score":8,"ircra":"2"},{"score":9,"ircra":"3"},{"score":10,"ircra":"3"},{"score":11,"ircra":"3"},{"score":12,"ircra":"4"},{"score":13,"ircra":"4"},{"score":14,"ircra":"4"},{"score":15,"ircra":"5"},{"score":16,"ircra":"5"},{"score":17,"ircra":"5"},{"score":18,"ircra":"6"},{"score":19,"ircra":"6"},{"score":20,"ircra":"6"},{"score":21,"ircra":"7"},{"score":22,"ircra":"7"},{"score":23,"ircra":"7"},{"score":24,"ircra":"8"},{"score":25,"ircra":"8"},{"score":26,"ircra":"8"},{"score":27,"ircra":"9"},{"score":28,"ircra":"9"},{"score":29,"ircra":"9"},{"score":30,"ircra":"9"},{"score":31,"ircra":"9"},{"score":32,"ircra":"9"},{"score":33,"ircra":"9"},{"score":34,"ircra":"9"},{"score":35,"ircra":"9"},{"score":36,"ircra":"10"},{"score":37,"ircra":"10"},{"score":38,"ircra":"10"},{"score":39,"ircra":"10"},{"score":40,"ircra":"10"},{"score":41,"ircra":"11"},{"score":42,"ircra":"11"},{"score":43,"ircra":"11"},{"score":44,"ircra":"11"},{"score":45,"ircra":"11"},{"score":46,"ircra":"12"},{"score":47,"ircra":"12"},{"score":48,"ircra":"12"},{"score":49,"ircra":"12"},{"score":50,"ircra":"12"},{"score":51,"ircra":"13"},{"score":52,"ircra":"13"},{"score":53,"ircra":"13"},{"score":54,"ircra":"13"},{"score":55,"ircra":"14"},{"score":56,"ircra":"14"},{"score":57,"ircra":"14"},{"score":58,"ircra":"15"},{"score":59,"ircra":"15"},{"score":60,"ircra":"15"},{"score":61,"ircra":"16"},{"score":62,"ircra":"16"},{"score":63,"ircra":"16"},{"score":64,"ircra":"17"},{"score":65,"ircra":"17"},{"score":66,"ircra":"17"},{"score":67,"ircra":"18"},{"score":68,"ircra":"18"},{"score":69,"ircra":"18"},{"score":70,"ircra":"19"},{"score":71,"ircra":"19"},{"score":72,"ircra":"20"},{"score":73,"ircra":"20"},{"score":74,"ircra":"20"},{"score":75,"ircra":"21"},{"score":76,"ircra":"21"},{"score":77,"ircra":"21"},{"score":78,"ircra":"22"},{"score":79,"ircra":"22"},{"score":80,"ircra":"23"},{"score":81,"ircra":"23"},{"score":82,"ircra":"23"},{"score":83,"ircra":"24"},{"score":84,"ircra":"24"},{"score":85,"ircra":"25"},{"score":86,"ircra":"25"},{"score":87,"ircra":"25"},{"score":88,"ircra":"26"},{"score":89,"ircra":"26"},{"score":90,"ircra":"27"},{"score":91,"ircra":"27"},{"score":92,"ircra":"28"},{"score":93,"ircra":"28"},{"score":94,"ircra":"29"},{"score":95,"ircra":"29"},{"score":96,"ircra":"30"},{"score":97,"ircra":"30"},{"score":98,"ircra":"31"},{"score":99,"ircra":"31"},{"score":100,"ircra":"32"},{"score":101,"ircra":"32"},{"score":102,"ircra":"32"},{"score":103,"ircra":"32"},{"score":104,"ircra":"32"},{"score":105,"ircra":"32"},{"score":106,"ircra":"32"},{"score":107,"ircra":"32"}] \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index a900ae2..01c246b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { convertGrade } from './GradeParser' import { GradeBands, GradeBandTypes } from './GradeBands' -import { AI, Aid, Ewbank, Font, French, Norwegian, Saxon, UIAA, VScale, WI, YosemiteDecimal, BrazilianCrux } from './scales' +import { AI, Aid, Ewbank, Font, French, Norwegian, Saxon, UIAA, VScale, WI, YosemiteDecimal, BrazilianCrux, IRCRA } from './scales' // Free Climbing Grades // YDS @@ -312,5 +312,6 @@ export { VScale, WI, YosemiteDecimal, - BrazilianCrux + BrazilianCrux, + IRCRA } diff --git a/src/scales/index.ts b/src/scales/index.ts index 7197fed..57b0beb 100644 --- a/src/scales/index.ts +++ b/src/scales/index.ts @@ -10,8 +10,9 @@ import Aid from './aid' import WI from './wi' import BrazilianCrux from './brazilian' import UIAA from './uiaa' +import IRCRA from './ircra' import GradeScale, { GradeScales } from '../GradeScale' -export { Aid, VScale, Font, YosemiteDecimal, French, Saxon, UIAA, Ewbank, AI, WI, Norwegian, BrazilianCrux } +export { Aid, VScale, Font, YosemiteDecimal, French, Saxon, UIAA, Ewbank, AI, WI, Norwegian, BrazilianCrux, IRCRA } export interface Boulder { score: number @@ -41,6 +42,11 @@ export interface AidGrade { aid: string } +export interface ResearchGrade { + score: number + ircra: string +} + export const scales: Record< typeof GradeScales[keyof typeof GradeScales], GradeScale | null @@ -56,5 +62,6 @@ GradeScale | null [GradeScales.BRAZILIAN_CRUX]: BrazilianCrux, [GradeScales.AI]: AI, [GradeScales.WI]: WI, - [GradeScales.AID]: Aid + [GradeScales.AID]: Aid, + [GradeScales.IRCRA]: IRCRA } diff --git a/src/scales/ircra.ts b/src/scales/ircra.ts new file mode 100644 index 0000000..8817a37 --- /dev/null +++ b/src/scales/ircra.ts @@ -0,0 +1,62 @@ +import GradeScale, { findScoreRange, getAvgScore, GradeScales, ConversionGroups, Tuple } from '../GradeScale' +import research from '../data/research.json' +import { GradeBandTypes, routeScoreToBand } from '../GradeBands' +import { ResearchGrade } from '.' + +const ircraGradeRegex = /^(\d{1,2})$/ +const isIRCRA = (grade: string): RegExpMatchArray | null => grade.match(ircraGradeRegex) + +// IRCRA (International Rock Climbing Research Association) grading system +// Uses simple numeric values from 1-32 representing difficulty levels +// This is a relatively new grading system designed to be more precise than traditional systems + +const IRCRAScale: GradeScale = { + displayName: 'IRCRA Scale', + name: GradeScales.IRCRA, + offset: 3000, + conversionGroup: ConversionGroups.RESEARCH, + isType: (grade: string): boolean => { + if (isIRCRA(grade) === null) { + return false + } + return true + }, + getScore: (grade: string): number | Tuple => { + return getScore(grade) + }, + getGrade: (score: number | Tuple): string => { + const validateScore = (score: number): number => { + const validScore = Number.isInteger(score) ? score : Math.ceil(score) + return Math.min(Math.max(0, validScore), research.length - 1) + } + + if (typeof score === 'number') { + return research[validateScore(score)].ircra + } + + const low: string = research[validateScore(score[0])].ircra + const high: string = research[validateScore(score[1])].ircra + if (low === high) return low + return `${low}/${high}` + }, + getGradeBand: (grade: string): GradeBandTypes => { + const score = getScore(grade) + return routeScoreToBand(getAvgScore(score)) + } +} + +const getScore = (grade: string): number | Tuple => { + const parse = isIRCRA(grade) + if (parse == null) { + console.warn(`Unexpected grade format: ${grade} for grade scale IRCRA`) + return -1 + } + const [, basicGrade] = parse + const basicScore = findScoreRange((r: ResearchGrade) => { + return r.ircra === basicGrade + }, research) + + return basicScore +} + +export default IRCRAScale