@openwaters/noaa-current-stations 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +181 -0
- package/bin/noaa-current-stations.mjs +103 -0
- package/docs/noaa-api.md +233 -0
- package/docs/releasing.md +27 -0
- package/docs/schema.md +107 -0
- package/docs/validation.md +102 -0
- package/index.d.ts +160 -0
- package/index.js +6 -0
- package/package.json +49 -0
- package/schema/currents.schema.json +105 -0
- package/src/cross-flow.js +74 -0
- package/src/drift.js +64 -0
- package/src/extract.js +156 -0
- package/src/golden.js +52 -0
- package/src/noaa.js +110 -0
- package/src/validate.js +61 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Bryan Clark
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# NOAA Current Stations
|
|
2
|
+
|
|
3
|
+
**NOAA CO-OPS tidal-current station data — the extractor, the schema, and the API's
|
|
4
|
+
undocumented behaviour, in one place.**
|
|
5
|
+
|
|
6
|
+
NOAA publishes harmonic constituents for **856 tidal-current stations** in US waters,
|
|
7
|
+
plus offset tables for **1,700-odd subordinate** stations. That is enough to predict slack
|
|
8
|
+
water and max flood/ebb offline, anywhere in US waters, with no network at runtime.
|
|
9
|
+
|
|
10
|
+
This package extracts that data, ships it as a versioned bundle, and documents the
|
|
11
|
+
schema — so you don't have to talk to the API at all.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install @openwaters/noaa-current-stations
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Use it as a CLI
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
# every US current station → currents.json (~2,800 stations, several minutes, paced)
|
|
23
|
+
npx noaa-current-stations extract currents.json
|
|
24
|
+
|
|
25
|
+
# one region
|
|
26
|
+
npx noaa-current-stations extract salish.json --box 47,-125,49.2,-122
|
|
27
|
+
|
|
28
|
+
# just the stations you care about
|
|
29
|
+
npx noaa-current-stations extract mine.json --stations PUG1717,PUG1701
|
|
30
|
+
|
|
31
|
+
# capture a validation fixture: constituents + NOAA's own predictions, one file
|
|
32
|
+
npx noaa-current-stations golden pug1741.json --station PUG1741 --bin 27 \
|
|
33
|
+
--start 2026-07-19 --end 2026-07-21
|
|
34
|
+
|
|
35
|
+
# has NOAA's station list changed since the bundle was built? (one request; exit 1 if so)
|
|
36
|
+
npx noaa-current-stations check
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
> A full US extraction is ~2,800 paced requests and takes several minutes. NOAA
|
|
40
|
+
> throttles bulk callers — leave the pacing alone unless you have a reason.
|
|
41
|
+
|
|
42
|
+
## Use it as a library
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
import { extractBundle, fetchCurrentPredictions, fetchHarcon } from '@openwaters/noaa-current-stations';
|
|
46
|
+
|
|
47
|
+
// A bundle you can ship and predict from offline.
|
|
48
|
+
const { bundle, skipped } = await extractBundle({ stations: ['PUG1717'] });
|
|
49
|
+
|
|
50
|
+
// Or NOAA's own published predictions, live.
|
|
51
|
+
const events = await fetchCurrentPredictions(
|
|
52
|
+
'PUG1717', 35, new Date('2026-07-19'), new Date('2026-07-21'),
|
|
53
|
+
);
|
|
54
|
+
// → [{ time: '2026-07-19T01:44:00.000Z', kind: 'flood', velocityMajor: 2.85, … }]
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Ships TypeScript types. No dependencies.
|
|
58
|
+
|
|
59
|
+
## What a bundle looks like
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"note": "Generated from NOAA CO-OPS mdapi …",
|
|
64
|
+
"crossFlow": {
|
|
65
|
+
"measured": "…", "records": 856, "gte0_25kn": 61, "gte0_50kn": 12,
|
|
66
|
+
"worstRatio": { "id": "BOS1130", "crossFlow": 0.178, "alongAxisPeak": 0.74, "ratio": 0.241 },
|
|
67
|
+
"worstAbsolute": { "id": "PUG1619", "crossFlow": 0.8 }
|
|
68
|
+
},
|
|
69
|
+
"stations": [
|
|
70
|
+
{
|
|
71
|
+
"id": "PUG1717", "name": "Turn Point, Boundary Pass", "type": "harmonic",
|
|
72
|
+
"floodDirection": 23.2, "ebbDirection": 203.2,
|
|
73
|
+
"offset": 0.297,
|
|
74
|
+
"constituents": [{ "name": "M2", "amplitude": 1.63, "phase": 295.3 }]
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"id": "PCT0236", "name": "…", "type": "subordinate",
|
|
78
|
+
"reference": "SFB1201@10",
|
|
79
|
+
"slackBeforeFloodOffset": -720, "slackBeforeEbbOffset": 480,
|
|
80
|
+
"floodSpeedRatio": 0.7, "ebbSpeedRatio": 1.2
|
|
81
|
+
}
|
|
82
|
+
]
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Speeds in knots, directions degrees true, time offsets in seconds. Full schema:
|
|
87
|
+
[schema/currents.schema.json](schema/currents.schema.json) ·
|
|
88
|
+
[docs/schema.md](docs/schema.md).
|
|
89
|
+
|
|
90
|
+
Four details that are easy to get wrong and expensive to debug:
|
|
91
|
+
|
|
92
|
+
- **`offset` is Z₀**, the station's net mean flow (NOAA `majorMeanSpeed`). Slack is
|
|
93
|
+
where the velocity curve crosses zero, so dropping this moves every slack time. The
|
|
94
|
+
Salish passes carry −0.74 to +0.30 kn of it. Measured cost of omitting it: **15.6 →
|
|
95
|
+
7.4 min** mean timing error.
|
|
96
|
+
- **A reference is `(station, bin)`**, hence `SFB1201@10`. Constituents vary by depth
|
|
97
|
+
bin and a station may publish several; keying by station id alone silently predicts
|
|
98
|
+
from the wrong depth.
|
|
99
|
+
- **A `type: S` station is not necessarily subordinate.** A few carry their own harcon
|
|
100
|
+
and NOAA predicts them harmonically; the offset reduction overshoots them badly
|
|
101
|
+
(89 min vs 6.8 min at PUG1716). Rare — 1 of 1,706 — but you can't tell which without
|
|
102
|
+
asking, and the ask is one request.
|
|
103
|
+
- **The model is one axis, and the bundle says how much that costs.** `crossFlow` is a
|
|
104
|
+
census of NOAA's `minorMeanSpeed`, the flow perpendicular to the flood axis that runs
|
|
105
|
+
even at slack. `validate` fails above a 0.5 ratio. Bundling the full minor axis was
|
|
106
|
+
measured and rejected: a 2D magnitude series never crosses zero, so slack detection
|
|
107
|
+
silently returns nothing.
|
|
108
|
+
|
|
109
|
+
## Maintenance
|
|
110
|
+
|
|
111
|
+
`currents.json` is committed, pretty-printed, so a change in NOAA's data is reviewable
|
|
112
|
+
as a diff. [`update-stations`](.github/workflows/update-stations.yml) keeps it current:
|
|
113
|
+
|
|
114
|
+
| Cadence | What runs | Catches |
|
|
115
|
+
|---|---|---|
|
|
116
|
+
| Weekly | pre-flight — **one** request for the station list | stations added, removed, or reclassified |
|
|
117
|
+
| Monthly | forced full extraction (~2,800 paced requests, ~25 min) | NOAA revising an existing station's constituents in place |
|
|
118
|
+
|
|
119
|
+
The weekly pre-flight only escalates to a full extraction when something moved, so the
|
|
120
|
+
common case costs a single request. Either way, a change opens a **pull request** with
|
|
121
|
+
the validation summary — nothing updates silently.
|
|
122
|
+
|
|
123
|
+
`stations.lock.json` pins the current list; `noaa-current-stations check` is the same
|
|
124
|
+
pre-flight you can run yourself, and exits non-zero on drift.
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
npx noaa-current-stations check # has NOAA's list moved?
|
|
128
|
+
npx noaa-current-stations validate currents.json # structural check on a bundle
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`validate` is what gates the automated PR: it fails on a subordinate whose reference
|
|
132
|
+
went missing, duplicate ids, a harmonic station with no constituents, or a bundle that
|
|
133
|
+
lost its Z₀ offsets — the shapes a truncated extraction takes.
|
|
134
|
+
|
|
135
|
+
## Don't trust it until you've diffed it
|
|
136
|
+
|
|
137
|
+
Both halves of a validation come from NOAA, so the check is self-contained: predict from
|
|
138
|
+
`harcon` constituents, compare against NOAA's own `currents_predictions` for the same
|
|
139
|
+
days. `noaa-current-stations golden` captures both into one fixture that replays offline.
|
|
140
|
+
|
|
141
|
+
Expect ~10 min / 0.05 kn at a clean reversing station. Measured results, realistic
|
|
142
|
+
tolerances, and the list of convention questions this method settled:
|
|
143
|
+
[docs/validation.md](docs/validation.md).
|
|
144
|
+
|
|
145
|
+
## Who uses this
|
|
146
|
+
|
|
147
|
+
- [slackwater-engine](https://github.com/openwatersio/slackwater-engine) — Swift tide
|
|
148
|
+
and current engine; vendors the released bundle for offline prediction.
|
|
149
|
+
- [signalk-currents](https://github.com/sailingnaturali/signalk-currents) — SignalK
|
|
150
|
+
plugin serving live and offline currents to a boat's instruments.
|
|
151
|
+
|
|
152
|
+
## Scope
|
|
153
|
+
|
|
154
|
+
US waters only — this is a NOAA client, and NOAA publishes US stations. Other national
|
|
155
|
+
hydrographic offices publish current data under their own terms; that is out of scope
|
|
156
|
+
here.
|
|
157
|
+
|
|
158
|
+
## If you're calling the NOAA API yourself
|
|
159
|
+
|
|
160
|
+
You probably don't need to — that's what the bundle is for. But if you are, the API has
|
|
161
|
+
undocumented behaviours that make the data look like it doesn't exist. The worst one:
|
|
162
|
+
|
|
163
|
+
```
|
|
164
|
+
GET /stations/PUG1701/harcon.json?bin=0 → { "HarmonicConstituents": [] }
|
|
165
|
+
GET /stations/PUG1701/harcon.json?bin=18 → 26 constituents
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
`bin=0` is the natural thing to try, and the empty array reads as "NOAA doesn't publish
|
|
169
|
+
current constituents." It does — at each station's `currbin`, and nowhere else.
|
|
170
|
+
|
|
171
|
+
**[→ docs/noaa-api.md](docs/noaa-api.md)** has the rest, including the widely-repeated
|
|
172
|
+
User-Agent-blocking claim that turns out **not** to be true.
|
|
173
|
+
|
|
174
|
+
## Licence and disclaimer
|
|
175
|
+
|
|
176
|
+
Code MIT. NOAA CO-OPS data is **public domain**.
|
|
177
|
+
|
|
178
|
+
**Predictions derived from this data are UNOFFICIAL.** They are not NOAA products, they
|
|
179
|
+
are not certified for navigation, and they should not be presented as either. Slack
|
|
180
|
+
timing at constricted passes can be off by 15 minutes or more. Use official published
|
|
181
|
+
tables to time anything that matters.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// CLI: extract a station bundle, or capture a validation fixture.
|
|
3
|
+
import { writeFileSync, readFileSync } from 'node:fs';
|
|
4
|
+
import { extractBundle } from '../src/extract.js';
|
|
5
|
+
import { captureGolden } from '../src/golden.js';
|
|
6
|
+
import { checkDrift, buildLock } from '../src/drift.js';
|
|
7
|
+
import { validateBundle } from '../src/validate.js';
|
|
8
|
+
import { fetchStationList } from '../src/noaa.js';
|
|
9
|
+
|
|
10
|
+
const USAGE = `noaa-current-stations — NOAA CO-OPS tidal-current station data
|
|
11
|
+
|
|
12
|
+
noaa-current-stations extract <out.json> [--box S,W,N,E] [--stations ID,ID] [--pace ms]
|
|
13
|
+
noaa-current-stations golden <out.json> --station ID --bin N --start ISO --end ISO
|
|
14
|
+
noaa-current-stations check [lock.json] exit 1 if NOAA's list has drifted from the lock
|
|
15
|
+
noaa-current-stations lock <out.json> re-pin the lock to NOAA's current list
|
|
16
|
+
noaa-current-stations validate <bundle.json> structural check on a bundle
|
|
17
|
+
|
|
18
|
+
Examples:
|
|
19
|
+
noaa-current-stations extract currents.json # all US stations
|
|
20
|
+
noaa-current-stations extract salish.json --box 47,-125,49.2,-122 # one region
|
|
21
|
+
noaa-current-stations extract mine.json --stations PUG1717,PUG1701
|
|
22
|
+
noaa-current-stations golden pug1741.json --station PUG1741 --bin 27 \\
|
|
23
|
+
--start 2026-07-19 --end 2026-07-21
|
|
24
|
+
|
|
25
|
+
A full extraction is thousands of paced requests. NOAA throttles bulk callers.
|
|
26
|
+
`;
|
|
27
|
+
|
|
28
|
+
const [cmd, out, ...rest] = process.argv.slice(2);
|
|
29
|
+
const flags = {};
|
|
30
|
+
for (let i = 0; i < rest.length; i += 2) flags[rest[i].replace(/^--/, '')] = rest[i + 1];
|
|
31
|
+
|
|
32
|
+
// `check` is the one command with a sensible default output path.
|
|
33
|
+
if (!cmd || (!out && cmd !== 'check') || flags.help !== undefined) {
|
|
34
|
+
console.log(USAGE);
|
|
35
|
+
process.exit(cmd ? 1 : 0);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const log = (m) => console.error(m);
|
|
39
|
+
const logCrossFlow = (cf) => {
|
|
40
|
+
if (!cf?.worstRatio) return log('cross-flow: not measured');
|
|
41
|
+
log(`cross-flow (${cf.records} harmonic records): `
|
|
42
|
+
+ `${cf.gte0_25kn} >= 0.25 kn, ${cf.gte0_50kn} >= 0.50 kn`);
|
|
43
|
+
log(` worst ratio ${cf.worstRatio.ratio} at ${cf.worstRatio.id} `
|
|
44
|
+
+ `(${cf.worstRatio.crossFlow} kn across a ${cf.worstRatio.alongAxisPeak} kn axis)`);
|
|
45
|
+
log(` worst absolute ${cf.worstAbsolute.crossFlow} kn at ${cf.worstAbsolute.id}`);
|
|
46
|
+
};
|
|
47
|
+
const paceMs = flags.pace !== undefined ? Number(flags.pace) : undefined;
|
|
48
|
+
|
|
49
|
+
if (cmd === 'extract') {
|
|
50
|
+
const { bundle, skipped } = await extractBundle({
|
|
51
|
+
box: flags.box?.split(',').map(Number),
|
|
52
|
+
stations: flags.stations?.split(','),
|
|
53
|
+
...(paceMs !== undefined ? { paceMs } : {}),
|
|
54
|
+
log,
|
|
55
|
+
});
|
|
56
|
+
// Pretty-printed on purpose: this file is committed and its diff is how a NOAA
|
|
57
|
+
// revision gets reviewed. Release artifacts are minified from it.
|
|
58
|
+
writeFileSync(out, JSON.stringify(bundle, null, 2) + '\n');
|
|
59
|
+
log(`wrote ${out} — ${bundle.stations.length} stations`);
|
|
60
|
+
logCrossFlow(bundle.crossFlow);
|
|
61
|
+
if (skipped.failed.length) process.exitCode = 1;
|
|
62
|
+
} else if (cmd === 'golden') {
|
|
63
|
+
const fixture = await captureGolden(
|
|
64
|
+
flags.station, Number(flags.bin), new Date(flags.start), new Date(flags.end),
|
|
65
|
+
{ ...(paceMs !== undefined ? { paceMs } : {}) },
|
|
66
|
+
);
|
|
67
|
+
writeFileSync(out, JSON.stringify(fixture, null, 2) + '\n');
|
|
68
|
+
log(`wrote ${out} — ${fixture.constituents.length} constituents, ${fixture.events.length} events`);
|
|
69
|
+
if (fixture.predictionsError) {
|
|
70
|
+
log(`WARNING: no predictions captured (${fixture.predictionsError}) — re-run later`);
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
} else if (cmd === 'check') {
|
|
74
|
+
const lockPath = out ?? new URL('../stations.lock.json', import.meta.url).pathname;
|
|
75
|
+
const lock = JSON.parse(readFileSync(lockPath, 'utf8'));
|
|
76
|
+
const d = await checkDrift(lock);
|
|
77
|
+
const c = d.counts;
|
|
78
|
+
log(`NOAA now: ${c.total} stations (H ${c.H}, S ${c.S}, W ${c.W})`);
|
|
79
|
+
if (!d.drifted) {
|
|
80
|
+
log('No drift — matches the lock.');
|
|
81
|
+
} else {
|
|
82
|
+
log(`Pinned: ${d.expected.total} stations (H ${d.expected.H}, S ${d.expected.S}, W ${d.expected.W})`);
|
|
83
|
+
for (const [label, ids] of [['ADDED', d.added], ['REMOVED', d.removed], ['RETYPED', d.retyped]]) {
|
|
84
|
+
if (ids.length) log(`\n${label} (${ids.length}):\n ${ids.join('\n ')}`);
|
|
85
|
+
}
|
|
86
|
+
log('\nNOAA\'s station list has changed. Re-extract the bundle, re-run `noaa-current-stations lock`, and '
|
|
87
|
+
+ 'release — consumers are pinned to a bundle that no longer matches NOAA.');
|
|
88
|
+
process.exitCode = 1;
|
|
89
|
+
}
|
|
90
|
+
} else if (cmd === 'validate') {
|
|
91
|
+
const v = validateBundle(JSON.parse(readFileSync(out, 'utf8')));
|
|
92
|
+
log(`${out}: ${v.counts.harmonic} harmonic, ${v.counts.subordinate} subordinate`);
|
|
93
|
+
logCrossFlow(v.crossFlow);
|
|
94
|
+
for (const e of v.errors) log(` ERROR: ${e}`);
|
|
95
|
+
if (!v.ok) process.exitCode = 1;
|
|
96
|
+
} else if (cmd === 'lock') {
|
|
97
|
+
const lock = buildLock(await fetchStationList({ paceMs: 0 }));
|
|
98
|
+
writeFileSync(out, JSON.stringify(lock, null, 2) + '\n');
|
|
99
|
+
log(`wrote ${out} — ${lock.counts.total} stations`);
|
|
100
|
+
} else {
|
|
101
|
+
console.log(USAGE);
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
package/docs/noaa-api.md
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
# The NOAA CO-OPS currents API, as it actually behaves
|
|
2
|
+
|
|
3
|
+
Everything here was found by building against the API and validating the output against
|
|
4
|
+
NOAA's own published predictions. Several of these behaviours are undocumented, and at
|
|
5
|
+
least two of them will make you conclude — wrongly — that the data you want doesn't
|
|
6
|
+
exist. That conclusion is why this document exists.
|
|
7
|
+
|
|
8
|
+
NOAA data is **public domain**. Predictions you derive from it are **unofficial** and
|
|
9
|
+
must not be presented as NOAA's own or used as a primary means of navigation.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## The one that costs everyone a day
|
|
14
|
+
|
|
15
|
+
**`harcon.json` returns an empty constituent list unless you query the station's
|
|
16
|
+
`currbin`.**
|
|
17
|
+
|
|
18
|
+
```
|
|
19
|
+
GET /mdapi/prod/webapi/stations/PUG1701/harcon.json?units=english&bin=0
|
|
20
|
+
→ { "HarmonicConstituents": [] }
|
|
21
|
+
|
|
22
|
+
GET /mdapi/prod/webapi/stations/PUG1701/harcon.json?units=english&bin=18
|
|
23
|
+
→ 26 constituents
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
There is no error, no hint, and `bin=0` is the natural thing to try. The empty array
|
|
27
|
+
reads as "NOAA doesn't publish harmonic constituents for currents" — which is what we
|
|
28
|
+
concluded, and it is wrong. NOAA publishes constituents for **856 current stations** in
|
|
29
|
+
US waters (2026-07; the count drifts as NOAA revises its list).
|
|
30
|
+
|
|
31
|
+
`currbin` comes from the station list. It is per-station and unguessable.
|
|
32
|
+
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
## Endpoints
|
|
36
|
+
|
|
37
|
+
Base: `https://api.tidesandcurrents.noaa.gov`
|
|
38
|
+
|
|
39
|
+
| Purpose | Path |
|
|
40
|
+
|---|---|
|
|
41
|
+
| Station list | `/mdapi/prod/webapi/stations.json?type=currentpredictions&units=english` |
|
|
42
|
+
| Harmonic constituents | `/mdapi/prod/webapi/stations/<id>/harcon.json?units=english&bin=<currbin>` |
|
|
43
|
+
| Subordinate offsets | `/mdapi/prod/webapi/stations/<id>_<currbin>/currentpredictionoffsets.json` |
|
|
44
|
+
| Published predictions | `/api/prod/datagetter?product=currents_predictions&interval=max_slack&bin=<currbin>&…` |
|
|
45
|
+
|
|
46
|
+
Note the subordinate-offsets path takes a **composite `<id>_<currbin>`**, unlike every
|
|
47
|
+
other station path. `stations/PCT0236/currentpredictionoffsets.json` 404s;
|
|
48
|
+
`stations/PCT0236_1/currentpredictionoffsets.json` works.
|
|
49
|
+
|
|
50
|
+
## The station list repeats itself
|
|
51
|
+
|
|
52
|
+
Each station appears **once per depth bin**. De-dup by `id` keeping the **first**
|
|
53
|
+
entry — that one carries the primary `currbin`. Fields that matter:
|
|
54
|
+
|
|
55
|
+
```
|
|
56
|
+
id, name, lat, lng, type, currbin
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
`type` is `H` (harmonic), `S` (subordinate), or `W` (weak and variable / rotary —
|
|
60
|
+
NOAA doesn't publish a usable reversing model for these; we skip them).
|
|
61
|
+
|
|
62
|
+
## `harcon.json` fields
|
|
63
|
+
|
|
64
|
+
```
|
|
65
|
+
constituentName, description,
|
|
66
|
+
majorAmplitude, majorPhase (local °), majorPhaseGMT (Greenwich °),
|
|
67
|
+
minorAmplitude, minorPhase, minorPhaseGMT,
|
|
68
|
+
majorMeanSpeed, minorMeanSpeed,
|
|
69
|
+
azi (major-axis azimuth, ° true), binNbr, binDepth, constNum
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Mapping that produces predictions matching NOAA's own:
|
|
73
|
+
|
|
74
|
+
| You want | Use | Notes |
|
|
75
|
+
|---|---|---|
|
|
76
|
+
| amplitude | `majorAmplitude` | **knots** under `units=english`, **cm/s** under `units=metric` (÷ 51.4444) |
|
|
77
|
+
| phase | `majorPhaseGMT` | Greenwich phase. Pairs with a Greenwich V₀ — confirmed empirically, see below |
|
|
78
|
+
| flood direction | `azi` | ebb is `azi + 180` |
|
|
79
|
+
| **Z₀ / mean flow** | `majorMeanSpeed` | signed, knots. **Do not drop this** — see below |
|
|
80
|
+
| minor axis | `minorAmplitude`/`minorPhaseGMT` | for a 2D/rotary model; unused by a major-axis model — but see `minorMeanSpeed` below |
|
|
81
|
+
| cross-flow | `minorMeanSpeed` | DC flow perpendicular to the axis, running at ALL times including slack. Not carried per station; summarised in the bundle's `crossFlow` census |
|
|
82
|
+
|
|
83
|
+
### Z₀ is not optional
|
|
84
|
+
|
|
85
|
+
`majorMeanSpeed` is the station's net mean flow along the major axis. It is a DC offset
|
|
86
|
+
on the whole velocity curve, and **slack is defined by where that curve crosses zero** —
|
|
87
|
+
so dropping it moves every slack time and skews peak speeds.
|
|
88
|
+
|
|
89
|
+
It is not a small correction. The Salish Sea passes run **−0.74 to +0.30 kn** of mean
|
|
90
|
+
flow (net ebb, as you would expect where a large river system drains to sea). Measured
|
|
91
|
+
at Turn Point (PUG1717) against NOAA's own predictions over three days:
|
|
92
|
+
|
|
93
|
+
| | mean timing error | worst | mean speed error |
|
|
94
|
+
|---|---|---|---|
|
|
95
|
+
| without Z₀ | 15.6 min | 55 min | 0.147 kn |
|
|
96
|
+
| with Z₀ | **7.4 min** | **21 min** | **0.066 kn** |
|
|
97
|
+
|
|
98
|
+
If your predictions are "close but consistently off around slack", this is why.
|
|
99
|
+
|
|
100
|
+
### Phase convention
|
|
101
|
+
|
|
102
|
+
`majorPhaseGMT` is correct for an engine using a Greenwich V₀. This was settled
|
|
103
|
+
empirically rather than from documentation: predict a station from its constituents and
|
|
104
|
+
compare against NOAA's `currents_predictions` for the same days. With the right
|
|
105
|
+
convention the max flood/ebb events land within ~10 minutes; with the wrong one the
|
|
106
|
+
error is structural and obvious.
|
|
107
|
+
|
|
108
|
+
## Subordinate stations
|
|
109
|
+
|
|
110
|
+
```
|
|
111
|
+
refStationId, refStationBin, meanFloodDir, meanEbbDir,
|
|
112
|
+
sbfTimeAdjMin (slack before flood), sbeTimeAdjMin (slack before ebb),
|
|
113
|
+
mfcTimeAdjMin (max flood current), mecTimeAdjMin (max ebb current),
|
|
114
|
+
mfcAmpAdj, mecAmpAdj
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
Two things people get wrong:
|
|
118
|
+
|
|
119
|
+
- **There are two slack offsets, not one.** A slack event takes the offset for the phase
|
|
120
|
+
it *precedes* — `sbfTimeAdjMin` for a slack before flood, `sbeTimeAdjMin` before ebb.
|
|
121
|
+
- **`mfcAmpAdj` / `mecAmpAdj` are ratios**, applied to the reference peak speed. Not
|
|
122
|
+
deltas. (Confirmed by validation: treating them as deltas fails immediately.)
|
|
123
|
+
|
|
124
|
+
### A reference is a (station, **bin**) pair
|
|
125
|
+
|
|
126
|
+
A reference station can publish several bins with **different constituents**, and the
|
|
127
|
+
subordinate names the one it wants in `refStationBin`:
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
SFB1201 currbin list: [26, 20, 10]
|
|
131
|
+
bin 26 → M2 2.359 kn @ 165.5°
|
|
132
|
+
bin 10 → M2 1.935 kn @ 161.7°
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Store references keyed by `(id, bin)`. Keying by `id` alone silently predicts from the
|
|
136
|
+
wrong depth — and it will *pass* a single-station test whenever that station's reference
|
|
137
|
+
happened to use the primary bin, then be ~50 min wrong elsewhere. Validate against a
|
|
138
|
+
diverse batch: mixed regions, positive and negative offsets, ratios from 0.2 to 1.5.
|
|
139
|
+
|
|
140
|
+
### `type: S` does not mean "use the offset reduction"
|
|
141
|
+
|
|
142
|
+
Some type-S stations carry their **own** harmonic constituents, and NOAA predicts those
|
|
143
|
+
**harmonically**. Applying the offset reduction to them overshoots badly:
|
|
144
|
+
|
|
145
|
+
| PUG1716 predicted as | error vs NOAA |
|
|
146
|
+
|---|---|
|
|
147
|
+
| offset reduction | 89 min / 0.72 kn |
|
|
148
|
+
| own harmonics | **6.8 min / 0.06 kn** |
|
|
149
|
+
|
|
150
|
+
**Rule:** for any type-S station, fetch its own `harcon.json` at its `currbin` first. If
|
|
151
|
+
it comes back non-empty, treat it as harmonic. Only fall back to
|
|
152
|
+
`currentpredictionoffsets.json` for stations whose harcon is genuinely empty — the true
|
|
153
|
+
table-subordinates.
|
|
154
|
+
|
|
155
|
+
**How often does this bite?** Measured across the full US set (2026-07): **1 of 1,706**
|
|
156
|
+
type-S stations — `PUG1716`, Waldron Island. Earlier notes of ours said "many"; that was
|
|
157
|
+
generalizing from the single case we happened to hit. The rule still stands, because the
|
|
158
|
+
check is one request you are already making and the cost of skipping it is an 89-minute
|
|
159
|
+
error at whichever station it turns out to be. But calibrate your expectations: this is a
|
|
160
|
+
rare-but-severe trap, not a widespread one.
|
|
161
|
+
|
|
162
|
+
---
|
|
163
|
+
|
|
164
|
+
## A correction
|
|
165
|
+
|
|
166
|
+
One widely-repeated claim that **does not reproduce**, recorded here because acting on
|
|
167
|
+
it costs real work.
|
|
168
|
+
|
|
169
|
+
### "NOAA 404s the default fetch/curl User-Agent"
|
|
170
|
+
|
|
171
|
+
**Not reproducible.** Tested 2026-07-19 from a residential connection with Node 24's
|
|
172
|
+
built-in `fetch`, default User-Agent, against five endpoints:
|
|
173
|
+
|
|
174
|
+
| endpoint | default UA | browser UA |
|
|
175
|
+
|---|---|---|
|
|
176
|
+
| `harcon.json` | 200, 10898 B | 200, 10898 B |
|
|
177
|
+
| `datagetter` (`max_slack`) | 200, 2023 B | 200, 2023 B |
|
|
178
|
+
| `stations.json` | 200, 3.75 MB | 200, 3.75 MB |
|
|
179
|
+
| `currentpredictionoffsets.json` | 200, 375 B | 200, 375 B |
|
|
180
|
+
|
|
181
|
+
Byte-identical responses. The original observation was almost certainly rate-limiting
|
|
182
|
+
from high-volume probing, coinciding with a `currents_predictions` product outage on
|
|
183
|
+
2026-07-18. This client still sends a browser User-Agent — it costs nothing and NOAA
|
|
184
|
+
may well throttle unfamiliar clients under load — but **a missing User-Agent is not the
|
|
185
|
+
cause of a 404 you are debugging.** Look at your `bin` first.
|
|
186
|
+
|
|
187
|
+
The supported way to identify yourself is the `application` parameter on `datagetter`.
|
|
188
|
+
|
|
189
|
+
### What *is* real about access
|
|
190
|
+
|
|
191
|
+
- **Datacenter IPs are *not* blocked**, contrary to what we believed. A GitHub-hosted
|
|
192
|
+
runner fetched all four endpoint families — `stations.json` (3.75 MB), `harcon`,
|
|
193
|
+
`currentpredictionoffsets`, and `datagetter` — with 200s and byte counts identical to
|
|
194
|
+
a residential connection (2026-07-19). A **full US extraction also completes from a
|
|
195
|
+
GitHub-hosted runner** — 2,785 stations, ~2,800 paced requests, 25 minutes, zero
|
|
196
|
+
throttling failures — so this is safe to automate in CI.
|
|
197
|
+
- **NOAA throttles bulk callers.** A full US extraction is thousands of requests. Pace
|
|
198
|
+
them (this client defaults to 400 ms) or you will get intermittent failures that look
|
|
199
|
+
like missing data.
|
|
200
|
+
- **The predictions product does go down.** It was unavailable on 2026-07-18. If
|
|
201
|
+
`currents_predictions` returns "not available" for a station you believe is served,
|
|
202
|
+
check a known-good station before concluding anything about yours.
|
|
203
|
+
|
|
204
|
+
---
|
|
205
|
+
|
|
206
|
+
## Dead ends, ruled out
|
|
207
|
+
|
|
208
|
+
- **XTide / Harmbase2 as the constituent source.** Unnecessary. NOAA publishes current
|
|
209
|
+
constituents directly, public domain, at `currbin`.
|
|
210
|
+
- **`harcon.json?bin=0`.** Empty for currents. Always use `currbin`.
|
|
211
|
+
- **`currents_predictions` for observation/survey stations.** Real-time buoys (e.g.
|
|
212
|
+
`cb0102`) aren't served by the predictions product; it is a published-tables product.
|
|
213
|
+
Note that station *type* alone doesn't tell you: PUG1717 is survey-flagged and **is**
|
|
214
|
+
served at bin 35. Ask the API rather than inferring.
|
|
215
|
+
|
|
216
|
+
## Validating your own implementation
|
|
217
|
+
|
|
218
|
+
Don't trust a currents engine that hasn't been diffed against NOAA. The method is
|
|
219
|
+
self-contained, since both halves come from NOAA:
|
|
220
|
+
|
|
221
|
+
1. Pull a station's constituents from `harcon.json` at its `currbin`.
|
|
222
|
+
2. Pull NOAA's own `currents_predictions` (`interval=max_slack`) for a window.
|
|
223
|
+
3. Predict that window from the constituents and diff the events.
|
|
224
|
+
|
|
225
|
+
`noaa-current-stations golden` captures both halves into one fixture so the comparison
|
|
226
|
+
replays offline. Expect ~10 min / 0.05 kn at a clean reversing station; see
|
|
227
|
+
[validation.md](validation.md) for measured results and realistic tolerances.
|
|
228
|
+
|
|
229
|
+
## Prior art
|
|
230
|
+
|
|
231
|
+
- [`RyanCardin15/Perigee-Tides`](https://github.com/RyanCardin15/Perigee-Tides) — an MCP
|
|
232
|
+
server over the same API. Comparing its request format against ours confirmed the
|
|
233
|
+
request shape was never the problem, which is what pointed us at `currbin`.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# Releasing
|
|
2
|
+
|
|
3
|
+
Each release ships the npm package (code, schema, and docs) and a minified
|
|
4
|
+
`currents.json` GitHub release asset. The reviewable `currents.json` source bundle stays
|
|
5
|
+
outside the npm tarball.
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm test
|
|
9
|
+
npm run bundle:min
|
|
10
|
+
npm run validate:bundle
|
|
11
|
+
npm pack --dry-run
|
|
12
|
+
|
|
13
|
+
mkdir -p /tmp/noaa-current-stations-release
|
|
14
|
+
cp currents.min.json /tmp/noaa-current-stations-release/currents.json
|
|
15
|
+
gh release create vX.Y.Z --notes "..."
|
|
16
|
+
gh release upload vX.Y.Z /tmp/noaa-current-stations-release/currents.json
|
|
17
|
+
gh release download vX.Y.Z --pattern currents.json --output /tmp/currents.json --clobber
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Run `node bin/noaa-current-stations.mjs check` before releasing a stale bundle. The scheduled
|
|
21
|
+
`update-stations` workflow creates a review issue when NOAA's station list changes.
|
|
22
|
+
|
|
23
|
+
## First publish
|
|
24
|
+
|
|
25
|
+
Publish `@openwaters/noaa-current-stations` once with an OTP, then configure npm Trusted
|
|
26
|
+
Publishing for `openwatersio/noaa-current-stations`, workflow `publish.yml`, with no
|
|
27
|
+
environment. Later GitHub releases publish through OIDC.
|
package/docs/schema.md
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# Bundle schema
|
|
2
|
+
|
|
3
|
+
Machine-readable: [`schema/currents.schema.json`](../schema/currents.schema.json).
|
|
4
|
+
|
|
5
|
+
A bundle is `{ note, generated, stations[] }`. Every station is either **harmonic** (has
|
|
6
|
+
its own constituents) or **subordinate** (reduces against a harmonic reference).
|
|
7
|
+
|
|
8
|
+
Units throughout: **speeds knots**, **directions degrees true**, **time offsets
|
|
9
|
+
seconds**, **phases degrees Greenwich**.
|
|
10
|
+
|
|
11
|
+
## Harmonic station
|
|
12
|
+
|
|
13
|
+
```json
|
|
14
|
+
{
|
|
15
|
+
"id": "PUG1717",
|
|
16
|
+
"name": "Turn Point, Boundary Pass",
|
|
17
|
+
"type": "harmonic",
|
|
18
|
+
"floodDirection": 23.2,
|
|
19
|
+
"ebbDirection": 203.2,
|
|
20
|
+
"offset": 0.297,
|
|
21
|
+
"constituents": [{ "name": "M2", "amplitude": 1.63, "phase": 295.3 }]
|
|
22
|
+
}
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
| Field | From NOAA | Notes |
|
|
26
|
+
|---|---|---|
|
|
27
|
+
| `id` | station id | `id` at the primary bin, **`id@bin`** for a reference at another bin |
|
|
28
|
+
| `floodDirection` | `azi` | major-axis azimuth — the flood set |
|
|
29
|
+
| `ebbDirection` | `azi + 180` | the reciprocal |
|
|
30
|
+
| `offset` | `majorMeanSpeed` | **Z₀**, signed net mean flow. Not optional — see below |
|
|
31
|
+
| `constituents[].amplitude` | `majorAmplitude` | knots (`units=english`) |
|
|
32
|
+
| `constituents[].phase` | `majorPhaseGMT` | Greenwich phase — pairs with a Greenwich V₀ |
|
|
33
|
+
|
|
34
|
+
### Predicting from it
|
|
35
|
+
|
|
36
|
+
Signed velocity along the major axis, at time *t*:
|
|
37
|
+
|
|
38
|
+
```
|
|
39
|
+
v(t) = Z₀ + Σ fᵢ · Aᵢ · cos(ωᵢ·t + (V₀ᵢ + uᵢ) − φᵢ)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
…the same sum-of-cosines as a tide station, with nodal corrections *f*, *u* applied as
|
|
43
|
+
usual. Then:
|
|
44
|
+
|
|
45
|
+
- **positive** velocity = flood, along `floodDirection`
|
|
46
|
+
- **negative** = ebb, along `ebbDirection`
|
|
47
|
+
- **max flood / max ebb** = the slope-zeros (extrema)
|
|
48
|
+
- **slack** = the **value**-zeros — where `v(t)` crosses zero
|
|
49
|
+
|
|
50
|
+
Label an extremum by the **sign of its velocity**, not by whether it's a curve high or
|
|
51
|
+
low. With Z₀ applied, a relaxation peak during a long ebb is a local maximum but is
|
|
52
|
+
still an ebb; NOAA labels it `maxEbb` and so should you.
|
|
53
|
+
|
|
54
|
+
That `Z₀` term is why slack is where it is. Drop it and every zero crossing moves —
|
|
55
|
+
measured at 15.6 min mean / 55 min worst error, versus 7.4 / 21 with it.
|
|
56
|
+
|
|
57
|
+
## Subordinate station
|
|
58
|
+
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"id": "PCT0236",
|
|
62
|
+
"name": "…",
|
|
63
|
+
"type": "subordinate",
|
|
64
|
+
"reference": "SFB1201@10",
|
|
65
|
+
"floodDirection": 60, "ebbDirection": 240,
|
|
66
|
+
"slackBeforeFloodOffset": -720,
|
|
67
|
+
"slackBeforeEbbOffset": 480,
|
|
68
|
+
"floodTimeOffset": -300,
|
|
69
|
+
"ebbTimeOffset": 180,
|
|
70
|
+
"floodSpeedRatio": 0.7,
|
|
71
|
+
"ebbSpeedRatio": 1.2
|
|
72
|
+
}
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Predict the **reference** station's events, then transform each:
|
|
76
|
+
|
|
77
|
+
| Event | Time shift | Speed |
|
|
78
|
+
|---|---|---|
|
|
79
|
+
| max flood | `+ floodTimeOffset` | `× floodSpeedRatio` |
|
|
80
|
+
| max ebb | `+ ebbTimeOffset` | `× ebbSpeedRatio` |
|
|
81
|
+
| slack before flood | `+ slackBeforeFloodOffset` | 0 |
|
|
82
|
+
| slack before ebb | `+ slackBeforeEbbOffset` | 0 |
|
|
83
|
+
|
|
84
|
+
Two traps:
|
|
85
|
+
|
|
86
|
+
- **Two slack offsets.** A slack takes the offset for the phase it *precedes* — which
|
|
87
|
+
means you must know what follows it before you can place it.
|
|
88
|
+
- **Speed fields are ratios**, multipliers on the reference peak. Not deltas.
|
|
89
|
+
|
|
90
|
+
`reference` is a key into `stations[]` **including any `@bin` suffix**. Resolve it
|
|
91
|
+
exactly; a reference station may appear at several bins with different constituents.
|
|
92
|
+
|
|
93
|
+
## Not in the bundle
|
|
94
|
+
|
|
95
|
+
- **Type-W (weak and variable / rotary) stations.** NOAA doesn't publish a usable
|
|
96
|
+
reversing model for them. The extractor counts them in `skipped.typeW` rather than
|
|
97
|
+
emitting something misleading.
|
|
98
|
+
- **Subordinates whose reference didn't resolve.** Counted in `skipped.unresolvable`. A
|
|
99
|
+
healthy full-US run drops zero.
|
|
100
|
+
- **Minor-axis constituents.** NOAA publishes `minorAmplitude`/`minorPhaseGMT` for a 2D
|
|
101
|
+
rotary model; a major-axis model doesn't use them, so they aren't carried per station.
|
|
102
|
+
Bundling them was measured and rejected — worth a median 4% of peak speed, and a 2D
|
|
103
|
+
magnitude series never crosses zero, which silently yields no slack events at all.
|
|
104
|
+
|
|
105
|
+
The bundle does carry a **`crossFlow` census** at the root: how much perpendicular flow
|
|
106
|
+
the major-axis model drops, and the worst station by ratio and by knots. `validate`
|
|
107
|
+
fails a bundle whose worst ratio exceeds 0.5.
|