mangrovedigital/tide-engine-api
0
1"""
2Data sources for the wind-corrected tide engine.
3
4Single responsibility: fetch REAL data from official / managed APIs, attach
5provenance, cache raw responses to disk, and return clean (utc_epoch, value)
6series. No modelling here.
7
8Sources (all commercially clean — public domain or commercial-OK w/ attribution)
9-------
10- USGS NWIS Instantaneous Values (US gov, public domain) -> ground-truth
11 water-surface elevation (NAVD88, param 63160) at gauges inside the target area.
12- Wind + surface pressure forcing at the target lat/lon, by window age:
13 * historical / training -> ERA5 reanalysis (Copernicus CDS). Free for
14 commercial use WITH attribution. Retrieval is async NetCDF and lives in a
15 BUILD-TIME tool (scripts/era5_fetch.py) that writes a processed hourly
16 point-cache; this runtime module reads that cache with stdlib only.
17 * live / forecast -> NWS api.weather.gov (US gov, public domain):
18 gridpoint wind forecast + station observations. NWS does not forecast
19 surface pressure at these grids -> the pressure term is degraded (held at
20 the last observed value); harmless here because windcorr high-passes at
21 14 days and the product is a woff(t)-woff(now) delta (constant cancels).
22
23Open-Meteo (previously used for both roles) is REMOVED: its free tier is
24CC-BY-NC (non-commercial), which is not sale-clean.
25
26Every fetch returns a Series with .provenance describing exactly where the
27numbers came from and when they were pulled.
28"""
29
30import json
31import os
32import time
33import hashlib
34import math
35import random
36import tempfile
37import threading
38import urllib.parse
39from datetime import datetime, timezone, timedelta
40
41import requests
42
43CACHE_DIR = os.path.join(os.path.dirname(__file__), "..", "data")
44USGS_IV = "https://waterservices.usgs.gov/nwis/iv/"
45NWS_API = "https://api.weather.gov"
46NWS_UA = "tides-engine (contact: forprophetoc@gmail.com)"
47ERA5_ATTRIBUTION = ("Generated using Copernicus Climate Change Service "
48 "information 2026 (ERA5 hourly data on single levels).")
49# window older than this many days -> reanalysis (ERA5); else live (NWS).
50LIVE_AGE_DAYS = 2
51# FIX 2 (never-blind 2026-07-18): near-real-time observed-water bodies go stale in
52# 15 minutes. Pinned historical/training windows keep ttl=None (immutable data).
53NRT_TTL_SECONDS = int(os.environ.get("TIDES_NRT_TTL_SECONDS", "900"))
54
55
56def _is_nrt_end(end_date):
57 """True when a fetch window reaches near-now — its body must expire so a
58 recovered gauge is noticed without a restart (Oscar's ruling 2026-07-18)."""
59 try:
60 end_d = datetime.fromisoformat(end_date).date()
61 except (ValueError, TypeError):
62 return False
63 return (_today_utc_date() - end_d).days <= LIVE_AGE_DAYS
64
65
66class UpstreamError(RuntimeError):
67 """Transport/network/5xx failure talking to an upstream — the data MIGHT
68 exist; we just couldn't reach it. Callers must HOLD, never silently fall
69 through to a different param/datum (that would swap the datum on a blip)."""
70
71
72class NoDataError(RuntimeError):
73 """Upstream reached fine but has genuinely no data for this site/param —
74 safe to fall through to the next candidate param."""
75
76
77def _datum_from_desc(desc):
78 """Derive the vertical datum from USGS's own variableDescription text —
79 the truthful source (verified against real fetches, Stage 3):
80 '... above NAVD 1988 ...' -> NAVD88 ; '... NGVD 1929 ...' -> NGVD29 ;
81 'Gage height, feet' -> gage height (local datum, NOT NAVD88)."""
82 d = (desc or "").lower()
83 if "navd" in d:
84 return "NAVD88"
85 if "ngvd" in d:
86 return "NGVD29"
87 if "gage height" in d:
88 return "gage height (local datum)"
89 return "unknown datum"
90
91
92class Series:
93 """A time series aligned to UTC epoch seconds, with provenance."""
94
95 def __init__(self, name, t, values, units, provenance, extra=None):
96 self.name = name
97 self.t = t # list[float] utc epoch seconds, ascending
98 self.values = values # list[float] (None for gaps already removed)
99 self.units = units
100 self.provenance = provenance # dict
101 self.extra = extra or {} # parallel dict of name -> list (e.g. wind dir)
102
103 def __len__(self):
104 return len(self.t)
105
106 def summary(self):
107 if not self.t:
108 return f"<{self.name}: EMPTY>"
109 return (f"<{self.name}: n={len(self.t)} "
110 f"{_iso(self.t[0])}..{_iso(self.t[-1])} "
111 f"units={self.units}>")
112
113
114def _iso(epoch):
115 return datetime.fromtimestamp(epoch, tz=timezone.utc).strftime("%Y-%m-%d %H:%M")
116
117
118# ---- observability (L1): per-request upstream-fetch counter (thread-local) ----
119_fetch_local = threading.local()
120
121
122def reset_fetch_count():
123 _fetch_local.n = 0
124
125
126def get_fetch_count():
127 return getattr(_fetch_local, "n", 0)
128
129
130def _get_with_retry(url, timeout):
131 """GET with bounded, jittered exponential backoff on transient failures
132 (connection/timeout/5xx). 4xx and the final attempt raise. No infinite retry."""
133 retries = int(os.environ.get("TIDES_HTTP_RETRIES", "3"))
134 backoff = float(os.environ.get("TIDES_HTTP_BACKOFF", "0.4"))
135 for attempt in range(retries):
136 try:
137 resp = requests.get(url, headers={"User-Agent": "tide-engine/1.0"},
138 timeout=timeout)
139 except requests.RequestException:
140 if attempt + 1 >= retries:
141 raise
142 time.sleep(backoff * (2 ** attempt) + random.uniform(0, backoff))
143 continue
144 if 500 <= resp.status_code < 600 and attempt + 1 < retries:
145 time.sleep(backoff * (2 ** attempt) + random.uniform(0, backoff))
146 continue
147 resp.raise_for_status() # 4xx, or the final 5xx -> raise
148 return resp
149 resp.raise_for_status()
150 return resp
151
152
153def _fetch_json(url, cache_key, ttl_seconds=None, timeout=90):
154 """GET url, caching the raw body to disk. Returns (parsed_json, cache_path).
155
156 ttl_seconds=None means cache never expires (historical data is immutable).
157 """
158 os.makedirs(CACHE_DIR, exist_ok=True)
159 h = hashlib.sha1(cache_key.encode()).hexdigest()[:16]
160 path = os.path.join(CACHE_DIR, f"{h}.json")
161 meta_path = path + ".meta"
162 if os.path.exists(path):
163 fresh = True
164 if ttl_seconds is not None:
165 if os.path.exists(meta_path):
166 with open(meta_path) as f:
167 fetched_at = float(f.read().strip())
168 fresh = (time.time() - fetched_at) < ttl_seconds
169 else:
170 fresh = False # expiring class without a timestamp = stale
171 if fresh:
172 with open(path, "r", encoding="utf-8") as f:
173 return json.load(f), path
174 _fetch_local.n = getattr(_fetch_local, "n", 0) + 1 # count real upstream hits
175 resp = _get_with_retry(url, timeout)
176 body = resp.text
177 data = json.loads(body)
178 # atomic write: a kill mid-write must leave the OLD file or the NEW complete
179 # file, never a truncated JSON (tempfile in the same dir + os.replace).
180 fd, tmp = tempfile.mkstemp(dir=CACHE_DIR, suffix=".tmp")
181 try:
182 with os.fdopen(fd, "w", encoding="utf-8") as f:
183 f.write(body)
184 os.replace(tmp, path)
185 except BaseException:
186 try:
187 os.unlink(tmp)
188 except OSError:
189 pass
190 raise
191 with open(meta_path, "w") as f:
192 f.write(str(time.time()))
193 return data, path
194
195
196def _parse_iso_to_epoch(s):
197 """USGS dateTime like '2026-06-17T17:15:00.000-04:00' -> utc epoch seconds."""
198 dt = datetime.fromisoformat(s)
199 if dt.tzinfo is None:
200 dt = dt.replace(tzinfo=timezone.utc)
201 return dt.timestamp()
202
203
204# --------------------------------------------------------------------------
205# USGS ground-truth water level
206# --------------------------------------------------------------------------
207def usgs_water_level(site, start_date, end_date, param="63160", ttl=None):
208 """Fetch USGS instantaneous water level.
209
210 param 63160 = water-surface elevation above NAVD88 (ft). Returns Series in
211 METERS (converted from feet) so the whole engine is metric.
212 Missing/flagged values (e.g. -999999) are dropped.
213 ttl=None on a NEAR-REAL-TIME window is upgraded to NRT_TTL_SECONDS (FIX 2) —
214 pinned historical/training windows keep the never-expire default.
215 """
216 if ttl is None and _is_nrt_end(end_date):
217 ttl = NRT_TTL_SECONDS
218 q = {
219 "format": "json",
220 "sites": site,
221 "parameterCd": param,
222 "startDT": start_date,
223 "endDT": end_date,
224 }
225 url = USGS_IV + "?" + urllib.parse.urlencode(q)
226 try:
227 data, path = _fetch_json(url, "usgs|" + url, ttl_seconds=ttl)
228 except requests.RequestException as exc:
229 # network/transport/HTTP error -> upstream unreachable, NOT no-data.
230 raise UpstreamError("USGS unreachable for %s/%s: %s" % (site, param, exc))
231 ts_list = data.get("value", {}).get("timeSeries", [])
232 if not ts_list:
233 raise NoDataError(f"USGS returned no timeSeries for {site}/{param}")
234 ts = ts_list[0]
235 si = ts["sourceInfo"]
236 vals = ts["values"][0]["value"]
237 t, v = [], []
238 for row in vals:
239 raw = row.get("value")
240 if raw is None or raw == "" or float(raw) <= -999998:
241 continue
242 t.append(_parse_iso_to_epoch(row["dateTime"]))
243 v.append(float(raw) * 0.3048) # ft -> m
244 param_desc = ts["variable"]["variableDescription"]
245 datum = _datum_from_desc(param_desc) # truthful — derived from THIS param
246 prov = {
247 "source": "USGS NWIS Instantaneous Values",
248 "site": site,
249 "site_name": si["siteName"],
250 "lat": float(si["geoLocation"]["geogLocation"]["latitude"]),
251 "lon": float(si["geoLocation"]["geogLocation"]["longitude"]),
252 "param": param,
253 "param_desc": param_desc,
254 "datum": datum,
255 "orig_units": "ft",
256 "url": url,
257 "cache": os.path.abspath(path),
258 "fetched_utc": _iso(time.time()),
259 }
260 return Series(f"usgs:{site}", t, v, "m (%s)" % datum, prov)
261
262
263# --------------------------------------------------------------------------
264# Wind + pressure forcing at exact location (ERA5 historical / NWS live)
265# --------------------------------------------------------------------------
266def _today_utc_date():
267 return datetime.now(tz=timezone.utc).date()
268
269
270def _win_bounds(start_date, end_date):
271 """[start 00:00 UTC, end 24:00 UTC) epoch bounds for an inclusive date range."""
272 s = datetime.fromisoformat(start_date).replace(tzinfo=timezone.utc).timestamp()
273 e = (datetime.fromisoformat(end_date).replace(tzinfo=timezone.utc)
274 + timedelta(days=1)).timestamp()
275 return s, e
276
277
278# ---- ERA5 reanalysis: read the processed point-cache written by the build-time
279# tool scripts/era5_fetch.py (NO cdsapi/netCDF at runtime) ---------------
280def era5_cache_path(lat, lon):
281 return os.path.join(CACHE_DIR, "era5_%.3f_%.3f.json" % (round(lat, 3), round(lon, 3)))
282
283
284def _era5_series(lat, lon, start_date, end_date):
285 p = era5_cache_path(lat, lon)
286 if not os.path.exists(p):
287 raise RuntimeError("ERA5 cache miss for %.3f,%.3f — run "
288 "scripts/era5_fetch.py fill" % (lat, lon))
289 with open(p, "r", encoding="utf-8") as f:
290 blob = json.load(f)
291 s, e = _win_bounds(start_date, end_date)
292 t, pres, ws, wd = [], [], [], []
293 for i, ti in enumerate(blob["t"]):
294 if s <= ti < e:
295 t.append(ti); pres.append(blob["pressure_hPa"][i])
296 ws.append(blob["wind_speed"][i]); wd.append(blob["wind_dir_from"][i])
297 if not t:
298 raise RuntimeError("ERA5 cache for %.3f,%.3f lacks %s..%s (have %s..%s)"
299 % (lat, lon, start_date, end_date,
300 _iso(blob["t"][0]), _iso(blob["t"][-1])))
301 prov = {"source": "ERA5 reanalysis (Copernicus CDS)",
302 "role": "historical/training", "lat": lat, "lon": lon,
303 "grid_lat": blob["provenance"].get("grid_lat"),
304 "grid_lon": blob["provenance"].get("grid_lon"),
305 "wind_units": "m/s", "pressure_units": "hPa",
306 "attribution": ERA5_ATTRIBUTION,
307 "licence": "Copernicus Licence (commercial use OK with attribution)",
308 "cache": os.path.abspath(p)}
309 return Series("met", t, pres, "hPa", prov,
310 extra={"wind_speed": ws, "wind_dir_from": wd})
311
312
313# ---- NWS live wind (US gov, public domain) via api.weather.gov -------------
314def _iso_dur_hours(dur):
315 """ISO8601 duration like 'PT1H','PT6H','P1DT2H' -> integer hours (>=1)."""
316 days = hours = 0
317 m = dur.replace("P", "")
318 if "T" in m:
319 d, t = m.split("T")
320 else:
321 d, t = m, ""
322 if d.endswith("D"):
323 days = int(d[:-1] or 0)
324 if t.endswith("H"):
325 hours = int(t[:-1] or 0)
326 return max(1, days * 24 + hours)
327
328
329def _nws_points(lat, lon):
330 url = "%s/points/%.4f,%.4f" % (NWS_API, lat, lon)
331 data, _ = _fetch_json(url, "nws|" + url, ttl_seconds=30 * 86400)
332 p = data["properties"]
333 return p["gridId"], p["gridX"], p["gridY"], p["observationStations"]
334
335
336def _expand_layer(values, factor):
337 """NWS gridpoint layer values (validTime interval + value) -> {epoch_hr: value}."""
338 out = {}
339 for v in values or []:
340 if v.get("value") is None:
341 continue
342 iso, dur = v["validTime"].split("/")
343 t0 = int(datetime.fromisoformat(iso).timestamp())
344 for h in range(_iso_dur_hours(dur)):
345 out[t0 + h * 3600] = float(v["value"]) * factor
346 return out
347
348
349def _nws_forecast_wind(gid, gx, gy):
350 url = "%s/gridpoints/%s/%d,%d" % (NWS_API, gid, gx, gy)
351 data, _ = _fetch_json(url, "nws|" + url, ttl_seconds=3600)
352 pr = data["properties"]
353 ws = _expand_layer(pr.get("windSpeed", {}).get("values"), 1 / 3.6) # km/h -> m/s
354 wd = _expand_layer(pr.get("windDirection", {}).get("values"), 1.0) # deg
355 return ws, wd
356
357
358def _nws_obs(stations_url, start_date, end_date):
359 """Recent hourly station obs -> {epoch_hr: (speed m/s, dir deg, slp hPa)}.
360
361 FIX 2 (2026-07-18): the observations endpoint caps at 500 features,
362 NEWEST-first — a single request silently returns only the last ~1.5 days
363 at a 5-min-cadence station, starving the wind model's 72 h trailing window
364 (the engine went blind with live gauges: 'no recent observation to anchor
365 to' was really 'no wind features at now'). Fetch BACKWARD in 1-day chunks
366 until the window is covered or history runs out."""
367 data, _ = _fetch_json(stations_url, "nws|" + stations_url, ttl_seconds=7 * 86400)
368 feats = data.get("features") or data.get("observationStations")
369 if not feats:
370 return {}
371 sid = feats[0]["properties"]["stationIdentifier"] if isinstance(feats[0], dict) \
372 else feats[0].rsplit("/", 1)[-1]
373 s, e = _win_bounds(start_date, end_date)
374 e = min(e, time.time())
375 out = {}
376
377 def _mps(q):
378 if not q or q.get("value") is None:
379 return None
380 val = float(q["value"])
381 return val / 3.6 if "km_h" in (q.get("unitCode") or "") else val
382
383 chunk = 86400 # 1 day < 500 obs at any reporting cadence
384 e_cur = e
385 while e_cur > s:
386 s_cur = max(s, e_cur - chunk)
387 url = ("%s/stations/%s/observations?start=%s&end=%s" %
388 (NWS_API, sid,
389 datetime.fromtimestamp(s_cur, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
390 datetime.fromtimestamp(e_cur, timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")))
391 try:
392 data, _ = _fetch_json(url, "nws|" + url, ttl_seconds=3600)
393 except Exception:
394 break # transport blip: keep what we have
395 feats = data.get("features", [])
396 if not feats:
397 break # history floor reached (NWS keeps ~7 days)
398 for f in feats:
399 p = f["properties"]
400 try:
401 t = int(datetime.fromisoformat(p["timestamp"].replace("Z", "+00:00")).timestamp())
402 except Exception:
403 continue
404 thr = t - (t % 3600)
405 sp = _mps(p.get("windSpeed"))
406 di = p.get("windDirection", {}).get("value")
407 slpq = p.get("seaLevelPressure") or p.get("barometricPressure") or {}
408 slp = None if slpq.get("value") is None else float(slpq["value"]) / 100.0
409 if sp is not None and di is not None:
410 out[thr] = (sp, float(di), slp)
411 e_cur = s_cur
412 return out
413
414
415def _live_series(lat, lon, start_date, end_date):
416 """Wind+pressure for a window reaching near-now/future: NWS obs (recent past)
417 + NWS gridpoint forecast (future), with ERA5 for the older past if cached.
418 NWS has no forecast surface pressure -> pressure is held at the last observed
419 value (documented degradation; cancels in the woff(t)-woff(now) delta)."""
420 s, e = _win_bounds(start_date, end_date)
421 grid = [int(s) + k * 3600 for k in range(int((e - s) // 3600))]
422 gid, gx, gy, stations_url = _nws_points(lat, lon)
423 fc_ws, fc_wd = _nws_forecast_wind(gid, gx, gy)
424 obs = _nws_obs(stations_url, start_date, end_date)
425 # older past from ERA5 cache (best-effort; extends high-pass support)
426 era = {}
427 try:
428 cut = (_today_utc_date() - timedelta(days=6)).isoformat()
429 es = _era5_series(lat, lon, start_date, cut)
430 era = {int(es.t[i]): (es.extra["wind_speed"][i], es.extra["wind_dir_from"][i],
431 es.values[i]) for i in range(len(es.t))}
432 except Exception:
433 pass
434 last_slp = next((v[2] for v in reversed(list(obs.values())) if v[2] is not None), 1013.25)
435 t, pres, ws, wd = [], [], [], []
436 used = set()
437 for g in grid:
438 sp = di = pp = None
439 if g in obs:
440 sp, di, pp = obs[g]; used.add("nws-obs")
441 elif g in era:
442 sp, di, pp = era[g]; used.add("era5")
443 elif g in fc_ws and g in fc_wd:
444 sp, di = fc_ws[g], fc_wd[g]; pp = last_slp; used.add("nws-forecast")
445 if sp is None or di is None:
446 continue
447 t.append(float(g)); ws.append(sp); wd.append(di)
448 pres.append(pp if pp is not None else last_slp)
449 if not t:
450 raise RuntimeError("no live wind for %.3f,%.3f %s..%s" % (lat, lon, start_date, end_date))
451 prov = {"source": "NWS api.weather.gov (gridpoint forecast + station obs)",
452 "role": "live/forecast", "lat": lat, "lon": lon,
453 "components": sorted(used),
454 "wind_units": "m/s", "pressure_units": "hPa",
455 "pressure_note": "NWS provides no forecast surface pressure; held at "
456 "last observed value (delta-cancels in the product)",
457 "licence": "US Government work — public domain",
458 "fetched_utc": _iso(time.time())}
459 if "era5" in used:
460 prov["attribution"] = ERA5_ATTRIBUTION
461 return Series("met", t, pres, "hPa", prov,
462 extra={"wind_speed": ws, "wind_dir_from": wd})
463
464
465def met_forcing(lat, lon, start_date, end_date, ttl=None):
466 """Hourly wind (m/s), wind dir (deg FROM), surface pressure (hPa) at (lat,lon).
467 Routes by window age: windows ending >= LIVE_AGE_DAYS days ago come from ERA5
468 reanalysis (training / backtest / fixed-epoch replay); windows reaching
469 near-now/future come from NWS (live). Series .values is pressure; .extra holds
470 wind. Contract is identical to the previous Open-Meteo implementation."""
471 end_d = datetime.fromisoformat(end_date).date()
472 if (_today_utc_date() - end_d).days >= LIVE_AGE_DAYS:
473 return _era5_series(lat, lon, start_date, end_date)
474 return _live_series(lat, lon, start_date, end_date)
475
476
477if __name__ == "__main__":
478 wl = usgs_water_level("255327081275900", "2026-06-10", "2026-06-17")
479 print(wl.summary())
480 print(" ground-truth provenance:", wl.provenance["site_name"],
481 wl.provenance["param_desc"])
482 met = met_forcing(wl.provenance["lat"], wl.provenance["lon"],
483 "2026-06-10", "2026-06-17")
484 print(met.summary())
485 print(" met source:", met.provenance["source"])
486 