Spaces:
Sleeping
Sleeping
| """Tidal harmonic predictor. | |
| Schureman + Cartwright 1985 astronomical longitudes. Standard convention used | |
| by SHOM, PREVIMER and most national hydrographic services: | |
| h(t) = Z0 + sum_i [ H_i * f_i(t) * cos(sigma_i * dt + V0_i(t) + u_i(t) - G_i) ] | |
| where H_i is the amplitude (m for heights, m/s for current components), | |
| G_i is the Greenwich phase lag (degrees, UTC reference), f_i/u_i are nodal | |
| corrections, and V0_i is the equilibrium argument at the start of the | |
| prediction day. sigma_i is the constituent angular frequency (deg/h). | |
| This module is a clean Python port of the public-domain NOC reconstruction | |
| code (Hughes/Williams, NOC-MSM/anyTide) using Cartwright (1985) astronomical | |
| longitudes. The formulation matches what the LEGOS Tidal ToolBox `predictor` | |
| applies on PREVIMER atlases. End-to-end validation against REFMAR Brest 2008 | |
| gives RMSE 14 cm and r-squared = 0.99 over 8000+ hourly observations using | |
| MARC PREVIMER FINIS atlas constants directly (no transform on G). | |
| Avoid utide.reconstruct or uptide.from_amplitude_phase with externally | |
| sourced constants: those libraries assume their internal V0 convention which | |
| differs from the absolute Schureman one expected here. | |
| """ | |
| from __future__ import annotations | |
| from datetime import UTC, datetime | |
| import numpy as np | |
| # fmt: off | |
| # 60 standard constituents, Doodson-indexed, with angular frequencies (deg/h). | |
| # Subset of NOC's 120; covers all MARC PREVIMER constituents we use. | |
| NAMES: tuple[str, ...] = ( | |
| "SA", "SSA", "MM", "MSF", "MF", "2Q1", "SIG1", "Q1", "RO1", "O1", | |
| "MP1", "M1", "CHI1", "PI1", "P1", "S1", "K1", "PSI1", "PHI1", "TH1", | |
| "J1", "SO1", "OO1", "OQ2", "MNS2", "2N2", "MU2", "N2", "NU2", "OP2", | |
| "M2", "MKS2", "LAM2", "L2", "T2", "S2", "R2", "K2", "MSN2", "KJ2", | |
| "2SM2", "MO3", "M3", "SO3", "MK3", "SK3", "MN4", "M4", "SN4", "MS4", | |
| "MK4", "S4", "SK4", "2MN6", "M6", "MSN6", "2MS6", "2MK6", "2SM6", "MSK6", | |
| ) | |
| FREQS_DEG_PER_H: tuple[float, ...] = ( | |
| 0.0410686, 0.0821373, 0.5443747, 1.0158958, 1.0980330, | |
| 12.8542862, 12.9271398, 13.3986609, 13.4715145, 13.9430356, | |
| 14.0251729, 14.4920521, 14.5695476, 14.9178647, 14.9589314, | |
| 15.0000000, 15.0410686, 15.0821353, 15.1232059, 15.5125897, | |
| 15.5854433, 16.0569644, 16.1391017, 27.3416965, 27.4238338, | |
| 27.8953548, 27.9682085, 28.4397295, 28.5125832, 28.9019670, | |
| 28.9841042, 29.0662415, 29.4556253, 29.5284789, 29.9589333, | |
| 30.0000000, 30.0410667, 30.0821373, 30.5443747, 30.6265120, | |
| 31.0158958, 42.9271398, 43.4761564, 43.9430356, 44.0251729, | |
| 45.0410686, 57.4238338, 57.9682085, 58.4397295, 58.9841042, | |
| 59.0662415, 60.0000000, 60.0821373, 86.4079380, 86.9523127, | |
| 87.4238338, 87.9682085, 88.0503458, 88.9841042, 89.0662415, | |
| ) | |
| # fmt: on | |
| NCONST = 60 | |
| _NAME_TO_IDX = {n: i for i, n in enumerate(NAMES)} | |
| # Constituent name aliases — MARC PREVIMER uses some non-standard spellings. | |
| # Maps any input to the canonical NOC table name. | |
| ALIASES: dict[str, str] = { | |
| # MARC -> canonical | |
| "Mf": "MF", | |
| "Mm": "MM", | |
| "Mu2": "MU2", | |
| "Nu2": "NU2", | |
| "Ki1": "CHI1", | |
| "Ro1": "RO1", | |
| "Sig1": "SIG1", | |
| "Tta1": "TH1", | |
| "Phi1": "PHI1", | |
| "Pi1": "PI1", | |
| "Psi1": "PSI1", | |
| "La2": "LAM2", | |
| # Same names, just casing | |
| "OO1": "OO1", | |
| "MSF": "MSF", | |
| # Aliases with no NOC equivalent — drop silently when not in table | |
| # (KQ1, E2, MP1 may apply depending on NOC version) | |
| } | |
| def _canonical(name: str) -> str | None: | |
| """Return canonical NOC name for ``name``, or ``None`` if unknown.""" | |
| n = ALIASES.get(name, name) | |
| return n if n in _NAME_TO_IDX else None | |
| def _utc_to_mjd(t: datetime) -> float: | |
| """Modified Julian Date (days since 1858-11-17 00:00 UT).""" | |
| if t.tzinfo is None: | |
| t = t.replace(tzinfo=UTC) | |
| epoch = datetime(1858, 11, 17, tzinfo=UTC) | |
| return (t - epoch).total_seconds() / 86400.0 | |
| def _astronomical_longitudes(mjdn: np.ndarray) -> tuple[np.ndarray, ...]: | |
| """Cartwright 1985 ecliptic mean longitudes (s, h, p, N, p1) at 00:00 UT of ``mjdn``. | |
| Includes UT→TDT correction (deltat ≈ 32 s + drift). Returns degrees mod 360. | |
| """ | |
| cycle = 360.0 | |
| c, b = 32.0, 90.0 | |
| t0 = (36204.0 - 51544.5) / 36525.0 | |
| a_const = 32.184 - b * t0 - c * t0**2 | |
| t = (mjdn - 51544 - 0.5) / 36525 # Julian centuries UTC after J2000 | |
| dt = a_const + b * t + c * t**2 | |
| tt = t + dt / (86400.0 * 36525.0) # TDT centuries | |
| s = 218.3166 + 481267.8811 * tt - 0.0019 * tt**2 | |
| h = 280.4661 + 36000.7698 * tt + 0.0003 * tt**2 | |
| p = 83.3532 + 4069.0136 * tt - 0.0106 * tt**2 | |
| en = 125.0445 - 1934.1364 * tt + 0.0018 * tt**2 | |
| p1 = 282.9384 + 1.7194 * tt + 0.0002 * tt**2 | |
| return tuple(np.mod(x, cycle) for x in (s, h, p, en, p1)) | |
| def _equilibrium_argument( | |
| s: np.ndarray, h: np.ndarray, p: np.ndarray, p1: np.ndarray | |
| ) -> np.ndarray: | |
| """V0 (degrees mod 360) at 00:00 UT for all 60 constituents. | |
| Returns shape (n_times, NCONST). NOC's vsetfast, using the table mapping. | |
| """ | |
| h2, h3, h4 = h + h, h + h + h, h + h + h + h | |
| s2, s3, s4 = s + s, s + s + s, s + s + s + s | |
| p2 = p + p | |
| n = len(s) | |
| # Use 1-indexed array (size 121) then drop and reindex to 0..59. | |
| v = np.zeros((n, 121)) | |
| v[:, 1] = h | |
| v[:, 2] = h2 | |
| v[:, 3] = s - p | |
| v[:, 4] = s2 - h2 | |
| v[:, 5] = s2 | |
| v[:, 6] = h - s4 + p2 + 270.0 | |
| v[:, 7] = h3 - s4 + 270.0 | |
| v[:, 8] = h - s3 + p + 270.0 | |
| v[:, 9] = h3 - s3 - p + 270.0 | |
| v[:, 10] = h - s2 + 270.0 | |
| v[:, 11] = h3 - s2 + 90.0 | |
| v[:, 12] = h - s + 90.0 | |
| v[:, 13] = h3 - s - p + 90.0 | |
| v[:, 14] = p1 - h2 + 270.0 | |
| v[:, 15] = 270.0 - h | |
| v[:, 16] = 180.0 | |
| v[:, 17] = h + 90.0 | |
| v[:, 18] = h2 - p1 + 90.0 | |
| v[:, 19] = h3 + 90.0 | |
| v[:, 20] = s - h + p + 90.0 | |
| v[:, 21] = s + h - p + 90.0 | |
| v[:, 23] = s2 + h + 90.0 | |
| v[:, 26] = h2 - s4 + p2 | |
| v[:, 27] = h4 - s4 | |
| v[:, 28] = h2 - s3 + p | |
| v[:, 29] = h4 - s3 - p | |
| v[:, 31] = h2 - s2 | |
| v[:, 32] = h4 - s2 | |
| v[:, 33] = p - s + 180.0 | |
| v[:, 34] = h2 - s - p + 180.0 | |
| v[:, 35] = p1 - h | |
| v[:, 36] = 0.0 | |
| v[:, 37] = h - p1 + 180.0 | |
| v[:, 38] = h2 | |
| v[:, 22] = -v[:, 10] | |
| v[:, 24] = v[:, 10] + v[:, 8] | |
| v[:, 25] = v[:, 31] + v[:, 28] | |
| v[:, 30] = v[:, 10] + v[:, 15] | |
| v[:, 39] = v[:, 31] - v[:, 28] | |
| v[:, 40] = v[:, 17] + v[:, 21] | |
| v[:, 41] = -v[:, 31] | |
| v[:, 42] = v[:, 31] + v[:, 10] | |
| v[:, 43] = h3 - s3 + 180.0 | |
| v[:, 44] = v[:, 10] | |
| v[:, 45] = v[:, 31] + v[:, 17] | |
| v[:, 46] = v[:, 17] | |
| v[:, 47] = v[:, 25] | |
| v[:, 48] = v[:, 31] + v[:, 31] | |
| v[:, 49] = v[:, 28] | |
| v[:, 50] = v[:, 31] | |
| v[:, 51] = v[:, 31] + v[:, 38] | |
| v[:, 52] = 0.0 | |
| v[:, 53] = v[:, 38] | |
| v[:, 54] = v[:, 48] + v[:, 28] | |
| v[:, 55] = v[:, 48] + v[:, 31] | |
| v[:, 56] = v[:, 47] | |
| v[:, 57] = v[:, 48] | |
| v[:, 58] = v[:, 48] + v[:, 38] | |
| v[:, 59] = v[:, 31] | |
| v[:, 60] = v[:, 51] | |
| v = np.mod(v, 360.0) | |
| v[v < 0.0] += 360.0 | |
| return np.roll(v, -1, axis=1)[:, :NCONST] | |
| def _nodal_corrections(p: np.ndarray, en: np.ndarray) -> tuple[np.ndarray, np.ndarray]: | |
| """Nodal phase u (degrees) and amplitude factor f (unitless) for all 60 constituents. | |
| Port of NOC's ufsetfast. Returns shape (n_times, NCONST) for both arrays. | |
| """ | |
| rad = np.pi / 180.0 | |
| deg = 180.0 / np.pi | |
| pw = p * rad | |
| nw = en * rad | |
| w1, w2, w3 = np.cos(nw), np.cos(2 * nw), np.cos(3 * nw) | |
| w4, w5, w6 = np.sin(nw), np.sin(2 * nw), np.sin(3 * nw) | |
| a1 = pw - nw | |
| a2 = 2.0 * pw | |
| a3 = a2 - nw | |
| a4 = a2 - 2.0 * nw | |
| n = len(p) | |
| u = np.zeros((n, 121)) | |
| f = np.zeros((n, 121)) | |
| # Primary formulas (1-indexed, NOC convention) | |
| u[:, 3] = 0.0 | |
| f[:, 3] = 1.0 - 0.1300 * w1 + 0.0013 * w2 # MM | |
| u[:, 5] = -0.4143 * w4 + 0.0468 * w5 - 0.0066 * w6 | |
| f[:, 5] = 1.0429 + 0.4135 * w1 - 0.004 * w2 # MF | |
| u[:, 10] = 0.1885 * w4 - 0.0234 * w5 + 0.0033 * w6 | |
| f[:, 10] = 1.0089 + 0.1871 * w1 - 0.0147 * w2 + 0.0014 * w3 # O1 | |
| # M1 (constituent index 12) — atan2 of (x, y) | |
| x = 2.0 * np.cos(pw) + 0.4 * np.cos(a1) | |
| y = np.sin(pw) + 0.2 * np.sin(a1) | |
| u[:, 12] = np.arctan2(y, x) | |
| f[:, 12] = np.sqrt(x**2 + y**2) | |
| u[:, 17] = -0.1546 * w4 + 0.0119 * w5 - 0.0012 * w6 | |
| f[:, 17] = 1.0060 + 0.1150 * w1 - 0.0088 * w2 + 0.0006 * w3 # K1 | |
| u[:, 21] = -0.2258 * w4 + 0.0234 * w5 - 0.0033 * w6 | |
| f[:, 21] = 1.0129 + 0.1676 * w1 - 0.0170 * w2 + 0.0016 * w3 # J1 | |
| f[:, 23] = 1.1027 + 0.6504 * w1 + 0.0317 * w2 - 0.0014 * w3 | |
| u[:, 23] = -0.6402 * w4 + 0.0702 * w5 - 0.0099 * w6 # OO1 | |
| u[:, 31] = -0.0374 * w4 | |
| f[:, 31] = 1.0004 - 0.0373 * w1 + 0.0002 * w2 # M2 | |
| # L2 (idx 34) — uses x,y / atan2 form | |
| x = 1.0 - 0.2505 * np.cos(a2) - 0.1102 * np.cos(a3) - 0.0156 * np.cos(a4) - 0.037 * w1 | |
| y = -0.2505 * np.sin(a2) - 0.1102 * np.sin(a3) - 0.0156 * np.sin(a4) - 0.037 * w4 | |
| u[:, 34] = np.arctan2(y, x) | |
| f[:, 34] = np.sqrt(x**2 + y**2) | |
| u[:, 38] = -0.3096 * w4 + 0.0119 * w5 - 0.0007 * w6 | |
| f[:, 38] = 1.0241 + 0.2863 * w1 + 0.0083 * w2 - 0.0015 * w3 # K2 | |
| # Compound u's (radians, copied from primaries) | |
| u[:, 1] = 0.0 | |
| u[:, 2] = 0.0 | |
| u[:, 4] = -u[:, 31] | |
| u[:, 6] = u[:, 10] | |
| u[:, 7] = u[:, 10] | |
| u[:, 8] = u[:, 10] | |
| u[:, 9] = u[:, 10] | |
| u[:, 11] = u[:, 31] | |
| u[:, 13] = u[:, 21] | |
| u[:, 14] = 0.0 | |
| u[:, 15] = 0.0 | |
| u[:, 16] = 0.0 | |
| u[:, 18] = 0.0 | |
| u[:, 19] = 0.0 | |
| u[:, 20] = u[:, 21] | |
| u[:, 22] = -u[:, 10] | |
| u[:, 24] = 2.0 * u[:, 10] | |
| u[:, 25] = 2.0 * u[:, 31] | |
| u[:, 26] = u[:, 31] | |
| u[:, 27] = u[:, 31] | |
| u[:, 28] = u[:, 31] | |
| u[:, 29] = u[:, 31] | |
| u[:, 30] = u[:, 10] | |
| u[:, 32] = u[:, 31] + u[:, 38] | |
| u[:, 33] = u[:, 31] | |
| u[:, 35] = 0.0 | |
| u[:, 36] = 0.0 | |
| u[:, 37] = 0.0 | |
| u[:, 39] = 0.0 | |
| u[:, 40] = u[:, 17] + u[:, 21] | |
| u[:, 41] = u[:, 4] | |
| u[:, 42] = u[:, 31] + u[:, 10] | |
| u[:, 43] = u[:, 31] * 1.5 | |
| u[:, 44] = u[:, 10] | |
| u[:, 45] = u[:, 31] + u[:, 17] | |
| u[:, 46] = u[:, 17] | |
| u[:, 47] = u[:, 25] | |
| u[:, 48] = u[:, 25] | |
| u[:, 49] = u[:, 31] | |
| u[:, 50] = u[:, 31] | |
| u[:, 51] = u[:, 32] | |
| u[:, 52] = 0.0 | |
| u[:, 53] = u[:, 38] | |
| u[:, 54] = u[:, 25] + u[:, 31] | |
| u[:, 55] = u[:, 54] | |
| u[:, 56] = u[:, 25] | |
| u[:, 57] = u[:, 25] | |
| u[:, 58] = u[:, 25] + u[:, 38] | |
| u[:, 59] = u[:, 31] | |
| u[:, 60] = u[:, 32] | |
| u = np.mod(u * deg, 360.0) | |
| u[u < 0.0] += 360.0 | |
| # Compound f's | |
| f[:, 1] = 1.0 | |
| f[:, 2] = 1.0 | |
| f[:, 4] = f[:, 31] | |
| f[:, 6] = f[:, 10] | |
| f[:, 7] = f[:, 10] | |
| f[:, 8] = f[:, 10] | |
| f[:, 9] = f[:, 10] | |
| f[:, 11] = f[:, 31] | |
| f[:, 13] = f[:, 21] | |
| f[:, 14] = 1.0 | |
| f[:, 15] = 1.0 | |
| f[:, 16] = 1.0 | |
| f[:, 18] = 1.0 | |
| f[:, 19] = 1.0 | |
| f[:, 20] = f[:, 21] | |
| f[:, 22] = f[:, 10] | |
| f[:, 24] = f[:, 10] ** 2 | |
| f[:, 25] = f[:, 31] ** 2 | |
| f[:, 26] = f[:, 31] | |
| f[:, 27] = f[:, 31] | |
| f[:, 28] = f[:, 31] | |
| f[:, 29] = f[:, 31] | |
| f[:, 30] = f[:, 10] | |
| f[:, 32] = f[:, 31] * f[:, 38] | |
| f[:, 33] = f[:, 31] | |
| f[:, 35] = 1.0 | |
| f[:, 36] = 1.0 | |
| f[:, 37] = 1.0 | |
| f[:, 39] = f[:, 25] | |
| f[:, 40] = f[:, 17] * f[:, 21] | |
| f[:, 41] = f[:, 31] | |
| f[:, 42] = f[:, 31] * f[:, 10] | |
| f[:, 43] = f[:, 31] ** 1.5 | |
| f[:, 44] = f[:, 10] | |
| f[:, 45] = f[:, 31] * f[:, 17] | |
| f[:, 46] = f[:, 17] | |
| f[:, 47] = f[:, 25] | |
| f[:, 48] = f[:, 25] | |
| f[:, 49] = f[:, 31] | |
| f[:, 50] = f[:, 31] | |
| f[:, 51] = f[:, 32] | |
| f[:, 52] = 1.0 | |
| f[:, 53] = f[:, 38] | |
| f[:, 54] = f[:, 25] * f[:, 31] | |
| f[:, 55] = f[:, 54] | |
| f[:, 56] = f[:, 25] | |
| f[:, 57] = f[:, 25] | |
| f[:, 58] = f[:, 25] * f[:, 38] | |
| f[:, 59] = f[:, 31] | |
| f[:, 60] = f[:, 32] | |
| u = np.roll(u, -1, axis=1)[:, :NCONST] | |
| f = np.roll(f, -1, axis=1)[:, :NCONST] | |
| return u, f | |
| def predict( | |
| times_utc: list[datetime], | |
| constants: dict[str, tuple[float, float]], | |
| z0: float = 0.0, | |
| ) -> np.ndarray: | |
| """Predict tide heights (or current component) at the given UTC times. | |
| Args: | |
| times_utc: list of timezone-aware (UTC) datetime instances. | |
| constants: mapping ``{constituent_name: (amplitude, phase_g_deg)}``. | |
| Names use either canonical NOC spelling (e.g. ``"M2"``, ``"MF"``) | |
| or MARC PREVIMER spelling (e.g. ``"Mf"``, ``"La2"``); see | |
| ``ALIASES``. Constituents not in the NOC-60 table are silently | |
| skipped. | |
| z0: constant offset added to the prediction (m). Default 0 (predict | |
| around mean sea level — the convention for MARC PREVIMER). | |
| Returns: | |
| Array of predicted heights in metres, shape (len(times_utc),). | |
| """ | |
| if not times_utc: | |
| return np.zeros(0) | |
| mjd = np.array([_utc_to_mjd(t) for t in times_utc]) | |
| mjdn = np.floor(mjd).astype(int) | |
| hrs = 24.0 * (mjd - mjdn) | |
| s, h, p, en, p1 = _astronomical_longitudes(mjdn) | |
| v_arr = _equilibrium_argument(s, h, p, p1) | |
| u_arr, f_arr = _nodal_corrections(p, en) | |
| pred = np.full(len(times_utc), z0, dtype=float) | |
| rad = np.pi / 180.0 | |
| for raw_name, (amp, ga) in constants.items(): | |
| canonical = _canonical(raw_name) | |
| if canonical is None: | |
| continue | |
| k = _NAME_TO_IDX[canonical] | |
| sigma = FREQS_DEG_PER_H[k] | |
| pred += amp * f_arr[:, k] * np.cos(rad * (sigma * hrs + v_arr[:, k] + u_arr[:, k] - ga)) | |
| return pred | |