CoolFace
Apppublic

mangrovedigital/tide-engine-api

sourceHugging Faceupdated 2mo agoView on Hugging Face
0likes
stations.py91 linesDownload Raw Back to engine
1"""Two-layer station selection (architect rework, Stage A/B).
2
3The engine used to collapse TIDE and WIND onto one nearest curated anchor. This
4module selects the two layers INDEPENDENTLY:
5
6  TIDE  -> nearest NOAA CO-OPS tide-PREDICTION station from the FULL regional
7           network (data/noaa_tide_stations.json). Usually much closer than any
8           observed sensor. (Stage A — this module, tide half.)
9
10Runtime is stdlib-only: the station table is a bundled data file, read once.
11"""
12import json
13import os
14import math
15
16_TABLE = os.path.join(os.path.dirname(__file__), "..", "data", "noaa_tide_stations.json")
17_STATIONS = None
18
19
20def _load():
21    global _STATIONS
22    if _STATIONS is None:
23        with open(_TABLE, "r", encoding="utf-8") as f:
24            _STATIONS = json.load(f)["stations"]
25    return _STATIONS
26
27
28def _mi(a, b, c, d):
29    R = 6371.0
30    p = math.pi / 180
31    dla = (c - a) * p
32    dlo = (d - b) * p
33    h = math.sin(dla / 2) ** 2 + math.cos(a * p) * math.cos(c * p) * math.sin(dlo / 2) ** 2
34    return 2 * R * math.asin(math.sqrt(h)) * 0.621371
35
36
37def nearest_tide_station(lat, lon):
38    """Nearest NOAA CO-OPS tide-prediction station to (lat, lon) from the full
39    regional network. Returns dict {id, name, lat, lon, dist_mi} or None if the
40    table is empty. No fallback, no curated shortlist — the actual closest one."""
41    best = None
42    bd = float("inf")
43    for s in _load():
44        d = _mi(lat, lon, s["lat"], s["lon"])
45        if d < bd:
46            bd = d
47            best = s
48    if best is None:
49        return None
50    return {"id": best["id"], "name": best["name"], "lat": best["lat"],
51            "lon": best["lon"], "dist_mi": round(bd, 1)}
52
53
54# ---- Stage B: WIND layer — nearest real-time OBSERVED sensor, independent ----
55_SENSOR_TABLE = os.path.join(os.path.dirname(__file__), "..", "data", "observed_sensors.json")
56_SENSORS = None
57
58
59def _load_sensors():
60    global _SENSORS
61    if _SENSORS is None:
62        with open(_SENSOR_TABLE, "r", encoding="utf-8") as f:
63            _SENSORS = json.load(f)["sensors"]
64    return _SENSORS
65
66
67def observed_sensors_by_distance(lat, lon):
68    """All observed sensors sorted nearest-first (for the tidal-test walk)."""
69    return sorted(_load_sensors(), key=lambda s: _mi(lat, lon, s["lat"], s["lon"]))
70
71
72def dist_mi(lat, lon, s):
73    return round(_mi(lat, lon, s["lat"], s["lon"]), 1)
74
75
76def nearest_observed_sensor(lat, lon):
77    """Nearest real-time OBSERVED water-level sensor (NOAA CO-OPS observed or a
78    USGS gauge with a live IV record) to (lat, lon). Independent of the tide
79    station. Returns {id, name, lat, lon, source, dist_mi} or None."""
80    best = None
81    bd = float("inf")
82    for s in _load_sensors():
83        d = _mi(lat, lon, s["lat"], s["lon"])
84        if d < bd:
85            bd = d
86            best = s
87    if best is None:
88        return None
89    return {"id": best["id"], "name": best["name"], "lat": best["lat"],
90            "lon": best["lon"], "source": best["source"], "dist_mi": round(bd, 1)}
91