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.

Against NOAA CO-OPS predictions at Friday Harbor the engine's worst error over 12 highs and lows is 7.9 minutes and 3.5 cm, roughly half the plus-or-minus 15 minute and 0.15 m tolerance you would accept from a printed tide table.

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.

A raw harmonic sum is referenced to mean sea level while printed tide tables use chart datum MLLW, so every unadjusted prediction reads high by exactly the published per-station MSL to MLLW offset.

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 validation harness runs two independent comparisons: golden vectors against the Neaps tide predictor agree to 1e-6, proving the port is faithful, while the same engine with a per-station MSL to MLLW datum offset applied matches NOAA CO-OPS predictions to 7.9 minutes and 3.5 cm. 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.