@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
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
A tidal-current engine always produces plausible-looking output. Sinusoids sum to
|
|
4
|
+
something that rises and falls twice a day no matter how wrong the constituents,
|
|
5
|
+
conventions, or offsets are. The only way to know an implementation is right is to
|
|
6
|
+
diff it against NOAA's own published predictions.
|
|
7
|
+
|
|
8
|
+
## Method
|
|
9
|
+
|
|
10
|
+
Both halves come from NOAA, so the comparison is self-contained:
|
|
11
|
+
|
|
12
|
+
1. Pull a station's constituents from `harcon.json` at its `currbin`.
|
|
13
|
+
2. Pull NOAA's `currents_predictions` (`interval=max_slack`) for a window of days.
|
|
14
|
+
3. Predict that window from the constituents; pair each NOAA event with the nearest
|
|
15
|
+
predicted event of the same kind; report mean and worst timing error and mean
|
|
16
|
+
speed error.
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
noaa-current-stations golden pug1741.json --station PUG1741 --bin 27 \
|
|
20
|
+
--start 2026-07-19 --end 2026-07-21
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The fixture holds constituents *and* NOAA's events, so the check replays offline
|
|
24
|
+
forever. Wire it into your test suite; it is the regression gate for every convention
|
|
25
|
+
decision below.
|
|
26
|
+
|
|
27
|
+
## What this method settled
|
|
28
|
+
|
|
29
|
+
Each of these was an open question that the diff answered unambiguously — the wrong
|
|
30
|
+
choice produces structural, obvious error, not noise:
|
|
31
|
+
|
|
32
|
+
| Question | Answer | Wrong-choice cost |
|
|
33
|
+
|---|---|---|
|
|
34
|
+
| Which phase field? | `majorPhaseGMT` | structural offset across all events |
|
|
35
|
+
| Sign convention for labeling? | classify by **velocity sign**, not extremum high/low | a relaxation peak during a long ebb mislabels as flood |
|
|
36
|
+
| Are `mfcAmpAdj`/`mecAmpAdj` ratios or deltas? | ratios | immediate, large speed error |
|
|
37
|
+
| Include `majorMeanSpeed` (Z₀)? | yes | 15.6 → 7.4 min mean timing (see below) |
|
|
38
|
+
| Reference keyed by id, or (id, bin)? | (id, bin) | ~50 min at any station whose reference isn't primary-bin |
|
|
39
|
+
| Predict type-S via offsets or own harcon? | own harcon when non-empty | 89 min vs 6.8 min at PUG1716 |
|
|
40
|
+
|
|
41
|
+
## Measured results
|
|
42
|
+
|
|
43
|
+
From the reference implementation ([slackwater-engine](https://github.com/openwatersio/slackwater-engine),
|
|
44
|
+
Swift), against NOAA's own predictions:
|
|
45
|
+
|
|
46
|
+
| Check | Station(s) | Result |
|
|
47
|
+
|---|---|---|
|
|
48
|
+
| Harmonic oracle | PUG1741 Bellingham Channel (2.8 kn reversing) | **9.7 min / 0.055 kn** (11 events) |
|
|
49
|
+
| Subordinate reduction | PCT0236 (ref SFB1201) | **6.1 min / 0.05 kn** (11 events) |
|
|
50
|
+
| Subordinate batch | 9 pure subordinates — mixed regions, offset signs, ratios 0.2–1.5 | worst **7.7 min / 0.101 kn** |
|
|
51
|
+
| Salish Sea passes | Deception, Rosario, San Juan Ch., Turn Point, Admiralty, Race Rocks | worst **15.3 min / 0.28 kn** |
|
|
52
|
+
|
|
53
|
+
Per-pass: Deception 14.2 min · Rosario 2.2 · San Juan Channel 8.4 · Turn Point 15.3 ·
|
|
54
|
+
Admiralty Inlet 3.5 · Race Rocks 6.0.
|
|
55
|
+
|
|
56
|
+
And independently, from a second implementation
|
|
57
|
+
([signalk-currents](https://github.com/sailingnaturali/signalk-currents), TypeScript
|
|
58
|
+
over [Neaps](https://github.com/neaps/neaps)) — the Z₀ measurement at Turn Point,
|
|
59
|
+
three days, 24 events:
|
|
60
|
+
|
|
61
|
+
| | mean timing | worst | mean speed |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| without Z₀ | 15.6 min | 55 min | 0.147 kn |
|
|
64
|
+
| with Z₀ | **7.4 min** | **21 min** | **0.066 kn** |
|
|
65
|
+
|
|
66
|
+
## Realistic tolerances
|
|
67
|
+
|
|
68
|
+
Harmonic **±20 min / ±0.35 kn**; subordinate **±30 min / ±0.4 kn**. Tighter than that
|
|
69
|
+
and you are fitting noise: the subordinate reduction is a published *table*
|
|
70
|
+
approximation, and constricted passes are genuinely harder than open water — Deception
|
|
71
|
+
Pass and Turn Point are the worst performers above for real physical reasons, not
|
|
72
|
+
implementation ones.
|
|
73
|
+
|
|
74
|
+
**These are planning-grade numbers.** A 15-minute slack error matters at a pass where
|
|
75
|
+
the gate is 20 minutes wide. Derived predictions are unofficial; don't time a transit
|
|
76
|
+
of a fast narrows on them alone.
|
|
77
|
+
|
|
78
|
+
## Extractor fidelity
|
|
79
|
+
|
|
80
|
+
`extract` is diffed against a known-good bundle whenever it changes. Full US run,
|
|
81
|
+
2026-07-19 — 2,785 stations selected, **856 harmonic + 1,705 subordinate**, 238 type-W
|
|
82
|
+
skipped, **0 unresolvable references**:
|
|
83
|
+
|
|
84
|
+
| | |
|
|
85
|
+
|---|---|
|
|
86
|
+
| Overlapping stations byte-identical | **2,558 of 2,558** |
|
|
87
|
+
| Stations NOAA added since the reference bundle | 3 (`PUG1519`, `PCT5721`, `PCT5726`) |
|
|
88
|
+
| Stations disappeared | 0 |
|
|
89
|
+
| Subordinate references unresolved | 0 |
|
|
90
|
+
| Reference entries at a **non-primary** bin (`id@bin`) | **14** |
|
|
91
|
+
|
|
92
|
+
That last row is the per-bin trap in live data: 14 subordinates in US waters reduce
|
|
93
|
+
against a reference bin that is *not* that station's primary bin. Key by station id
|
|
94
|
+
alone and those 14 silently predict from the wrong depth — while every other station
|
|
95
|
+
keeps passing.
|
|
96
|
+
|
|
97
|
+
Station counts drift as NOAA revises its list; treat the totals as approximate and the
|
|
98
|
+
**0 unresolvable references** as the invariant worth gating on.
|
|
99
|
+
|
|
100
|
+
Unit tests (`npm test`) cover the traps with a fake NOAA: per-bin reference keying,
|
|
101
|
+
type-S own-harcon precedence, two-slack offsets, ratio handling, list de-duplication,
|
|
102
|
+
and both response shapes NOAA has shipped for `currents_predictions`.
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
export interface FetchOptions {
|
|
2
|
+
/** Delay before each request, ms. NOAA throttles bulk callers. Default 400. */
|
|
3
|
+
paceMs?: number;
|
|
4
|
+
/** Injectable fetch, for tests. */
|
|
5
|
+
fetchFn?: typeof fetch;
|
|
6
|
+
/** `application` parameter NOAA uses to identify a caller. */
|
|
7
|
+
application?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface NoaaStation {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
lat: number;
|
|
14
|
+
lng: number;
|
|
15
|
+
/** H = harmonic, S = subordinate (may still carry own harcon), W = weak/rotary. */
|
|
16
|
+
type: 'H' | 'S' | 'W';
|
|
17
|
+
/** The station's reference bin. harcon is EMPTY at any other bin. */
|
|
18
|
+
currbin: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface NoaaConstituent {
|
|
22
|
+
constituentName: string;
|
|
23
|
+
/** Knots under units=english; cm/s under units=metric. */
|
|
24
|
+
majorAmplitude: number;
|
|
25
|
+
/** Greenwich phase, degrees. */
|
|
26
|
+
majorPhaseGMT: number;
|
|
27
|
+
/** Major-axis azimuth, degrees true — the flood set. */
|
|
28
|
+
azi: number;
|
|
29
|
+
/** Z0: net mean flow along the major axis, signed. */
|
|
30
|
+
majorMeanSpeed: number;
|
|
31
|
+
/** DC cross-axis flow, knots — the minor-axis counterpart to `majorMeanSpeed`. */
|
|
32
|
+
minorMeanSpeed: number;
|
|
33
|
+
binNbr: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface CurrentPrediction {
|
|
37
|
+
/** ISO 8601, UTC. */
|
|
38
|
+
time: string;
|
|
39
|
+
/** 'unknown' for any Type NOAA sends that isn't one of the three — never folded in. */
|
|
40
|
+
kind: 'slack' | 'flood' | 'ebb' | 'unknown';
|
|
41
|
+
/** Signed major-axis velocity, knots. */
|
|
42
|
+
velocityMajor: number;
|
|
43
|
+
meanFloodDir: number;
|
|
44
|
+
meanEbbDir: number;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface HarmonicStation {
|
|
48
|
+
/** `id` at the primary bin, `id@bin` for a reference at another bin. */
|
|
49
|
+
id: string;
|
|
50
|
+
name: string;
|
|
51
|
+
latitude: number;
|
|
52
|
+
longitude: number;
|
|
53
|
+
type: 'harmonic';
|
|
54
|
+
floodDirection: number;
|
|
55
|
+
ebbDirection: number;
|
|
56
|
+
/** Z0 mean flow, knots. */
|
|
57
|
+
offset: number;
|
|
58
|
+
constituents: { name: string; amplitude: number; phase: number }[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface SubordinateStation {
|
|
62
|
+
id: string;
|
|
63
|
+
name: string;
|
|
64
|
+
latitude: number;
|
|
65
|
+
longitude: number;
|
|
66
|
+
type: 'subordinate';
|
|
67
|
+
/** Bundle key of the harmonic station this reduces against. */
|
|
68
|
+
reference: string;
|
|
69
|
+
floodDirection: number;
|
|
70
|
+
ebbDirection: number;
|
|
71
|
+
/** Seconds. A slack takes the offset for the phase it precedes. */
|
|
72
|
+
slackBeforeFloodOffset: number;
|
|
73
|
+
slackBeforeEbbOffset: number;
|
|
74
|
+
floodTimeOffset: number;
|
|
75
|
+
ebbTimeOffset: number;
|
|
76
|
+
/** Ratios applied to the reference peak speed, not deltas. */
|
|
77
|
+
floodSpeedRatio: number;
|
|
78
|
+
ebbSpeedRatio: number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CrossFlowCensus {
|
|
82
|
+
/** What was measured, carried inline so the number explains itself. */
|
|
83
|
+
measured: string;
|
|
84
|
+
/** Harmonic records sampled, `@bin` entries included. */
|
|
85
|
+
records: number;
|
|
86
|
+
gte0_25kn: number;
|
|
87
|
+
gte0_50kn: number;
|
|
88
|
+
/** Where the flood axis describes the station worst. */
|
|
89
|
+
worstRatio: {
|
|
90
|
+
id: string;
|
|
91
|
+
crossFlow: number;
|
|
92
|
+
/** Σ|majorAmplitude| + |Z0| — a theoretical max if every constituent aligned in phase, not a realized peak speed. */
|
|
93
|
+
alongAxisPeak: number;
|
|
94
|
+
ratio: number;
|
|
95
|
+
};
|
|
96
|
+
/** Largest cross-flow in knots — usually a different station from `worstRatio`. */
|
|
97
|
+
worstAbsolute: { id: string; crossFlow: number };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export interface Bundle {
|
|
101
|
+
note: string;
|
|
102
|
+
generated: string;
|
|
103
|
+
/**
|
|
104
|
+
* How much the major-axis model drops. Null when the bundle has no harmonic
|
|
105
|
+
* stations. Never carried per station — see docs/schema.md.
|
|
106
|
+
*/
|
|
107
|
+
crossFlow: CrossFlowCensus | null;
|
|
108
|
+
stations: (HarmonicStation | SubordinateStation)[];
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface GoldenFixture {
|
|
112
|
+
note: string;
|
|
113
|
+
station: string;
|
|
114
|
+
bin: number;
|
|
115
|
+
start: string;
|
|
116
|
+
end: string;
|
|
117
|
+
floodDirection: number;
|
|
118
|
+
ebbDirection: number;
|
|
119
|
+
offset: number;
|
|
120
|
+
constituents: { name: string; amplitude: number; phase: number }[];
|
|
121
|
+
events: CurrentPrediction[];
|
|
122
|
+
/** Present when NOAA's predictions product was unavailable at capture time. */
|
|
123
|
+
predictionsError?: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface ExtractOptions extends FetchOptions {
|
|
127
|
+
/** [south, west, north, east]. Omit for all US stations. */
|
|
128
|
+
box?: [number, number, number, number];
|
|
129
|
+
/** Explicit station ids; overrides `box`. */
|
|
130
|
+
stations?: string[];
|
|
131
|
+
log?: (message: string) => void;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export interface ExtractResult {
|
|
135
|
+
bundle: Bundle;
|
|
136
|
+
skipped: {
|
|
137
|
+
typeW: number;
|
|
138
|
+
emptyHarcon: string[];
|
|
139
|
+
noReference: string[];
|
|
140
|
+
failed: string[];
|
|
141
|
+
unresolvable: number;
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function getJson(url: string, opts?: FetchOptions): Promise<unknown>;
|
|
146
|
+
export function fetchStationList(opts?: FetchOptions): Promise<NoaaStation[]>;
|
|
147
|
+
export function fetchHarcon(stationId: string, bin: number, opts?: FetchOptions): Promise<NoaaConstituent[]>;
|
|
148
|
+
export function fetchOffsets(stationId: string, currbin: number, opts?: FetchOptions): Promise<Record<string, number | string>>;
|
|
149
|
+
export function fetchCurrentPredictions(
|
|
150
|
+
stationId: string, bin: number, start: Date, end: Date, opts?: FetchOptions,
|
|
151
|
+
): Promise<CurrentPrediction[]>;
|
|
152
|
+
export function extractBundle(opts?: ExtractOptions): Promise<ExtractResult>;
|
|
153
|
+
export function harmonicKey(id: string, bin: number, primaryBin: number): string;
|
|
154
|
+
export function captureGolden(
|
|
155
|
+
stationId: string, currbin: number, start: Date, end: Date, opts?: FetchOptions,
|
|
156
|
+
): Promise<GoldenFixture>;
|
|
157
|
+
|
|
158
|
+
export const MDAPI: string;
|
|
159
|
+
export const DATAGETTER: string;
|
|
160
|
+
export const UA: string;
|
package/index.js
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@openwaters/noaa-current-stations",
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "NOAA CO-OPS tidal-current station data: harmonic constituents, subordinate offsets, and the live predictions API — extractor, schema, and the API's undocumented gotchas",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"types": "index.d.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"noaa-current-stations": "bin/noaa-current-stations.mjs"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"index.d.ts",
|
|
14
|
+
"src/",
|
|
15
|
+
"bin/",
|
|
16
|
+
"schema/",
|
|
17
|
+
"docs/"
|
|
18
|
+
],
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test",
|
|
21
|
+
"extract:us": "node bin/noaa-current-stations.mjs extract currents.json",
|
|
22
|
+
"validate": "node --test",
|
|
23
|
+
"bundle:min": "node -e \"const f=require('fs');f.writeFileSync('currents.min.json',JSON.stringify(JSON.parse(f.readFileSync('currents.json','utf8'))))\"",
|
|
24
|
+
"validate:bundle": "node bin/noaa-current-stations.mjs validate currents.json"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"noaa",
|
|
28
|
+
"co-ops",
|
|
29
|
+
"tidal-currents",
|
|
30
|
+
"currents",
|
|
31
|
+
"harmonic-constituents",
|
|
32
|
+
"tides",
|
|
33
|
+
"marine",
|
|
34
|
+
"navigation"
|
|
35
|
+
],
|
|
36
|
+
"repository": {
|
|
37
|
+
"type": "git",
|
|
38
|
+
"url": "git+https://github.com/openwatersio/noaa-current-stations.git"
|
|
39
|
+
},
|
|
40
|
+
"homepage": "https://github.com/openwatersio/noaa-current-stations#readme",
|
|
41
|
+
"bugs": "https://github.com/openwatersio/noaa-current-stations/issues",
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
},
|
|
46
|
+
"engines": {
|
|
47
|
+
"node": ">=20"
|
|
48
|
+
}
|
|
49
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/openwatersio/noaa-current-stations/schema/currents.schema.json",
|
|
4
|
+
"title": "NOAA current-station bundle",
|
|
5
|
+
"description": "Tidal-current stations extracted from NOAA CO-OPS metadata. Harmonic stations carry their own constituents; subordinate stations reduce against a harmonic reference. Speeds are knots, directions degrees true, time offsets seconds.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"required": ["note", "stations"],
|
|
8
|
+
"properties": {
|
|
9
|
+
"note": { "type": "string", "description": "Provenance and licensing." },
|
|
10
|
+
"generated": { "type": "string", "format": "date-time" },
|
|
11
|
+
"crossFlow": {
|
|
12
|
+
"type": ["object", "null"],
|
|
13
|
+
"description": "Census of NOAA `minorMeanSpeed` — flow perpendicular to the flood axis, present at all times including slack. This bundle's major-axis model drops it; this records how much it drops. Not carried per station. Null when the bundle has no harmonic stations.",
|
|
14
|
+
"properties": {
|
|
15
|
+
"measured": { "type": "string" },
|
|
16
|
+
"records": { "type": "integer", "description": "Harmonic records sampled, `@bin` entries included." },
|
|
17
|
+
"gte0_25kn": { "type": "integer" },
|
|
18
|
+
"gte0_50kn": { "type": "integer" },
|
|
19
|
+
"worstRatio": {
|
|
20
|
+
"type": "object",
|
|
21
|
+
"description": "Largest crossFlow/alongAxisPeak — where the flood axis describes the station worst.",
|
|
22
|
+
"properties": {
|
|
23
|
+
"id": { "type": "string" },
|
|
24
|
+
"crossFlow": { "type": "number" },
|
|
25
|
+
"alongAxisPeak": {
|
|
26
|
+
"type": "number",
|
|
27
|
+
"description": "Σ|majorAmplitude| + |Z0| — a theoretical maximum reached only if every constituent aligned in phase, not a realized peak speed. The denominator `ratio` and the 0.5 bound are only meaningful given this definition."
|
|
28
|
+
},
|
|
29
|
+
"ratio": { "type": "number" }
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"worstAbsolute": {
|
|
33
|
+
"type": "object",
|
|
34
|
+
"description": "Largest crossFlow in knots — usually a different station from worstRatio.",
|
|
35
|
+
"properties": {
|
|
36
|
+
"id": { "type": "string" },
|
|
37
|
+
"crossFlow": { "type": "number" }
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
"stations": {
|
|
43
|
+
"type": "array",
|
|
44
|
+
"items": { "oneOf": [{ "$ref": "#/$defs/harmonic" }, { "$ref": "#/$defs/subordinate" }] }
|
|
45
|
+
}
|
|
46
|
+
},
|
|
47
|
+
"$defs": {
|
|
48
|
+
"harmonic": {
|
|
49
|
+
"type": "object",
|
|
50
|
+
"required": ["id", "name", "latitude", "longitude", "type", "floodDirection", "ebbDirection", "offset", "constituents"],
|
|
51
|
+
"properties": {
|
|
52
|
+
"id": {
|
|
53
|
+
"type": "string",
|
|
54
|
+
"description": "NOAA station id at its primary bin, or `id@bin` for a reference at a non-primary bin. Constituents vary by depth bin, so identity is (station, bin)."
|
|
55
|
+
},
|
|
56
|
+
"name": { "type": "string" },
|
|
57
|
+
"latitude": { "type": "number" },
|
|
58
|
+
"longitude": { "type": "number" },
|
|
59
|
+
"type": { "const": "harmonic" },
|
|
60
|
+
"floodDirection": { "type": "number", "description": "Major-axis azimuth (NOAA `azi`), degrees true." },
|
|
61
|
+
"ebbDirection": { "type": "number", "description": "floodDirection + 180." },
|
|
62
|
+
"offset": {
|
|
63
|
+
"type": "number",
|
|
64
|
+
"description": "Z0 — net mean flow along the major axis, knots, signed (NOAA `majorMeanSpeed`). A DC offset on the velocity curve; omitting it moves every slack."
|
|
65
|
+
},
|
|
66
|
+
"constituents": {
|
|
67
|
+
"type": "array",
|
|
68
|
+
"minItems": 1,
|
|
69
|
+
"items": {
|
|
70
|
+
"type": "object",
|
|
71
|
+
"required": ["name", "amplitude", "phase"],
|
|
72
|
+
"properties": {
|
|
73
|
+
"name": { "type": "string", "description": "Darwin symbol, e.g. M2, K1." },
|
|
74
|
+
"amplitude": { "type": "number", "description": "Knots (NOAA `majorAmplitude` under units=english)." },
|
|
75
|
+
"phase": { "type": "number", "description": "Greenwich phase, degrees (NOAA `majorPhaseGMT`)." }
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
},
|
|
81
|
+
"subordinate": {
|
|
82
|
+
"type": "object",
|
|
83
|
+
"required": ["id", "name", "latitude", "longitude", "type", "reference"],
|
|
84
|
+
"properties": {
|
|
85
|
+
"id": { "type": "string" },
|
|
86
|
+
"name": { "type": "string" },
|
|
87
|
+
"latitude": { "type": "number" },
|
|
88
|
+
"longitude": { "type": "number" },
|
|
89
|
+
"type": { "const": "subordinate" },
|
|
90
|
+
"reference": {
|
|
91
|
+
"type": "string",
|
|
92
|
+
"description": "`id` of the harmonic station in this bundle to reduce against — including the `@bin` suffix when the reference is at a non-primary bin."
|
|
93
|
+
},
|
|
94
|
+
"floodDirection": { "type": "number" },
|
|
95
|
+
"ebbDirection": { "type": "number" },
|
|
96
|
+
"slackBeforeFloodOffset": { "type": "number", "description": "Seconds. Applied to a slack that precedes a flood." },
|
|
97
|
+
"slackBeforeEbbOffset": { "type": "number", "description": "Seconds. Applied to a slack that precedes an ebb." },
|
|
98
|
+
"floodTimeOffset": { "type": "number", "description": "Seconds, applied to max flood." },
|
|
99
|
+
"ebbTimeOffset": { "type": "number", "description": "Seconds, applied to max ebb." },
|
|
100
|
+
"floodSpeedRatio": { "type": "number", "description": "Multiplier on the reference max flood speed. A ratio, not a delta." },
|
|
101
|
+
"ebbSpeedRatio": { "type": "number", "description": "Multiplier on the reference max ebb speed." }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// What this package emits is a MAJOR-AXIS model: one signed speed along a fixed
|
|
2
|
+
// flood axis. NOAA also publishes `minorMeanSpeed` — the DC component of flow
|
|
3
|
+
// perpendicular to that axis, running at all times INCLUDING slack — and a
|
|
4
|
+
// major-axis model drops it.
|
|
5
|
+
//
|
|
6
|
+
// The per-station minor axis is deliberately not carried (docs/schema.md). But a
|
|
7
|
+
// bundle should be able to state the bound on its own approximation rather than
|
|
8
|
+
// leave it unmeasured, so the extractor records this census instead.
|
|
9
|
+
//
|
|
10
|
+
// Measured 2026-08-15. Across the 856 records a full US bundle holds: worst
|
|
11
|
+
// ratio 0.241 (BOS1130), worst absolute 0.80 kn (PUG1619 Marrowstone Point).
|
|
12
|
+
// The 0.241 also holds across the full 2,800 bin-records across NOAA's 850
|
|
13
|
+
// harmonic stations (not every bin at every one of NOAA's ~2,785 stations —
|
|
14
|
+
// subordinates have no harcon at all), so the bound below is safe for bins we
|
|
15
|
+
// don't currently take.
|
|
16
|
+
|
|
17
|
+
/** Provenance, carried in the census so the number explains itself in the file. */
|
|
18
|
+
const MEASURED =
|
|
19
|
+
'NOAA minorMeanSpeed — flow perpendicular to the flood axis, present at all times including slack';
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Ratio of cross-flow to along-axis peak above which the flood axis is a poor
|
|
23
|
+
* description of the station, and so the major-axis model there is suspect.
|
|
24
|
+
* ~2x the worst real value: a regression guard, not a quality gate.
|
|
25
|
+
*/
|
|
26
|
+
export const CROSS_FLOW_RATIO_MAX = 0.5;
|
|
27
|
+
|
|
28
|
+
const r3 = (n) => Math.round(n * 1000) / 1000;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {{id: string, crossFlow: number, alongAxisPeak: number}[]} samples
|
|
32
|
+
* @returns {object|null} the census, or null when there is nothing to measure
|
|
33
|
+
*/
|
|
34
|
+
export function crossFlowCensus(samples) {
|
|
35
|
+
if (!samples?.length) return null;
|
|
36
|
+
|
|
37
|
+
let gte0_25kn = 0;
|
|
38
|
+
let gte0_50kn = 0;
|
|
39
|
+
// Kept as raw (unrounded) values through the loop — comparing a raw incoming
|
|
40
|
+
// value against an already-r3-rounded stored one lets a within-0.0005 runner-up
|
|
41
|
+
// win by rounding luck. Rounding happens once, on the way out.
|
|
42
|
+
let worstRatio = null;
|
|
43
|
+
let worstAbsolute = null;
|
|
44
|
+
|
|
45
|
+
for (const s of samples) {
|
|
46
|
+
// A station with no along-axis flow has no axis to be wrong about.
|
|
47
|
+
const ratio = s.alongAxisPeak > 0 ? s.crossFlow / s.alongAxisPeak : 0;
|
|
48
|
+
if (s.crossFlow >= 0.25) gte0_25kn += 1;
|
|
49
|
+
if (s.crossFlow >= 0.5) gte0_50kn += 1;
|
|
50
|
+
|
|
51
|
+
// Tracked separately on purpose: the largest ratio and the largest current
|
|
52
|
+
// are usually different stations, and they answer different questions.
|
|
53
|
+
if (!worstRatio || ratio > worstRatio.ratio) {
|
|
54
|
+
worstRatio = { id: s.id, crossFlow: s.crossFlow, alongAxisPeak: s.alongAxisPeak, ratio };
|
|
55
|
+
}
|
|
56
|
+
if (!worstAbsolute || s.crossFlow > worstAbsolute.crossFlow) {
|
|
57
|
+
worstAbsolute = { id: s.id, crossFlow: s.crossFlow };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return {
|
|
62
|
+
measured: MEASURED,
|
|
63
|
+
records: samples.length,
|
|
64
|
+
gte0_25kn,
|
|
65
|
+
gte0_50kn,
|
|
66
|
+
worstRatio: worstRatio && {
|
|
67
|
+
id: worstRatio.id,
|
|
68
|
+
crossFlow: r3(worstRatio.crossFlow),
|
|
69
|
+
alongAxisPeak: r3(worstRatio.alongAxisPeak),
|
|
70
|
+
ratio: r3(worstRatio.ratio),
|
|
71
|
+
},
|
|
72
|
+
worstAbsolute: worstAbsolute && { id: worstAbsolute.id, crossFlow: r3(worstAbsolute.crossFlow) },
|
|
73
|
+
};
|
|
74
|
+
}
|
package/src/drift.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Detect drift in NOAA's current-station list.
|
|
2
|
+
//
|
|
3
|
+
// NOAA revises the list — stations get added, retired, or reclassified. That is not an
|
|
4
|
+
// error, but it must never be silent: a station appearing means the bundle is stale, and
|
|
5
|
+
// one disappearing means something downstream may reference a station that no longer
|
|
6
|
+
// exists. We went 855 → 856 harmonic between two extractions and only noticed by
|
|
7
|
+
// diffing bundles.
|
|
8
|
+
//
|
|
9
|
+
// This is deliberately cheap — ONE request for the station list, no per-station harcon
|
|
10
|
+
// fetches — so it can run on a schedule. It detects list-level drift (which stations
|
|
11
|
+
// exist, and their type). It does NOT detect a station's constituents being revised in
|
|
12
|
+
// place; that needs a full re-extraction and a bundle diff.
|
|
13
|
+
|
|
14
|
+
import { fetchStationList } from './noaa.js';
|
|
15
|
+
|
|
16
|
+
/** Reduce a live station list to the shape we pin. */
|
|
17
|
+
export function summarize(stations) {
|
|
18
|
+
const counts = { H: 0, S: 0, W: 0 };
|
|
19
|
+
for (const s of stations) counts[s.type] = (counts[s.type] ?? 0) + 1;
|
|
20
|
+
return {
|
|
21
|
+
counts: { ...counts, total: stations.length },
|
|
22
|
+
ids: stations.map((s) => `${s.id}:${s.type}`).sort(),
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Compare a live summary against a pinned lock.
|
|
28
|
+
* @returns {{drifted: boolean, added: string[], removed: string[], retyped: string[],
|
|
29
|
+
* counts: object, expected: object}}
|
|
30
|
+
*/
|
|
31
|
+
export function diffAgainstLock(live, lock) {
|
|
32
|
+
const parse = (list) => new Map(list.map((e) => e.split(':')));
|
|
33
|
+
const now = parse(live.ids);
|
|
34
|
+
const then = parse(lock.ids);
|
|
35
|
+
|
|
36
|
+
const added = [...now.keys()].filter((id) => !then.has(id)).map((id) => `${id} (${now.get(id)})`);
|
|
37
|
+
const removed = [...then.keys()].filter((id) => !now.has(id)).map((id) => `${id} (${then.get(id)})`);
|
|
38
|
+
// A reclassification changes how a station is predicted — it matters as much as an
|
|
39
|
+
// addition, and a plain count check can miss it entirely (H→S keeps the total equal).
|
|
40
|
+
const retyped = [...now.keys()]
|
|
41
|
+
.filter((id) => then.has(id) && then.get(id) !== now.get(id))
|
|
42
|
+
.map((id) => `${id} (${then.get(id)} → ${now.get(id)})`);
|
|
43
|
+
|
|
44
|
+
return {
|
|
45
|
+
drifted: added.length > 0 || removed.length > 0 || retyped.length > 0,
|
|
46
|
+
added, removed, retyped,
|
|
47
|
+
counts: live.counts, expected: lock.counts,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function checkDrift(lock, opts = {}) {
|
|
52
|
+
const live = summarize(await fetchStationList({ paceMs: 0, ...opts }));
|
|
53
|
+
return diffAgainstLock(live, lock);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function buildLock(stations) {
|
|
57
|
+
return {
|
|
58
|
+
note: 'Pinned NOAA current-station list. `noaa-current-stations check` fails when NOAA\'s '
|
|
59
|
+
+ 'live list no longer matches this. Regenerate with `noaa-current-stations lock`, and '
|
|
60
|
+
+ 're-extract the bundle when it changes.',
|
|
61
|
+
generated: new Date().toISOString(),
|
|
62
|
+
...summarize(stations),
|
|
63
|
+
};
|
|
64
|
+
}
|