Skip to content

Commit

Permalink
feat: US aid grades
Browse files Browse the repository at this point in the history
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: #79
  • Loading branch information
musoke committed Jun 4, 2023
1 parent b34020e commit 38e6ca2
Show file tree
Hide file tree
Showing 11 changed files with 263 additions and 7 deletions.
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

Javascript utilities for working with rock climbing grades.

### Supported systems
### Supported systems

**Sport & Traditional climbing**
- [x] Yosemite Decimal System
Expand All @@ -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#)
Expand Down
1 change: 1 addition & 0 deletions src/GradeScale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export default interface GradeScale {

export const GradeScales = {
AI: 'ai',
AID: 'aid',
WI: 'wi',
VSCALE: 'vscale',
YDS: 'yds',
Expand Down
37 changes: 37 additions & 0 deletions src/data/aid.csv
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions src/data/aid.json
Original file line number Diff line number Diff line change
@@ -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"}]
19 changes: 18 additions & 1 deletion src/data/csvtojson.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = []
Expand Down Expand Up @@ -69,3 +69,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)
})
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
convertGrade
} from './GradeParser'
import { GradeBands, GradeBandTypes } from './GradeBands'
import { AI, Ewbank, Font, French, Saxon, UIAA, VScale, WI, YosemiteDecimal } from './scales'
import { Aid, AI, Ewbank, Font, French, Saxon, UIAA, VScale, WI, YosemiteDecimal } from './scales'

// Free Climbing Grades
// YDS
Expand Down Expand Up @@ -263,6 +263,7 @@ export {

export {
AI,
Aid,
Ewbank,
Font,
French,
Expand Down
2 changes: 1 addition & 1 deletion src/scales/__tests__/ai.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
127 changes: 127 additions & 0 deletions src/scales/__tests__/aid.ts
Original file line number Diff line number Diff line change
@@ -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')
})
})
})
2 changes: 1 addition & 1 deletion src/scales/__tests__/wi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 61 additions & 0 deletions src/scales/aid.ts
Original file line number Diff line number Diff line change
@@ -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
11 changes: 9 additions & 2 deletions src/scales/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@ import French from './french'
import Ewbank from './ewbank'
import Saxon from './saxon'
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 }
export { VScale, Font, YosemiteDecimal, French, Saxon, UIAA, Ewbank, AI, WI, Aid }

export interface Boulder {
score: number
Expand All @@ -34,6 +35,11 @@ export interface IceGrade {
wi: string
}

export interface AidGrade {
score: number
aid: string
}

export const scales: Record<
typeof GradeScales[keyof typeof GradeScales],
GradeScale | null
Expand All @@ -46,5 +52,6 @@ GradeScale | null
[GradeScales.EWBANK]: Ewbank,
[GradeScales.SAXON]: Saxon,
[GradeScales.AI]: AI,
[GradeScales.WI]: WI
[GradeScales.WI]: WI,
[GradeScales.AID]: Aid
}

0 comments on commit 38e6ca2

Please sign in to comment.