From 0517a8650462c0e5d57f80cf197a112df82c7bbc Mon Sep 17 00:00:00 2001 From: Nathan Musoke Date: Tue, 11 Apr 2023 18:41:01 -0400 Subject: [PATCH] feat: US aid grades Add a US scale for aid grades. It treats A# and C# as being part of the same scale, so that different grade contexts do not need to be assigned to neighbouring routes, e.g. if there is a grade with grade A2 next to a route with grade C1. Aid + mandatory free is not covered by this commit. Resolves: https://github.com/OpenBeta/sandbag/issues/79 --- README.md | 6 +- src/GradeScale.ts | 1 + src/data/aid.csv | 37 +++++++++++ src/data/aid.json | 1 + src/data/csvtojson.ts | 19 +++++- src/index.ts | 3 +- src/scales/__tests__/ai.ts | 2 +- src/scales/__tests__/aid.ts | 127 ++++++++++++++++++++++++++++++++++++ src/scales/__tests__/wi.ts | 2 +- src/scales/aid.ts | 61 +++++++++++++++++ src/scales/index.ts | 11 +++- 11 files changed, 263 insertions(+), 7 deletions(-) create mode 100644 src/data/aid.csv create mode 100644 src/data/aid.json create mode 100644 src/scales/__tests__/aid.ts create mode 100644 src/scales/aid.ts diff --git a/README.md b/README.md index 4ad8f46..2466306 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Javascript utilities for working with rock climbing grades. -### Supported systems +### Supported systems **Sport & Traditional climbing** - [x] Yosemite Decimal System @@ -19,6 +19,10 @@ Javascript utilities for working with rock climbing grades. - [x] Vermin (V-scale) - [x] Fontainebleau +**Aid** +- [x] A# & C# +- [ ] Aid with mandatory free climbing (5.8 A0, etc) + **Ice** - [x] Winter Ice (WI#) - [x] Alpine Ice (AI#) diff --git a/src/GradeScale.ts b/src/GradeScale.ts index cfc373e..43877f7 100644 --- a/src/GradeScale.ts +++ b/src/GradeScale.ts @@ -15,6 +15,7 @@ export default interface GradeScale { export const GradeScales = { AI: 'ai', + AID: 'aid', WI: 'wi', VSCALE: 'vscale', YDS: 'yds', diff --git a/src/data/aid.csv b/src/data/aid.csv new file mode 100644 index 0000000..bf17f0c --- /dev/null +++ b/src/data/aid.csv @@ -0,0 +1,37 @@ +Score,Aid +0,A0 +1,A0 +2,A0 +3,A0 +4,A1 +5,A1 +6,A1 +7,A1 +8,A2 +9,A2 +10,A2 +11,A2 +12,A2+ +13,A2+ +14,A2+ +15,A2+ +16,A3 +17,A3 +18,A3 +19,A3 +20,A3+ +21,A3+ +22,A3+ +23,A3+ +24,A4 +25,A4 +26,A4 +27,A4 +28,A4+ +29,A4+ +30,A4+ +31,A4+ +32,A5 +33,A5 +34,A5 +35,A5 diff --git a/src/data/aid.json b/src/data/aid.json new file mode 100644 index 0000000..5f16850 --- /dev/null +++ b/src/data/aid.json @@ -0,0 +1 @@ +[{"score":0,"aid":"A0"},{"score":1,"aid":"A0"},{"score":2,"aid":"A0"},{"score":3,"aid":"A0"},{"score":4,"aid":"A1"},{"score":5,"aid":"A1"},{"score":6,"aid":"A1"},{"score":7,"aid":"A1"},{"score":8,"aid":"A2"},{"score":9,"aid":"A2"},{"score":10,"aid":"A2"},{"score":11,"aid":"A2"},{"score":12,"aid":"A2+"},{"score":13,"aid":"A2+"},{"score":14,"aid":"A2+"},{"score":15,"aid":"A2+"},{"score":16,"aid":"A3"},{"score":17,"aid":"A3"},{"score":18,"aid":"A3"},{"score":19,"aid":"A3"},{"score":20,"aid":"A3+"},{"score":21,"aid":"A3+"},{"score":22,"aid":"A3+"},{"score":23,"aid":"A3+"},{"score":24,"aid":"A4"},{"score":25,"aid":"A4"},{"score":26,"aid":"A4"},{"score":27,"aid":"A4"},{"score":28,"aid":"A4+"},{"score":29,"aid":"A4+"},{"score":30,"aid":"A4+"},{"score":31,"aid":"A4+"},{"score":32,"aid":"A5"},{"score":33,"aid":"A5"},{"score":34,"aid":"A5"},{"score":35,"aid":"A5"}] \ No newline at end of file diff --git a/src/data/csvtojson.ts b/src/data/csvtojson.ts index 3d5afc0..f443f1e 100644 --- a/src/data/csvtojson.ts +++ b/src/data/csvtojson.ts @@ -3,7 +3,7 @@ import csv from 'csv-parser' import * as fs from 'fs' -import { Boulder, Route, IceGrade } from '../scales' +import { AidGrade, Boulder, Route, IceGrade } from '../scales' import path from 'path' const boulderGrades: Boulder[] = [] @@ -70,3 +70,20 @@ fs.createReadStream(path.join(process.cwd(), 'src/data/ice.csv')) const data = JSON.stringify(iceGrades) fs.writeFileSync(`${writeDir}/ice.json`, data) }) + +const aidGrades: AidGrade[] = [] +fs.createReadStream(path.join(process.cwd(), 'src/data/aid.csv')) + .pipe(csv()) + .on('data', (data) => { + if (data.Aid === '' && data.Aid === '') { + return + } + aidGrades.push({ + score: parseInt(data.Score, 10), + aid: data.Aid + }) + }) + .on('end', () => { + const data = JSON.stringify(aidGrades) + fs.writeFileSync(`${writeDir}/aid.json`, data) + }) diff --git a/src/index.ts b/src/index.ts index 52eef2a..83b25c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,7 +8,7 @@ import { convertGrade } from './GradeParser' import { GradeBands, GradeBandTypes } from './GradeBands' -import { AI, Ewbank, Font, French, Norwegian, Saxon, UIAA, VScale, WI, YosemiteDecimal } from './scales' +import { AI, Aid, Ewbank, Font, French, Norwegian, Saxon, UIAA, VScale, WI, YosemiteDecimal } from './scales' // Free Climbing Grades // YDS @@ -302,6 +302,7 @@ export { export { AI, + Aid, Ewbank, Font, French, diff --git a/src/scales/__tests__/ai.ts b/src/scales/__tests__/ai.ts index 3e018b2..c321672 100644 --- a/src/scales/__tests__/ai.ts +++ b/src/scales/__tests__/ai.ts @@ -64,7 +64,7 @@ describe('AI', () => { expect(score).toEqual(-1) }) - test('not aid scale', () => { + test('not AI scale', () => { const score = AI.getScore('v11') expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: v11 for grade scale AI') expect(score).toEqual(-1) diff --git a/src/scales/__tests__/aid.ts b/src/scales/__tests__/aid.ts new file mode 100644 index 0000000..31c2301 --- /dev/null +++ b/src/scales/__tests__/aid.ts @@ -0,0 +1,127 @@ +import { Aid } from '../../scales' + +describe('Aid', () => { + describe('Get Score', () => { + describe('valid grade formats', () => { + jest.spyOn(console, 'warn').mockImplementation() + beforeEach(() => { + jest.clearAllMocks() + }) + + test('basic grade A', () => { + const score = Aid.getScore('A0') + expect(console.warn).not.toHaveBeenCalled() + expect(score).not.toEqual(-1) + }) + + test('basic grade C', () => { + const score = Aid.getScore('C0') + expect(console.warn).not.toHaveBeenCalled() + expect(score).not.toEqual(-1) + }) + + test('valid + modifier', () => { + const score = Aid.getScore('A3+') + expect(console.warn).not.toHaveBeenCalled() + expect(score).not.toEqual(-1) + }) + + test.failing('mandatory free', () => { + const score = Aid.getScore('5.9 A0') + expect(console.warn).not.toHaveBeenCalled() + expect(score).not.toEqual(-1) + }) + }) + + describe('invalid grade formats', () => { + jest.spyOn(console, 'warn').mockImplementation() + beforeEach(() => { + jest.clearAllMocks() + }) + + test('A6 out of range', () => { + const score = Aid.getScore('A6') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: A6 for grade scale Aid') + expect(score).toEqual(-1) + }) + + test('invalid minus modifier', () => { + const score = Aid.getScore('A3-') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: A3- for grade scale Aid') + expect(score).toEqual(-1) + }) + + test('invalid plus modifier', () => { + const score = Aid.getScore('A5+') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: A5+ for grade scale Aid') + expect(score).toEqual(-1) + }) + + test('plain YDS grade', () => { + const score = Aid.getScore('5.9') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: 5.9 for grade scale Aid') + expect(score).toEqual(-1) + }) + + test('slash grade', () => { + const score = Aid.getScore('A0/A1') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: A0/A1 for grade scale Aid') + expect(score).toEqual(-1) + }) + + test('not aid scale', () => { + const score = Aid.getScore('v11') + expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: v11 for grade scale Aid') + expect(score).toEqual(-1) + }) + }) + + describe('correct relative scores', () => { + test('C0 = A0', () => { + const aGrade = Aid.getScore('A0') + const cGrade = Aid.getScore('C0') + expect(cGrade).toEqual(aGrade) + }) + + test('A3+ > A1', () => { + const lowGrade = Aid.getScore('A1') + const highGrade = Aid.getScore('A3+') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('A4 > A3+', () => { + const lowGrade = Aid.getScore('A3+') + const highGrade = Aid.getScore('A4') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('C4 > C3+', () => { + const lowGrade = Aid.getScore('A3+') + const highGrade = Aid.getScore('A4') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('C4 > A3+', () => { + const lowGrade = Aid.getScore('A3+') + const highGrade = Aid.getScore('A4') + expect(highGrade[0]).toBeGreaterThan(lowGrade[1]) + }) + + test('C5 = A5', () => { + const aGrade = Aid.getScore('A5') + const cGrade = Aid.getScore('C5') + expect(cGrade).toEqual(aGrade) + }) + }) + }) + + describe('Get Grade', () => { + test('bottom of range', () => { + expect(Aid.getGrade(0)).toBe('A0') + }) + + test('top of range', () => { + expect(Aid.getGrade(Infinity)).toBe('A5') + }) + }) +}) diff --git a/src/scales/__tests__/wi.ts b/src/scales/__tests__/wi.ts index e82efb4..6926f7b 100644 --- a/src/scales/__tests__/wi.ts +++ b/src/scales/__tests__/wi.ts @@ -64,7 +64,7 @@ describe('WI', () => { expect(score).toEqual(-1) }) - test('not aid scale', () => { + test('not WI scale', () => { const score = WI.getScore('v11') expect(console.warn).toHaveBeenCalledWith('Unexpected grade format: v11 for grade scale WI') expect(score).toEqual(-1) diff --git a/src/scales/aid.ts b/src/scales/aid.ts new file mode 100644 index 0000000..48b27d3 --- /dev/null +++ b/src/scales/aid.ts @@ -0,0 +1,61 @@ +import GradeScale, { findScoreRange, getAvgScore, GradeScales, Tuple } from '../GradeScale' +import aid_table from '../data/aid.json' +import { AidGrade } from '.' +import { GradeBandTypes, routeScoreToBand } from '../GradeBands' + +// Supports [AC]0 -> [AC]5, with + grades on [AC]2 -> [AC]4 and no slash grades +// https://en.wikipedia.org/wiki/Grade_(climbing)#Clean_scale +const aidGradeRegex = /^([AC])([0-5]|[2-4]\+)$/i +const isAid = (grade: string): RegExpMatchArray | null => grade.match(aidGradeRegex) + +const AidScale: GradeScale = { + displayName: 'Aid Grade', + name: GradeScales.AID, + offset: 1000, + allowableConversionType: [], + isType: (grade: string): boolean => { + if (isAid(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), aid_table.length - 1) + } + + if (typeof score === 'number') { + return aid_table[validateScore(score)].aid + } + + const low: string = aid_table[validateScore(score[0])].aid + const high: string = aid_table[validateScore(score[1])].aid + 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 = isAid(grade) + if (parse == null) { + console.warn(`Unexpected grade format: ${grade} for grade scale Aid`) + return -1 + } + const [wholeMatch, AorC, gradeNum] = parse // eslint-disable-line @typescript-eslint/no-unused-vars + + const score = findScoreRange((r: AidGrade) => { + return r.aid === ('A' + gradeNum) + }, aid_table) + + return score +} + +export default AidScale diff --git a/src/scales/index.ts b/src/scales/index.ts index acb155e..db22e8c 100644 --- a/src/scales/index.ts +++ b/src/scales/index.ts @@ -7,10 +7,11 @@ import Ewbank from './ewbank' import Saxon from './saxon' import Norwegian from './norwegian' import AI from './ai' +import Aid from './aid' import WI from './wi' import UIAA from './uiaa' import GradeScale, { GradeScales } from '../GradeScale' -export { VScale, Font, YosemiteDecimal, French, Saxon, UIAA, Ewbank, AI, WI, Norwegian } +export { Aid, VScale, Font, YosemiteDecimal, French, Saxon, UIAA, Ewbank, AI, WI, Norwegian } export interface Boulder { score: number @@ -36,6 +37,11 @@ export interface IceGrade { wi: string } +export interface AidGrade { + score: number + aid: string +} + export const scales: Record< typeof GradeScales[keyof typeof GradeScales], GradeScale | null @@ -49,5 +55,6 @@ GradeScale | null [GradeScales.SAXON]: Saxon, [GradeScales.NORWEGIAN]: Norwegian, [GradeScales.AI]: AI, - [GradeScales.WI]: WI + [GradeScales.WI]: WI, + [GradeScales.AID]: Aid }