Skip to content

Commit 77b98ec

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

3 files changed

Lines changed: 91 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: 84 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,99 @@
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+
describe('NOAA benchmarks', () => {
25+
noaa.forEach((station) => {
26+
it(
27+
station.source.id,
28+
async () => {
29+
const { harmonics, levels } = await getStation(station.source.id)
30+
const tideStation = tidePrediction(harmonics.HarmonicConstituents)
31+
32+
// No predictions available
33+
if (!levels.predictions) return
34+
35+
let count = 0
36+
let sumError = 0
37+
let sumAbsError = 0
38+
let sumSqError = 0
39+
40+
levels.predictions.forEach((prediction: { t: string; v: string }) => {
41+
const expected = parseFloat(prediction.v)
42+
43+
const { level: actual } = tideStation.getWaterLevelAtTime({
44+
time: new Date(prediction.t)
45+
})
46+
47+
const error = actual - expected
48+
49+
count += 1
50+
sumError += error
51+
sumAbsError += Math.abs(error)
52+
sumSqError += error * error
53+
})
54+
55+
const mae = sumAbsError / count
56+
const rmse = Math.sqrt(sumSqError / count)
57+
const bias = sumError / count
958

10-
const makeRequest = async (url: string) => {
59+
stats.push({ station: station.source.id, count, mae, rmse, bias })
60+
},
61+
20000
62+
)
63+
})
64+
65+
afterAll(async () => {
66+
// Write stats to file for later analysis
67+
const summary = createWriteStream('noaa-benchmarks.csv')
68+
summary.write('station,count,mae_m,rmse_m,bias_m\n')
69+
stats.forEach(({ station, count, mae, rmse, bias }) => {
70+
summary.write(
71+
[station, count, mae.toFixed(4), rmse.toFixed(4), bias.toFixed(4)].join(
72+
','
73+
) + '\n'
74+
)
75+
})
76+
77+
// Baseline expectations based on current performance. The goal should be to move these toward zero over time.
78+
const maeValues = stats.map((s) => s.mae).sort((a, b) => a - b)
79+
const medianMAE = maeValues[Math.floor(stats.length / 2)]
80+
const p90MAE = maeValues[Math.floor(stats.length * 0.9)]
81+
const p95MAE = maeValues[Math.floor(stats.length * 0.95)]
82+
83+
expect(medianMAE).toBeLessThan(0.03) // 3 cm
84+
expect(p90MAE).toBeLessThan(0.06) // 6 cm
85+
expect(p95MAE).toBeLessThan(0.08) // 8 cm
86+
expect(stats.length).toBeGreaterThanOrEqual(1178) // Ensure enough stations were tested
87+
})
88+
})
89+
90+
async function makeRequest(url: string) {
1191
const res = await fetch(url)
1292
if (!res.ok) throw new Error(`Request failed with status ${res.status}`)
1393
return res.json()
1494
}
1595

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

1999
try {
@@ -36,18 +116,3 @@ const getStation = async (station: string) => {
36116
return data
37117
}
38118
}
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)