Not a bug report — a characterization I ended up with while validating a port, and I think it's worth a line in the docs. I don't think it changes predictions meaningfully, and I'm not suggesting you change the implementation.
Context
I was porting a tidal-current harmonic fit from Python/utide to TypeScript on @neaps/tide-predictor, keeping the Python as an oracle. Extrema came out at parity across 19 stations, but slack-water timing diverged at the weak, slow-reversing ones. Feeding utide's own fitted constituents through neaps' synthesis isolated it to the engines: ~24.6 min median slack difference at the worst station.
Dropping constituents one at a time never found a culprit — biggest single mover was ~5 min. So I compared the two libraries' internals instead of their outputs, which needs no station data at all:
- neaps:
constituents[name].value(astro(t)) for V, .correction(astro(t)) for f/u
- utide:
utide.harmonics.FUV(t, tref, lind, lat, [0, 0, 0, 0]) for F, U, V
(Validating constituent identity by frequency first — they match to 1e-7 °/h — is worth doing before trusting any of it. My first run looked alarming and was almost an index-mapping bug on my side.)
What I found
The astronomical arguments are not the issue. V agrees between the two to 0.00° on every constituent in my basis. Whatever else is true, that part of neaps is solid.
neaps' V₀ + ωt shortcut is also sound. I checked whether the tabulated speed matches the time derivative of neaps' own value(): agrees to ~1e-8 °/h, total drift under 0.0002° over 187 days.
The difference is entirely in the nodal corrections, and it's a convention difference rather than a defect. neaps applies Schureman's grouped factors — M2, N2, 2N2, MU2 and NU2 share one f/u to six decimals, as do O1 and Q1. utide applies Foreman's satellite-derived per-constituent factors, where each gets its own. Largest split is 2N2: f = 0.965 in neaps (M2's value) vs 1.110 in utide, ~13%, with ~3.6° of phase alongside.
Grouped-Schureman is the classical approximation, satellite-Foreman the refined one. Neither is wrong, and against published ground truth neither won outright in my validation — utide was closer at two stations, neaps at four.
Why I don't think it matters
Most of the difference never reaches output. The 24.6 min was measured in a regime where nothing is re-fitted. If you fit and synthesize in the same engine — which most consumers do — any constant f/u offset is absorbed into the fitted amplitude and phase, and only the drift across the fit-to-prediction span survives. Amplitude-weighted, that took my case from 2.9% of M2 down to 0.72%, i.e. under a minute at an ordinary zero crossing. It only becomes visible where a slow reversal amplifies it.
Same story for M3, which looks scary and isn't: neaps and utide differ by 180° on it (utide encodes the offset as semi=-0.5, neaps as extended-Doodson digit 7), but the difference is constant — drift 0.03° over the span — so a re-fit absorbs it entirely.
What I'd suggest
Just a docs note: something to the effect of "nodal corrections follow Schureman's grouped formulation; results will differ slightly from satellite-based implementations such as utide, most visibly near slack water, and are largely absorbed if you fit and predict with the same engine."
That's enough to save the next person the week I spent, without implying anyone needs to change code.
One thing pointing the other way, for what it's worth: utide returns f = 1.0 and u = 0.0 exactly for MM, MSF and MF — no nodal correction at all on the long-period constituents, where neaps applies the Schureman factors. That looks like a gap on utide's side rather than yours.
Not a bug report — a characterization I ended up with while validating a port, and I think it's worth a line in the docs. I don't think it changes predictions meaningfully, and I'm not suggesting you change the implementation.
Context
I was porting a tidal-current harmonic fit from Python/utide to TypeScript on
@neaps/tide-predictor, keeping the Python as an oracle. Extrema came out at parity across 19 stations, but slack-water timing diverged at the weak, slow-reversing ones. Feeding utide's own fitted constituents through neaps' synthesis isolated it to the engines: ~24.6 min median slack difference at the worst station.Dropping constituents one at a time never found a culprit — biggest single mover was ~5 min. So I compared the two libraries' internals instead of their outputs, which needs no station data at all:
constituents[name].value(astro(t))for V,.correction(astro(t))for f/uutide.harmonics.FUV(t, tref, lind, lat, [0, 0, 0, 0])for F, U, V(Validating constituent identity by frequency first — they match to 1e-7 °/h — is worth doing before trusting any of it. My first run looked alarming and was almost an index-mapping bug on my side.)
What I found
The astronomical arguments are not the issue. V agrees between the two to 0.00° on every constituent in my basis. Whatever else is true, that part of neaps is solid.
neaps'
V₀ + ωtshortcut is also sound. I checked whether the tabulatedspeedmatches the time derivative of neaps' ownvalue(): agrees to ~1e-8 °/h, total drift under 0.0002° over 187 days.The difference is entirely in the nodal corrections, and it's a convention difference rather than a defect. neaps applies Schureman's grouped factors — M2, N2, 2N2, MU2 and NU2 share one f/u to six decimals, as do O1 and Q1. utide applies Foreman's satellite-derived per-constituent factors, where each gets its own. Largest split is 2N2: f = 0.965 in neaps (M2's value) vs 1.110 in utide, ~13%, with ~3.6° of phase alongside.
Grouped-Schureman is the classical approximation, satellite-Foreman the refined one. Neither is wrong, and against published ground truth neither won outright in my validation — utide was closer at two stations, neaps at four.
Why I don't think it matters
Most of the difference never reaches output. The 24.6 min was measured in a regime where nothing is re-fitted. If you fit and synthesize in the same engine — which most consumers do — any constant f/u offset is absorbed into the fitted amplitude and phase, and only the drift across the fit-to-prediction span survives. Amplitude-weighted, that took my case from 2.9% of M2 down to 0.72%, i.e. under a minute at an ordinary zero crossing. It only becomes visible where a slow reversal amplifies it.
Same story for M3, which looks scary and isn't: neaps and utide differ by 180° on it (utide encodes the offset as
semi=-0.5, neaps as extended-Doodson digit 7), but the difference is constant — drift 0.03° over the span — so a re-fit absorbs it entirely.What I'd suggest
Just a docs note: something to the effect of "nodal corrections follow Schureman's grouped formulation; results will differ slightly from satellite-based implementations such as utide, most visibly near slack water, and are largely absorbed if you fit and predict with the same engine."
That's enough to save the next person the week I spent, without implying anyone needs to change code.
One thing pointing the other way, for what it's worth: utide returns f = 1.0 and u = 0.0 exactly for MM, MSF and MF — no nodal correction at all on the long-period constituents, where neaps applies the Schureman factors. That looks like a gap on utide's side rather than yours.