Skip to content

Commit fc25938

Browse files
committed
WIP: add support for subordinate stations
1 parent 75fbdab commit fc25938

8 files changed

Lines changed: 198 additions & 91 deletions

File tree

packages/neaps/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
},
2424
"dependencies": {
2525
"@neaps/tide-predictor": "^0.1.1",
26-
"@neaps/tide-stations": "github:neaps/tide-database",
26+
"@neaps/tide-stations": "github:neaps/tide-database#subordinate",
2727
"geolib": "^3.3.4"
2828
}
2929
}

packages/neaps/src/index.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -95,23 +95,30 @@ export function findStation(query: string) {
9595
}
9696

9797
export function useStation(station: Station, distance?: number) {
98+
// If subordinate station, use the reference station for datums and constituents
99+
let reference = station
100+
if (station.type === 'subordinate') {
101+
reference = findStation(station.offsets?.reference || '')
102+
}
103+
const { datums, harmonic_constituents } = reference
104+
98105
// Use MLLW as the default datum if available
99-
const defaultDatum = 'MLLW' in station.datums ? 'MLLW' : undefined
106+
const defaultDatum = 'MLLW' in datums ? 'MLLW' : undefined
100107

101108
function getPredictor({ datum = defaultDatum }: DatumOption = {}) {
102109
let offset = 0
103110

104111
if (datum) {
105-
const datumOffset = station.datums?.[datum]
106-
const mslOffset = station.datums?.['MSL']
112+
const datumOffset = datums?.[datum]
113+
const mslOffset = datums?.['MSL']
107114

108-
if (!datumOffset) {
115+
if (typeof datumOffset !== 'number') {
109116
throw new Error(
110-
`Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(station.datums || {}).join(', ')}`
117+
`Station ${station.id} missing ${datum} datum. Available datums: ${Object.keys(datums || {}).join(', ')}`
111118
)
112119
}
113120

114-
if (!mslOffset) {
121+
if (typeof mslOffset !== 'number') {
115122
throw new Error(
116123
`Station ${station.id} missing MSL datum, so predictions can't be given in ${datum}.`
117124
)
@@ -120,7 +127,7 @@ export function useStation(station: Station, distance?: number) {
120127
offset = mslOffset - datumOffset
121128
}
122129

123-
return tidePredictor(station.harmonic_constituents, {
130+
return tidePredictor(harmonic_constituents, {
124131
phaseKey: 'phase_UTC',
125132
offset
126133
})
@@ -129,20 +136,31 @@ export function useStation(station: Station, distance?: number) {
129136
return {
130137
...station,
131138
distance,
139+
datums,
140+
harmonic_constituents,
132141
defaultDatum,
133142
getExtremesPrediction({ datum = defaultDatum, ...input }: ExtremesOptions) {
134143
return {
135144
datum,
136145
distance,
137146
station,
138-
extremes: getPredictor({ datum }).getExtremesPrediction(input)
147+
extremes: getPredictor({ datum }).getExtremesPrediction({
148+
...input,
149+
offsets: station.offsets
150+
})
139151
}
140152
},
141153

142154
getTimelinePrediction({
143155
datum = defaultDatum,
144156
...params
145157
}: TimelineOptions) {
158+
if (station.type === 'subordinate') {
159+
throw new Error(
160+
`Timeline predictions are not supported for subordinate stations.`
161+
)
162+
}
163+
146164
return {
147165
datum,
148166
station,
@@ -151,6 +169,12 @@ export function useStation(station: Station, distance?: number) {
151169
},
152170

153171
getWaterLevelAtTime({ time, datum = defaultDatum }: WaterLevelOptions) {
172+
if (station.type === 'subordinate') {
173+
throw new Error(
174+
`Water level predictions are not supported for subordinate stations.`
175+
)
176+
}
177+
154178
return {
155179
datum,
156180
station,

packages/neaps/test/index.test.ts

Lines changed: 108 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -13,23 +13,43 @@ import { describe, test, expect } from 'vitest'
1313
// predictions for a station in a non-UTC timezone without this.
1414
process.env.TZ = 'UTC'
1515

16+
expect.extend({
17+
toBeWithin(received, expected, delta) {
18+
const diff = Math.abs(received - expected)
19+
const pass = diff <= delta
20+
return {
21+
pass,
22+
message: () =>
23+
`expected ${received} to be within ±${delta} of ${expected}, but was ${diff}`
24+
}
25+
}
26+
})
27+
28+
interface CustomMatchers<R = unknown> {
29+
toBeWithin: (expected: number, delta: number) => R
30+
}
31+
32+
declare module 'vitest' {
33+
interface Matchers<T = any> extends CustomMatchers<T> {}
34+
}
35+
1636
describe('getExtremesPrediction', () => {
1737
test('gets extremes from nearest station', () => {
1838
const prediction = getExtremesPrediction({
19-
lat: 26.7,
39+
lat: 26.772,
2040
lon: -80.05,
2141
start: new Date('2025-12-18T00:00:00-05:00'),
2242
end: new Date('2025-12-19T00:00:00-05:00'),
23-
timeFidelity: 6,
43+
timeFidelity: 60,
2444
datum: 'MLLW'
2545
})
2646

27-
expect(prediction.station.id).toEqual('us-fl-port-of-west-palm-beach')
47+
expect(prediction.station.id).toEqual('us-fl-port-of-palm-beach')
2848
expect(prediction.datum).toBe('MLLW')
2949

3050
const { extremes } = prediction
3151
expect(extremes.length).toBe(4)
32-
expect(extremes[0].time).toEqual(new Date('2025-12-18T05:29:48.000Z'))
52+
expect(extremes[0].time).toEqual(new Date('2025-12-18T05:30:00.000Z'))
3353
expect(extremes[0].level).toBeCloseTo(0.02, 2)
3454
expect(extremes[0].high).toBe(false)
3555
expect(extremes[0].low).toBe(true)
@@ -40,13 +60,13 @@ describe('getExtremesPrediction', () => {
4060
describe('getTimelinePrediction', () => {
4161
test('gets timeline from nearest station', () => {
4262
const timeline = getTimelinePrediction({
43-
lat: 26.7,
63+
lat: 26.772,
4464
lon: -80.05,
4565
start: new Date('2025-12-19T00:00:00-05:00'),
4666
end: new Date('2025-12-19T01:00:00-05:00')
4767
})
4868

49-
expect(timeline.station.id).toEqual('us-fl-port-of-west-palm-beach')
69+
expect(timeline.station.id).toEqual('us-fl-port-of-palm-beach')
5070
expect(timeline.datum).toBe('MLLW')
5171
expect(timeline.timeline.length).toBe(7) // Every 10 minutes for 1 hour = 7 points
5272
})
@@ -55,13 +75,13 @@ describe('getTimelinePrediction', () => {
5575
describe('getWaterLevelAtTime', () => {
5676
test('gets water level at specific time from nearest station', () => {
5777
const prediction = getWaterLevelAtTime({
58-
lat: 26.7,
78+
lat: 26.772,
5979
lon: -80.05,
6080
time: new Date('2025-12-19T00:30:00-05:00'),
6181
datum: 'MSL'
6282
})
6383

64-
expect(prediction.station.id).toEqual('us-fl-port-of-west-palm-beach')
84+
expect(prediction.station.id).toEqual('us-fl-port-of-palm-beach')
6585
expect(prediction.datum).toBe('MSL')
6686
expect(prediction.time).toEqual(new Date('2025-12-19T05:30:00.000Z'))
6787
expect(typeof prediction.level).toBe('number')
@@ -73,27 +93,88 @@ describe('for a specific station', () => {
7393

7494
describe('getExtremesPrediction', () => {
7595
test('can return extremes from station', () => {
76-
const station = nearestStation({ lat: 26.7, lon: -80.05 })
96+
const station = nearestStation({ lat: 26.772, lon: -80.05 })
7797

7898
const start = new Date('2025-12-17T00:00:00-05:00')
7999
const end = new Date('2025-12-18T05:00:00-05:00')
80100

81101
const { extremes: predictions } = station.getExtremesPrediction({
82102
start,
83103
end,
84-
timeFidelity: 6,
104+
timeFidelity: 60,
85105
datum: 'MLLW'
86106
})
87107

88108
expect(predictions.length).toBe(4)
89-
expect(predictions[0].time).toEqual(new Date('2025-12-17T11:23:06.000Z'))
109+
expect(predictions[0].time).toEqual(new Date('2025-12-17T11:23:00.000Z'))
90110
expect(predictions[0].level).toBeCloseTo(0.9, 1)
91111
expect(predictions[0].high).toBe(true)
92112
expect(predictions[0].low).toBe(false)
93113
expect(predictions[0].label).toBe('High')
94114
})
95115
})
96116

117+
describe('for a subordinate station', () => {
118+
const station = findStation('9411189')
119+
120+
test('gets datums and harmonic_constituents from reference station', () => {
121+
expect(station.type).toBe('subordinate')
122+
expect(station.datums).toBeDefined()
123+
expect(station.datums).toEqual(findStation('9410660').datums)
124+
expect(station.harmonic_constituents).toBeDefined()
125+
expect(station.harmonic_constituents).toEqual(
126+
findStation('9410660').harmonic_constituents
127+
)
128+
expect(station.defaultDatum).toBe('MLLW')
129+
})
130+
131+
describe('getExtremesPrediction', () => {
132+
test('returns adjusted predictions', async () => {
133+
const bigtime = stations
134+
.filter((s) => s.type === 'subordinate')
135+
.sort(
136+
(a, b) =>
137+
Math.abs(a.offsets?.height.high) -
138+
Math.abs(b.offsets?.height.high)
139+
)
140+
.reverse()
141+
142+
console.log(bigtime[0])
143+
144+
const station = useStation(bigtime[0])
145+
146+
const start = new Date('2025-12-17T00:00:00Z')
147+
const end = new Date('2025-12-19T00:00:00Z')
148+
149+
const prediction = station.getExtremesPrediction({
150+
start,
151+
end,
152+
timeFidelity: 60,
153+
datum: 'MLLW'
154+
})
155+
156+
console.log(prediction.extremes)
157+
158+
// https://api.tidesandcurrents.noaa.gov/api/prod/datagetter?format=json&product=predictions&units=metric&time_zone=gmt&station=TEC4769&begin_date=2025-12-17&end_date=2025-12-18&interval=hilo&datum=MLLW
159+
const noaa = [
160+
{ t: '2025-12-17T01:44:00Z', v: -0.091, type: 'L' },
161+
{ t: '2025-12-17T08:50:00Z', v: 0.75, type: 'H' },
162+
{ t: '2025-12-18T02:15:00Z', v: -0.13, type: 'L' },
163+
{ t: '2025-12-18T09:24:00Z', v: 0.754, type: 'H' }
164+
]
165+
166+
noaa.forEach((expected, index) => {
167+
const actual = prediction.extremes[index]
168+
expect(actual.time).toBeWithin(
169+
new Date(expected.t),
170+
25 * 60 * 1000 /* min */
171+
)
172+
expect(actual.level).toBeWithin(expected.v, 0.04 /* m */)
173+
})
174+
})
175+
})
176+
})
177+
97178
describe('getTimelinePrediction', () => {
98179
test('gets timeline', () => {
99180
const prediction = station.getTimelinePrediction({
@@ -119,20 +200,14 @@ describe('for a specific station', () => {
119200
})
120201

121202
describe('nearestStation', () => {
122-
test('finds the nearest station', () => {
123-
const station = nearestStation({ lat: 26.7, lon: -80.05 })
124-
expect(station.source.id).toBe('8722588')
125-
expect(station.latitude).toBeCloseTo(26.77)
126-
expect(station.longitude).toBeCloseTo(-80.0517)
127-
})
128203
;[
129-
{ lat: 26.7, lon: -80.05 },
130-
{ lat: 26.7, lng: -80.05 },
131-
{ latitude: 26.7, longitude: -80.05 }
204+
{ lat: 26.772, lon: -80.052 },
205+
{ lat: 26.772, lng: -80.052 },
206+
{ latitude: 26.772, longitude: -80.052 }
132207
].forEach((position) => {
133208
test(`finds station with ${Object.keys(position).join('/')}`, () => {
134209
const station = nearestStation(position)
135-
expect(station.id).toEqual('us-fl-port-of-west-palm-beach')
210+
expect(station.source.id).toBe('8722588')
136211
})
137212
})
138213
})
@@ -143,9 +218,9 @@ describe('findStation', () => {
143218
})
144219

145220
test('finds station by id', () => {
146-
const station = findStation('us-fl-ankona-indian-river')
221+
const station = findStation('us-ma-boston')
147222
expect(station).toBeDefined()
148-
expect(station.id).toBe('us-fl-ankona-indian-river')
223+
expect(station.id).toBe('us-ma-boston')
149224
expect(station.getExtremesPrediction).toBeDefined()
150225
})
151226

@@ -159,7 +234,7 @@ describe('findStation', () => {
159234

160235
describe('datum', () => {
161236
test('defaults to MLLW datum', () => {
162-
const station = findStation('us-fl-ankona-indian-river')
237+
const station = findStation('8722274')
163238
const extremes = station.getExtremesPrediction({
164239
start: new Date('2025-12-17T00:00:00Z'),
165240
end: new Date('2025-12-18T00:00:00Z')
@@ -168,7 +243,7 @@ describe('datum', () => {
168243
})
169244

170245
test('accepts datum option', () => {
171-
const station = findStation('us-fl-ankona-indian-river')
246+
const station = findStation('8722274')
172247
const extremes = station.getExtremesPrediction({
173248
start: new Date('2025-12-17T00:00:00Z'),
174249
end: new Date('2025-12-18T00:00:00Z'),
@@ -191,21 +266,26 @@ describe('datum', () => {
191266
test('throws error when missing MSL datum', () => {
192267
// Find station without MSL but with other datums
193268
const station = stations.find(
194-
(s) => !('MSL' in s.datums) && Object.keys(s.datums).length > 0
269+
(s) =>
270+
s.type === 'reference' &&
271+
!('MSL' in s.datums) &&
272+
Object.keys(s.datums).length > 0
195273
)
196274
if (!station) expect.fail('No station without MSL datum found')
197275
expect(() => {
198276
useStation(station).getExtremesPrediction({
199277
start: new Date('2025-12-17T00:00:00Z'),
200278
end: new Date('2025-12-18T00:00:00Z'),
201-
datum: 'NAVD88'
279+
datum: Object.keys(station.datums)[0]
202280
})
203281
}).toThrow(/missing MSL/)
204282
})
205283

206284
test('does not apply datums when non available', () => {
207285
// Find a station with no datums
208-
const station = stations.find((s) => Object.entries(s.datums).length === 0)
286+
const station = stations.find(
287+
(s) => s.type === 'reference' && Object.entries(s.datums).length === 0
288+
)
209289
if (!station) expect.fail('No station without datums found')
210290
const extremes = useStation(station).getExtremesPrediction({
211291
start: new Date('2025-12-17T00:00:00Z'),

packages/tide-predictor/README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -179,20 +179,20 @@ Tidal constituents should be an array of objects with at least:
179179

180180
Some stations do not have defined harmonic data, but do have published offets and a reference station. These include the offsets in time or amplitude of the high and low tides. Subservient station definitions are objects that include:
181181

182-
- `height_offset` - **object** - An object of height offets, in the same units as the reference station.
182+
- `height` - **object** - An object of height offets, in the same units as the reference station.
183183
- `high` - **float** - The offset to be added to high tide (can be negative)
184184
- `low` - **float** - The offset to be added to low tide (can be negative)
185-
- `time_offset` - **object** - An object of time offets, in number of minutes
185+
- `time` - **object** - An object of time offets, in number of minutes
186186
- `high` - **float** - The number of minutes to add to high tide times (can be negative)
187187
- `low` - **float** - The number of minutes to add to low tide times (can be negative)
188188

189189
```
190190
{
191-
height_offset: {
191+
height: {
192192
high: 1,
193193
low: 2
194194
},
195-
time_offset: {
195+
time: {
196196
high: 1,
197197
low: 2
198198
}

0 commit comments

Comments
 (0)