You wrote a tide engine. It computes a number: 1.42 m at 3:10 PM. How do you know
that number is right? You can't measure the future ocean. You can't diff against
"the truth" because the truth is a prediction too. This is the whole problem with a
from-scratch harmonic tide predictor — the output looks authoritative the day you
first run it, and authoritative-looking is not the same as correct.
TL;DR — Validate a harmonic tide engine on two axes. Algorithm: golden
vectors against a trusted oracle (@neaps/tide-predictor) — every layer to
floating-point agreement. Reality: head-to-head against the tide authority's
own published predictions — NOAA CO-OPS at Friday Harbor, max 7.9 min / 3.5 cm
across 12 highs and lows, inside the ±15 min / ±0.15 m you'd accept from a printed
table. And before you trust any of it: if your predictions read higher than the
printed tide tables, that's usually not a bug — it's the chart-datum offset.
Jump to the numbers.
The trap that makes a correct engine look wrong
Here's the first thing that will convince you your engine is broken when it isn't.
Compute the harmonic tide height for a station, line it up against the printed tide
tables for the same minute, and your number comes out higher — consistently,
by a fixed amount. The instinct is to hunt for the bug. There isn't one.
A harmonic sum is referenced to the datum its constituents were derived against —
typically mean sea level (MSL), the average of all water levels. Printed tide
tables and nautical charts are referenced to chart datum: MLLW (mean lower
low water) in the US, LAT (lowest astronomical tide) in Canada. Chart datum sits
below MSL by design — it's a low-water reference so that charted depths are
conservative. So a raw harmonic prediction reads higher than the table by exactly the
distance between the two datums.
The fix is a single per-station offset. In this engine it's one optional field on the
station:
let station = Station(
constituents: [HarmonicConstituent(name: "M2", amplitude: 0.96, phase: 128) /* … */],
offset: 1.387 // datum offset (e.g. MSL → MLLW), optional
)
And that offset isn't a fudge factor — it's published metadata. NOAA ships the datum
values per station; the offset is just their difference:
const fh = stations.find((s) => s.id === 'noaa/9449880');
const offset = fh.datums.MSL - fh.datums.MLLW; // shift MSL-relative harmonics to chart datum MLLW
The apps that "match the printed number" without applying this are the ones actually
carrying an error — they've hidden a datum mismatch by accident, or they never summed
the harmonics in the first place. Once you know the offset exists, it's a knob, not a
mystery. (This is a subtlety of the harmonic-prediction path — computing heights
from constituents versus reading a printed table. Hydrographic services like NOAA and
CHS serve their own predictions at their own stated datum; they're not wrong, they're
just answering a different question than a from-scratch sum does.)
That's the trap. Now the actual validation.
Method: golden vectors against an oracle
You can't test a predictor against the ocean. You can test it against another
predictor that's already trusted — and if the two agree to floating-point precision,
your port of the algorithm is faithful by construction.
The oracle here is @neaps/tide-predictor
@0.10.0, a well-exercised JavaScript harmonic engine. The Swift engine is a port of
its algorithm, so the bar isn't "close" — it's the same number. A Node tool
generates golden fixtures from Neaps; swift test replays them and asserts agreement
layer by layer:
swift test # golden validation against the Neaps reference
node tools/gen-golden.mjs # regenerate golden fixtures from @neaps/tide-predictor
node tools/gen-realworld.mjs # refresh the NOAA real-world fixture
The point of going layer by layer is that a tide prediction is a stack of
computations, and an error in any one of them hides inside a plausible-looking final
curve. Test the whole pipeline only end-to-end and a sign error in the node
corrections can cancel against a phase error somewhere else for the 48 hours you
happened to check. So each layer gets pinned independently:
| Layer | Check | Tolerance | Result |
|---|---|---|---|
| Astronomy | mean longitudes + node angles, 8 times across the 18.6-yr nodal cycle | 1e-6° | pass |
| Node corrections | IHO f/u, 17 base constituents × 3 times | 1e-6 | pass |
| Constituents | V₀ + compound f/u, ~39 constituents × 2 times | 1e-6 | pass |
| Prediction | 48 h height series, mixed-tide set | < 1e-6 m | pass |
| Extremes | hi/lo count, kind, time, height | 60 s / 0.02 m | pass |
A few of these deserve a note on why they're the layers that break:
- Astronomy across the 18.6-year nodal cycle. The moon's orbital plane precesses over 18.6 years, and the mean longitudes and node angle are sampled across that full cycle — not just at one date — because a bug in the node angle is invisible near one epoch and large near another.
-
Node corrections (the f/u factors). These are the per-constituent amplitude (
f) and phase (u) adjustments that account for that same nodal modulation. They're the most common place a harmonic engine quietly drifts, which is why they get their own fixture instead of only being checked through the final height. - Extremes, not just the height curve. Sampling heights on a grid and calling the peaks "high tide" gives you the wrong minute. The extremes layer solves for the actual turning points and is checked on count, kind (high vs low), time, and height separately — the tolerances there (60 s, 0.02 m) are the tight ones.
Every layer lands at floating-point agreement (1e-6). The algorithm is faithful to
the reference. But "faithful to the reference" only proves the port is correct — it
says nothing about whether the reference matches the real world. That's the second
axis.
The datum offset is a step in the harness, not a footnote — it's the only thing standing between "agrees to a few centimetres" and "off by the whole MSL-to-MLLW gap."
The receipt: vs NOAA's own predictions
The stronger test is against a completely independent authority: NOAA's own published
predictions, straight from the CO-OPS Data Retrieval
API. This is a real head-to-head —
feed the engine the published harmonic constituents for a station, then compare its
highs and lows to what NOAA's own internal engine says for the same station and days.
Station: Friday Harbor, WA (NOAA 9449880). Constituents come from
@neaps/tide-database (sourced from
NOAA, public domain, bundled offline). The comparison target is fetched live from
CO-OPS at MLLW, GMT:
const url = `https://api.tidesandcurrents.noaa.gov/api/prod/datagetter`
+ `?begin_date=20260715&end_date=20260717`
+ `&station=9449880&product=predictions&datum=MLLW&interval=hilo`
+ `&units=metric&time_zone=gmt&format=json`;
Twelve highs and lows over 2026-07-15…17:
| Metric | Engine vs NOAA | Navigational tolerance |
|---|---|---|
| Max time error | 7.9 min | ±15 min |
| Max height error | 3.5 cm | ±0.15 m |
The engine reproduces NOAA's published tide tables to a few minutes and a few
centimetres — inside half the tolerance you'd accept from a paper table, on both axes,
computed entirely offline from the bundled constants. That's the claim that matters:
not "agrees with the code I ported from," but "agrees with the tide authority."
The honest residual — why ~8 minutes
I'm not going to tell you the error is zero, because it isn't, and the residual is
worth understanding rather than papering over.
The ~8 min / 3.5 cm gap against CO-OPS is expected. NOAA's operational engine
isn't the same engine — it uses a different node-correction epoch and a somewhat
different constituent set than the Neaps algorithm this port follows. Two correct
harmonic engines, fed the same station's constants but differing in those internals,
will land a few minutes apart. That's not error accumulating toward a wrong answer;
it's the known spread between two legitimate methods, and it sits an order of magnitude
inside navigational tolerance.
The tell that it's method-spread and not a bug: against the Neaps oracle — same
algorithm — the agreement is 1e-6, essentially exact. Against NOAA — different
algorithm — it's minutes. If the port were broken, the first number wouldn't be
floating-point tight. The two results are consistent with exactly one story: a faithful
port of one correct method, differing from another correct method by the amount two
correct methods differ.
A note on data sourcing
The constituents themselves are public. NOAA harmonic constants are public domain and
bundled with the engine (~3400 stations via @neaps/tide-database), which is what makes
offline prediction possible at all — no network, no key, no live dependency, predictions
years ahead from data that ships with the app. For Canadian waters, CHS publishes
predictions (not just constants) through its IWLS API, fetched and cached online.
One gotcha worth flagging for anyone bundling multi-source constants: constituent
naming isn't standardized. NOAA writes NU2, MM, RHO; CHS and other sources use
their own variants for the same physical constituents. Feed a station's published
constants straight in and the ones whose names don't match your catalog silently drop
out of the sum — a prediction that's subtly, plausibly wrong. The engine's catalog
resolves 83 aliases to canonical names so published constants from any source
predict correctly.
Close
A tide number you computed yourself is only worth anything if you can show your work.
Two axes do it: golden vectors prove the algorithm is a faithful port, and a
head-to-head against the authority's own predictions proves the port matches reality —
with the datum offset applied so you're comparing to the right zero. The engine is
MIT-licensed and the validation is reproducible from the fixtures:
slackwater-engine. If it's
off in your home waters, the fixtures are right there — check it and send a number back.
Not for navigation. Predictions are astronomical estimates and don't account for
weather, surge, or local effects. Carry official tables and charts.
Top comments (0)