All posts

Predicting Tides for Water-Sports Safety: A Small, Obsessively Accurate Microservice

Published Jun 19, 20263 min read
  • MeteoTrail
  • Engineering
  • Mountain

Key takeaways

  • Astronomical tide predictions (FES2022b via pyfes) are not observed sea level — storm surge, pressure and wind setup are excluded by design.
  • Fail-closed defaults — no CORS, mandatory bearer tokens, 503-with-Retry-After while the atlas loads — matter for internal machine-to-machine services too.
  • Cropping a multi-gigabyte global tide atlas into a small regional 'pack' cut pod startup from minutes to seconds.

We've written before about MeteoTrail and about the government weather-alerts aggregator that feeds it — both grew out of the same instinct: water-sports operators need to know, precisely and early, when conditions turn against them. Diving, kayaking, surf lessons — the five verticals we described in "One booking engine, five sports" — all share a dependency most people never think about: the actual height of the sea at a given moment. Some dive sites and kayak put-ins in Lueira's catalog are only safely accessible within a specific tide window. So this year we built a small, deliberately narrow service to answer one question well: what will the astronomical tide be, at this point, at this time.

1. A narrow, precise question

tides-service is an internal FastAPI service. It predicts astronomical tides using the FES2022b global ocean tide atlas, through the open-source pyfes library. It's consumed machine-to-machine, mainly by the Lueira backend, and it isn't exposed publicly. It answers exactly three questions:

  • GET /v1/tides — a height time series between two timestamps
  • GET /v1/tides/extremes — the high/low events for a given UTC day
  • GET /v1/locations/check — is this coordinate wet or on land, and if it's on land, what's the nearest wet cell within a configured radius

All three are Bearer-token protected. CORS is disabled by default — the same fail-closed posture as the auth requirement. And until the multi-gigabyte tide atlas has finished loading into memory at startup, the two prediction endpoints return 503 with a Retry-After header and a {"code": "model_loading"} body. GET /healthz is the one open endpoint, and reports whether that atlas has finished loading.

A simplified response from /v1/tides/extremes:

{
  "location": {"lat": 43.283, "lon": -2.170},
  "date": "2026-06-19",
  "reference": "MSL",
  "extremes": [
    {"type": "low",  "time": "2026-06-19T02:14:00Z", "height_m": 0.62},
    {"type": "high", "time": "2026-06-19T08:31:00Z", "height_m": 3.41},
    {"type": "low",  "time": "2026-06-19T14:42:00Z", "height_m": 0.58},
    {"type": "high", "time": "2026-06-19T20:59:00Z", "height_m": 3.55}
  ],
  "attribution": "FES2022 was produced by LEGOS, NOVELTIS and CLS; funded by CNES; distributed by AVISO, DOI 10.24400/527896/A01-2024.004."
}

2. What the tide prediction does NOT tell you

tides-service predicts the astronomical tide only, relative to Mean Sea Level. That's the height the moon and sun, through their gravitational pull, would produce at a given point and time if nothing else were going on. It does not include storm surge, atmospheric pressure effects, wind setup, or any other non-astronomical contribution to observed sea level.

A deep low-pressure system can raise the water noticeably above what the astronomical tide says. A sustained onshore wind piles water against the coast on top of that. None of that shows up in a tides-service response, by design. A safety feature for water-sports bookings can't just treat a tides-service number as "the water level" — it's one precise input among several, alongside wind and pressure data.

3. Shrinking a multi-gigabyte atlas down to what you actually need

The full global FES2022b atlas is several gigabytes, licensed for us to use but not to redistribute publicly under AVISO's terms, so it lives in a private DigitalOcean Space. Rather than have every pod pull the full global atlas at startup, we wrote a script that crops the atlas down to a bounding box around our actual operating area — Iberia, the Bay of Biscay, and the Canary Islands. Startup went from minutes to seconds.

The fiddly part is that the atlas stores longitude in the 0–360° convention. Our operating area spans the Greenwich meridian, so the bounding box straddles the 0/360 seam:

REGION_BBOX = {
    "lat_min": 27.0, "lat_max": 46.5,
    "lon_ranges_0_360": [(342.0, 360.0), (0.0, 4.5)],
}

def crop_region(atlas, bbox):
    west, east = bbox["lon_ranges_0_360"]
    west_slice = atlas.sel(lon=slice(*west))
    east_slice = atlas.sel(lon=slice(*east))
    west_slice = west_slice.assign_coords(lon=west_slice.lon - 360)
    return xr.concat([west_slice, east_slice], dim="lon").sortby("lon")

Get that stitch wrong and you don't get an error — you get a tide prediction that's silently wrong for exactly the western edge of the region.

4. Checking our work against a real port authority

Alongside the usual test suite, we run "golden" tests comparing tides-service's predicted tidal extremes at a real location — Zarautz, on the Basque coast — against Puertos del Estado's published tide tables. It's the difference between "our model agrees with itself" and "our model agrees with reality."

None of this is glamorous work. But a narrow, precise answer to one physics question, honestly bounded, is worth more to a safety feature than a broad "ocean conditions" service that quietly blurs astronomical tide into everything else affecting sea level.