Skip to content

Commit 98e7915

Browse files
committed
Add meta package to pull together predictor and db
1 parent 5c6902b commit 98e7915

3 files changed

Lines changed: 251 additions & 0 deletions

File tree

README.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
![example workflow](https://github.com/neaps/tide-predictor/actions/workflows/test.yml/badge.svg) [![codecov](https://codecov.io/gh/neaps/tide-predictor/branch/main/graph/badge.svg?token=KEJK5NQR5H)](https://codecov.io/gh/neaps/tide-predictor)
2+
3+
# Neaps
4+
5+
A tide prediction engine written in TypeScript.
6+
7+
## 🚨Not for navigational use🚨
8+
9+
**Do not use calculations from this project for navigation, or depend on them in any situation where inaccuracies could result in harm to a person or property.**
10+
11+
Tide predictions are only as good as the harmonics data available, and these can be inconsistent and vary widely based on the accuracy of the source data and local conditions.
12+
13+
The tide predictions do not factor events such as storm surge, wind waves, uplift, tsunamis, or sadly, climate change. 😢
14+
15+
# Installation
16+
17+
```sh
18+
npm install neaps
19+
```
20+
21+
# Usage
22+
23+
```typescript
24+
import { getExtremesPrediction } from 'neaps'
25+
26+
const prediction = getExtremesPrediction({
27+
latitude: 26.7, // or `lat`
28+
longitude: -80.05, // or `lng` or `lon`
29+
start: new Date('2025-12-17'),
30+
end: new Date('2025-12-18'),
31+
datum: 'MLLW', // optional, defaults to MLLW if available
32+
})
33+
34+
console.log(extremes)
35+
// {
36+
// datum: 'MLLW',
37+
// station: {
38+
// id: '8723214',
39+
// name: 'Fort Lauderdale, FL',
40+
// // ...
41+
// },
42+
// distance: 12.3,
43+
// extremes: [
44+
// { time: 2019-01-01T03:12:00.000Z, level: 3.2, high: true, low: false, label: 'High' },
45+
// { time: 2019-01-01T09:45:00.000Z, level: 0.5, high: false, low: true, label: 'Low' },
46+
// ]
47+
// }
48+
```

src/index.ts

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { getDistance } from 'geolib'
2+
import stations, { type Station } from '@neaps/tide-stations'
3+
import tidePredictor, { type ExtremesInput } from '@neaps/tide-predictor'
4+
import type { GeolibInputCoordinates } from 'geolib/es/types'
5+
6+
export type ExtremesOptions = ExtremesInput & {
7+
/** Datum to return predictions in. Defaults to 'MLLW' if available for the nearest station. */
8+
datum?: string
9+
}
10+
11+
/**
12+
* Get extremes prediction using the nearest station to the given position.
13+
*
14+
* @example
15+
* ```ts
16+
* import { getExtremesPrediction } from 'neaps'
17+
*
18+
* const prediction = getExtremesPrediction({
19+
* latitude: 26.7, // or `lat`
20+
* longitude: -80.05, // or `lng` or `lon`
21+
* start: new Date('2025-12-17'),
22+
* end: new Date('2025-12-18'),
23+
* datum: 'MLLW', // optional, defaults to MLLW if available
24+
* })
25+
*/
26+
export function getExtremesPrediction(
27+
options: GeolibInputCoordinates & ExtremesOptions
28+
) {
29+
return nearestStation(options).getExtremesPrediction(options)
30+
}
31+
32+
/**
33+
* Find the nearest station to the given position.
34+
*/
35+
export function nearestStation(position: GeolibInputCoordinates) {
36+
return stationsNear(position, 1)[0]
37+
}
38+
39+
/**
40+
* Find stations closest to the given position.
41+
*/
42+
export function stationsNear(position: GeolibInputCoordinates, limit = 10) {
43+
return stations
44+
.map((station) => ({ station, distance: getDistance(position, station) }))
45+
.sort((a, b) => a.distance - b.distance)
46+
.slice(0, limit)
47+
.map(({ station, distance }) => createStation(station, distance))
48+
}
49+
50+
/**
51+
* Find a station by its ID or source ID.
52+
*/
53+
export function findStation(query: string) {
54+
const searches = [
55+
(s: Station) => s.id === query,
56+
(s: Station) => s.source.id === query
57+
]
58+
59+
let found: Station | undefined = undefined
60+
61+
for (const search of searches) {
62+
found = stations.find(search)
63+
if (found) break
64+
}
65+
66+
if (!found) throw new Error(`Station not found: ${query}`)
67+
68+
return createStation(found)
69+
}
70+
71+
function createStation(metadata: Station, distance?: number) {
72+
// Use MLLW as the default datum if available
73+
const defaultDatum = 'MLLW' in metadata.datums ? 'MLLW' : undefined
74+
75+
return {
76+
metadata,
77+
distance,
78+
getExtremesPrediction({ datum = defaultDatum, ...input }: ExtremesOptions) {
79+
let offset = 0
80+
81+
if (datum) {
82+
const datumOffset = metadata.datums?.[datum]
83+
const mslOffset = metadata.datums?.['MSL']
84+
85+
if (!datum) {
86+
throw new Error(
87+
`Station ${metadata.id} missing ${datum} datum. Available datums: ${Object.keys(metadata.datums || {}).join(', ')}`
88+
)
89+
}
90+
91+
if (!mslOffset) {
92+
throw new Error(
93+
`Station ${metadata.id} missing MSL datum, so predictions can't be given in ${datum}.`
94+
)
95+
}
96+
97+
offset = mslOffset - datumOffset
98+
}
99+
100+
const predictor = tidePredictor(metadata.harmonic_constituents, {
101+
phaseKey: 'phase_UTC',
102+
offset
103+
})
104+
105+
return {
106+
datum,
107+
distance,
108+
station: metadata,
109+
predictions: predictor.getExtremesPrediction(input)
110+
}
111+
}
112+
}
113+
}

test/index.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import {
2+
getExtremesPrediction,
3+
nearestStation,
4+
findStation
5+
} from '../src/index.js'
6+
import { describe, test, expect } from 'vitest'
7+
8+
describe('getExtremesPrediction', () => {
9+
test('gets extremes from nearest station', () => {
10+
const { predictions } = getExtremesPrediction({
11+
lat: 26.7,
12+
lon: -80.05,
13+
start: new Date('2025-12-17 00:00'),
14+
end: new Date('2025-12-18 00:00'),
15+
timeFidelity: 6,
16+
datum: 'MLLW'
17+
})
18+
19+
expect(predictions.length).toBe(4)
20+
expect(predictions[0].time).toEqual(new Date('2025-12-17T09:47:36.000Z')) // 04:47 EST
21+
expect(predictions[0].level).toBeCloseTo(0.03, 1) // NOAA prediction is 0.03, neaps is 0.05
22+
expect(predictions[0].high).toBe(false)
23+
expect(predictions[0].low).toBe(true)
24+
expect(predictions[0].label).toBe('Low')
25+
})
26+
;[
27+
{ lat: 26.7, lon: -80.05 },
28+
{ lat: 26.7, lng: -80.05 },
29+
{ latitude: 26.7, longitude: -80.05 }
30+
].forEach((position) => {
31+
test(`accepts position with ${Object.keys(position).join('/')}`, () => {
32+
const { predictions } = getExtremesPrediction({
33+
...position,
34+
start: new Date('2025-12-17 00:00'),
35+
end: new Date('2025-12-18 00:00')
36+
})
37+
expect(predictions.length).toBe(4)
38+
})
39+
})
40+
})
41+
42+
describe('nearestStation', () => {
43+
test('finds the nearest station', () => {
44+
const station = nearestStation({ lat: 26.7, lon: -80.05 })
45+
expect(station.metadata.source.id).toBe('8722588')
46+
expect(station.metadata.latitude).toBeCloseTo(26.77)
47+
expect(station.metadata.longitude).toBeCloseTo(-80.0517)
48+
})
49+
50+
test('can return extremes from station', () => {
51+
const station = nearestStation({ lat: 26.7, lon: -80.05 })
52+
53+
const start = new Date('2025-12-17 00:00')
54+
const end = new Date('2025-12-18 00:00')
55+
56+
const { predictions } = station.getExtremesPrediction({
57+
start,
58+
end,
59+
timeFidelity: 6,
60+
datum: 'MLLW'
61+
})
62+
63+
expect(predictions.length).toBe(4)
64+
expect(predictions[0].time).toEqual(new Date('2025-12-17T09:47:36.000Z')) // 04:47 EST
65+
expect(predictions[0].level).toBeCloseTo(0.03, 1) // NOAA prediction is 0.03, neaps is 0.05
66+
expect(predictions[0].high).toBe(false)
67+
expect(predictions[0].low).toBe(true)
68+
expect(predictions[0].label).toBe('Low')
69+
})
70+
})
71+
72+
describe('findStation', () => {
73+
test('raises error for unknown station', () => {
74+
expect(() => findStation('unknown')).toThrow('Station not found: unknown')
75+
})
76+
77+
test('finds station by id', () => {
78+
const station = findStation('us-fl-ankona-indian-river')
79+
expect(station).toBeDefined()
80+
expect(station.metadata.id).toBe('us-fl-ankona-indian-river')
81+
expect(station.getExtremesPrediction).toBeDefined()
82+
})
83+
84+
test('finds station by source id', () => {
85+
const station = findStation('8443970')
86+
expect(station).toBeDefined()
87+
expect(station.metadata.id).toBe('us-ma-boston')
88+
expect(station.getExtremesPrediction).toBeDefined()
89+
})
90+
})

0 commit comments

Comments
 (0)