Skip to content

Commit 10c32dd

Browse files
committed
Test against all NOAA stations, compute benchmarks
1 parent 83d00e3 commit 10c32dd

3 files changed

Lines changed: 110 additions & 19 deletions

File tree

.github/workflows/test.yml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,9 @@ jobs:
2222
with:
2323
fail_ci_if_error: true
2424
files: ./coverage/coverage-final.json
25+
26+
- name: Upload NOAA summary
27+
uses: actions/upload-artifact@v6
28+
with:
29+
name: noaa-benchmarks
30+
path: noaa-benchmarks.csv

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
],
2020
"devDependencies": {
2121
"@eslint/js": "^9.39.2",
22+
"@neaps/tide-stations": "github:neaps/tide-database",
2223
"@types/node": "^20.10.6",
2324
"@vitest/coverage-v8": "^3.0.0",
2425
"eslint": "^9.39.2",

test/noaa.ts

Lines changed: 103 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,118 @@
1-
import { describe, it, expect } from 'vitest'
1+
import { describe, it, expect, afterAll } from 'vitest'
22
import fs from 'fs/promises'
33
import tidePrediction from '../src/index.js'
4+
import db from '@neaps/tide-stations'
5+
import { createWriteStream } from 'fs'
6+
7+
const noaa = db.filter((station) =>
8+
station.source.source_url.includes('noaa.gov')
9+
)
410

511
// Create a directory for test cache
612
await fs.mkdir('./.test-cache', { recursive: true })
713

8-
const stations = ['9413450', '9411340', '2695535', '8761724', '8410140']
14+
interface Stat {
15+
station: string
16+
count: number
17+
mae: number
18+
rmse: number
19+
bias: number
20+
}
21+
22+
const stats: Stat[] = []
23+
24+
const knownOutliers = [
25+
'9455920',
26+
'9455963'
27+
// ...
28+
]
29+
30+
describe('NOAA benchmarks', () => {
31+
noaa.forEach((station) => {
32+
it(
33+
station.source.id,
34+
async () => {
35+
const { harmonics, levels } = await getStation(station.source.id)
36+
const tideStation = tidePrediction(harmonics.HarmonicConstituents)
37+
38+
// No predictions available
39+
if (!levels.predictions) return
40+
41+
let count = 0
42+
let sumError = 0
43+
let sumAbsError = 0
44+
let sumSqError = 0
45+
46+
levels.predictions.forEach((prediction: { t: string; v: string }) => {
47+
const expected = parseFloat(prediction.v)
48+
49+
const { level: actual } = tideStation.getWaterLevelAtTime({
50+
time: new Date(prediction.t)
51+
})
52+
53+
const error = actual - expected
54+
55+
count += 1
56+
sumError += error
57+
sumAbsError += Math.abs(error)
58+
sumSqError += error * error
59+
})
60+
61+
const mae = sumAbsError / count
62+
const rmse = Math.sqrt(sumSqError / count)
63+
const bias = sumError / count
64+
65+
if (knownOutliers.includes(station.source.id)) {
66+
expect(
67+
mae,
68+
`Remove ${station.source.id} from known outliers`
69+
).toBeGreaterThan(0.15)
70+
expect(mae).toBeLessThan(0.3)
71+
} else {
72+
expect(mae).toBeLessThanOrEqual(0.15)
73+
}
974

10-
const makeRequest = async (url: string) => {
75+
stats.push({ station: station.source.id, count, mae, rmse, bias })
76+
},
77+
20000
78+
)
79+
})
80+
81+
afterAll(async () => {
82+
// Write stats to file for later analysis
83+
const summary = createWriteStream('noaa-benchmarks.csv')
84+
summary.write('station,count,mae_m,rmse_m,bias_m\n')
85+
stats.forEach(({ station, count, mae, rmse, bias }) => {
86+
summary.write(
87+
[station, count, mae.toFixed(4), rmse.toFixed(4), bias.toFixed(4)].join(
88+
','
89+
) + '\n'
90+
)
91+
})
92+
93+
// Baseline expectations based on current performance. The goal should be to move these toward zero over time.
94+
const maeValues = stats.map((s) => s.mae).sort((a, b) => a - b)
95+
const medianMAE = maeValues[Math.floor(stats.length / 2)]
96+
const p90MAE = maeValues[Math.floor(stats.length * 0.9)]
97+
const p95MAE = maeValues[Math.floor(stats.length * 0.95)]
98+
99+
expect(medianMAE).toBeLessThan(0.03) // 3 cm
100+
expect(p90MAE).toBeLessThan(0.06) // 6 cm
101+
expect(p95MAE).toBeLessThan(0.08) // 8 cm
102+
expect(stats.length).toBeGreaterThanOrEqual(1187) // Ensure enough stations were tested
103+
104+
// Ensure only a small number of stations have very high errors
105+
expect(stats.filter(({ mae }) => mae > 0.1).length).toBeLessThan(10)
106+
})
107+
})
108+
109+
async function makeRequest(url: string) {
11110
const res = await fetch(url)
12111
if (!res.ok) throw new Error(`Request failed with status ${res.status}`)
13112
return res.json()
14113
}
15114

16-
const getStation = async (station: string) => {
115+
async function getStation(station: string) {
17116
const filePath = `./.test-cache/${station}.json`
18117

19118
try {
@@ -36,18 +135,3 @@ const getStation = async (station: string) => {
36135
return data
37136
}
38137
}
39-
40-
describe('Results compare to NOAA', () => {
41-
stations.forEach((station) => {
42-
it(`it compares with station ${station}`, async () => {
43-
const { harmonics, levels } = await getStation(station)
44-
const tideStation = tidePrediction(harmonics.HarmonicConstituents)
45-
levels.predictions.forEach((prediction: { t: string; v: string }) => {
46-
const neapsPrediction = tideStation.getWaterLevelAtTime({
47-
time: new Date(prediction.t)
48-
})
49-
expect(neapsPrediction.level).toBeCloseTo(parseFloat(prediction.v), 0)
50-
})
51-
}, 20000)
52-
})
53-
})

0 commit comments

Comments
 (0)