@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/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 };
|
package/src/validate.js
ADDED
|
@@ -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
|
+
}
|