forked from OurHike/OurHike
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathelevationGain.test.ts
More file actions
193 lines (165 loc) · 7.28 KB
/
Copy pathelevationGain.test.ts
File metadata and controls
193 lines (165 loc) · 7.28 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
// Tests for elevationGain.ts.
//
// Why this exists
// ---------------
// Summing every rise in the 25 m elevation profile over-counts the full AT by
// ~17% - 594,520 ft against a ~510,000 ft consensus. The profile is right; the
// sum is wrong, because summing is the one operation that turns DEM
// measurement error into signal. That number feeds naismith.ts, so it is a
// hiking time estimate that is wrong, not just a figure on a screen.
//
// Two ways to be wrong, and both are held at once below:
// - count the noise, and every estimate downstream inflates;
// - reject it with something that also shaves real climbs, and steep
// pitches get under-counted exactly where a hiker wants them counted.
//
// The shared-vector block at the bottom is the load-bearing part. The same
// algorithm lives in pipeline/lib/elevation_gain.py, and two implementations
// of one number drift the first time someone fixes an edge case in one
// language without opening the other file. Both suites read one JSON table,
// so that is a failing test rather than a silent disagreement.
import { existsSync, readFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { describe, expect, it } from 'vitest'
import {
cumulativeGain,
cumulativeGainOverGaps,
cumulativeGainOverProfile,
gainBetween,
rawCumulativeGain,
THRESHOLD_FT,
THRESHOLD_M,
type ProfileSample,
} from './elevationGain'
const T = 3
describe('real climbs are counted at their real size', () => {
it('counts a single climb whole', () => {
expect(cumulativeGain([100, 200, 300, 400], T)).toBeCloseTo(300)
})
it('does not shave a climb by the threshold it was filtered with', () => {
// The failure mode of the obvious implementation: carrying a running
// reference and adding whenever it moves past the threshold loses up to
// one threshold at the top of every climb.
expect(cumulativeGain([0, 500, 0, 500, 0, 500], T)).toBeCloseTo(1500)
})
it('counts a gentle climb whose every step is under the threshold', () => {
const profile = Array.from({ length: 101 }, (_, i) => i)
expect(cumulativeGain(profile, T)).toBeCloseTo(100)
})
it('counts a climb the window ends on', () => {
expect(cumulativeGain([0, 100, 50, 400], T)).toBeCloseTo(450)
})
it('measures from the true low rather than the first sample', () => {
expect(cumulativeGain([100, 98, 500], T)).toBeCloseTo(402)
})
})
describe('noise is not climbing', () => {
it('finds no gain in flat ground with DEM jitter', () => {
expect(cumulativeGain([1000, 1000.4, 999.3, 1000.2, 999.7, 1000.6, 999.5], T)).toBe(0)
})
it('drops a swing just under the threshold whole', () => {
expect(cumulativeGain([100, 102.9, 100, 102.9, 100], T)).toBe(0)
})
it('keeps a swing just over the threshold whole', () => {
expect(cumulativeGain([100, 103.1, 100, 103.1], T)).toBeCloseTo(6.2)
})
it('does not let denser sampling manufacture more climbing', () => {
// Why "sample more finely" is not the fix. On the same flat ground,
// doubling the samples doubles the fake gain, forever.
const coarse = Array.from({ length: 100 }, (_, i) => 1000 + (i % 2 ? -0.4 : 0.3))
const fine = Array.from({ length: 200 }, (_, i) => 1000 + (i % 2 ? -0.4 : 0.3))
expect(rawCumulativeGain(fine)).toBeGreaterThan(1.9 * rawCumulativeGain(coarse))
expect(cumulativeGain(fine, T)).toBe(0)
expect(cumulativeGain(coarse, T)).toBe(0)
})
})
describe('DEM coverage gaps', () => {
it('does not bridge a gap into a climb nobody made', () => {
expect(cumulativeGainOverGaps([100, 110, null, 3000, 3010], T)).toBeCloseTo(20)
})
it('still measures each side of a gap', () => {
expect(cumulativeGainOverGaps([0, 500, null, 0, 500], T)).toBeCloseTo(1000)
})
it('treats a NaN the same as a null', () => {
// A profile parsed from JSON can produce one; counting NaN as an
// elevation poisons the whole running total to NaN, which then renders
// as an empty ascent figure rather than as an error.
expect(cumulativeGainOverGaps([100, 110, NaN, 3000, 3010], T)).toBeCloseTo(20)
})
})
describe('windowing', () => {
const profile: ProfileSample[] = [
{ distanceMi: 0, elevationFt: 1000 },
{ distanceMi: 1, elevationFt: 2000 },
{ distanceMi: 2, elevationFt: 1000 },
{ distanceMi: 3, elevationFt: 2000 },
]
it('uses only the requested window', () => {
expect(gainBetween(profile, 0, 1)).toBeCloseTo(1000)
expect(gainBetween(profile, 0, 3)).toBeCloseTo(2000)
})
it('has no gain for a window too short to hold two samples', () => {
// On a 25 m profile, asking about the next tenth of a mile is a
// reasonable question that happens to select one sample.
expect(gainBetween(profile, 0, 0.01)).toBe(0)
})
it('defaults to the shared threshold', () => {
expect(THRESHOLD_FT * 0.3048).toBeCloseTo(THRESHOLD_M)
})
})
describe('shared with the Python implementation', () => {
interface Vector {
name: string
elevations: (number | null)[]
threshold: number
expected_gain: number
}
interface BoundaryVector {
name: string
samples: { elevation_ft: number | null; part_start?: boolean }[]
threshold: number
expected_gain: number
}
// Walked up from the working directory rather than resolved from
// import.meta.url: Vitest transforms this module, so its import.meta.url is
// not a file: URL and fileURLToPath throws on it. Walking also survives the
// suite being run from the repo root instead of client/.
const findRepoFile = (relative: string): string => {
let dir = process.cwd()
for (;;) {
const candidate = resolve(dir, relative)
if (existsSync(candidate)) return candidate
const parent = dirname(dir)
if (parent === dir) throw new Error(`${relative} not found above ${process.cwd()}`)
dir = parent
}
}
const vectors = JSON.parse(
readFileSync(findRepoFile('pipeline/reference/gain_vectors.json'), 'utf8'),
) as { cases: Vector[]; gap_cases: Vector[]; boundary_cases: BoundaryVector[] }
it('has vectors to run', () => {
// A vector file that silently emptied would turn every case below into
// zero cases, and a suite that runs nothing passes.
expect(vectors.cases.length).toBeGreaterThanOrEqual(10)
expect(vectors.gap_cases.length).toBeGreaterThanOrEqual(3)
expect(vectors.boundary_cases.length).toBeGreaterThanOrEqual(5)
})
it.each(vectors.cases)('$name', ({ elevations, threshold, expected_gain }) => {
expect(cumulativeGain(elevations as number[], threshold)).toBeCloseTo(expected_gain)
})
it.each(vectors.gap_cases)('$name', ({ elevations, threshold, expected_gain }) => {
expect(cumulativeGainOverGaps(elevations, threshold)).toBeCloseTo(expected_gain)
})
// #559's break, and the reason these carry `samples` rather than
// `elevations`: a centerline part boundary is a marker on a record, not a
// value in a list. It is not a DEM gap either - the measurement is fine and
// the TRAIL is discontinuous, so the step across it is not a slope.
it.each(vectors.boundary_cases)('$name', ({ samples, threshold, expected_gain }) => {
const profile = samples.map((s, i) => ({
distanceMi: i,
elevationFt: s.elevation_ft,
partStart: s.part_start === true,
}))
expect(cumulativeGainOverProfile(profile, threshold)).toBeCloseTo(expected_gain)
})
})