This one started with a snap judgment, and the snap judgment was wrong in the most
useful way. I was looking at , a
third-party client that talks to the same CO-OPS endpoints, and diffed its request
construction (src/services/data-api.ts, metadata-api.ts) against mine. Byte for byte,
the URL shape, the parameters, the field names — identical. My request format was
correct. It had been correct the whole time.
That's the pivot the whole debugging session turned on, and it generalizes:
When your request matches a known-good client and it still fails, the variable is the
target, not the syntax. Stop editing the request and start changing what you point
it at — the station, the bin, the headers, the rate.
Once the format was ruled out, the remaining differences could only be the station, the
bin, the UA, and the request rate — which is exactly where the three real problems were.
I'd spent hours suspecting my URL. The URL was fine.
Outcome: XTide dropped entirely
Both walls fell, and on the far side of them the original question answered itself: I
don't need XTide at all. Everything comes straight from NOAA CO-OPS, public domain:
Constituents —harcon.jsonat the station'scurrbin.
Flood/ebb axis —azi(flood) andazi+180 (ebb).
Mean flow —majorMeanSpeedas the Z₀ offset.
Subordinate stations —currentpredictionoffsets.json, giving time and speed
offsets against a reference harmonic station.
The build-time extractor sweeps NOAA's station list and pulls each station's harcon (at
its currbin) and offsets, producing a bundled currents.json computed offline at runtime
— 855 harmonic + ~1,700 subordinate stations, all of US waters, including the US Salish
Sea passes I set out for: Deception Pass, Rosario, San Juan Channel, Turn Point. No XTide
data, no tide binary, no libtcd, at build time or run time.
The receipt: vs NOAA's own predictions
Once currents_predictions was serving, it became the oracle it was always meant to be —
the independent authority check. Feed the engine a station's published constituents,
predict its slack/max events, and compare to NOAA's own published events for the same
station and days.
Harmonic station — PUG1741 (Bellingham Channel), a clean ~2.8 kn reversing station:
max flood/ebb events match NOAA to 9.7 min / 0.055 kn across 11 events — on par with
the tide engine's Phase-0 (7.9 min / 3.5 cm). The fixture is captured offline so the check
runs in CI without the network.
Subordinate reduction — PCT0236 (reference SFB1201): 6.1 min / 0.05 kn over 11
events. Subordinate stations were subtler than tides because NOAA publishes two slack
offsets — slack-before-flood (sbfTimeAdjMin) and slack-before-ebb (sbeTimeAdjMin) —
not one. A slack takes the offset for the phase it precedes. Model it with a single slack
offset and half your slacks land at the wrong minute.
One labeling insight fell out of matching NOAA at weak stations: classify max flood vs
max ebb by the sign of the velocity, not by slope high/low. At a strong reversing pass,
a velocity peak (slope zero) that's positive is a flood and negative is an ebb, and
high/low slope gives the same answer. But at a weak, non-reversing station a relaxation
peak never crosses zero — a −0.3 kn local maximum during a long ebb is slope-high but
it's still an ebb. Label it by sign and you match NOAA's max_slack exactly.
The bugs only a batch caught
Validating one station passed clean. Then I validated a diverse batch — different regions,
offset signs, speed ratios, harmonic and subordinate — and it broke in two different ways the
single-station test had walked right past. Both are the same shape: a category I'd treated as
monolithic turned out to have a second variety, and only the second variety triggered the bug.
A reference is (station, bin), not just a station. A subordinate references a specific
bin of its reference station (refStationBin) — because, same lesson as Wall 1, a current
reference is depth-keyed. My extractor stored one bin's constituents per reference. That's
fine until a reference publishes multiple bins with different constituents. SFB1201
publishes bins [26, 20, 10], each with its own harmonic set. Storing one silently resolved
half the subordinates against the wrong constituents — ~50 min and ~0.6 kn off. The
single-station test had happened to hit the bin I'd stored, so it passed and told me nothing.
Fix: resolve each reference at its exact refStationBin.
"Subordinate" doesn't always mean subordinate. Some NOAA type: S stations carry their
own harmonic constituents, and NOAA predicts those harmonically — the offset reduction is
only for stations that have none. PUG1716 (which references San Juan Channel) is type S but
has its own 25-constituent harcon; reducing it from San Juan Channel over-shot it by 89 min
/ 0.7 kn. Predicted from its own constituents: 6.8 min / 0.06 kn. Fix: for a type-S
station, fetch its own harcon first; fall back to the reduction only when it's empty. With
both fixes, nine genuinely-subordinate stations match NOAA to 0.9–7.7 min.
The lesson keeps recurring in this engine: n=1 validation hides class bugs. One passing
station proves one station. Validate across the diversity of the data — regions, bins, ratios,
harmonic and subordinate — and the class of bug that only shows up at the second variety finally
has somewhere to show up.
The passes I set out for turned out served
There's a fitting last twist. This whole project started to get currents for my home passes —
Deception Pass, Rosario Strait, San Juan Channel, Turn Point. Early on those returned
"not available", so I filed them as survey stations NOAA doesn't predict and validated them
only transitively — engine-correct-on-other-stations plus faithful constituents. That was Wall
2 in miniature, and I'd quietly surrendered to it.
Re-checked with the right bin and a browser User-Agent: every one is served. They validate
directly against NOAA's own predictions now — Deception Pass to 14 min, Admiralty Inlet to 3.5,
Race Rocks to 6.0, on the significant currents. (The weak sub-¾-knot relaxation extrema at the
mixed-tide stations disagree by tens of minutes — but those are ill-conditioned in NOAA's
computation too, and a boat doesn't care when a third of a knot peaks.) The premise that my home
water was unreachable was, like every wall in this story, my request being wrong — not the data
being absent.
Five things I'm keeping
Empty ≠ absent. An empty result to a query with a defaulted parameter (bin=0) is a
fact about your query, not about the data. Check what you defaulted before you conclude
the data doesn't exist.
"The service is down" is usually your request. Three stacked causes — User-Agent,
rate, target — each looked like an outage. The endpoint was serving the whole time.
An ugly UI is not evidence about data quality. XTide's 1998 homepage says nothing
about its constants. The reaction was wrong, but interrogating it led to the better
architecture — go to the source.
Verify against a known-good client before you touch your request. When your request
matches a working client and still fails, the variable is the target, not the syntax.
Validate at scale, not n=1. One passing station hid two bugs here, each triggered
only by a second kind of station. Test across the diversity of the data — and the passes
you gave up on may turn out served all along.
The engine is MIT-licensed and the currents fixtures are reproducible from NOAA's own
predictions: slackwater-engine.
If it's off at your home pass, the golden generator points at any served NOAA station — run
it and send a number back.
Not for navigation. Current predictions are astronomical estimates and don't account
for wind, freshet, or local effects. Carry official current tables and charts.
SOCIAL SHARE CARD GENERATOR