@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 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
+ }
@@ -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.