@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/src/extract.js ADDED
@@ -0,0 +1,156 @@
1
+ // Build a current-station bundle from NOAA CO-OPS metadata.
2
+ //
3
+ // Two station kinds end up in the bundle:
4
+ // harmonic — has its own constituents; predicted by summing them.
5
+ // subordinate — no constituents; predicted by applying time/speed offsets to a
6
+ // reference station's harmonic prediction.
7
+ //
8
+ // The two traps this encodes, both found by validating against NOAA's own
9
+ // predictions (docs/noaa-api.md, docs/validation.md):
10
+ // 1. A reference is (station, BIN), not a station. Constituents vary by depth bin
11
+ // and a station may publish several. Keying by id alone silently predicts from
12
+ // the wrong depth — and a single-station test can pass by luck.
13
+ // 2. A `type: S` station is not necessarily offset-reduced. Many survey-derived
14
+ // ones carry their own harcon and NOAA predicts those harmonically; applying
15
+ // the reduction to them overshoots badly (PUG1716: 89 min wrong as a reduction,
16
+ // 6.8 min as harmonic). Always try own-harcon first.
17
+
18
+ import { fetchStationList, fetchHarcon, fetchOffsets } from './noaa.js';
19
+ import { crossFlowCensus } from './cross-flow.js';
20
+
21
+ const BUNDLE_NOTE = 'Generated from NOAA CO-OPS mdapi (harcon@currbin + currentpredictionoffsets). '
22
+ + 'NOAA data is public domain; derived predictions are UNOFFICIAL and not for navigation.';
23
+
24
+ /** A harmonic entry's bundle key: plain id at the primary bin, `id@bin` otherwise. */
25
+ export const harmonicKey = (id, bin, primaryBin) => (bin === primaryBin ? id : `${id}@${bin}`);
26
+
27
+ /**
28
+ * @param {object} opts
29
+ * @param {[number,number,number,number]} [opts.box] [south, west, north, east]; omit for all US.
30
+ * @param {string[]} [opts.stations] explicit station ids; overrides `box`.
31
+ * @param {(msg: string) => void} [opts.log]
32
+ */
33
+ export async function extractBundle(opts = {}) {
34
+ const { box, stations: wanted, log = () => {}, ...fetchOpts } = opts;
35
+
36
+ const all = await fetchStationList(fetchOpts);
37
+ const primaryBin = new Map(all.map((s) => [s.id, s.currbin]));
38
+
39
+ let selected = all;
40
+ if (wanted?.length) {
41
+ const want = new Set(wanted);
42
+ selected = all.filter((s) => want.has(s.id));
43
+ const missing = wanted.filter((id) => !selected.some((s) => s.id === id));
44
+ if (missing.length) throw new Error(`not in NOAA's station list: ${missing.join(', ')}`);
45
+ } else if (box) {
46
+ const [south, west, north, east] = box;
47
+ selected = all.filter((s) => s.lat >= south && s.lat <= north && s.lng >= west && s.lng <= east);
48
+ }
49
+ log(`${selected.length} stations selected (of ${all.length} US current stations)`);
50
+
51
+ const harmonic = new Map();
52
+ const subs = [];
53
+ const skipped = { typeW: 0, emptyHarcon: [], noReference: [], failed: [] };
54
+ // Sampled as each harmonic record is built, because the minor axis is in the
55
+ // harcon we already hold here and nowhere else downstream.
56
+ const crossFlowSamples = [];
57
+
58
+ // Store (id, bin) if it has a non-empty harcon. Returns whether it did.
59
+ async function ensureHarmonic(key, id, bin, name, lat, lng) {
60
+ if (harmonic.has(key)) return true;
61
+ let cons;
62
+ try {
63
+ cons = await fetchHarcon(id, bin, fetchOpts);
64
+ } catch (e) {
65
+ skipped.failed.push(`${key}: ${e.message}`);
66
+ return false;
67
+ }
68
+ if (!cons.length) return false;
69
+ // azi is the major-axis azimuth (flood set); ebb is its reciprocal.
70
+ // majorMeanSpeed is Z0 — the station's net mean flow along that axis. Omitting
71
+ // it shifts every slack, because slack is where the curve crosses zero.
72
+ const azi = cons[0].azi ?? 0;
73
+ harmonic.set(key, {
74
+ id: key, name, type: 'harmonic',
75
+ latitude: lat, longitude: lng,
76
+ floodDirection: azi,
77
+ ebbDirection: (azi + 180) % 360,
78
+ offset: cons[0].majorMeanSpeed ?? 0,
79
+ constituents: cons.map((c) => ({
80
+ name: c.constituentName,
81
+ amplitude: c.majorAmplitude, // knots, because units=english
82
+ phase: c.majorPhaseGMT, // Greenwich phase — pairs with a Greenwich V₀
83
+ })),
84
+ });
85
+ crossFlowSamples.push({
86
+ id: key,
87
+ crossFlow: Math.abs(cons[0].minorMeanSpeed ?? 0),
88
+ alongAxisPeak: cons.reduce((sum, c) => sum + Math.abs(c.majorAmplitude ?? 0), 0)
89
+ + Math.abs(cons[0].majorMeanSpeed ?? 0),
90
+ });
91
+ return true;
92
+ }
93
+
94
+ for (const s of selected) {
95
+ if (s.type === 'W') { skipped.typeW++; continue; } // weak/rotary — not modeled
96
+ if (await ensureHarmonic(s.id, s.id, s.currbin, s.name, s.lat, s.lng)) continue;
97
+ if (s.type === 'H') { skipped.emptyHarcon.push(s.id); continue; }
98
+
99
+ let o;
100
+ try {
101
+ o = await fetchOffsets(s.id, s.currbin, fetchOpts);
102
+ } catch (e) {
103
+ skipped.failed.push(`${s.id}: ${e.message}`);
104
+ continue;
105
+ }
106
+ if (!o.refStationId) { skipped.noReference.push(s.id); continue; }
107
+ subs.push({
108
+ id: s.id, name: s.name, type: 'subordinate',
109
+ latitude: s.lat, longitude: s.lng,
110
+ reference: harmonicKey(o.refStationId, o.refStationBin, primaryBin.get(o.refStationId)),
111
+ _refId: o.refStationId, _refBin: o.refStationBin,
112
+ floodDirection: o.meanFloodDir, ebbDirection: o.meanEbbDir,
113
+ // Two slack offsets: a slack takes the offset for the phase it PRECEDES.
114
+ slackBeforeFloodOffset: Math.round((o.sbfTimeAdjMin ?? 0) * 60),
115
+ slackBeforeEbbOffset: Math.round((o.sbeTimeAdjMin ?? 0) * 60),
116
+ floodTimeOffset: Math.round((o.mfcTimeAdjMin ?? 0) * 60),
117
+ ebbTimeOffset: Math.round((o.mecTimeAdjMin ?? 0) * 60),
118
+ floodSpeedRatio: o.mfcAmpAdj ?? 1, // ratios on the reference peak, not deltas
119
+ ebbSpeedRatio: o.mecAmpAdj ?? 1,
120
+ });
121
+ }
122
+
123
+ // Pull in each referenced (id, bin) that selection didn't already cover.
124
+ for (const sub of subs) {
125
+ if (harmonic.has(sub.reference)) continue;
126
+ const ref = all.find((s) => s.id === sub._refId);
127
+ if (!ref) {
128
+ // No station-list record means no lat/lng — building the harmonic entry anyway
129
+ // would silently violate the schema's required position fields.
130
+ skipped.failed.push(`${sub.id}: reference station ${sub._refId} not in NOAA's station list`);
131
+ continue;
132
+ }
133
+ await ensureHarmonic(sub.reference, sub._refId, sub._refBin, ref.name, ref.lat, ref.lng);
134
+ }
135
+
136
+ const resolved = subs.filter((x) => harmonic.has(x.reference));
137
+ const unresolvable = subs.length - resolved.length;
138
+ const stations = [
139
+ ...harmonic.values(),
140
+ ...resolved.map(({ _refId, _refBin, ...x }) => x),
141
+ ];
142
+
143
+ log(`${harmonic.size} harmonic, ${resolved.length} subordinate, ${skipped.typeW} type-W skipped, `
144
+ + `${unresolvable} unresolvable references dropped`);
145
+ for (const f of skipped.failed) log(` failed: ${f}`);
146
+
147
+ return {
148
+ bundle: {
149
+ note: BUNDLE_NOTE,
150
+ generated: new Date().toISOString(),
151
+ crossFlow: crossFlowCensus(crossFlowSamples),
152
+ stations,
153
+ },
154
+ skipped: { ...skipped, unresolvable },
155
+ };
156
+ }
package/src/golden.js ADDED
@@ -0,0 +1,52 @@
1
+ // Capture a validation fixture: a station's constituents PLUS NOAA's own published
2
+ // predictions for the same station and days.
3
+ //
4
+ // This is the heart of how anything here gets trusted. A current engine is a pile of
5
+ // trigonometry that always produces plausible-looking output; the only way to know it
6
+ // is right is to feed it NOAA's constituents, predict the same window NOAA published,
7
+ // and diff. Both halves come from NOAA, so the comparison is self-contained and the
8
+ // resulting fixture replays offline forever.
9
+
10
+ import { fetchHarcon, fetchCurrentPredictions } from './noaa.js';
11
+
12
+ /**
13
+ * @param {string} stationId
14
+ * @param {number} currbin the station's reference bin — harcon is EMPTY at any other
15
+ * @param {Date} start
16
+ * @param {Date} end
17
+ */
18
+ export async function captureGolden(stationId, currbin, start, end, opts = {}) {
19
+ const cons = await fetchHarcon(stationId, currbin, opts);
20
+ if (!cons.length) {
21
+ throw new Error(`${stationId}: empty harcon at bin ${currbin} — wrong bin, or a true subordinate`);
22
+ }
23
+
24
+ let events = [];
25
+ let predictionsError;
26
+ try {
27
+ events = await fetchCurrentPredictions(stationId, currbin, start, end, opts);
28
+ } catch (e) {
29
+ // The currents_predictions product has had outages (it was down 2026-07-18, which
30
+ // cost us a day of wrong conclusions). Capture the constituents anyway and let the
31
+ // consuming test skip until this is re-run.
32
+ predictionsError = e.message;
33
+ }
34
+
35
+ const azi = cons[0].azi ?? 0;
36
+ return {
37
+ note: 'NOAA CO-OPS: harmonic constituents + NOAA\'s own published predictions for the '
38
+ + 'same window. Public domain. Regenerate with `noaa-current-stations golden`.',
39
+ station: stationId,
40
+ bin: currbin,
41
+ start: start.toISOString(),
42
+ end: end.toISOString(),
43
+ floodDirection: azi,
44
+ ebbDirection: (azi + 180) % 360,
45
+ offset: cons[0].majorMeanSpeed ?? 0,
46
+ constituents: cons.map((c) => ({
47
+ name: c.constituentName, amplitude: c.majorAmplitude, phase: c.majorPhaseGMT,
48
+ })),
49
+ events,
50
+ ...(predictionsError ? { predictionsError } : {}),
51
+ };
52
+ }
package/src/noaa.js ADDED
@@ -0,0 +1,110 @@
1
+ // NOAA CO-OPS API client — the single place this org talks to tidesandcurrents.noaa.gov.
2
+ //
3
+ // Everything surprising about this API is documented in docs/noaa-api.md. The short
4
+ // version, because it costs people days:
5
+ // - harcon.json returns an EMPTY constituent list unless you pass the station's
6
+ // `currbin`. bin=0 looks like "NOAA has no current constituents". It does.
7
+ // - `units=english` yields knots; `units=metric` yields cm/s. We use english.
8
+ // - the station list repeats each station once per depth bin; de-dup keep-first.
9
+ // - NOAA throttles bulk callers. An extraction is thousands of requests: pace them.
10
+
11
+ const MDAPI = 'https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi';
12
+ const DATAGETTER = 'https://api.tidesandcurrents.noaa.gov/api/prod/datagetter';
13
+
14
+ // NOAA has been reported to 404 unfamiliar clients. We could not reproduce that in
15
+ // 2026-07 (see docs/noaa-api.md § User-Agent), but a real UA costs nothing and the
16
+ // `application` parameter below is NOAA's documented way to identify a caller.
17
+ const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 '
18
+ + '(KHTML, like Gecko) Chrome/126.0 Safari/537.36';
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ /**
23
+ * Paced JSON GET. NOAA throttles bulk callers, and an extraction is thousands of
24
+ * requests — `paceMs` is the knob that keeps a full run from getting rate-limited.
25
+ *
26
+ * Retries 5xx/429 and network errors with exponential backoff. A full extraction is
27
+ * ~2,800 requests over ~25 minutes; without this a single transient 504 fails the run
28
+ * (2026-08-01: HUR0615 504'd once, discarding the whole monthly refresh). A 4xx is a
29
+ * real answer about the station — retrying it just wastes the pacing budget.
30
+ */
31
+ export async function getJson(url, { paceMs = 400, fetchFn = fetch, retries = 3, retryMs = 1000 } = {}) {
32
+ for (let attempt = 0; ; attempt++) {
33
+ if (paceMs) await sleep(paceMs);
34
+ let resp;
35
+ try {
36
+ resp = await fetchFn(url, { headers: { 'User-Agent': UA } });
37
+ } catch (e) {
38
+ if (attempt >= retries) throw e;
39
+ await sleep(retryMs * 2 ** attempt);
40
+ continue;
41
+ }
42
+ if (resp.ok) return resp.json();
43
+ if (attempt >= retries || (resp.status < 500 && resp.status !== 429)) {
44
+ throw new Error(`NOAA ${resp.status} ${url}`);
45
+ }
46
+ await sleep(retryMs * 2 ** attempt);
47
+ }
48
+ }
49
+
50
+ /** Every current-prediction station NOAA publishes, de-duped to its primary bin. */
51
+ export async function fetchStationList(opts = {}) {
52
+ const list = await getJson(`${MDAPI}/stations.json?type=currentpredictions&units=english`, opts);
53
+ const seen = new Set();
54
+ // The list repeats a station once per bin; the FIRST entry carries the primary bin.
55
+ return list.stations.filter((s) => (seen.has(s.id) ? false : (seen.add(s.id), true)));
56
+ }
57
+
58
+ /**
59
+ * Harmonic constituents at a specific depth bin. Returns [] when the station has
60
+ * none at that bin — which is the normal, expected answer for a true subordinate,
61
+ * and also what you get for ANY station if you pass the wrong bin.
62
+ */
63
+ export async function fetchHarcon(stationId, bin, opts = {}) {
64
+ const hc = await getJson(`${MDAPI}/stations/${stationId}/harcon.json?units=english&bin=${bin}`, opts);
65
+ return hc.HarmonicConstituents ?? [];
66
+ }
67
+
68
+ /** Subordinate-station time/speed offsets. Note the `<id>_<currbin>` composite path. */
69
+ export async function fetchOffsets(stationId, currbin, opts = {}) {
70
+ return getJson(`${MDAPI}/stations/${stationId}_${currbin}/currentpredictionoffsets.json`, opts);
71
+ }
72
+
73
+ /**
74
+ * NOAA's own published slack/max-flood/max-ebb predictions — the oracle every
75
+ * prediction engine in this org is validated against, and a live data source in
76
+ * its own right.
77
+ */
78
+ export async function fetchCurrentPredictions(stationId, bin, start, end, opts = {}) {
79
+ const ymd = (d) => d.toISOString().slice(0, 10).replace(/-/g, '');
80
+ const params = new URLSearchParams({
81
+ product: 'currents_predictions', interval: 'max_slack', time_zone: 'gmt',
82
+ units: 'english', format: 'json', application: opts.application ?? 'noaa-current-stations',
83
+ station: stationId, bin: String(bin), begin_date: ymd(start), end_date: ymd(end),
84
+ });
85
+ const body = await getJson(`${DATAGETTER}?${params}`, { paceMs: 0, ...opts });
86
+ // NOAA has shipped both shapes of this response.
87
+ const rows = Array.isArray(body.current_predictions)
88
+ ? body.current_predictions
89
+ : (body.current_predictions?.cp ?? []);
90
+ return rows.map((r) => {
91
+ const raw = String(r.Type ?? r.type ?? '').toLowerCase();
92
+ // Only these three are meaningful. Anything else becomes 'unknown' rather than
93
+ // being folded into one of them — a mislabeled ebb is worse than an ignored row.
94
+ const kind = raw.startsWith('slack') ? 'slack'
95
+ : raw.startsWith('flood') ? 'flood'
96
+ : raw.startsWith('ebb') ? 'ebb' : 'unknown';
97
+ return {
98
+ // "YYYY-MM-DD HH:MM" is UTC when requested with time_zone=gmt.
99
+ time: new Date(String(r.Time ?? r.t).replace(' ', 'T') + 'Z').toISOString(),
100
+ kind,
101
+ velocityMajor: Number(r.Velocity_Major ?? r.velocity ?? 0),
102
+ // Both directions are repeated on every row; they are the station's measured
103
+ // principal axes and are more authoritative than anything hand-configured.
104
+ meanFloodDir: Number(r.meanFloodDir),
105
+ meanEbbDir: Number(r.meanEbbDir),
106
+ };
107
+ });
108
+ }
109
+
110
+ export { MDAPI, DATAGETTER, UA };
@@ -0,0 +1,61 @@
1
+ // Structural checks on a bundle. Cheap, and they catch the failures that actually
2
+ // happen: a truncated/partial extraction, or a subordinate pointing at a reference
3
+ // that isn't there (which silently yields no prediction at that station).
4
+
5
+ import { CROSS_FLOW_RATIO_MAX } from './cross-flow.js';
6
+
7
+ /** @returns {{ok: boolean, counts: object, crossFlow: object|null, errors: string[]}} */
8
+ export function validateBundle(bundle) {
9
+ const errors = [];
10
+ const stations = bundle?.stations;
11
+ if (!Array.isArray(stations)) return { ok: false, counts: {}, crossFlow: null, errors: ['no stations array'] };
12
+
13
+ const harmonic = stations.filter((s) => s.type === 'harmonic');
14
+ const subordinate = stations.filter((s) => s.type === 'subordinate');
15
+ const ids = new Set(harmonic.map((s) => s.id));
16
+
17
+ const orphans = subordinate.filter((s) => !ids.has(s.reference));
18
+ if (orphans.length) {
19
+ errors.push(`${orphans.length} subordinate(s) reference a missing station: `
20
+ + orphans.slice(0, 5).map((s) => `${s.id}→${s.reference}`).join(', '));
21
+ }
22
+
23
+ const dupes = stations.length - new Set(stations.map((s) => s.id)).size;
24
+ if (dupes) errors.push(`${dupes} duplicate station id(s)`);
25
+
26
+ const noCons = harmonic.filter((s) => !s.constituents?.length);
27
+ if (noCons.length) errors.push(`${noCons.length} harmonic station(s) with no constituents`);
28
+
29
+ // Z0 absent is a silent, systematic slack-timing error — the exact bug this project
30
+ // was started to fix. A bundle without it anywhere is a broken extraction.
31
+ const withZ0 = harmonic.filter((s) => typeof s.offset === 'number').length;
32
+ if (harmonic.length && withZ0 === 0) errors.push('no harmonic station carries a Z0 offset');
33
+
34
+ const unknownType = stations.filter((s) => s.type !== 'harmonic' && s.type !== 'subordinate');
35
+ if (unknownType.length) errors.push(`${unknownType.length} station(s) of unknown type`);
36
+
37
+ const badPosition = stations.filter((s) => !Number.isFinite(s.latitude) || !Number.isFinite(s.longitude));
38
+ if (badPosition.length) {
39
+ errors.push(`${badPosition.length} station(s) missing a finite latitude/longitude: `
40
+ + badPosition.slice(0, 5).map((s) => s.id).join(', '));
41
+ }
42
+
43
+ // The bundle's whole model is one signed speed along a fixed flood axis. When
44
+ // cross-axis flow gets large next to the along-axis flow, that axis has stopped
45
+ // describing the station and the model there is suspect. A bundle predating this
46
+ // census carries no block — absent is "not measured", not "failed".
47
+ const cf = bundle.crossFlow ?? null;
48
+ if (cf?.worstRatio && cf.worstRatio.ratio > CROSS_FLOW_RATIO_MAX) {
49
+ errors.push(
50
+ `cross-flow ratio ${cf.worstRatio.ratio} at ${cf.worstRatio.id} exceeds ${CROSS_FLOW_RATIO_MAX} `
51
+ + '— the flood axis no longer describes that station, so its major-axis model is suspect',
52
+ );
53
+ }
54
+
55
+ return {
56
+ ok: errors.length === 0,
57
+ counts: { harmonic: harmonic.length, subordinate: subordinate.length, total: stations.length },
58
+ crossFlow: cf,
59
+ errors,
60
+ };
61
+ }