Skip to content

Commit e28d541

Browse files
committed
tide and sea temp data
1 parent 98f4372 commit e28d541

3 files changed

Lines changed: 178 additions & 9 deletions

File tree

src/api.py

Lines changed: 84 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import logging
66
from concurrent.futures import ThreadPoolExecutor
7-
from datetime import datetime, timedelta
7+
from datetime import datetime, timedelta, timezone
88
from functools import lru_cache
99
from http import HTTPStatus
1010
from threading import Lock
@@ -14,6 +14,7 @@
1414
import requests
1515
from cachetools import TTLCache, cached
1616
from geopy.geocoders import Nominatim
17+
from geopy.distance import great_circle
1718

1819
from src import helper
1920
from src.open_meteo import openmeteo_client
@@ -32,9 +33,11 @@
3233
_rain_cache = TTLCache(maxsize=_MAXSIZE, ttl=_TTL)
3334
forecast_cache = TTLCache(maxsize=_MAXSIZE, ttl=_TTL)
3435
_hourlyforecast_cache = TTLCache(maxsize=_MAXSIZE, ttl=_TTL)
36+
_tide_cache = TTLCache(maxsize=_MAXSIZE, ttl=_TTL)
3537
_ocean_lock = Lock()
3638

3739

40+
3841
@lru_cache(maxsize=128)
3942
def get_coordinates(args: tuple) -> list | str:
4043
"""
@@ -183,7 +186,7 @@ def ocean_information(
183186
params = {
184187
"latitude": lat,
185188
"longitude": long,
186-
"current": ["wave_height", "wave_direction", "wave_period"],
189+
"current": ["wave_height", "wave_direction", "wave_period", "sea_surface_temperature"],
187190
"length_unit": unit,
188191
"timezone": "auto",
189192
"forecast_days": 3,
@@ -202,8 +205,9 @@ def ocean_information(
202205
current_wave_height = round(current.Variables(0).Value(), decimal)
203206
current_wave_direction = round(current.Variables(1).Value(), decimal)
204207
current_wave_period = round(current.Variables(2).Value(), decimal)
208+
current_sea_surface_temperature = current.Variables(3).Value()
205209

206-
return [current_wave_height, current_wave_direction, current_wave_period]
210+
return [current_wave_height, current_wave_direction, current_wave_period, current_sea_surface_temperature]
207211

208212

209213
@cached(ocean_history_cache, lock=_ocean_lock)
@@ -482,6 +486,69 @@ def get_hourly_forecast(
482486

483487
return curr_hour_data
484488

489+
@cached(_tide_cache, lock=_ocean_lock)
490+
def get_tide_data(lat: float, long: float):
491+
"""
492+
Fetches tide data for the given cords
493+
"""
494+
station_id, station_distance = nearest_station(lat, long)
495+
begin = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y%m%d")
496+
497+
498+
url = "https://api.tidesandcurrents.noaa.gov/api/prod/datagetter"
499+
params = {
500+
"station": station_id,
501+
"product": "predictions",
502+
"datum": "MLLW",
503+
"interval": "hilo", # high/low only; omit for 6-min intervals
504+
"units": "english",
505+
"time_zone": "gmt",
506+
"format": "json",
507+
"begin_date": begin,
508+
"range": 72, # hours
509+
}
510+
r = requests.get(url, params=params, timeout=10)
511+
r.raise_for_status()
512+
return r.json()
513+
514+
515+
@lru_cache(maxsize=1)
516+
def _get_stations():
517+
url = "https://api.tidesandcurrents.noaa.gov/mdapi/prod/webapi/stations.json"
518+
r = requests.get(url, params={"type": "tidepredictions"}, timeout=10)
519+
r.raise_for_status()
520+
return r.json()["stations"]
521+
522+
def nearest_station(lat: float, long: float) -> tuple[str, float]:
523+
"""
524+
returns the id of the nearest NOAA station in respect to the given cords
525+
"""
526+
min_station_distance = float("inf")
527+
nearest_id = None
528+
surf_spot_cords = (float(lat), float(long))
529+
530+
stations = _get_stations()
531+
532+
for station in stations:
533+
station_cords = (float(station["lat"]), float(station["lng"]))
534+
station_id = station["id"]
535+
distance = great_circle(surf_spot_cords, station_cords).mi
536+
if distance < min_station_distance:
537+
min_station_distance = distance
538+
nearest_id = station_id
539+
540+
if nearest_id is None:
541+
raise RuntimeError("No stations returned")
542+
543+
return nearest_id, min_station_distance
544+
545+
546+
def _safe_current_tide(lat: float, long: float):
547+
try:
548+
return helper.current_tide(lat, long)
549+
except Exception:
550+
return None
551+
485552

486553
def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict:
487554
"""
@@ -492,7 +559,7 @@ def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict:
492559
lat, long = float(lat), float(long)
493560
dec, unit = arguments["decimal"], arguments["unit"]
494561

495-
with ThreadPoolExecutor(max_workers=8) as executor:
562+
with ThreadPoolExecutor(max_workers=9) as executor:
496563
futures = {
497564
"ocean": executor.submit(ocean_information, lat, long, dec, unit),
498565
"uv": executor.submit(get_uv, lat, long, dec, unit),
@@ -504,6 +571,7 @@ def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict:
504571
ocean_information_history, lat, long, dec, unit
505572
),
506573
"uv_hist": executor.submit(get_uv_history, lat, long, dec, unit),
574+
"tide": executor.submit(_safe_current_tide, lat, long),
507575
}
508576
results = {k: f.result() for k, f in futures.items()}
509577

@@ -514,6 +582,12 @@ def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict:
514582
rain_sum, precipitation_probability_max = results["rain"]
515583
json_forecast = helper.forecast_to_json(results["forecast"], dec)
516584

585+
sea_temp_c = results["ocean"][3]
586+
if unit == "imperial":
587+
sea_temp = round(sea_temp_c * 9 / 5 + 32, dec)
588+
else:
589+
sea_temp = round(sea_temp_c, dec)
590+
517591
return {
518592
"Lat": lat,
519593
"Long": long,
@@ -535,6 +609,8 @@ def gather_data(lat: float | str, long: float | str, arguments: dict) -> dict:
535609
"Precipitation Probability Max": precipitation_probability_max,
536610
"Cloud Cover": results["hourly"]["cloud_cover"],
537611
"Visibility": results["hourly"]["visibility"],
612+
"Tide": results["tide"],
613+
"Sea Surface Temperature": sea_temp,
538614
}
539615

540616

@@ -556,3 +632,7 @@ def separate_args_and_get_location(args: list) -> dict:
556632

557633
# Backward-compatible alias for the misspelled name
558634
seperate_args_and_get_location = separate_args_and_get_location
635+
636+
637+
if __name__ == "__main__":
638+
print(get_tide_data(40.741895, -73.989308))

src/helper.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@
44

55
import json
66
import logging
7+
import sys
8+
from datetime import datetime, timezone
9+
from math import cos, pi
710

811
from src import api, art
912
from src.gpt import get_llm_client
1013

14+
1115
logger = logging.getLogger(__name__)
1216

1317
MAX_FORECAST_DAYS = 7
18+
EARTH_RADIUS_MILES = 3956
1419

1520
DEFAULT_ARGUMENTS = {
1621
"show_wave": True,
@@ -35,6 +40,8 @@
3540
"gpt": False,
3641
"show_cloud_cover": False,
3742
"show_visibility": False,
43+
"show_tide": True,
44+
"show_sea_temp": True,
3845
}
3946

4047

@@ -113,6 +120,8 @@ def set_output_values(args, args_dict): # noqa
113120
"scc": ("show_cloud_cover", True),
114121
"show_visibility": ("show_visibility", True),
115122
"sv": ("show_visibility", True),
123+
"hide_tide": ("show_tide", False),
124+
"ht": ("show_tide", False),
116125
}
117126

118127
for arg in args:
@@ -232,12 +241,25 @@ def print_ocean_data(arguments_dict, ocean_data_dict):
232241
),
233242
("show_cloud_cover", "Cloud Cover", "Cloud Cover: "),
234243
("show_visibility", "Visibility", "Visibility: "),
244+
("show_sea_temp", "Sea Surface Temperature", "Sea Surface Temp: "),
235245
]
236246

237247
for arg_key, data_key, label in mappings:
238248
if arguments_dict[arg_key]:
239249
print(f"{label}{ocean_data_dict[data_key]}")
240250

251+
if arguments_dict.get("show_tide") and ocean_data_dict.get("Tide"):
252+
tide = ocean_data_dict["Tide"]
253+
incoming = tide["direction"] == "incoming"
254+
arrow = "↑" if incoming else "↓"
255+
if sys.stdout.isatty():
256+
color = art.colors["green"] if incoming else art.colors["red"]
257+
arrow = f"{color}{arrow}{art.colors['end']}"
258+
extreme = tide["next_extreme"]
259+
extreme_type = "High" if extreme["type"] == "H" else "Low"
260+
extreme_time = datetime.fromisoformat(extreme["time"]).strftime("%H:%M UTC")
261+
print(f"Tide: {tide['height_ft']} ft {arrow} | Next {extreme_type}: {extreme['height']:.2f} ft @ {extreme_time}")
262+
241263

242264
def print_forecast(ocean, forecast):
243265
"""
@@ -382,14 +404,77 @@ def get_gpt_response(surf_data):
382404
"""
383405
Builds a surf summary and returns the GPT response.
384406
"""
407+
unit_label = "°F" if surf_data.get("Unit") == "imperial" else "°C"
408+
409+
tide_desc = ""
410+
if surf_data.get("Tide"):
411+
tide = surf_data["Tide"]
412+
extreme = tide["next_extreme"]
413+
extreme_type = "high" if extreme["type"] == "H" else "low"
414+
extreme_time = datetime.fromisoformat(extreme["time"]).strftime("%H:%M UTC")
415+
tide_desc = (
416+
f" The tide is currently {tide['direction']} at {tide['height_ft']} ft,"
417+
f" with the next {extreme_type} tide of {extreme['height']:.2f} ft at {extreme_time}."
418+
)
419+
420+
sea_temp_desc = ""
421+
if surf_data.get("Sea Surface Temperature") is not None:
422+
sea_temp_desc = f" The sea surface temperature is {surf_data['Sea Surface Temperature']} {unit_label}."
423+
385424
summary = (
386425
f"Today at {surf_data['Location']}, the surf height is "
387426
f"{surf_data['Height']} {surf_data['Unit']}, the direction of the "
388427
f"swell is {surf_data['Swell Direction']} degrees and the swell "
389428
f"period is {surf_data['Period']} seconds."
429+
f"{sea_temp_desc}{tide_desc}"
390430
)
391431
try:
392432
return get_llm_client().call_llm(summary)
393433
except Exception as e:
394434
logger.error("LLM request failed: %s", e)
395435
return "Unable to generate GPT response."
436+
437+
def current_tide(lat: float, long: float) -> dict:
438+
predictions = api.get_tide_data(lat, long)["predictions"]
439+
440+
parsed = [
441+
{
442+
"time": datetime.strptime(p["t"], "%Y-%m-%d %H:%M").replace(tzinfo=timezone.utc),
443+
"height": float(p["v"]),
444+
"type": p["type"],
445+
}
446+
for p in predictions
447+
]
448+
449+
now = datetime.now(timezone.utc) # ← tz-aware UTC
450+
451+
prev = next_ = None
452+
for a, b in zip(parsed, parsed[1:]):
453+
if a["time"] <= now <= b["time"]:
454+
prev, next_ = a, b
455+
break
456+
457+
if not prev:
458+
raise ValueError("Current time outside prediction window")
459+
460+
span = (next_["time"] - prev["time"]).total_seconds()
461+
elapsed = (now - prev["time"]).total_seconds()
462+
x = elapsed / span
463+
height = prev["height"] + (next_["height"] - prev["height"]) * (1 - cos(pi * x)) / 2
464+
465+
direction = "incoming" if next_["type"] == "H" else "outgoing"
466+
467+
return {
468+
"height_ft": round(height, 2),
469+
"direction": direction,
470+
"next_extreme": {
471+
"time": next_["time"].isoformat(),
472+
"height": next_["height"],
473+
"type": next_["type"],
474+
},
475+
}
476+
477+
478+
479+
if __name__ == "__main__":
480+
print(current_tide(32.856132, -117.2175761))

tests/test_api.py

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ def test_get_uv(mock_create_client):
112112

113113
@patch("src.api.openmeteo_client")
114114
def test_ocean_information(mock_create_client):
115-
# fake each variable (height, direction, period)
115+
# fake each variable (height, direction, period, sea surface temp)
116116
mock_var_0 = MagicMock()
117117
mock_var_0.Value.return_value = 3.5
118118

@@ -122,9 +122,11 @@ def test_ocean_information(mock_create_client):
122122
mock_var_2 = MagicMock()
123123
mock_var_2.Value.return_value = 12.0
124124

125-
# Variables(0), Variables(1), Variables(2) return different mocks
125+
mock_var_3 = MagicMock()
126+
mock_var_3.Value.return_value = 20.0
127+
126128
mock_current = MagicMock()
127-
mock_current.Variables.side_effect = [mock_var_0, mock_var_1, mock_var_2]
129+
mock_current.Variables.side_effect = [mock_var_0, mock_var_1, mock_var_2, mock_var_3]
128130

129131
mock_response = MagicMock()
130132
mock_response.Current.return_value = mock_current
@@ -133,7 +135,7 @@ def test_ocean_information(mock_create_client):
133135

134136
result = ocean_information(31.41, -84.92, 2, "imperial")
135137

136-
assert result == [3.5, 180.0, 12.0]
138+
assert result == [3.5, 180.0, 12.0, 20.0]
137139

138140

139141
@patch("src.api.openmeteo_client")
@@ -176,7 +178,7 @@ def test_forecast(mock_create_client):
176178
assert len(fc["wave_period_max"]) == FORECAST_LENGTH
177179

178180

179-
@patch("src.api.ocean_information", return_value=[3.5, 180.0, 12.0])
181+
@patch("src.api.ocean_information", return_value=[3.5, 180.0, 12.0, 20.0])
180182
@patch("src.api.get_uv", return_value=5.0)
181183
@patch(
182184
"src.api.get_hourly_forecast",
@@ -190,7 +192,9 @@ def test_forecast(mock_create_client):
190192
)
191193
@patch("src.api.get_uv_history", return_value="5.0")
192194
@patch("src.helper.forecast_to_json", return_value={})
195+
@patch("src.api._safe_current_tide", return_value=None)
193196
def test_gather_data( # noqa: PLR0913, PLR0917
197+
mock_tide,
194198
mock_ftj,
195199
mock_uv_hist,
196200
mock_ocean_hist,

0 commit comments

Comments
 (0)