CoolFace
Apppublic

mangrovedigital/tide-engine-api

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
synoptic.py130 linesDownload Raw Back to engine
1# ===== LEGACY / EXPERIMENTAL — NOT SHIPPED =====
2# Part of the superseded engine.core research stack (neighbour-transfer
3# nowcast). NOT imported by the product path (api_server / point_forecast /
4# engine.forecast). Kept for reference only. See LEGACY.md.
5"""Synoptic meteorological correction: the learned, direction-dependent
6wind/pressure surge.
7
8Design (validated against held-out data):
9  * NO intercept  -- an intercept encodes the training period's mean state and
10    injects bias when applied to another period.
11  * High-pass both target and forcing (subtract a centered running mean) to
12    isolate the synoptic weather band from the slow seasonal/runoff band.
13  * Wind enters as eastward/northward velocity (u, v) AND stress (speed*u,
14    speed*v ~ speed^2). The direction dependence is LEARNED, not assumed.
15  * Lags capture the bays' delayed response to wind.
16
17The centered high-pass uses neighbouring hours; this is operationally valid
18because the forcing comes from a met source that provides forecast hours on both
19sides of the target time.
20"""
21import math
22from . import linalg
23
24P_REF = 1013.25
25
26
27def running_mean(x, win):
28    n = len(x); half = win // 2; out = [None] * n
29    for i in range(n):
30        lo = max(0, i - half); hi = min(n, i + half + 1)
31        vals = [x[j] for j in range(lo, hi) if x[j] is not None]
32        if len(vals) >= max(3, win // 3):
33            out[i] = sum(vals) / len(vals)
34    return out
35
36
37def highpass(x, win):
38    if not win:
39        return list(x)
40    rm = running_mean(x, win)
41    return [None if (x[i] is None or rm[i] is None) else x[i] - rm[i]
42            for i in range(len(x))]
43
44
45def _wind_comp(speed, dirf):
46    r = math.radians(dirf)
47    u = -speed * math.sin(r)   # eastward velocity (toward)
48    v = -speed * math.cos(r)   # northward velocity (toward)
49    return u, v, speed * u, speed * v
50
51
52class SynopticModel:
53    def __init__(self, coeffs, lags, hp_win):
54        self.coeffs = coeffs
55        self.lags = lags
56        self.hp_win = hp_win
57
58    def _forcing_arrays(self, speed, dirf, pres):
59        n = len(speed)
60        U = [0.0] * n; V = [0.0] * n; SX = [0.0] * n; SY = [0.0] * n
61        for i in range(n):
62            U[i], V[i], SX[i], SY[i] = _wind_comp(speed[i], dirf[i])
63        dP = highpass(pres, self.hp_win) if self.hp_win else \
64            [pres[i] - P_REF for i in range(n)]
65        if self.hp_win:
66            U = highpass(U, self.hp_win); V = highpass(V, self.hp_win)
67            SX = highpass(SX, self.hp_win); SY = highpass(SY, self.hp_win)
68        return U, V, SX, SY, dP
69
70    def rows(self, speed, dirf, pres):
71        """Feature rows aligned to the hourly arrays; None where a lag underflows
72        or a high-pass value is missing (-> caller treats as HOLD)."""
73        U, V, SX, SY, dP = self._forcing_arrays(speed, dirf, pres)
74        out = []
75        for i in range(len(speed)):
76            row = []; ok = True
77            for L in self.lags:
78                j = i - L
79                if j < 0 or U[j] is None or dP[j] is None:
80                    ok = False; break
81                row += [U[j], V[j], SX[j], SY[j], dP[j]]
82            out.append(row if ok else None)
83        return out
84
85    def predict(self, speed, dirf, pres):
86        rows = self.rows(speed, dirf, pres)
87        return [None if r is None else sum(r[k] * self.coeffs[k]
88                                           for k in range(len(self.coeffs)))
89                for r in rows]
90
91
92def fit(target_resid, speed, dirf, pres, lags=(0, 1, 2, 3, 4, 5, 6),
93        hp_win=120, ridge=1e-6):
94    """Fit on hourly arrays. target_resid = observed - astronomical (the
95    non-tidal residual). It is high-passed to the synoptic band before fitting."""
96    model = SynopticModel(None, list(lags), hp_win)
97    tgt = highpass(target_resid, hp_win) if hp_win else list(target_resid)
98    rows = model.rows(speed, dirf, pres)
99    X, y = [], []
100    for i in range(len(tgt)):
101        if tgt[i] is None or rows[i] is None:
102            continue
103        X.append(rows[i]); y.append(tgt[i])
104    if len(X) < len(rows[0] or [1]) * 3:
105        raise RuntimeError("too few usable samples for synoptic fit")
106    model.coeffs = linalg.solve_lstsq(X, y, ridge=ridge)
107    return model, len(X)
108
109
110def direction_response(model, speed=10.0):
111    """Diagnostic: steady-state surge (m) the model predicts for a sustained wind
112    of `speed` m/s blowing FROM each compass direction. Reveals the LEARNED
113    geometry (which winds fill vs drain the bays)."""
114    out = {}
115    dirs = {"N": 0, "NE": 45, "E": 90, "SE": 135,
116            "S": 180, "SW": 225, "W": 270, "NW": 315}
117    # steady wind -> high-pass of a constant is ~0, so evaluate the LINEAR wind
118    # response directly from the per-lag coefficients (sum over lags), using the
119    # raw (un-high-passed) wind components, which is what a sustained anomaly is.
120    ncoef_per_lag = 5
121    for name, deg in dirs.items():
122        u, v, sx, sy = _wind_comp(speed, deg)
123        s = 0.0
124        for li in range(len(model.lags)):
125            base = li * ncoef_per_lag
126            s += (model.coeffs[base] * u + model.coeffs[base + 1] * v +
127                  model.coeffs[base + 2] * sx + model.coeffs[base + 3] * sy)
128        out[name] = s
129    return out
130