Spaces:
Running
Running
Commit ·
fb7515e
1
Parent(s): 5ba47e2
Worker A (v2-core): port core package + scoring v2 with offline tests
Browse files- wavereader/openmeteo.py from app/forecasts.py + swell_wave_direction/period, CACHE_VERSION 3
- wavereader/scoring.py merges app/adapters.py + app/scoring.py; v2: swell-first,
arc distance, type-based logistic wind cap, daylight filter, p75 daily_summary,
surfable-hours rank_spots
- wavereader/seafloor.py from app/seafloor.py; per-axis lon step via cos(lat), None-safe cells
- wavereader/breaks.py from app/breaks_data.py; pandas dropped, jsonschema validation,
resolve_break fuzzy match
- tests: 51 offline tests + recorded Bells Beach fixture; deps pydantic/jsonschema/pytest
- .env.example +6 -0
- pyproject.toml +5 -0
- tests/conftest.py +27 -0
- tests/fixtures/openmeteo_bells.json +1 -0
- tests/test_breaks.py +66 -0
- tests/test_openmeteo.py +119 -0
- tests/test_scoring.py +259 -0
- tests/test_seafloor.py +123 -0
- uv.lock +161 -0
- wavereader/__init__.py +25 -0
- wavereader/breaks.py +169 -0
- wavereader/openmeteo.py +244 -0
- wavereader/scoring.py +666 -0
- wavereader/seafloor.py +459 -0
.env.example
CHANGED
|
@@ -1,3 +1,9 @@
|
|
| 1 |
# Copy to .env and fill in. Never commit .env.
|
| 2 |
# HF token for the HF Router (Nemotron) — https://huggingface.co/settings/tokens
|
| 3 |
HF_TOKEN=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
# Copy to .env and fill in. Never commit .env.
|
| 2 |
# HF token for the HF Router (Nemotron) — https://huggingface.co/settings/tokens
|
| 3 |
HF_TOKEN=
|
| 4 |
+
# Model override (default: nvidia/NVIDIA-Nemotron-3.5-Lightning-30B-A3B-BF16)
|
| 5 |
+
WR_MODEL=
|
| 6 |
+
# Inference provider override (default: fireworks-ai)
|
| 7 |
+
WR_PROVIDER=
|
| 8 |
+
# Base URL override for a local OpenAI-compatible endpoint (e.g. Ollama/NIM)
|
| 9 |
+
WR_BASE_URL=
|
pyproject.toml
CHANGED
|
@@ -8,7 +8,12 @@ dependencies = [
|
|
| 8 |
"gradio>=5.0.0",
|
| 9 |
"huggingface-hub>=1.30.0",
|
| 10 |
"httpx>=0.27.0",
|
|
|
|
| 11 |
"pandas>=3.0.5",
|
| 12 |
"plotly>=7.0.0",
|
|
|
|
| 13 |
"smolagents[openai]>=1.26.0",
|
| 14 |
]
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
"gradio>=5.0.0",
|
| 9 |
"huggingface-hub>=1.30.0",
|
| 10 |
"httpx>=0.27.0",
|
| 11 |
+
"jsonschema>=4.0.0",
|
| 12 |
"pandas>=3.0.5",
|
| 13 |
"plotly>=7.0.0",
|
| 14 |
+
"pydantic>=2.0.0",
|
| 15 |
"smolagents[openai]>=1.26.0",
|
| 16 |
]
|
| 17 |
+
|
| 18 |
+
[dependency-groups]
|
| 19 |
+
dev = ["pytest>=8.0.0"]
|
tests/conftest.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Shared fixtures: no test may hit the network.
|
| 2 |
+
|
| 3 |
+
The single live Open-Meteo call was recorded once to
|
| 4 |
+
``tests/fixtures/openmeteo_bells.json``. Here every HTTP attempt fails
|
| 5 |
+
fast so an accidental network dependency surfaces as an error, while
|
| 6 |
+
stale-cache fallback paths still work (they catch ``httpx.HTTPError``).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import sys
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
import pytest
|
| 14 |
+
|
| 15 |
+
# Repo root on sys.path so `wavereader` imports however pytest is invoked
|
| 16 |
+
# (subset runs must not depend on other test modules' bootstraps).
|
| 17 |
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@pytest.fixture(autouse=True)
|
| 21 |
+
def _no_network(monkeypatch):
|
| 22 |
+
class _DeadClient:
|
| 23 |
+
def __init__(self, *a, **k):
|
| 24 |
+
raise httpx.ConnectError("network disabled in tests")
|
| 25 |
+
|
| 26 |
+
monkeypatch.setattr(httpx, "Client", _DeadClient)
|
| 27 |
+
yield
|
tests/fixtures/openmeteo_bells.json
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
{"hourly": [{"time": "2026-09-09T00:00", "wave_height": 2.98, "wave_period": 9.7, "wave_direction": 209, "wind_wave_height": 2.2, "swell_wave_height": 1.84, "swell_wave_direction": 219, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 12.3, "wind_direction_10m": 273, "wind_gusts_10m": 23.0}, {"time": "2026-09-09T01:00", "wave_height": 2.98, "wave_period": 9.9, "wave_direction": 209, "wind_wave_height": 2.08, "swell_wave_height": 1.88, "swell_wave_direction": 218, "swell_wave_period": 9.85, "sea_surface_temperature": 13.7, "wind_speed_10m": 13.7, "wind_direction_10m": 259, "wind_gusts_10m": 25.6}, {"time": "2026-09-09T02:00", "wave_height": 3.0, "wave_period": 10.15, "wave_direction": 210, "wind_wave_height": 1.94, "swell_wave_height": 1.92, "swell_wave_direction": 217, "swell_wave_period": 9.65, "sea_surface_temperature": 13.7, "wind_speed_10m": 20.6, "wind_direction_10m": 228, "wind_gusts_10m": 39.6}, {"time": "2026-09-09T03:00", "wave_height": 3.0, "wave_period": 10.4, "wave_direction": 210, "wind_wave_height": 1.82, "swell_wave_height": 1.96, "swell_wave_direction": 216, "swell_wave_period": 9.7, "sea_surface_temperature": 13.7, "wind_speed_10m": 27.8, "wind_direction_10m": 212, "wind_gusts_10m": 54.7}, {"time": "2026-09-09T04:00", "wave_height": 3.0, "wave_period": 10.65, "wave_direction": 210, "wind_wave_height": 1.72, "swell_wave_height": 2.12, "swell_wave_direction": 216, "swell_wave_period": 10.25, "sea_surface_temperature": 13.7, "wind_speed_10m": 28.8, "wind_direction_10m": 210, "wind_gusts_10m": 56.9}, {"time": "2026-09-09T05:00", "wave_height": 3.02, "wave_period": 10.9, "wave_direction": 211, "wind_wave_height": 1.64, "swell_wave_height": 2.3, "swell_wave_direction": 216, "swell_wave_period": 11.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 28.5, "wind_direction_10m": 211, "wind_gusts_10m": 55.8}, {"time": "2026-09-09T06:00", "wave_height": 3.02, "wave_period": 11.1, "wave_direction": 211, "wind_wave_height": 1.54, "swell_wave_height": 2.46, "swell_wave_direction": 216, "swell_wave_period": 11.7, "sea_surface_temperature": 13.7, "wind_speed_10m": 26.6, "wind_direction_10m": 207, "wind_gusts_10m": 55.8}, {"time": "2026-09-09T07:00", "wave_height": 3.04, "wave_period": 11.3, "wave_direction": 211, "wind_wave_height": 1.54, "swell_wave_height": 2.54, "swell_wave_direction": 216, "swell_wave_period": 11.85, "sea_surface_temperature": 13.7, "wind_speed_10m": 28.1, "wind_direction_10m": 204, "wind_gusts_10m": 54.7}, {"time": "2026-09-09T08:00", "wave_height": 3.04, "wave_period": 11.45, "wave_direction": 211, "wind_wave_height": 1.56, "swell_wave_height": 2.6, "swell_wave_direction": 216, "swell_wave_period": 11.75, "sea_surface_temperature": 13.7, "wind_speed_10m": 28.8, "wind_direction_10m": 203, "wind_gusts_10m": 65.5}, {"time": "2026-09-09T09:00", "wave_height": 3.06, "wave_period": 11.6, "wave_direction": 211, "wind_wave_height": 1.56, "swell_wave_height": 2.68, "swell_wave_direction": 216, "swell_wave_period": 11.6, "sea_surface_temperature": 13.7, "wind_speed_10m": 32.2, "wind_direction_10m": 203, "wind_gusts_10m": 70.2}, {"time": "2026-09-09T10:00", "wave_height": 3.08, "wave_period": 11.75, "wave_direction": 211, "wind_wave_height": 1.56, "swell_wave_height": 2.72, "swell_wave_direction": 216, "swell_wave_period": 11.45, "sea_surface_temperature": 13.7, "wind_speed_10m": 28.0, "wind_direction_10m": 201, "wind_gusts_10m": 70.2}, {"time": "2026-09-09T11:00", "wave_height": 3.08, "wave_period": 11.85, "wave_direction": 211, "wind_wave_height": 1.56, "swell_wave_height": 2.74, "swell_wave_direction": 215, "swell_wave_period": 11.25, "sea_surface_temperature": 13.7, "wind_speed_10m": 25.9, "wind_direction_10m": 205, "wind_gusts_10m": 64.1}, {"time": "2026-09-09T12:00", "wave_height": 3.1, "wave_period": 11.95, "wave_direction": 211, "wind_wave_height": 1.56, "swell_wave_height": 2.78, "swell_wave_direction": 215, "swell_wave_period": 11.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 26.6, "wind_direction_10m": 207, "wind_gusts_10m": 58.7}, {"time": "2026-09-09T13:00", "wave_height": 3.1, "wave_period": 12.05, "wave_direction": 211, "wind_wave_height": 1.48, "swell_wave_height": 2.82, "swell_wave_direction": 215, "swell_wave_period": 11.15, "sea_surface_temperature": 13.7, "wind_speed_10m": 26.7, "wind_direction_10m": 207, "wind_gusts_10m": 61.6}, {"time": "2026-09-09T14:00", "wave_height": 3.12, "wave_period": 12.15, "wave_direction": 210, "wind_wave_height": 1.42, "swell_wave_height": 2.86, "swell_wave_direction": 214, "swell_wave_period": 11.25, "sea_surface_temperature": 13.7, "wind_speed_10m": 26.3, "wind_direction_10m": 207, "wind_gusts_10m": 59.8}, {"time": "2026-09-09T15:00", "wave_height": 3.12, "wave_period": 12.3, "wave_direction": 210, "wind_wave_height": 1.34, "swell_wave_height": 2.9, "swell_wave_direction": 214, "swell_wave_period": 11.3, "sea_surface_temperature": 13.6, "wind_speed_10m": 26.1, "wind_direction_10m": 210, "wind_gusts_10m": 59.0}, {"time": "2026-09-09T16:00", "wave_height": 3.1, "wave_period": 12.45, "wave_direction": 210, "wind_wave_height": 1.04, "swell_wave_height": 2.96, "swell_wave_direction": 213, "swell_wave_period": 11.15, "sea_surface_temperature": 13.6, "wind_speed_10m": 23.2, "wind_direction_10m": 213, "wind_gusts_10m": 57.6}, {"time": "2026-09-09T17:00", "wave_height": 3.06, "wave_period": 12.65, "wave_direction": 210, "wind_wave_height": 0.76, "swell_wave_height": 3.0, "swell_wave_direction": 211, "swell_wave_period": 10.85, "sea_surface_temperature": 13.6, "wind_speed_10m": 23.8, "wind_direction_10m": 214, "wind_gusts_10m": 51.1}, {"time": "2026-09-09T18:00", "wave_height": 3.04, "wave_period": 12.8, "wave_direction": 210, "wind_wave_height": 0.46, "swell_wave_height": 3.06, "swell_wave_direction": 210, "swell_wave_period": 10.7, "sea_surface_temperature": 13.6, "wind_speed_10m": 22.5, "wind_direction_10m": 213, "wind_gusts_10m": 51.8}, {"time": "2026-09-09T19:00", "wave_height": 2.98, "wave_period": 12.9, "wave_direction": 210, "wind_wave_height": 0.44, "swell_wave_height": 3.02, "swell_wave_direction": 210, "swell_wave_period": 10.75, "sea_surface_temperature": 13.6, "wind_speed_10m": 23.3, "wind_direction_10m": 208, "wind_gusts_10m": 45.0}, {"time": "2026-09-09T20:00", "wave_height": 2.94, "wave_period": 13.0, "wave_direction": 211, "wind_wave_height": 0.4, "swell_wave_height": 2.96, "swell_wave_direction": 210, "swell_wave_period": 10.85, "sea_surface_temperature": 13.6, "wind_speed_10m": 23.8, "wind_direction_10m": 204, "wind_gusts_10m": 46.8}, {"time": "2026-09-09T21:00", "wave_height": 2.88, "wave_period": 13.05, "wave_direction": 211, "wind_wave_height": 0.38, "swell_wave_height": 2.92, "swell_wave_direction": 210, "swell_wave_period": 11.0, "sea_surface_temperature": 13.6, "wind_speed_10m": 24.0, "wind_direction_10m": 199, "wind_gusts_10m": 50.8}, {"time": "2026-09-09T22:00", "wave_height": 2.84, "wave_period": 13.05, "wave_direction": 211, "wind_wave_height": 0.42, "swell_wave_height": 2.86, "swell_wave_direction": 210, "swell_wave_period": 11.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 20.7, "wind_direction_10m": 199, "wind_gusts_10m": 47.2}, {"time": "2026-09-09T23:00", "wave_height": 2.78, "wave_period": 13.05, "wave_direction": 211, "wind_wave_height": 0.44, "swell_wave_height": 2.8, "swell_wave_direction": 211, "swell_wave_period": 11.15, "sea_surface_temperature": 13.7, "wind_speed_10m": 21.6, "wind_direction_10m": 193, "wind_gusts_10m": 41.8}, {"time": "2026-09-10T00:00", "wave_height": 2.74, "wave_period": 13.0, "wave_direction": 211, "wind_wave_height": 0.48, "swell_wave_height": 2.74, "swell_wave_direction": 211, "swell_wave_period": 11.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 20.7, "wind_direction_10m": 191, "wind_gusts_10m": 42.5}, {"time": "2026-09-10T01:00", "wave_height": 2.68, "wave_period": 12.95, "wave_direction": 211, "wind_wave_height": 0.44, "swell_wave_height": 2.68, "swell_wave_direction": 212, "swell_wave_period": 11.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 21.8, "wind_direction_10m": 188, "wind_gusts_10m": 43.6}, {"time": "2026-09-10T02:00", "wave_height": 2.64, "wave_period": 12.9, "wave_direction": 211, "wind_wave_height": 0.42, "swell_wave_height": 2.62, "swell_wave_direction": 212, "swell_wave_period": 10.95, "sea_surface_temperature": 13.7, "wind_speed_10m": 15.5, "wind_direction_10m": 193, "wind_gusts_10m": 42.5}, {"time": "2026-09-10T03:00", "wave_height": 2.58, "wave_period": 12.85, "wave_direction": 211, "wind_wave_height": 0.38, "swell_wave_height": 2.56, "swell_wave_direction": 213, "swell_wave_period": 10.9, "sea_surface_temperature": 13.7, "wind_speed_10m": 10.5, "wind_direction_10m": 217, "wind_gusts_10m": 29.9}, {"time": "2026-09-10T04:00", "wave_height": 2.54, "wave_period": 12.75, "wave_direction": 211, "wind_wave_height": 0.46, "swell_wave_height": 2.5, "swell_wave_direction": 213, "swell_wave_period": 11.0, "sea_surface_temperature": 13.8, "wind_speed_10m": 10.1, "wind_direction_10m": 235, "wind_gusts_10m": 18.4}, {"time": "2026-09-10T05:00", "wave_height": 2.5, "wave_period": 12.65, "wave_direction": 212, "wind_wave_height": 0.54, "swell_wave_height": 2.44, "swell_wave_direction": 213, "swell_wave_period": 11.15, "sea_surface_temperature": 13.8, "wind_speed_10m": 10.2, "wind_direction_10m": 260, "wind_gusts_10m": 18.4}, {"time": "2026-09-10T06:00", "wave_height": 2.46, "wave_period": 12.55, "wave_direction": 212, "wind_wave_height": 0.62, "swell_wave_height": 2.38, "swell_wave_direction": 213, "swell_wave_period": 11.25, "sea_surface_temperature": 13.8, "wind_speed_10m": 10.6, "wind_direction_10m": 268, "wind_gusts_10m": 19.8}, {"time": "2026-09-10T07:00", "wave_height": 2.4, "wave_period": 12.4, "wave_direction": 212, "wind_wave_height": 0.62, "swell_wave_height": 2.32, "swell_wave_direction": 213, "swell_wave_period": 11.15, "sea_surface_temperature": 13.8, "wind_speed_10m": 10.6, "wind_direction_10m": 251, "wind_gusts_10m": 19.4}, {"time": "2026-09-10T08:00", "wave_height": 2.36, "wave_period": 12.3, "wave_direction": 212, "wind_wave_height": 0.64, "swell_wave_height": 2.28, "swell_wave_direction": 213, "swell_wave_period": 11.0, "sea_surface_temperature": 13.8, "wind_speed_10m": 11.4, "wind_direction_10m": 219, "wind_gusts_10m": 26.3}, {"time": "2026-09-10T09:00", "wave_height": 2.3, "wave_period": 12.2, "wave_direction": 212, "wind_wave_height": 0.64, "swell_wave_height": 2.22, "swell_wave_direction": 213, "swell_wave_period": 10.8, "sea_surface_temperature": 13.7, "wind_speed_10m": 14.5, "wind_direction_10m": 202, "wind_gusts_10m": 33.5}, {"time": "2026-09-10T10:00", "wave_height": 2.24, "wave_period": 12.15, "wave_direction": 212, "wind_wave_height": 0.46, "swell_wave_height": 2.18, "swell_wave_direction": 213, "swell_wave_period": 10.55, "sea_surface_temperature": 13.7, "wind_speed_10m": 17.2, "wind_direction_10m": 193, "wind_gusts_10m": 40.3}, {"time": "2026-09-10T11:00", "wave_height": 2.18, "wave_period": 12.2, "wave_direction": 213, "wind_wave_height": 0.26, "swell_wave_height": 2.16, "swell_wave_direction": 213, "swell_wave_period": 10.3, "sea_surface_temperature": 13.7, "wind_speed_10m": 17.5, "wind_direction_10m": 188, "wind_gusts_10m": 41.8}, {"time": "2026-09-10T12:00", "wave_height": 2.12, "wave_period": 12.2, "wave_direction": 213, "wind_wave_height": 0.08, "swell_wave_height": 2.12, "swell_wave_direction": 213, "swell_wave_period": 10.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 16.4, "wind_direction_10m": 189, "wind_gusts_10m": 41.8}, {"time": "2026-09-10T13:00", "wave_height": 2.08, "wave_period": 12.2, "wave_direction": 213, "wind_wave_height": 0.06, "swell_wave_height": 2.08, "swell_wave_direction": 213, "swell_wave_period": 10.05, "sea_surface_temperature": 13.7, "wind_speed_10m": 14.6, "wind_direction_10m": 181, "wind_gusts_10m": 40.0}, {"time": "2026-09-10T14:00", "wave_height": 2.04, "wave_period": 12.25, "wave_direction": 213, "wind_wave_height": 0.06, "swell_wave_height": 2.02, "swell_wave_direction": 213, "swell_wave_period": 10.1, "sea_surface_temperature": 13.7, "wind_speed_10m": 16.1, "wind_direction_10m": 174, "wind_gusts_10m": 39.2}, {"time": "2026-09-10T15:00", "wave_height": 2.0, "wave_period": 12.25, "wave_direction": 213, "wind_wave_height": 0.04, "swell_wave_height": 1.98, "swell_wave_direction": 213, "swell_wave_period": 10.15, "sea_surface_temperature": 13.7, "wind_speed_10m": 18.1, "wind_direction_10m": 175, "wind_gusts_10m": 42.5}, {"time": "2026-09-10T16:00", "wave_height": 1.96, "wave_period": 12.25, "wave_direction": 213, "wind_wave_height": 0.02, "swell_wave_height": 1.94, "swell_wave_direction": 213, "swell_wave_period": 10.15, "sea_surface_temperature": 13.7, "wind_speed_10m": 17.5, "wind_direction_10m": 178, "wind_gusts_10m": 42.5}, {"time": "2026-09-10T17:00", "wave_height": 1.94, "wave_period": 12.2, "wave_direction": 214, "wind_wave_height": 0.02, "swell_wave_height": 1.92, "swell_wave_direction": 214, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 15.7, "wind_direction_10m": 181, "wind_gusts_10m": 40.0}, {"time": "2026-09-10T18:00", "wave_height": 1.9, "wave_period": 12.2, "wave_direction": 214, "wind_wave_height": 0.0, "swell_wave_height": 1.88, "swell_wave_direction": 214, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 16.7, "wind_direction_10m": 186, "wind_gusts_10m": 34.6}, {"time": "2026-09-10T19:00", "wave_height": 1.88, "wave_period": 12.2, "wave_direction": 214, "wind_wave_height": 0.0, "swell_wave_height": 1.84, "swell_wave_direction": 214, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 17.1, "wind_direction_10m": 197, "wind_gusts_10m": 33.1}, {"time": "2026-09-10T20:00", "wave_height": 1.84, "wave_period": 12.15, "wave_direction": 214, "wind_wave_height": 0.0, "swell_wave_height": 1.82, "swell_wave_direction": 215, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 13.5, "wind_direction_10m": 205, "wind_gusts_10m": 32.4}, {"time": "2026-09-10T21:00", "wave_height": 1.82, "wave_period": 12.15, "wave_direction": 214, "wind_wave_height": 0.0, "swell_wave_height": 1.78, "swell_wave_direction": 215, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 8.7, "wind_direction_10m": 220, "wind_gusts_10m": 24.8}, {"time": "2026-09-10T22:00", "wave_height": 1.8, "wave_period": 12.15, "wave_direction": 214, "wind_wave_height": 0.0, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.2, "sea_surface_temperature": 13.7, "wind_speed_10m": 7.3, "wind_direction_10m": 237, "wind_gusts_10m": 15.1}, {"time": "2026-09-10T23:00", "wave_height": 1.8, "wave_period": 12.15, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.25, "sea_surface_temperature": 13.7, "wind_speed_10m": 7.4, "wind_direction_10m": 252, "wind_gusts_10m": 12.2}, {"time": "2026-09-11T00:00", "wave_height": 1.78, "wave_period": 12.2, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.74, "swell_wave_direction": 215, "swell_wave_period": 10.3, "sea_surface_temperature": 13.8, "wind_speed_10m": 7.8, "wind_direction_10m": 266, "wind_gusts_10m": 13.7}, {"time": "2026-09-11T01:00", "wave_height": 1.78, "wave_period": 12.25, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.72, "swell_wave_direction": 215, "swell_wave_period": 10.35, "sea_surface_temperature": 13.8, "wind_speed_10m": 7.0, "wind_direction_10m": 270, "wind_gusts_10m": 13.3}, {"time": "2026-09-11T02:00", "wave_height": 1.78, "wave_period": 12.3, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.72, "swell_wave_direction": 215, "swell_wave_period": 10.4, "sea_surface_temperature": 13.9, "wind_speed_10m": 5.6, "wind_direction_10m": 268, "wind_gusts_10m": 12.2}, {"time": "2026-09-11T03:00", "wave_height": 1.78, "wave_period": 12.4, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.7, "swell_wave_direction": 215, "swell_wave_period": 10.45, "sea_surface_temperature": 13.9, "wind_speed_10m": 4.3, "wind_direction_10m": 272, "wind_gusts_10m": 10.1}, {"time": "2026-09-11T04:00", "wave_height": 1.78, "wave_period": 12.5, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.72, "swell_wave_direction": 215, "swell_wave_period": 10.5, "sea_surface_temperature": 14.0, "wind_speed_10m": 3.8, "wind_direction_10m": 287, "wind_gusts_10m": 8.3}, {"time": "2026-09-11T05:00", "wave_height": 1.8, "wave_period": 12.6, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.72, "swell_wave_direction": 215, "swell_wave_period": 10.55, "sea_surface_temperature": 14.1, "wind_speed_10m": 3.3, "wind_direction_10m": 292, "wind_gusts_10m": 9.0}, {"time": "2026-09-11T06:00", "wave_height": 1.8, "wave_period": 12.65, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.74, "swell_wave_direction": 215, "swell_wave_period": 10.6, "sea_surface_temperature": 14.1, "wind_speed_10m": 3.1, "wind_direction_10m": 301, "wind_gusts_10m": 9.0}, {"time": "2026-09-11T07:00", "wave_height": 1.8, "wave_period": 12.7, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.74, "swell_wave_direction": 215, "swell_wave_period": 10.65, "sea_surface_temperature": 14.1, "wind_speed_10m": 3.4, "wind_direction_10m": 302, "wind_gusts_10m": 9.4}, {"time": "2026-09-11T08:00", "wave_height": 1.8, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.02, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.7, "sea_surface_temperature": 14.0, "wind_speed_10m": 2.7, "wind_direction_10m": 298, "wind_gusts_10m": 10.1}, {"time": "2026-09-11T09:00", "wave_height": 1.8, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.02, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.7, "sea_surface_temperature": 14.0, "wind_speed_10m": 2.5, "wind_direction_10m": 180, "wind_gusts_10m": 11.2}, {"time": "2026-09-11T10:00", "wave_height": 1.8, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.02, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.7, "sea_surface_temperature": 14.0, "wind_speed_10m": 4.4, "wind_direction_10m": 145, "wind_gusts_10m": 16.2}, {"time": "2026-09-11T11:00", "wave_height": 1.78, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.65, "sea_surface_temperature": 13.9, "wind_speed_10m": 6.8, "wind_direction_10m": 130, "wind_gusts_10m": 20.9}, {"time": "2026-09-11T12:00", "wave_height": 1.78, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.65, "sea_surface_temperature": 13.9, "wind_speed_10m": 10.4, "wind_direction_10m": 132, "wind_gusts_10m": 28.1}, {"time": "2026-09-11T13:00", "wave_height": 1.76, "wave_period": 12.75, "wave_direction": 215, "wind_wave_height": 0.0, "swell_wave_height": 1.76, "swell_wave_direction": 215, "swell_wave_period": 10.65, "sea_surface_temperature": 13.9, "wind_speed_10m": 11.7, "wind_direction_10m": 139, "wind_gusts_10m": 31.7}, {"time": "2026-09-11T14:00", "wave_height": 1.76, "wave_period": 12.75, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.74, "swell_wave_direction": 216, "swell_wave_period": 10.7, "sea_surface_temperature": 13.8, "wind_speed_10m": 9.6, "wind_direction_10m": 140, "wind_gusts_10m": 31.3}, {"time": "2026-09-11T15:00", "wave_height": 1.74, "wave_period": 12.75, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.74, "swell_wave_direction": 216, "swell_wave_period": 10.7, "sea_surface_temperature": 13.8, "wind_speed_10m": 7.4, "wind_direction_10m": 137, "wind_gusts_10m": 27.0}, {"time": "2026-09-11T16:00", "wave_height": 1.72, "wave_period": 12.75, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.72, "swell_wave_direction": 216, "swell_wave_period": 10.7, "sea_surface_temperature": 13.8, "wind_speed_10m": 6.1, "wind_direction_10m": 137, "wind_gusts_10m": 22.0}, {"time": "2026-09-11T17:00", "wave_height": 1.7, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.7, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.8, "wind_speed_10m": 5.4, "wind_direction_10m": 143, "wind_gusts_10m": 18.7}, {"time": "2026-09-11T18:00", "wave_height": 1.68, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.68, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.8, "wind_speed_10m": 3.8, "wind_direction_10m": 155, "wind_gusts_10m": 14.8}, {"time": "2026-09-11T19:00", "wave_height": 1.66, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.66, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.8, "wind_speed_10m": 3.6, "wind_direction_10m": 153, "wind_gusts_10m": 6.1}, {"time": "2026-09-11T20:00", "wave_height": 1.64, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.64, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.7, "wind_speed_10m": 3.7, "wind_direction_10m": 157, "wind_gusts_10m": 5.4}, {"time": "2026-09-11T21:00", "wave_height": 1.62, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.0, "swell_wave_height": 1.62, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.7, "wind_speed_10m": 2.4, "wind_direction_10m": 144, "wind_gusts_10m": 5.4}, {"time": "2026-09-11T22:00", "wave_height": 1.6, "wave_period": 12.8, "wave_direction": 216, "wind_wave_height": 0.02, "swell_wave_height": 1.6, "swell_wave_direction": 216, "swell_wave_period": 10.75, "sea_surface_temperature": 13.7, "wind_speed_10m": 0.9, "wind_direction_10m": 180, "wind_gusts_10m": 4.0}, {"time": "2026-09-11T23:00", "wave_height": 1.58, "wave_period": 12.85, "wave_direction": 217, "wind_wave_height": 0.02, "swell_wave_height": 1.58, "swell_wave_direction": 217, "swell_wave_period": 10.75, "sea_surface_temperature": 13.7, "wind_speed_10m": 0.5, "wind_direction_10m": 315, "wind_gusts_10m": 1.8}], "daily": {"time": ["2026-09-09", "2026-09-10", "2026-09-11"], "sunrise": ["2026-09-09T06:34", "2026-09-10T06:32", "2026-09-11T06:30"], "sunset": ["2026-09-09T18:08", "2026-09-10T18:09", "2026-09-11T18:10"]}}
|
tests/test_breaks.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline tests for wavereader.breaks (load, validate, resolve, filter)."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
|
| 5 |
+
import pytest
|
| 6 |
+
|
| 7 |
+
from wavereader import breaks as br
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def test_load_breaks_count_and_sort():
|
| 11 |
+
breaks = br.load_breaks()
|
| 12 |
+
assert len(breaks) == 238
|
| 13 |
+
assert br.load_breaks.last_skipped == 0
|
| 14 |
+
keys = [(b["state"], b["region"], b["name"]) for b in breaks]
|
| 15 |
+
assert keys == sorted(keys)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_all_records_validate_strict():
|
| 19 |
+
breaks = br.load_breaks(strict=True) # raises on any invalid record
|
| 20 |
+
assert len(breaks) == 238
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_is_valid_break_rejects_garbage():
|
| 24 |
+
assert not br.is_valid_break({"name": "Nope"})
|
| 25 |
+
bells = br.resolve_break("Bells Beach")
|
| 26 |
+
assert bells is not None and br.is_valid_break(bells)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_resolve_break_exact_and_case_insensitive():
|
| 30 |
+
assert br.resolve_break("Bells Beach")["region"] == "Surf Coast"
|
| 31 |
+
assert br.resolve_break("bells beach")["name"] == "Bells Beach"
|
| 32 |
+
assert br.resolve_break("BELLS BEACH", region="surf coast")["name"] == "Bells Beach"
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_resolve_break_fuzzy_and_scoped():
|
| 36 |
+
assert br.resolve_break("bells")["name"] == "Bells Beach"
|
| 37 |
+
# Unknown region falls back to the global pool.
|
| 38 |
+
assert br.resolve_break("Bells Beach", region="No Such Region")["name"] == "Bells Beach"
|
| 39 |
+
assert br.resolve_break("Bells Beach", region="Surf Coast")["name"] == "Bells Beach"
|
| 40 |
+
assert br.resolve_break("definitely not a break xyz") is None
|
| 41 |
+
assert br.resolve_break("") is None
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def test_resolve_break_region_disambiguates():
|
| 45 |
+
pool = [
|
| 46 |
+
{"name": "The Pass", "region": "North", "state": "NSW"},
|
| 47 |
+
{"name": "The Pass", "region": "South", "state": "NSW"},
|
| 48 |
+
]
|
| 49 |
+
assert br.resolve_break("The Pass", region="south", breaks=pool)["region"] == "South"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def test_filter_breaks():
|
| 53 |
+
breaks = br.load_breaks()
|
| 54 |
+
vic = br.filter_breaks(breaks, state="victoria")
|
| 55 |
+
assert vic and all(b["state"] == "Victoria" for b in vic)
|
| 56 |
+
surf_coast = br.filter_breaks(breaks, region="Surf Coast")
|
| 57 |
+
assert surf_coast and all(b["region"] == "Surf Coast" for b in surf_coast)
|
| 58 |
+
assert br.filter_breaks(breaks, state="All") == breaks
|
| 59 |
+
assert br.filter_breaks(breaks, skill="advanced")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def test_get_coords():
|
| 63 |
+
lat, lng = br.get_coords(br.resolve_break("Bells Beach"))
|
| 64 |
+
assert lat == pytest.approx(-38.3667)
|
| 65 |
+
assert lng == pytest.approx(144.2833)
|
| 66 |
+
assert br.get_coords({}) == (None, None)
|
tests/test_openmeteo.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline tests for wavereader.openmeteo (cache, merge, offset contract)."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
|
| 9 |
+
from wavereader import openmeteo as om
|
| 10 |
+
|
| 11 |
+
FIXTURE = Path(__file__).parent / "fixtures" / "openmeteo_bells.json"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _use_tmp_cache(monkeypatch, tmp_path):
|
| 15 |
+
monkeypatch.setattr(om, "CACHE_DIR", tmp_path)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# ---- Fetch contract ----
|
| 19 |
+
|
| 20 |
+
def test_marine_fields_include_swell_components():
|
| 21 |
+
assert "swell_wave_direction" in om.MARINE_HOURLY_FIELDS
|
| 22 |
+
assert "swell_wave_period" in om.MARINE_HOURLY_FIELDS
|
| 23 |
+
assert "swell_wave_height" in om.MARINE_HOURLY_FIELDS
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_cache_version_bumped():
|
| 27 |
+
# Old-shape (v2, no swell_* fields) cache must never serve as fresh.
|
| 28 |
+
assert om.CACHE_VERSION == 3
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---- Seaward offset (Worker B reuse contract) ----
|
| 32 |
+
|
| 33 |
+
@pytest.mark.parametrize(
|
| 34 |
+
("lat", "lon", "expected"),
|
| 35 |
+
[
|
| 36 |
+
(-28.17, 153.55, (0.0, 0.13)), # east coast → east
|
| 37 |
+
(-33.80, 115.00, (0.0, -0.13)), # west coast → west
|
| 38 |
+
(-35.00, 138.00, (-0.13, 0.0)), # south coast SA → south
|
| 39 |
+
(-38.37, 144.28, (-0.13, 0.0)), # Surf Coast VIC → south
|
| 40 |
+
(-42.90, 147.30, (-0.13, 0.0)), # Tasmania → south
|
| 41 |
+
],
|
| 42 |
+
)
|
| 43 |
+
def test_seaward_offset_directions(lat, lon, expected):
|
| 44 |
+
assert om._seaward_offset(lat, lon) == pytest.approx(expected)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
# ---- Merge ----
|
| 48 |
+
|
| 49 |
+
def test_build_normalized_merges_and_carries_daily():
|
| 50 |
+
marine = {
|
| 51 |
+
"hourly": {
|
| 52 |
+
"time": ["2026-09-01T00:00", "2026-09-01T01:00"],
|
| 53 |
+
"wave_height": [1.2, 1.3],
|
| 54 |
+
"swell_wave_height": [1.0, 1.1],
|
| 55 |
+
"swell_wave_direction": [180.0, 185.0],
|
| 56 |
+
"swell_wave_period": [12.0, 12.5],
|
| 57 |
+
}
|
| 58 |
+
}
|
| 59 |
+
weather = {
|
| 60 |
+
"hourly": {
|
| 61 |
+
"time": ["2026-09-01T00:00", "2026-09-01T01:00"],
|
| 62 |
+
"wind_speed_10m": [10.0, 40.0],
|
| 63 |
+
"wind_direction_10m": [0.0, 90.0],
|
| 64 |
+
"wind_gusts_10m": [15.0, 50.0],
|
| 65 |
+
},
|
| 66 |
+
"daily": {
|
| 67 |
+
"time": ["2026-09-01"],
|
| 68 |
+
"sunrise": ["2026-09-01T06:00"],
|
| 69 |
+
"sunset": ["2026-09-01T18:00"],
|
| 70 |
+
},
|
| 71 |
+
}
|
| 72 |
+
out = om._build_normalized(marine, weather)
|
| 73 |
+
assert len(out["hourly"]) == 2
|
| 74 |
+
assert out["hourly"][0]["swell_wave_direction"] == 180.0
|
| 75 |
+
assert out["hourly"][0]["wind_speed_10m"] == 10.0
|
| 76 |
+
assert out["daily"]["sunrise"] == ["2026-09-01T06:00"]
|
| 77 |
+
assert out["daily"]["sunset"] == ["2026-09-01T18:00"]
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
# ---- Disk cache, fully offline ----
|
| 81 |
+
|
| 82 |
+
def test_get_forecast_serves_fresh_cache_without_network(monkeypatch, tmp_path):
|
| 83 |
+
_use_tmp_cache(monkeypatch, tmp_path)
|
| 84 |
+
payload = {"hourly": [{"time": "2026-09-01T12:00", "wave_height": 1.0}], "daily": {}}
|
| 85 |
+
om._save_cache(-38.37, 144.28, 3, payload)
|
| 86 |
+
assert om.get_forecast(-38.37, 144.28, days=3) == payload
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def test_get_forecast_falls_back_to_stale_cache(monkeypatch, tmp_path):
|
| 90 |
+
_use_tmp_cache(monkeypatch, tmp_path)
|
| 91 |
+
payload = {"hourly": [{"time": "2026-09-01T12:00", "wave_height": 2.0}]}
|
| 92 |
+
path = om._cache_path(-38.37, 144.28, 3)
|
| 93 |
+
tmp_path.mkdir(parents=True, exist_ok=True)
|
| 94 |
+
path.write_text(json.dumps({"fetched_at": 0.0, "data": payload}))
|
| 95 |
+
# httpx.Client raises (conftest) → stale fallback, not an error.
|
| 96 |
+
assert om.get_forecast(-38.37, 144.28, days=3) == payload
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_get_forecast_raises_with_no_cache_and_no_network(monkeypatch, tmp_path):
|
| 100 |
+
_use_tmp_cache(monkeypatch, tmp_path)
|
| 101 |
+
with pytest.raises(Exception):
|
| 102 |
+
om.get_forecast(-38.37, 144.28, days=3)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def test_cache_key_embeds_version():
|
| 106 |
+
assert om._cache_key(-38.37, 144.28, 3).startswith(f"v{om.CACHE_VERSION}_")
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
# ---- Recorded live fixture ----
|
| 110 |
+
|
| 111 |
+
def test_recorded_bells_fixture_shape():
|
| 112 |
+
fc = json.loads(FIXTURE.read_text())
|
| 113 |
+
assert len(fc["hourly"]) == 72
|
| 114 |
+
first = fc["hourly"][0]
|
| 115 |
+
for field in ("swell_wave_height", "swell_wave_direction", "swell_wave_period",
|
| 116 |
+
"wave_height", "wind_speed_10m", "wind_direction_10m"):
|
| 117 |
+
assert field in first, f"fixture missing {field}"
|
| 118 |
+
assert "sunrise" in fc["daily"] and "sunset" in fc["daily"]
|
| 119 |
+
assert len(fc["daily"]["time"]) >= 3
|
tests/test_scoring.py
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline tests for scoring v2 (arc distance, logistic wind, daylight,
|
| 2 |
+
p75 summaries, surfable-hours rank, swell preference, monotonicity)."""
|
| 3 |
+
|
| 4 |
+
import pytest
|
| 5 |
+
|
| 6 |
+
from wavereader import scoring as sc
|
| 7 |
+
from wavereader.scoring import (
|
| 8 |
+
daily_summary,
|
| 9 |
+
enriched_to_scoring_spot,
|
| 10 |
+
normalize_skill,
|
| 11 |
+
rank_spots,
|
| 12 |
+
score_hour,
|
| 13 |
+
score_week,
|
| 14 |
+
wind_cap,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _spot(**over):
|
| 19 |
+
spot = {
|
| 20 |
+
"name": "Test",
|
| 21 |
+
"region": "Test Coast",
|
| 22 |
+
"break_type": "point",
|
| 23 |
+
"ideal_swell": {"direction": "SW/S/SSW", "size_ft_min": 4.0, "size_ft_max": 12.0},
|
| 24 |
+
"ideal_wind": {"direction": "N", "type": "offshore"},
|
| 25 |
+
}
|
| 26 |
+
for k, v in over.items():
|
| 27 |
+
if isinstance(v, dict):
|
| 28 |
+
spot[k] = {**spot[k], **v}
|
| 29 |
+
else:
|
| 30 |
+
spot[k] = v
|
| 31 |
+
return spot
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _row(time="2026-09-01T12:00", **over):
|
| 35 |
+
row = {
|
| 36 |
+
"time": time,
|
| 37 |
+
"wave_height": 1.5,
|
| 38 |
+
"wave_period": 12.0,
|
| 39 |
+
"wave_direction": 180.0,
|
| 40 |
+
"wind_speed_10m": 9.0,
|
| 41 |
+
"wind_direction_10m": 0.0,
|
| 42 |
+
}
|
| 43 |
+
row.update(over)
|
| 44 |
+
return row
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _frame(times, **over):
|
| 48 |
+
return {"hourly": [_row(t, **over) for t in times]}
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
# ---- Adapter shaping ----
|
| 52 |
+
|
| 53 |
+
def test_enriched_to_scoring_spot_carries_wind_type_and_break_type():
|
| 54 |
+
b = {
|
| 55 |
+
"name": "Bells Beach", "region": "Surf Coast", "breakType": "point",
|
| 56 |
+
"idealSwell": {"direction": ["SW", "S"], "sizeRangeFt": {"min": 4, "max": 12}},
|
| 57 |
+
"idealWind": {"direction": ["N"], "type": "offshore"},
|
| 58 |
+
"skillLevel": "advanced",
|
| 59 |
+
}
|
| 60 |
+
spot = enriched_to_scoring_spot(b)
|
| 61 |
+
assert spot["ideal_swell"]["direction"] == "SW/S"
|
| 62 |
+
assert spot["ideal_wind"] == {"direction": "N", "type": "offshore"}
|
| 63 |
+
assert spot["break_type"] == "point"
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def test_normalize_skill():
|
| 67 |
+
assert normalize_skill("pro-only") == "expert"
|
| 68 |
+
assert normalize_skill("BEGINNER") == "beginner"
|
| 69 |
+
assert normalize_skill("made-up") == "intermediate"
|
| 70 |
+
assert normalize_skill(None) == "intermediate"
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
# ---- Direction arc (not cyclic mean) ----
|
| 74 |
+
|
| 75 |
+
def test_direction_arc_peaks_at_each_ideal_bearing():
|
| 76 |
+
spot = _spot(ideal_swell={"direction": "E/SE"})
|
| 77 |
+
at_e = sc._score_swell_direction(90.0, spot)
|
| 78 |
+
at_se = sc._score_swell_direction(135.0, spot)
|
| 79 |
+
at_mid = sc._score_swell_direction(112.5, spot) # cyclic mean would peak here
|
| 80 |
+
assert at_e == pytest.approx(10.0)
|
| 81 |
+
assert at_se == pytest.approx(10.0)
|
| 82 |
+
assert at_mid < 9.0 # dip between the two ideal bearings
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def test_direction_arc_min_distance():
|
| 86 |
+
# Bells-like arc: S is ideal, N is ~opposite → near zero.
|
| 87 |
+
spot = _spot()
|
| 88 |
+
assert sc._score_swell_direction(180.0, spot) == pytest.approx(10.0)
|
| 89 |
+
assert sc._score_swell_direction(0.0, spot) < 1.0
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def test_dir_to_deg_still_available_legacy():
|
| 93 |
+
assert sc._dir_to_deg("E") == pytest.approx(90.0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# ---- Wind cap table + logistic roll-off ----
|
| 97 |
+
|
| 98 |
+
@pytest.mark.parametrize(
|
| 99 |
+
("wind_type", "expected"),
|
| 100 |
+
[("offshore", 18.0), ("cross-shore", 12.0), ("onshore", 8.0), ("light/variable", 10.0)],
|
| 101 |
+
)
|
| 102 |
+
def test_wind_cap_by_type(wind_type, expected):
|
| 103 |
+
spot = _spot(ideal_wind={"direction": "N", "type": wind_type})
|
| 104 |
+
assert wind_cap(spot, "expert") == pytest.approx(expected)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def test_wind_cap_bounded_by_skill_profile():
|
| 108 |
+
spot = _spot() # offshore → 18, but beginners cap at 12
|
| 109 |
+
assert wind_cap(spot, "beginner") == pytest.approx(12.0)
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def test_wind_speed_is_smooth_not_a_cliff():
|
| 113 |
+
spot = _spot() # offshore cap 18 for advanced (profile max 25)
|
| 114 |
+
just_under = sc._score_wind(17.0, 0.0, spot, "advanced")
|
| 115 |
+
at_cap = sc._score_wind(18.0, 0.0, spot, "advanced")
|
| 116 |
+
just_over = sc._score_wind(19.0, 0.0, spot, "advanced")
|
| 117 |
+
# No 10→0 cliff: adjacent knots differ by small steps.
|
| 118 |
+
assert abs(just_under - at_cap) < 2.0
|
| 119 |
+
assert abs(at_cap - just_over) < 2.0
|
| 120 |
+
assert just_under > at_cap > just_over
|
| 121 |
+
# Glassy air still excellent; howling onshore air scores ~0
|
| 122 |
+
# (speed ~0 and direction 0 averaged: the composite, not speed alone).
|
| 123 |
+
glassy = sc._score_wind(2.0, 0.0, spot, "advanced")
|
| 124 |
+
howling_onshore = sc._score_wind(40.0, 180.0, spot, "advanced")
|
| 125 |
+
assert glassy > 9.0
|
| 126 |
+
assert howling_onshore < 1.0
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def test_wind_monotonic_beyond_cap_property():
|
| 130 |
+
"""Stronger offshore-beyond-cap wind scores <= lighter wind."""
|
| 131 |
+
spot = _spot()
|
| 132 |
+
scores = [sc._score_wind(ws, 0.0, spot, "advanced") for ws in (18, 20, 25, 30)]
|
| 133 |
+
assert scores == sorted(scores, reverse=True)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ---- Swell-component preference ----
|
| 137 |
+
|
| 138 |
+
def test_score_week_prefers_swell_fields():
|
| 139 |
+
spot = _spot()
|
| 140 |
+
generic = {"hourly": [_row()]}
|
| 141 |
+
swell_same = {"hourly": [_row(
|
| 142 |
+
swell_wave_height=1.5, swell_wave_period=12.0, swell_wave_direction=180.0,
|
| 143 |
+
)]}
|
| 144 |
+
assert score_week(generic, spot) == score_week(swell_same, spot)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def test_score_week_uses_swell_values_not_generic():
|
| 148 |
+
spot = _spot()
|
| 149 |
+
# Generic aggregate is flat small; the real swell is overhead-high.
|
| 150 |
+
frame = {"hourly": [_row(
|
| 151 |
+
wave_height=1.5, wave_period=12.0, wave_direction=180.0,
|
| 152 |
+
swell_wave_height=0.2, swell_wave_period=5.0, swell_wave_direction=0.0,
|
| 153 |
+
)]}
|
| 154 |
+
(got,) = score_week(frame, spot, daylight_only=False)
|
| 155 |
+
assert got["wave_height_m"] == pytest.approx(0.2)
|
| 156 |
+
assert got["wave_period_s"] == pytest.approx(5.0)
|
| 157 |
+
assert got["wave_direction_deg"] == pytest.approx(0.0)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---- Daylight filter ----
|
| 161 |
+
|
| 162 |
+
def _sunny_frame():
|
| 163 |
+
return {
|
| 164 |
+
"hourly": [
|
| 165 |
+
_row("2026-09-01T02:00"), # night
|
| 166 |
+
_row("2026-09-01T12:00"), # day
|
| 167 |
+
_row("2026-09-01T23:00"), # night
|
| 168 |
+
],
|
| 169 |
+
"daily": {
|
| 170 |
+
"time": ["2026-09-01"],
|
| 171 |
+
"sunrise": ["2026-09-01T06:00"],
|
| 172 |
+
"sunset": ["2026-09-01T18:00"],
|
| 173 |
+
},
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def test_score_week_drops_night_hours_by_default():
|
| 178 |
+
hours = score_week(_sunny_frame(), _spot())
|
| 179 |
+
assert [h["time"] for h in hours] == ["2026-09-01T12:00"]
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def test_score_week_daylight_opt_out_keeps_all():
|
| 183 |
+
hours = score_week(_sunny_frame(), _spot(), daylight_only=False)
|
| 184 |
+
assert len(hours) == 3
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_score_week_without_daily_keeps_all():
|
| 188 |
+
hours = score_week(_frame(["2026-09-01T02:00", "2026-09-01T12:00"]), _spot())
|
| 189 |
+
assert len(hours) == 2
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
# ---- Daily summary (p75) ----
|
| 193 |
+
|
| 194 |
+
def test_daily_summary_is_p75():
|
| 195 |
+
hours = [
|
| 196 |
+
{"time": "2026-09-01T09:00", "score": 2.0},
|
| 197 |
+
{"time": "2026-09-01T10:00", "score": 4.0},
|
| 198 |
+
{"time": "2026-09-01T11:00", "score": 6.0},
|
| 199 |
+
{"time": "2026-09-01T12:00", "score": 8.0},
|
| 200 |
+
{"time": "2026-09-02T12:00", "score": 9.0},
|
| 201 |
+
]
|
| 202 |
+
(d1, d2) = daily_summary(hours)
|
| 203 |
+
assert d1["date"] == "2026-09-01"
|
| 204 |
+
assert d1["p75"] == pytest.approx(6.5) # linear interp between 6 and 8
|
| 205 |
+
assert d1["best"] == pytest.approx(8.0)
|
| 206 |
+
assert d1["n"] == 4
|
| 207 |
+
assert d1["surfable_hours"] == 2
|
| 208 |
+
assert d2["p75"] == pytest.approx(9.0)
|
| 209 |
+
|
| 210 |
+
|
| 211 |
+
# ---- Rank by surfable hours ----
|
| 212 |
+
|
| 213 |
+
def test_rank_spots_prefers_surfable_hours_over_single_best():
|
| 214 |
+
alto = _spot(name="Alto", region="R")
|
| 215 |
+
bajo = _spot(name="Bajo", region="R")
|
| 216 |
+
# Alto: one epic hour, otherwise junk. Bajo: many decent hours.
|
| 217 |
+
alto_frame = {"hourly": [
|
| 218 |
+
_row("2026-09-01T12:00", wave_height=1.8, wave_period=14.0,
|
| 219 |
+
wind_speed_10m=5.0, wind_direction_10m=0.0),
|
| 220 |
+
*[_row(f"2026-09-0{d}T12:00", wave_height=0.1, wave_period=4.0,
|
| 221 |
+
wind_speed_10m=80.0, wind_direction_10m=180.0) for d in (2, 3, 4, 5)],
|
| 222 |
+
]}
|
| 223 |
+
bajo_frame = {"hourly": [
|
| 224 |
+
_row(f"2026-09-0{d}T12:00", wave_height=1.8, wave_period=14.0,
|
| 225 |
+
wind_speed_10m=5.0, wind_direction_10m=0.0) for d in (1, 2, 3, 4, 5)
|
| 226 |
+
]}
|
| 227 |
+
ranked = rank_spots({("Alto", "R"): alto_frame, ("Bajo", "R"): bajo_frame}, [alto, bajo])
|
| 228 |
+
assert ranked[0]["name"] == "Bajo"
|
| 229 |
+
assert ranked[0]["surfable_hours"] > ranked[1]["surfable_hours"]
|
| 230 |
+
assert "best_time" in ranked[0] and "best_hour" in ranked[0]
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def test_rank_spots_skips_missing_forecasts():
|
| 234 |
+
ranked = rank_spots({}, [_spot(name="Ghost", region="R")])
|
| 235 |
+
assert ranked == []
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
# ---- Monotonicity properties ----
|
| 239 |
+
|
| 240 |
+
def test_bigger_swell_in_window_scores_ge(tmp_path=None):
|
| 241 |
+
"""Bigger swell inside the ideal window scores >= smaller swell."""
|
| 242 |
+
spot = _spot()
|
| 243 |
+
scores = [
|
| 244 |
+
score_hour(h, 14.0, 5.0, 0.0, spot, wave_direction_deg=180.0, skill_level="advanced")["score"]
|
| 245 |
+
for h in (0.8, 1.2, 1.6, 2.0)
|
| 246 |
+
]
|
| 247 |
+
assert scores == sorted(scores)
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def test_score_hour_rejects_bad_skill():
|
| 251 |
+
with pytest.raises(ValueError):
|
| 252 |
+
score_hour(1.5, 12.0, 5.0, 0.0, _spot(), skill_level="kook")
|
| 253 |
+
|
| 254 |
+
|
| 255 |
+
def test_rank_spots_this_week_alias():
|
| 256 |
+
frame = _frame(["2026-09-01T12:00"])
|
| 257 |
+
spot = _spot()
|
| 258 |
+
key = (spot["name"], spot["region"])
|
| 259 |
+
assert sc.rank_spots_this_week({key: frame}, [spot]) == rank_spots({key: frame}, [spot])
|
tests/test_seafloor.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Offline tests for wavereader.seafloor (slope math, None-skipping).
|
| 2 |
+
|
| 3 |
+
All grids are synthetic — no network. ``get_grid`` is monkeypatched where
|
| 4 |
+
``get_seafloor`` is exercised.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
import math
|
| 8 |
+
|
| 9 |
+
import pytest
|
| 10 |
+
|
| 11 |
+
from wavereader import seafloor as sf
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
def _synthetic_grid(n=5, center=(-38.37, 144.28), ramp=None, none_at=()):
|
| 15 |
+
"""Build a grid via the real _deg_offsets; elev optional ramp fn(i, j)."""
|
| 16 |
+
dlat, dlon = sf._deg_offsets(center[0], 1.2, n)
|
| 17 |
+
lats = [round(center[0] + d, 5) for d in dlat]
|
| 18 |
+
lngs = [round(center[1] + d, 5) for d in dlon]
|
| 19 |
+
elev = []
|
| 20 |
+
for i in range(n):
|
| 21 |
+
for j in range(n):
|
| 22 |
+
v = float(ramp(i, j)) if ramp else -10.0
|
| 23 |
+
elev.append(None if (i, j) in none_at else v)
|
| 24 |
+
return {
|
| 25 |
+
"lats": lats, "lngs": lngs, "elev": elev, "n": n,
|
| 26 |
+
"radius_km": 1.2, "dataset": "test",
|
| 27 |
+
"center": {"lat": center[0], "lng": center[1]},
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
# ---- Step math ----
|
| 32 |
+
|
| 33 |
+
def _degree_grid(n=5, center=(-38.37, 144.28), step_deg=0.01, ramp=None, none_at=()):
|
| 34 |
+
"""Grid with EQUAL degree spacing on both axes (not square in km).
|
| 35 |
+
|
| 36 |
+
This is the case the v1 slope bug got wrong: at lat -38°, 0.01° of
|
| 37 |
+
longitude is only ~0.87 km while 0.01° of latitude is 1.11 km.
|
| 38 |
+
"""
|
| 39 |
+
lats = [round(center[0] + (i - n // 2) * step_deg, 5) for i in range(n)]
|
| 40 |
+
lngs = [round(center[1] + (j - n // 2) * step_deg, 5) for j in range(n)]
|
| 41 |
+
elev = []
|
| 42 |
+
for i in range(n):
|
| 43 |
+
for j in range(n):
|
| 44 |
+
v = float(ramp(i, j)) if ramp else -10.0
|
| 45 |
+
elev.append(None if (i, j) in none_at else v)
|
| 46 |
+
return {
|
| 47 |
+
"lats": lats, "lngs": lngs, "elev": elev, "n": n,
|
| 48 |
+
"radius_km": 1.2, "dataset": "test",
|
| 49 |
+
"center": {"lat": center[0], "lng": center[1]},
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
# ---- Step math ----
|
| 54 |
+
|
| 55 |
+
def test_lon_step_scaled_by_cos_lat():
|
| 56 |
+
grid = _degree_grid()
|
| 57 |
+
lat_step, lon_step = sf._grid_steps_km(grid)
|
| 58 |
+
assert lat_step == pytest.approx(0.01 * 111.0, rel=1e-6)
|
| 59 |
+
assert lon_step == pytest.approx(0.01 * 111.0 * math.cos(math.radians(-38.37)), rel=1e-6)
|
| 60 |
+
assert lon_step < lat_step # meridians converge away from the equator
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def test_square_grid_steps_approx_equal():
|
| 64 |
+
# Grids built by _deg_offsets are square in km by construction;
|
| 65 |
+
# per-axis derivation must recover (approximately) equal steps.
|
| 66 |
+
grid = _synthetic_grid()
|
| 67 |
+
lat_step, lon_step = sf._grid_steps_km(grid)
|
| 68 |
+
assert lat_step == pytest.approx(0.6, abs=0.02)
|
| 69 |
+
assert lon_step == pytest.approx(0.6, abs=0.02)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_lon_ramp_scores_steeper_than_equal_lat_ramp():
|
| 73 |
+
"""Equal m-per-degree ramps on a degree grid: E–W must score steeper.
|
| 74 |
+
|
| 75 |
+
At lat -38° the same 5 m/cell over 0.01° is a steeper gradient along
|
| 76 |
+
longitude (~0.87 km) than along latitude (1.11 km). The v1 bug (lat
|
| 77 |
+
step reused for both axes) reported them equal.
|
| 78 |
+
"""
|
| 79 |
+
ramp_ew = lambda i, j: -10.0 - 5.0 * j # varies along longitude
|
| 80 |
+
ramp_ns = lambda i, j: -10.0 - 5.0 * i # varies along latitude
|
| 81 |
+
ew = sf.analyze_grid(_degree_grid(ramp=ramp_ew))["stats"]
|
| 82 |
+
ns = sf.analyze_grid(_degree_grid(ramp=ramp_ns))["stats"]
|
| 83 |
+
assert ew["max_slope_m_per_km"] == pytest.approx(5.0 / (0.01 * 111.0 * math.cos(math.radians(-38.37))), rel=0.05)
|
| 84 |
+
assert ns["max_slope_m_per_km"] == pytest.approx(5.0 / (0.01 * 111.0), rel=0.05)
|
| 85 |
+
assert ew["max_slope_m_per_km"] > ns["max_slope_m_per_km"] * 1.1
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# ---- None handling ----
|
| 89 |
+
|
| 90 |
+
def test_analyze_grid_skips_none_cells():
|
| 91 |
+
grid = _synthetic_grid(none_at={(0, 0), (2, 2), (4, 4)})
|
| 92 |
+
out = sf.analyze_grid(grid)
|
| 93 |
+
assert "stats" in out
|
| 94 |
+
assert out["stats"]["points"] == 25 - 3
|
| 95 |
+
assert out["stats"]["center_elev_m"] is None # centre cell is None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def test_analyze_grid_empty():
|
| 99 |
+
assert sf.analyze_grid({"elev": [None, None], "n": 2}) == {"error": "Empty seafloor grid"}
|
| 100 |
+
assert sf.analyze_grid({"elev": [], "n": 3}) == {"error": "Empty seafloor grid"}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def test_analyze_grid_stats_shape():
|
| 104 |
+
out = sf.analyze_grid(_synthetic_grid())
|
| 105 |
+
stats = out["stats"]
|
| 106 |
+
assert stats["shelf_class"] in (
|
| 107 |
+
"steep reef edge / drop-off",
|
| 108 |
+
"moderately sloping reef/shelf",
|
| 109 |
+
"shallow, gently shelving platform",
|
| 110 |
+
"gradual sandy shelf",
|
| 111 |
+
)
|
| 112 |
+
assert "Seafloor" in out["markdown"]
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
# ---- get_seafloor without network ----
|
| 116 |
+
|
| 117 |
+
def test_get_seafloor_uses_grid(monkeypatch):
|
| 118 |
+
grid = _synthetic_grid()
|
| 119 |
+
monkeypatch.setattr(sf, "get_grid", lambda *a, **k: grid)
|
| 120 |
+
out = sf.get_seafloor(-38.37, 144.28)
|
| 121 |
+
assert out["grid"] is grid
|
| 122 |
+
assert out["stats"]["points"] == 25
|
| 123 |
+
assert isinstance(out["analysis"], str)
|
uv.lock
CHANGED
|
@@ -38,6 +38,15 @@ wheels = [
|
|
| 38 |
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
| 39 |
]
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
[[package]]
|
| 42 |
name = "audioop-lts"
|
| 43 |
version = "0.2.2"
|
|
@@ -456,6 +465,15 @@ wheels = [
|
|
| 456 |
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
| 457 |
]
|
| 458 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 459 |
[[package]]
|
| 460 |
name = "jinja2"
|
| 461 |
version = "3.1.6"
|
|
@@ -504,6 +522,33 @@ wheels = [
|
|
| 504 |
{ url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
|
| 505 |
]
|
| 506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
[[package]]
|
| 508 |
name = "markdown-it-py"
|
| 509 |
version = "4.2.0"
|
|
@@ -763,6 +808,15 @@ wheels = [
|
|
| 763 |
{ url = "https://files.pythonhosted.org/packages/e0/2f/6f492108d9955bac97979d9949c1b35eab30fc630b1f22bbdd2c7cacbab4/plotly-7.0.0-py3-none-any.whl", hash = "sha256:78cbf7bd06d1b05bb3b8ec1b709864695229b55151b6f7530fbf55517ead6fdd", size = 9052859, upload-time = "2026-08-25T17:47:24.689Z" },
|
| 764 |
]
|
| 765 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 766 |
[[package]]
|
| 767 |
name = "pydantic"
|
| 768 |
version = "2.13.5"
|
|
@@ -837,6 +891,22 @@ wheels = [
|
|
| 837 |
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
| 838 |
]
|
| 839 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 840 |
[[package]]
|
| 841 |
name = "python-dateutil"
|
| 842 |
version = "2.9.0.post0"
|
|
@@ -902,6 +972,19 @@ wheels = [
|
|
| 902 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 903 |
]
|
| 904 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
[[package]]
|
| 906 |
name = "requests"
|
| 907 |
version = "2.34.2"
|
|
@@ -930,6 +1013,72 @@ wheels = [
|
|
| 930 |
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
| 931 |
]
|
| 932 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 933 |
[[package]]
|
| 934 |
name = "safehttpx"
|
| 935 |
version = "0.1.7"
|
|
@@ -1117,17 +1266,29 @@ dependencies = [
|
|
| 1117 |
{ name = "gradio" },
|
| 1118 |
{ name = "httpx" },
|
| 1119 |
{ name = "huggingface-hub" },
|
|
|
|
| 1120 |
{ name = "pandas" },
|
| 1121 |
{ name = "plotly" },
|
|
|
|
| 1122 |
{ name = "smolagents", extra = ["openai"] },
|
| 1123 |
]
|
| 1124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1125 |
[package.metadata]
|
| 1126 |
requires-dist = [
|
| 1127 |
{ name = "gradio", specifier = ">=5.0.0" },
|
| 1128 |
{ name = "httpx", specifier = ">=0.27.0" },
|
| 1129 |
{ name = "huggingface-hub", specifier = ">=1.30.0" },
|
|
|
|
| 1130 |
{ name = "pandas", specifier = ">=3.0.5" },
|
| 1131 |
{ name = "plotly", specifier = ">=7.0.0" },
|
|
|
|
| 1132 |
{ name = "smolagents", extras = ["openai"], specifier = ">=1.26.0" },
|
| 1133 |
]
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
{ url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" },
|
| 39 |
]
|
| 40 |
|
| 41 |
+
[[package]]
|
| 42 |
+
name = "attrs"
|
| 43 |
+
version = "26.1.0"
|
| 44 |
+
source = { registry = "https://pypi.org/simple" }
|
| 45 |
+
sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" }
|
| 46 |
+
wheels = [
|
| 47 |
+
{ url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" },
|
| 48 |
+
]
|
| 49 |
+
|
| 50 |
[[package]]
|
| 51 |
name = "audioop-lts"
|
| 52 |
version = "0.2.2"
|
|
|
|
| 465 |
{ url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" },
|
| 466 |
]
|
| 467 |
|
| 468 |
+
[[package]]
|
| 469 |
+
name = "iniconfig"
|
| 470 |
+
version = "2.3.0"
|
| 471 |
+
source = { registry = "https://pypi.org/simple" }
|
| 472 |
+
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
| 473 |
+
wheels = [
|
| 474 |
+
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
| 475 |
+
]
|
| 476 |
+
|
| 477 |
[[package]]
|
| 478 |
name = "jinja2"
|
| 479 |
version = "3.1.6"
|
|
|
|
| 522 |
{ url = "https://files.pythonhosted.org/packages/7c/a2/d88de6d313d734a544a7901353ad5db67cb38dcfcd91713b7979dafc345d/jiter-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0fa25b09b13075c46f5bc174f2690525a925a4fc2f7c82969a2bbabff22386ce", size = 190516, upload-time = "2026-06-29T13:04:38.004Z" },
|
| 523 |
]
|
| 524 |
|
| 525 |
+
[[package]]
|
| 526 |
+
name = "jsonschema"
|
| 527 |
+
version = "4.26.0"
|
| 528 |
+
source = { registry = "https://pypi.org/simple" }
|
| 529 |
+
dependencies = [
|
| 530 |
+
{ name = "attrs" },
|
| 531 |
+
{ name = "jsonschema-specifications" },
|
| 532 |
+
{ name = "referencing" },
|
| 533 |
+
{ name = "rpds-py" },
|
| 534 |
+
]
|
| 535 |
+
sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" }
|
| 536 |
+
wheels = [
|
| 537 |
+
{ url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630, upload-time = "2026-01-07T13:41:05.306Z" },
|
| 538 |
+
]
|
| 539 |
+
|
| 540 |
+
[[package]]
|
| 541 |
+
name = "jsonschema-specifications"
|
| 542 |
+
version = "2025.9.1"
|
| 543 |
+
source = { registry = "https://pypi.org/simple" }
|
| 544 |
+
dependencies = [
|
| 545 |
+
{ name = "referencing" },
|
| 546 |
+
]
|
| 547 |
+
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
| 548 |
+
wheels = [
|
| 549 |
+
{ url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" },
|
| 550 |
+
]
|
| 551 |
+
|
| 552 |
[[package]]
|
| 553 |
name = "markdown-it-py"
|
| 554 |
version = "4.2.0"
|
|
|
|
| 808 |
{ url = "https://files.pythonhosted.org/packages/e0/2f/6f492108d9955bac97979d9949c1b35eab30fc630b1f22bbdd2c7cacbab4/plotly-7.0.0-py3-none-any.whl", hash = "sha256:78cbf7bd06d1b05bb3b8ec1b709864695229b55151b6f7530fbf55517ead6fdd", size = 9052859, upload-time = "2026-08-25T17:47:24.689Z" },
|
| 809 |
]
|
| 810 |
|
| 811 |
+
[[package]]
|
| 812 |
+
name = "pluggy"
|
| 813 |
+
version = "1.6.0"
|
| 814 |
+
source = { registry = "https://pypi.org/simple" }
|
| 815 |
+
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
| 816 |
+
wheels = [
|
| 817 |
+
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
| 818 |
+
]
|
| 819 |
+
|
| 820 |
[[package]]
|
| 821 |
name = "pydantic"
|
| 822 |
version = "2.13.5"
|
|
|
|
| 891 |
{ url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" },
|
| 892 |
]
|
| 893 |
|
| 894 |
+
[[package]]
|
| 895 |
+
name = "pytest"
|
| 896 |
+
version = "9.1.1"
|
| 897 |
+
source = { registry = "https://pypi.org/simple" }
|
| 898 |
+
dependencies = [
|
| 899 |
+
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
| 900 |
+
{ name = "iniconfig" },
|
| 901 |
+
{ name = "packaging" },
|
| 902 |
+
{ name = "pluggy" },
|
| 903 |
+
{ name = "pygments" },
|
| 904 |
+
]
|
| 905 |
+
sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" }
|
| 906 |
+
wheels = [
|
| 907 |
+
{ url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" },
|
| 908 |
+
]
|
| 909 |
+
|
| 910 |
[[package]]
|
| 911 |
name = "python-dateutil"
|
| 912 |
version = "2.9.0.post0"
|
|
|
|
| 972 |
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
| 973 |
]
|
| 974 |
|
| 975 |
+
[[package]]
|
| 976 |
+
name = "referencing"
|
| 977 |
+
version = "0.37.0"
|
| 978 |
+
source = { registry = "https://pypi.org/simple" }
|
| 979 |
+
dependencies = [
|
| 980 |
+
{ name = "attrs" },
|
| 981 |
+
{ name = "rpds-py" },
|
| 982 |
+
]
|
| 983 |
+
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
| 984 |
+
wheels = [
|
| 985 |
+
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
| 986 |
+
]
|
| 987 |
+
|
| 988 |
[[package]]
|
| 989 |
name = "requests"
|
| 990 |
version = "2.34.2"
|
|
|
|
| 1013 |
{ url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
|
| 1014 |
]
|
| 1015 |
|
| 1016 |
+
[[package]]
|
| 1017 |
+
name = "rpds-py"
|
| 1018 |
+
version = "2026.6.3"
|
| 1019 |
+
source = { registry = "https://pypi.org/simple" }
|
| 1020 |
+
sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" }
|
| 1021 |
+
wheels = [
|
| 1022 |
+
{ url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" },
|
| 1023 |
+
{ url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" },
|
| 1024 |
+
{ url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" },
|
| 1025 |
+
{ url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" },
|
| 1026 |
+
{ url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" },
|
| 1027 |
+
{ url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" },
|
| 1028 |
+
{ url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" },
|
| 1029 |
+
{ url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" },
|
| 1030 |
+
{ url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" },
|
| 1031 |
+
{ url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" },
|
| 1032 |
+
{ url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" },
|
| 1033 |
+
{ url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" },
|
| 1034 |
+
{ url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" },
|
| 1035 |
+
{ url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" },
|
| 1036 |
+
{ url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" },
|
| 1037 |
+
{ url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" },
|
| 1038 |
+
{ url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" },
|
| 1039 |
+
{ url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" },
|
| 1040 |
+
{ url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" },
|
| 1041 |
+
{ url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" },
|
| 1042 |
+
{ url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" },
|
| 1043 |
+
{ url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" },
|
| 1044 |
+
{ url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" },
|
| 1045 |
+
{ url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" },
|
| 1046 |
+
{ url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" },
|
| 1047 |
+
{ url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" },
|
| 1048 |
+
{ url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" },
|
| 1049 |
+
{ url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" },
|
| 1050 |
+
{ url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" },
|
| 1051 |
+
{ url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" },
|
| 1052 |
+
{ url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" },
|
| 1053 |
+
{ url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" },
|
| 1054 |
+
{ url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" },
|
| 1055 |
+
{ url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" },
|
| 1056 |
+
{ url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" },
|
| 1057 |
+
{ url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" },
|
| 1058 |
+
{ url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" },
|
| 1059 |
+
{ url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" },
|
| 1060 |
+
{ url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" },
|
| 1061 |
+
{ url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" },
|
| 1062 |
+
{ url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" },
|
| 1063 |
+
{ url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" },
|
| 1064 |
+
{ url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" },
|
| 1065 |
+
{ url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" },
|
| 1066 |
+
{ url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" },
|
| 1067 |
+
{ url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" },
|
| 1068 |
+
{ url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" },
|
| 1069 |
+
{ url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" },
|
| 1070 |
+
{ url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" },
|
| 1071 |
+
{ url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" },
|
| 1072 |
+
{ url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" },
|
| 1073 |
+
{ url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" },
|
| 1074 |
+
{ url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" },
|
| 1075 |
+
{ url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" },
|
| 1076 |
+
{ url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" },
|
| 1077 |
+
{ url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" },
|
| 1078 |
+
{ url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" },
|
| 1079 |
+
{ url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" },
|
| 1080 |
+
]
|
| 1081 |
+
|
| 1082 |
[[package]]
|
| 1083 |
name = "safehttpx"
|
| 1084 |
version = "0.1.7"
|
|
|
|
| 1266 |
{ name = "gradio" },
|
| 1267 |
{ name = "httpx" },
|
| 1268 |
{ name = "huggingface-hub" },
|
| 1269 |
+
{ name = "jsonschema" },
|
| 1270 |
{ name = "pandas" },
|
| 1271 |
{ name = "plotly" },
|
| 1272 |
+
{ name = "pydantic" },
|
| 1273 |
{ name = "smolagents", extra = ["openai"] },
|
| 1274 |
]
|
| 1275 |
|
| 1276 |
+
[package.dev-dependencies]
|
| 1277 |
+
dev = [
|
| 1278 |
+
{ name = "pytest" },
|
| 1279 |
+
]
|
| 1280 |
+
|
| 1281 |
[package.metadata]
|
| 1282 |
requires-dist = [
|
| 1283 |
{ name = "gradio", specifier = ">=5.0.0" },
|
| 1284 |
{ name = "httpx", specifier = ">=0.27.0" },
|
| 1285 |
{ name = "huggingface-hub", specifier = ">=1.30.0" },
|
| 1286 |
+
{ name = "jsonschema", specifier = ">=4.0.0" },
|
| 1287 |
{ name = "pandas", specifier = ">=3.0.5" },
|
| 1288 |
{ name = "plotly", specifier = ">=7.0.0" },
|
| 1289 |
+
{ name = "pydantic", specifier = ">=2.0.0" },
|
| 1290 |
{ name = "smolagents", extras = ["openai"], specifier = ">=1.26.0" },
|
| 1291 |
]
|
| 1292 |
+
|
| 1293 |
+
[package.metadata.requires-dev]
|
| 1294 |
+
dev = [{ name = "pytest", specifier = ">=8.0.0" }]
|
wavereader/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""wavereader — pure-core surf forecasting package (typed, tested, no Gradio)."""
|
| 2 |
+
|
| 3 |
+
from wavereader.breaks import filter_breaks, load_breaks, resolve_break
|
| 4 |
+
from wavereader.openmeteo import get_forecast
|
| 5 |
+
from wavereader.scoring import (
|
| 6 |
+
daily_summary,
|
| 7 |
+
rank_spots,
|
| 8 |
+
rank_spots_this_week,
|
| 9 |
+
score_hour,
|
| 10 |
+
score_week,
|
| 11 |
+
)
|
| 12 |
+
from wavereader.seafloor import get_seafloor
|
| 13 |
+
|
| 14 |
+
__all__ = [
|
| 15 |
+
"load_breaks",
|
| 16 |
+
"resolve_break",
|
| 17 |
+
"filter_breaks",
|
| 18 |
+
"get_forecast",
|
| 19 |
+
"score_hour",
|
| 20 |
+
"score_week",
|
| 21 |
+
"daily_summary",
|
| 22 |
+
"rank_spots",
|
| 23 |
+
"rank_spots_this_week",
|
| 24 |
+
"get_seafloor",
|
| 25 |
+
]
|
wavereader/breaks.py
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Surf-break catalogue: load, validate, resolve (wavereader v2 core port).
|
| 2 |
+
|
| 3 |
+
Ported from ``app/breaks_data.py`` with the pandas dependency dropped:
|
| 4 |
+
plain ``json`` + lists throughout. Every record is validated against
|
| 5 |
+
``data/surf-break-schema.json`` via jsonschema; invalid records are
|
| 6 |
+
skipped (with a count available) unless ``strict=True``.
|
| 7 |
+
|
| 8 |
+
Public surface::
|
| 9 |
+
|
| 10 |
+
load_breaks(path=...) -> list[dict]
|
| 11 |
+
resolve_break(name, region=None, breaks=None) -> dict | None
|
| 12 |
+
filter_breaks(breaks, state=None, region=None, skill=None) -> list[dict]
|
| 13 |
+
get_coords(break_) -> (lat, lng)
|
| 14 |
+
|
| 15 |
+
Deterministic only: no LLM, no network.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import difflib
|
| 21 |
+
import json
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import jsonschema
|
| 25 |
+
|
| 26 |
+
DATA_PATH = Path(__file__).resolve().parent.parent / "data" / "australia-surf-breaks-enriched.json"
|
| 27 |
+
SCHEMA_PATH = Path(__file__).resolve().parent.parent / "data" / "surf-break-schema.json"
|
| 28 |
+
|
| 29 |
+
ALL = "All"
|
| 30 |
+
|
| 31 |
+
_SCHEMA_CACHE: dict | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def load_schema(path: Path = SCHEMA_PATH) -> dict:
|
| 35 |
+
"""Load (and memoize) the surf-break JSON schema."""
|
| 36 |
+
global _SCHEMA_CACHE
|
| 37 |
+
if _SCHEMA_CACHE is None:
|
| 38 |
+
_SCHEMA_CACHE = json.loads(path.read_text(encoding="utf-8"))
|
| 39 |
+
return _SCHEMA_CACHE
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def is_valid_break(break_: dict, schema: dict | None = None) -> bool:
|
| 43 |
+
"""True when a record conforms to the surf-break schema."""
|
| 44 |
+
try:
|
| 45 |
+
jsonschema.validate(instance=break_, schema=schema or load_schema())
|
| 46 |
+
except jsonschema.ValidationError:
|
| 47 |
+
return False
|
| 48 |
+
return True
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_breaks(path: Path = DATA_PATH, strict: bool = False) -> list[dict]:
|
| 52 |
+
"""Load the enriched break catalogue as a list of validated records.
|
| 53 |
+
|
| 54 |
+
Handles the legacy ``{"name | state | region": {...}}`` dict format
|
| 55 |
+
and drops ``error`` rows, mirroring the v1 loader. Records failing
|
| 56 |
+
schema validation are skipped (``strict=True`` raises instead).
|
| 57 |
+
"""
|
| 58 |
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 59 |
+
if isinstance(data, dict):
|
| 60 |
+
records = list(data.values())
|
| 61 |
+
else:
|
| 62 |
+
records = list(data)
|
| 63 |
+
|
| 64 |
+
schema = load_schema()
|
| 65 |
+
out: list[dict] = []
|
| 66 |
+
skipped = 0
|
| 67 |
+
for rec in records:
|
| 68 |
+
if not isinstance(rec, dict):
|
| 69 |
+
skipped += 1
|
| 70 |
+
continue
|
| 71 |
+
if rec.get("error") is not None:
|
| 72 |
+
# v1 semantics: rows carrying an "error" marker are dropped.
|
| 73 |
+
skipped += 1
|
| 74 |
+
continue
|
| 75 |
+
try:
|
| 76 |
+
jsonschema.validate(instance=rec, schema=schema)
|
| 77 |
+
except jsonschema.ValidationError:
|
| 78 |
+
if strict:
|
| 79 |
+
raise
|
| 80 |
+
skipped += 1
|
| 81 |
+
continue
|
| 82 |
+
out.append(rec)
|
| 83 |
+
out.sort(key=lambda b: (str(b.get("state", "")), str(b.get("region", "")), str(b.get("name", ""))))
|
| 84 |
+
load_breaks.last_skipped = skipped # type: ignore[attr-defined]
|
| 85 |
+
return out
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
# Count of records skipped by the most recent load_breaks() call.
|
| 89 |
+
load_breaks.last_skipped = 0 # type: ignore[attr-defined]
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
def filter_breaks(
|
| 93 |
+
breaks: list[dict],
|
| 94 |
+
state: str | None = None,
|
| 95 |
+
region: str | None = None,
|
| 96 |
+
skill: str | None = None,
|
| 97 |
+
) -> list[dict]:
|
| 98 |
+
"""Filter breaks by state / region / skill level ("All"/None = no filter)."""
|
| 99 |
+
out = list(breaks)
|
| 100 |
+
if state and state != ALL:
|
| 101 |
+
out = [b for b in out if str(b.get("state", "")).lower() == state.lower()]
|
| 102 |
+
if region and region != ALL:
|
| 103 |
+
out = [b for b in out if str(b.get("region", "")).lower() == region.lower()]
|
| 104 |
+
if skill and skill != ALL:
|
| 105 |
+
out = [b for b in out if str(b.get("skillLevel", "")).lower() == skill.lower()]
|
| 106 |
+
return out
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _norm(text: str | None) -> str:
|
| 110 |
+
return str(text or "").strip().lower()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def resolve_break(
|
| 114 |
+
name: str, region: str | None = None, breaks: list[dict] | None = None
|
| 115 |
+
) -> dict | None:
|
| 116 |
+
"""Resolve a break by name (fuzzy, case-insensitive), optionally scoped to a region.
|
| 117 |
+
|
| 118 |
+
Match order: exact name (+ region when given) → substring match →
|
| 119 |
+
difflib close match. Returns the record dict, or None when nothing
|
| 120 |
+
matches.
|
| 121 |
+
"""
|
| 122 |
+
pool = breaks if breaks is not None else load_breaks()
|
| 123 |
+
want = _norm(name)
|
| 124 |
+
if not want:
|
| 125 |
+
return None
|
| 126 |
+
if region is not None:
|
| 127 |
+
scoped = [b for b in pool if _norm(b.get("region")) == _norm(region)]
|
| 128 |
+
if scoped:
|
| 129 |
+
pool = scoped
|
| 130 |
+
|
| 131 |
+
for b in pool:
|
| 132 |
+
if _norm(b.get("name")) == want:
|
| 133 |
+
return b
|
| 134 |
+
for b in pool:
|
| 135 |
+
if want in _norm(b.get("name")):
|
| 136 |
+
return b
|
| 137 |
+
names = [_norm(b.get("name")) for b in pool]
|
| 138 |
+
close = difflib.get_close_matches(want, names, n=1, cutoff=0.6)
|
| 139 |
+
if close:
|
| 140 |
+
for b in pool:
|
| 141 |
+
if _norm(b.get("name")) == close[0]:
|
| 142 |
+
return b
|
| 143 |
+
return None
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def get_coords(break_: dict) -> tuple[float | None, float | None]:
|
| 147 |
+
"""Return ``(lat, lng)`` (None on missing coords)."""
|
| 148 |
+
try:
|
| 149 |
+
lat = float(break_["location"]["coordinates"]["lat"])
|
| 150 |
+
except (KeyError, TypeError, ValueError):
|
| 151 |
+
lat = None
|
| 152 |
+
try:
|
| 153 |
+
lng = float(break_["location"]["coordinates"]["lng"])
|
| 154 |
+
except (KeyError, TypeError, ValueError):
|
| 155 |
+
lng = None
|
| 156 |
+
return lat, lng
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
__all__ = [
|
| 160 |
+
"DATA_PATH",
|
| 161 |
+
"SCHEMA_PATH",
|
| 162 |
+
"ALL",
|
| 163 |
+
"load_schema",
|
| 164 |
+
"is_valid_break",
|
| 165 |
+
"load_breaks",
|
| 166 |
+
"filter_breaks",
|
| 167 |
+
"resolve_break",
|
| 168 |
+
"get_coords",
|
| 169 |
+
]
|
wavereader/openmeteo.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Open-Meteo Marine + weather forecast client (wavereader v2 core port).
|
| 2 |
+
|
| 3 |
+
Ported verbatim-first from ``app/forecasts.py``. v2 changes:
|
| 4 |
+
|
| 5 |
+
* marine fetch adds hourly ``swell_wave_direction`` and
|
| 6 |
+
``swell_wave_period`` (scoring v2 prefers ``swell_wave_*`` components
|
| 7 |
+
over the generic ``wave_*`` aggregates when present);
|
| 8 |
+
* ``CACHE_VERSION`` bumped 2 → 3, so old-shape cache entries (without the
|
| 9 |
+
new swell fields) are never served as fresh.
|
| 10 |
+
|
| 11 |
+
Two endpoints, merged on ``time``:
|
| 12 |
+
|
| 13 |
+
* ``marine-api.open-meteo.com`` — hourly ``wave_height`` (m),
|
| 14 |
+
``wave_period`` (s), ``wave_direction`` (deg), ``wind_wave_height`` (m),
|
| 15 |
+
``swell_wave_height`` (m), ``swell_wave_direction`` (deg),
|
| 16 |
+
``swell_wave_period`` (s), ``sea_surface_temperature`` (C, when served).
|
| 17 |
+
* ``api.open-meteo.com`` — hourly ``wind_speed_10m`` (km/h; convert to
|
| 18 |
+
knots for scoring), ``wind_direction_10m`` (deg),
|
| 19 |
+
``wind_gusts_10m`` (km/h, when served), plus ``daily``
|
| 20 |
+
``sunrise``/``sunset`` (ISO local, ``timezone=auto``) used by the
|
| 21 |
+
scoring v2 daylight filter.
|
| 22 |
+
|
| 23 |
+
Grid points snap ~10 km offshore of a spot's coords: fetch slightly
|
| 24 |
+
seaward of the spot (east-coast breaks shift east, west-coast breaks
|
| 25 |
+
shift west, south-coast breaks (SA/VIC/TAS) shift south).
|
| 26 |
+
|
| 27 |
+
Cache every response to disk. Fresh cache (within ``CACHE_TTL_SECONDS``)
|
| 28 |
+
is served without a network call; when the API fails, a stale cache
|
| 29 |
+
entry is served as a fallback rather than raising. Horizon: hourly,
|
| 30 |
+
7 days.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import json
|
| 36 |
+
import os
|
| 37 |
+
import time
|
| 38 |
+
from pathlib import Path
|
| 39 |
+
|
| 40 |
+
import httpx
|
| 41 |
+
|
| 42 |
+
MARINE_URL = "https://marine-api.open-meteo.com/v1/marine"
|
| 43 |
+
WEATHER_URL = "https://api.open-meteo.com/v1/forecast"
|
| 44 |
+
|
| 45 |
+
MARINE_HOURLY_FIELDS = (
|
| 46 |
+
"wave_height",
|
| 47 |
+
"wave_period",
|
| 48 |
+
"wave_direction",
|
| 49 |
+
"wind_wave_height",
|
| 50 |
+
"swell_wave_height",
|
| 51 |
+
"swell_wave_direction",
|
| 52 |
+
"swell_wave_period",
|
| 53 |
+
"sea_surface_temperature",
|
| 54 |
+
)
|
| 55 |
+
WEATHER_HOURLY_FIELDS = ("wind_speed_10m", "wind_direction_10m", "wind_gusts_10m")
|
| 56 |
+
# Daily sun times come from the same weather endpoint (no new API/key).
|
| 57 |
+
WEATHER_DAILY_FIELDS = ("sunrise", "sunset")
|
| 58 |
+
|
| 59 |
+
# ~0.13° is roughly 14 km — enough to move off the coast onto a marine grid point.
|
| 60 |
+
_SEAWARD_OFFSET_DEG = 0.13
|
| 61 |
+
|
| 62 |
+
CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "forecasts"
|
| 63 |
+
CACHE_TTL_SECONDS = 6 * 3600
|
| 64 |
+
# Bumped when the cached frame shape changes (v3: swell_wave_direction /
|
| 65 |
+
# swell_wave_period added) so old-shape entries are never served as fresh.
|
| 66 |
+
CACHE_VERSION = 3
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _seaward_offset(lat: float, lon: float) -> tuple[float, float]:
|
| 70 |
+
"""Offset a spot's coords toward open ocean so Open-Meteo resolves a marine grid point.
|
| 71 |
+
|
| 72 |
+
Direction is inferred from position: Tasmania and the south coast shift
|
| 73 |
+
south, the east coast (lon >= 147) shifts east, the west coast
|
| 74 |
+
(lon <= 125) shifts west.
|
| 75 |
+
|
| 76 |
+
Shared with the climatology builder (Worker B): reuse this instead of
|
| 77 |
+
re-implementing the offset for archive calls.
|
| 78 |
+
"""
|
| 79 |
+
if lat <= -39.5: # Tasmania: open ocean to the south
|
| 80 |
+
dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
|
| 81 |
+
elif lon >= 147: # east coast (QLD/NSW)
|
| 82 |
+
dlat, dlon = 0.0, _SEAWARD_OFFSET_DEG
|
| 83 |
+
elif lon <= 125: # west coast (WA)
|
| 84 |
+
dlat, dlon = 0.0, -_SEAWARD_OFFSET_DEG
|
| 85 |
+
else: # south coast (SA/VIC)
|
| 86 |
+
dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
|
| 87 |
+
return dlat, dlon
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def _round_coords(lat: float, lon: float) -> tuple[float, float]:
|
| 91 |
+
dlat, dlon = _seaward_offset(lat, lon)
|
| 92 |
+
return round(lat + dlat, 2), round(lon + dlon, 2)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _cache_key(lat: float, lon: float, days: int) -> str:
|
| 96 |
+
"""Cache key from the *raw* spot coords (offset is a fetch detail only)."""
|
| 97 |
+
return f"v{CACHE_VERSION}_{round(lat, 2):.2f}_{round(lon, 2):.2f}_{days}"
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _cache_path(lat: float, lon: float, days: int) -> Path:
|
| 101 |
+
return CACHE_DIR / f"{_cache_key(lat, lon, days)}.json"
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def _load_cache(lat: float, lon: float, days: int) -> tuple[dict, float] | None:
|
| 105 |
+
"""Return ``(data, fetched_at)`` from disk, or None if absent/corrupt.
|
| 106 |
+
|
| 107 |
+
Legacy cache files written before the envelope format are returned with
|
| 108 |
+
``fetched_at = 0`` so they still work as a stale fallback.
|
| 109 |
+
"""
|
| 110 |
+
p = _cache_path(lat, lon, days)
|
| 111 |
+
if not p.exists():
|
| 112 |
+
return None
|
| 113 |
+
try:
|
| 114 |
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
| 115 |
+
except (json.JSONDecodeError, OSError):
|
| 116 |
+
return None
|
| 117 |
+
if isinstance(raw, dict) and "fetched_at" in raw and "data" in raw:
|
| 118 |
+
return raw["data"], float(raw["fetched_at"])
|
| 119 |
+
return raw, 0.0
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _save_cache(lat: float, lon: float, days: int, data: dict) -> None:
|
| 123 |
+
"""Write the cache envelope atomically (crash-safe write + rename)."""
|
| 124 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 125 |
+
envelope = {"fetched_at": time.time(), "data": data}
|
| 126 |
+
tmp = _cache_path(lat, lon, days).with_suffix(".tmp")
|
| 127 |
+
tmp.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
|
| 128 |
+
os.replace(tmp, _cache_path(lat, lon, days))
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def _build_normalized(marine: dict, weather: dict) -> dict:
|
| 132 |
+
"""Merge marine + weather hourly on ``time`` into a clean frame.
|
| 133 |
+
|
| 134 |
+
Also carries ``daily`` sun times (``{"time", "sunrise", "sunset"}``)
|
| 135 |
+
straight from the weather endpoint, plus optional per-hour
|
| 136 |
+
``sea_surface_temperature`` (C) and ``wind_gusts_10m`` (km/h) when
|
| 137 |
+
the API serves them. Scoring ignores unknown fields, so old
|
| 138 |
+
consumers keep working.
|
| 139 |
+
"""
|
| 140 |
+
m = marine["hourly"]
|
| 141 |
+
w = weather["hourly"]
|
| 142 |
+
marine_times = m["time"]
|
| 143 |
+
|
| 144 |
+
weather_index: dict[str, dict[str, float]] = {}
|
| 145 |
+
for i, t in enumerate(w["time"]):
|
| 146 |
+
entry: dict[str, float] = {"time": t}
|
| 147 |
+
for field in WEATHER_HOURLY_FIELDS:
|
| 148 |
+
if field in w and i < len(w[field]):
|
| 149 |
+
entry[field] = w[field][i]
|
| 150 |
+
weather_index[t] = entry
|
| 151 |
+
|
| 152 |
+
frames: list[dict] = []
|
| 153 |
+
for i, t in enumerate(marine_times):
|
| 154 |
+
row: dict[str, float | str] = {"time": t}
|
| 155 |
+
for field in MARINE_HOURLY_FIELDS:
|
| 156 |
+
if field in m and i < len(m[field]):
|
| 157 |
+
row[field] = m[field][i]
|
| 158 |
+
wr = weather_index.get(t)
|
| 159 |
+
if wr:
|
| 160 |
+
for field in WEATHER_HOURLY_FIELDS:
|
| 161 |
+
if field in wr:
|
| 162 |
+
row[field] = wr[field]
|
| 163 |
+
frames.append(row)
|
| 164 |
+
|
| 165 |
+
daily: dict = {}
|
| 166 |
+
w_daily = weather.get("daily") or {}
|
| 167 |
+
if isinstance(w_daily, dict) and w_daily.get("time"):
|
| 168 |
+
daily = {
|
| 169 |
+
k: w_daily.get(k)
|
| 170 |
+
for k in ("time", *WEATHER_DAILY_FIELDS)
|
| 171 |
+
if k in w_daily
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
out: dict = {"hourly": frames}
|
| 175 |
+
if daily:
|
| 176 |
+
out["daily"] = daily
|
| 177 |
+
return out
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _fetch_open_meteo(rlat: float, rlon: float, days: int) -> dict:
|
| 181 |
+
"""Call both Open-Meteo endpoints and return the merged frame."""
|
| 182 |
+
params: dict[str, str | int | float] = {
|
| 183 |
+
"latitude": rlat,
|
| 184 |
+
"longitude": rlon,
|
| 185 |
+
"forecast_days": days,
|
| 186 |
+
}
|
| 187 |
+
|
| 188 |
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
| 189 |
+
resp_m = client.get(MARINE_URL, params={**params, "hourly": ",".join(MARINE_HOURLY_FIELDS)})
|
| 190 |
+
resp_m.raise_for_status()
|
| 191 |
+
marine = resp_m.json()
|
| 192 |
+
resp_w = client.get(
|
| 193 |
+
WEATHER_URL,
|
| 194 |
+
params={
|
| 195 |
+
**params,
|
| 196 |
+
"hourly": ",".join(WEATHER_HOURLY_FIELDS),
|
| 197 |
+
"daily": ",".join(WEATHER_DAILY_FIELDS),
|
| 198 |
+
"timezone": "auto",
|
| 199 |
+
},
|
| 200 |
+
)
|
| 201 |
+
resp_w.raise_for_status()
|
| 202 |
+
weather = resp_w.json()
|
| 203 |
+
|
| 204 |
+
return _build_normalized(marine, weather)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
|
| 208 |
+
"""Fetch marine + weather hourly data for a coordinate, merged on time.
|
| 209 |
+
|
| 210 |
+
Returns a normalised frame: ``{"hourly": [{"time": ..., "wave_height": ...,
|
| 211 |
+
"swell_wave_height": ..., "wind_speed_10m": ..., ...}, ...], "daily":
|
| 212 |
+
{"time": [...], "sunrise": [...], "sunset": [...]}}``. Disk-cached by
|
| 213 |
+
rounded coords + days: entries younger than ``CACHE_TTL_SECONDS`` are
|
| 214 |
+
served directly; on a network failure the most recent cache entry
|
| 215 |
+
(however old) is served as a fallback. Raises only when there is no
|
| 216 |
+
usable data at all.
|
| 217 |
+
"""
|
| 218 |
+
rlat, rlon = _round_coords(lat, lon)
|
| 219 |
+
cached = _load_cache(lat, lon, days)
|
| 220 |
+
if cached is not None and time.time() - cached[1] < CACHE_TTL_SECONDS:
|
| 221 |
+
return cached[0]
|
| 222 |
+
|
| 223 |
+
try:
|
| 224 |
+
merged = _fetch_open_meteo(rlat, rlon, days)
|
| 225 |
+
except (httpx.HTTPError, KeyError, ValueError):
|
| 226 |
+
if cached is not None:
|
| 227 |
+
return cached[0]
|
| 228 |
+
raise
|
| 229 |
+
|
| 230 |
+
_save_cache(lat, lon, days, merged)
|
| 231 |
+
return merged
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
__all__ = [
|
| 235 |
+
"MARINE_URL",
|
| 236 |
+
"WEATHER_URL",
|
| 237 |
+
"MARINE_HOURLY_FIELDS",
|
| 238 |
+
"WEATHER_HOURLY_FIELDS",
|
| 239 |
+
"WEATHER_DAILY_FIELDS",
|
| 240 |
+
"CACHE_TTL_SECONDS",
|
| 241 |
+
"CACHE_VERSION",
|
| 242 |
+
"_seaward_offset",
|
| 243 |
+
"get_forecast",
|
| 244 |
+
]
|
wavereader/scoring.py
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic surf-quality scoring engine, v2 (wavereader core port).
|
| 2 |
+
|
| 3 |
+
Merges ``app/adapters.py`` (break-record → spot shaping) with
|
| 4 |
+
``app/scoring.py`` (skill-calibrated Gaussian component curves).
|
| 5 |
+
|
| 6 |
+
Scoring v2 upgrades over the v1 port:
|
| 7 |
+
|
| 8 |
+
* **Swell components first** — :func:`score_week` prefers
|
| 9 |
+
``swell_wave_height`` / ``swell_wave_direction`` / ``swell_wave_period``
|
| 10 |
+
when the frame carries them, falling back to the generic ``wave_*``
|
| 11 |
+
aggregates otherwise.
|
| 12 |
+
* **Direction arc, not cyclic mean** — the ideal direction list is parsed
|
| 13 |
+
to a *set* of bearings (:func:`_ideal_bearings`) and the score uses the
|
| 14 |
+
minimum angular distance to *any* of them. A break that takes both E
|
| 15 |
+
and S no longer scores its middle (SE-ish mean) as perfect.
|
| 16 |
+
* **Wind cap from ``idealWind.type``** — offshore 18 kt, cross-shore 12,
|
| 17 |
+
onshore 8, light/variable 10 — with a smooth logistic roll-off centred
|
| 18 |
+
at the cap instead of the old 10→0 cliff. The effective cap is still
|
| 19 |
+
bounded above by the skill profile's ``max_wind_kt``.
|
| 20 |
+
* **Daylight-only** — :func:`score_week` drops night hours using
|
| 21 |
+
``daily.sunrise``/``sunset`` from the forecast frame
|
| 22 |
+
(``daylight_only=False`` restores all hours).
|
| 23 |
+
* **Daily summary = p75** — :func:`daily_summary` reports the 75th
|
| 24 |
+
percentile of daylight scores per date (consistency over one lucky
|
| 25 |
+
hour).
|
| 26 |
+
* **Rank by surfable hours** — :func:`rank_spots` orders by
|
| 27 |
+
(hours with score ≥ 6, then best score).
|
| 28 |
+
|
| 29 |
+
Stable public signatures (Worker C builds against these)::
|
| 30 |
+
|
| 31 |
+
score_week(forecast, spot, skill_level="intermediate", daylight_only=True)
|
| 32 |
+
rank_spots(forecasts, spots_list, skill_level="intermediate")
|
| 33 |
+
|
| 34 |
+
Deterministic only: no LLM, no network.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
from __future__ import annotations
|
| 38 |
+
|
| 39 |
+
import math
|
| 40 |
+
from typing import Any, Optional
|
| 41 |
+
|
| 42 |
+
# ---- 16-Point Compass Reference ----
|
| 43 |
+
|
| 44 |
+
_COMPASS_DEG: dict[str, float] = {
|
| 45 |
+
"N": 0.0, "NNE": 22.5, "NE": 45.0, "ENE": 67.5,
|
| 46 |
+
"E": 90.0, "ESE": 112.5, "SE": 135.0, "SSE": 157.5,
|
| 47 |
+
"S": 180.0, "SSW": 202.5, "SW": 225.0, "WSW": 247.5,
|
| 48 |
+
"W": 270.0, "WNW": 292.5, "NW": 315.0, "NNW": 337.5,
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
_WEIGHTS = {
|
| 52 |
+
"swell_size": 0.30,
|
| 53 |
+
"swell_direction": 0.20,
|
| 54 |
+
"wind": 0.30,
|
| 55 |
+
"period": 0.20,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
# Skill Profile Thresholds (values in feet, knots, seconds)
|
| 59 |
+
_SKILL_PROFILES: dict[str, dict[str, Any]] = {
|
| 60 |
+
"beginner": {
|
| 61 |
+
"comfort_size_min_ft": 1.0,
|
| 62 |
+
"comfort_size_max_ft": 3.5,
|
| 63 |
+
"max_safe_size_ft": 4.5,
|
| 64 |
+
"max_wind_kt": 12.0,
|
| 65 |
+
"ideal_period_max_s": 12.0,
|
| 66 |
+
},
|
| 67 |
+
"intermediate": {
|
| 68 |
+
"comfort_size_min_ft": 2.0,
|
| 69 |
+
"comfort_size_max_ft": 6.5,
|
| 70 |
+
"max_safe_size_ft": 8.5,
|
| 71 |
+
"max_wind_kt": 18.0,
|
| 72 |
+
"ideal_period_max_s": 16.0,
|
| 73 |
+
},
|
| 74 |
+
"advanced": {
|
| 75 |
+
"comfort_size_min_ft": 3.0,
|
| 76 |
+
"comfort_size_max_ft": 12.0,
|
| 77 |
+
"max_safe_size_ft": 18.0,
|
| 78 |
+
"max_wind_kt": 25.0,
|
| 79 |
+
"ideal_period_max_s": 22.0,
|
| 80 |
+
},
|
| 81 |
+
"expert": {
|
| 82 |
+
"comfort_size_min_ft": 4.0,
|
| 83 |
+
"comfort_size_max_ft": 30.0,
|
| 84 |
+
"max_safe_size_ft": 50.0,
|
| 85 |
+
"max_wind_kt": 35.0,
|
| 86 |
+
"ideal_period_max_s": 25.0,
|
| 87 |
+
},
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
SKILL_LEVELS = tuple(_SKILL_PROFILES.keys())
|
| 91 |
+
_DEFAULT_SKILL_LEVEL = "intermediate"
|
| 92 |
+
|
| 93 |
+
# Wind caps (kt) by ideal-wind type. Break type is carried on the spot
|
| 94 |
+
# dict for downstream use; the cap itself is driven by wind type.
|
| 95 |
+
_WIND_CAP_BY_TYPE: dict[str, float] = {
|
| 96 |
+
"offshore": 18.0,
|
| 97 |
+
"cross-shore": 12.0,
|
| 98 |
+
"onshore": 8.0,
|
| 99 |
+
"light/variable": 10.0,
|
| 100 |
+
}
|
| 101 |
+
# Logistic steepness (kt): score falls from ~9.5 to ~0.5 over ±3 kt
|
| 102 |
+
# around the cap.
|
| 103 |
+
_WIND_LOGISTIC_STEEPNESS = 1.0
|
| 104 |
+
|
| 105 |
+
SURFABLE_THRESHOLD = 6.0
|
| 106 |
+
|
| 107 |
+
DEFAULT_DIRECTION = "E"
|
| 108 |
+
|
| 109 |
+
_SCORING_SKILLS = frozenset({"beginner", "intermediate", "advanced", "expert"})
|
| 110 |
+
_FALLBACK_SKILL = "intermediate"
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
# ---- Adapter shaping (ported from app/adapters.py) ----
|
| 114 |
+
|
| 115 |
+
def normalize_skill(skill: str | None) -> str:
|
| 116 |
+
"""Normalise an enriched ``skillLevel`` to a scoring skill tier."""
|
| 117 |
+
if skill is None:
|
| 118 |
+
return _FALLBACK_SKILL
|
| 119 |
+
s = str(skill).strip().lower()
|
| 120 |
+
if s in ("pro-only", "pro only", "pro"):
|
| 121 |
+
return "expert"
|
| 122 |
+
if s in _SCORING_SKILLS:
|
| 123 |
+
return s
|
| 124 |
+
return _FALLBACK_SKILL
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _join_direction(directions) -> str:
|
| 128 |
+
"""Join a compass-point list with ``/``; default to ``"E"`` when empty."""
|
| 129 |
+
if not directions:
|
| 130 |
+
return DEFAULT_DIRECTION
|
| 131 |
+
if isinstance(directions, str):
|
| 132 |
+
text = directions.strip()
|
| 133 |
+
return text if text else DEFAULT_DIRECTION
|
| 134 |
+
parts = [str(d).strip() for d in directions if str(d).strip()]
|
| 135 |
+
return "/".join(parts) if parts else DEFAULT_DIRECTION
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _normalize_wind_type(wind_type: str | None) -> str:
|
| 139 |
+
"""Normalise an enriched ``idealWind.type`` to a wind-cap key."""
|
| 140 |
+
t = str(wind_type or "").strip().lower()
|
| 141 |
+
if "cross" in t:
|
| 142 |
+
return "cross-shore"
|
| 143 |
+
if "onshore" in t or t == "on":
|
| 144 |
+
return "onshore"
|
| 145 |
+
if "offshore" in t or t == "off":
|
| 146 |
+
return "offshore"
|
| 147 |
+
if "light" in t or "variable" in t:
|
| 148 |
+
return "light/variable"
|
| 149 |
+
return "light/variable"
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def enriched_to_scoring_spot(break_: dict) -> dict:
|
| 153 |
+
"""Convert one enriched break record to the scoring spot shape.
|
| 154 |
+
|
| 155 |
+
v2: carries ``ideal_wind.type`` (drives the wind cap) and
|
| 156 |
+
``break_type`` through; the legacy flat ``strength_kt_max`` is gone.
|
| 157 |
+
"""
|
| 158 |
+
swell = break_.get("idealSwell", {}) or {}
|
| 159 |
+
size = swell.get("sizeRangeFt", {}) or {}
|
| 160 |
+
wind = break_.get("idealWind", {}) or {}
|
| 161 |
+
return {
|
| 162 |
+
"name": break_.get("name", ""),
|
| 163 |
+
"region": break_.get("region", ""),
|
| 164 |
+
"break_type": break_.get("breakType", ""),
|
| 165 |
+
"ideal_swell": {
|
| 166 |
+
"direction": _join_direction(swell.get("direction")),
|
| 167 |
+
"size_ft_min": float(size.get("min", 0.0)),
|
| 168 |
+
"size_ft_max": float(size.get("max", 0.0)),
|
| 169 |
+
},
|
| 170 |
+
"ideal_wind": {
|
| 171 |
+
"direction": _join_direction(wind.get("direction")),
|
| 172 |
+
"type": _normalize_wind_type(wind.get("type")),
|
| 173 |
+
},
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def get_coords(break_: dict) -> tuple[float | None, float | None]:
|
| 178 |
+
"""Return ``(lat, lng)`` (None on missing coords)."""
|
| 179 |
+
try:
|
| 180 |
+
lat = float(break_["location"]["coordinates"]["lat"])
|
| 181 |
+
except (KeyError, TypeError, ValueError):
|
| 182 |
+
lat = None
|
| 183 |
+
try:
|
| 184 |
+
lng = float(break_["location"]["coordinates"]["lng"])
|
| 185 |
+
except (KeyError, TypeError, ValueError):
|
| 186 |
+
lng = None
|
| 187 |
+
return lat, lng
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def break_skill(break_: dict) -> str:
|
| 191 |
+
"""Return the normalised skill tier for an enriched break record."""
|
| 192 |
+
return normalize_skill(break_.get("skillLevel"))
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def wind_cap(spot: dict, skill_level: str = _DEFAULT_SKILL_LEVEL) -> float:
|
| 196 |
+
"""Effective wind cap (kt) for a spot + skill tier.
|
| 197 |
+
|
| 198 |
+
Base cap from ``ideal_wind.type`` (offshore 18 / cross-shore 12 /
|
| 199 |
+
onshore 8 / light-variable 10), bounded above by the skill profile's
|
| 200 |
+
``max_wind_kt``.
|
| 201 |
+
"""
|
| 202 |
+
wind = spot.get("ideal_wind", {}) or {}
|
| 203 |
+
base = _WIND_CAP_BY_TYPE.get(
|
| 204 |
+
_normalize_wind_type(wind.get("type")), _WIND_CAP_BY_TYPE["light/variable"]
|
| 205 |
+
)
|
| 206 |
+
try:
|
| 207 |
+
profile_max = float(_SKILL_PROFILES[skill_level]["max_wind_kt"])
|
| 208 |
+
except (KeyError, TypeError, ValueError):
|
| 209 |
+
return base
|
| 210 |
+
return min(base, profile_max)
|
| 211 |
+
|
| 212 |
+
|
| 213 |
+
# ---- Direction helpers ----
|
| 214 |
+
|
| 215 |
+
def _ideal_bearings(direction: str) -> list[float]:
|
| 216 |
+
"""Parse a slash-separated ideal-direction string to a set of bearings.
|
| 217 |
+
|
| 218 |
+
Accepts the ``"SW/S/SSW"`` shape produced by :func:`_join_direction`
|
| 219 |
+
(also tolerates commas and " to " separators). Unknown tokens are
|
| 220 |
+
ignored; raises ``ValueError`` when nothing parses.
|
| 221 |
+
"""
|
| 222 |
+
normalized = (
|
| 223 |
+
direction.replace(",", "/").replace(" to ", "/").replace(" ", "").upper()
|
| 224 |
+
)
|
| 225 |
+
degs = [_COMPASS_DEG[p] for p in normalized.split("/") if p in _COMPASS_DEG]
|
| 226 |
+
if not degs:
|
| 227 |
+
raise ValueError(f"Invalid compass direction input: {direction!r}")
|
| 228 |
+
# Dedupe while preserving order.
|
| 229 |
+
seen: set[float] = set()
|
| 230 |
+
out: list[float] = []
|
| 231 |
+
for d in degs:
|
| 232 |
+
if d not in seen:
|
| 233 |
+
seen.add(d)
|
| 234 |
+
out.append(d)
|
| 235 |
+
return out
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def _dir_to_deg(direction: str) -> float:
|
| 239 |
+
"""Mean bearing of a slash-separated direction string (legacy helper).
|
| 240 |
+
|
| 241 |
+
Kept for compatibility; scoring v2 uses :func:`_ideal_bearings` (arc
|
| 242 |
+
distance to the nearest ideal bearing) instead of the cyclic mean.
|
| 243 |
+
"""
|
| 244 |
+
degs = _ideal_bearings(direction)
|
| 245 |
+
sin_sum = sum(math.sin(math.radians(d)) for d in degs)
|
| 246 |
+
cos_sum = sum(math.cos(math.radians(d)) for d in degs)
|
| 247 |
+
mean_deg = math.degrees(math.atan2(sin_sum, cos_sum)) % 360
|
| 248 |
+
return round(mean_deg, 1)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
def _angular_diff(a: float, b: float) -> float:
|
| 252 |
+
"""Shortest arc distance between two bearings in degrees."""
|
| 253 |
+
d = abs(a - b) % 360.0
|
| 254 |
+
return d if d <= 180.0 else 360.0 - d
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def _arc_distance(wave_dir_deg: float, ideal_direction: str) -> float:
|
| 258 |
+
"""Minimum angular distance from a bearing to the ideal-direction arc."""
|
| 259 |
+
return min(_angular_diff(wave_dir_deg, b) for b in _ideal_bearings(ideal_direction))
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _gaussian_decay(value: float, target: float, sigma: float) -> float:
|
| 263 |
+
"""Smooth Gaussian decay yielding 1.0 at target and trailing to 0.0."""
|
| 264 |
+
return math.exp(-((value - target) ** 2) / (2 * (sigma ** 2)))
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
def _logistic_wind_speed_score(wind_speed_kt: float, cap_kt: float) -> float:
|
| 268 |
+
"""Smooth 0–10 speed score: ~10 well below cap, 5.0 at cap, ~0 above."""
|
| 269 |
+
return 10.0 / (1.0 + math.exp((wind_speed_kt - cap_kt) / _WIND_LOGISTIC_STEEPNESS))
|
| 270 |
+
|
| 271 |
+
|
| 272 |
+
# ---- Core Scoring Components ----
|
| 273 |
+
|
| 274 |
+
def _score_swell_size(wave_height_m: float, spot: dict, skill_level: str) -> float:
|
| 275 |
+
"""Evaluates swell size considering spot ideals and surfer safety constraints."""
|
| 276 |
+
profile = _SKILL_PROFILES[skill_level]
|
| 277 |
+
wave_height_ft = wave_height_m * 3.28084
|
| 278 |
+
|
| 279 |
+
spot_ideal = spot["ideal_swell"]
|
| 280 |
+
spot_min_ft = float(spot_ideal["size_ft_min"])
|
| 281 |
+
spot_max_ft = float(spot_ideal["size_ft_max"])
|
| 282 |
+
|
| 283 |
+
# 1. Spot Suitability (Does the spot work?)
|
| 284 |
+
if spot_min_ft <= wave_height_ft <= spot_max_ft:
|
| 285 |
+
spot_score = 10.0
|
| 286 |
+
elif wave_height_ft < spot_min_ft:
|
| 287 |
+
sigma = max(1.0, spot_min_ft * 0.4)
|
| 288 |
+
spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_min_ft, sigma)
|
| 289 |
+
else:
|
| 290 |
+
sigma = max(1.5, spot_max_ft * 0.5)
|
| 291 |
+
spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_max_ft, sigma)
|
| 292 |
+
|
| 293 |
+
# 2. Safety & Comfort Filter
|
| 294 |
+
comf_min = profile["comfort_size_min_ft"]
|
| 295 |
+
comf_max = profile["comfort_size_max_ft"]
|
| 296 |
+
max_safe = profile["max_safe_size_ft"]
|
| 297 |
+
|
| 298 |
+
if wave_height_ft > max_safe:
|
| 299 |
+
# Zero tolerance for dangerous wave heights relative to skill
|
| 300 |
+
return 0.0
|
| 301 |
+
|
| 302 |
+
skill_mult = 1.0
|
| 303 |
+
if wave_height_ft < comf_min:
|
| 304 |
+
skill_mult = max(0.2, wave_height_ft / comf_min)
|
| 305 |
+
elif wave_height_ft > comf_max:
|
| 306 |
+
# Smooth penalty down to zero at max_safe_size
|
| 307 |
+
overage = wave_height_ft - comf_max
|
| 308 |
+
allowed_headroom = max_safe - comf_max
|
| 309 |
+
skill_mult = max(0.0, 1.0 - (overage / allowed_headroom) ** 1.5)
|
| 310 |
+
|
| 311 |
+
return round(spot_score * skill_mult, 2)
|
| 312 |
+
|
| 313 |
+
|
| 314 |
+
def _score_swell_direction(wave_dir_deg: float, spot: dict) -> float:
|
| 315 |
+
"""Directional match = Gaussian falloff from the *nearest* ideal bearing.
|
| 316 |
+
|
| 317 |
+
v2 replaces the cyclic mean: a break ideal at ``"E/SE"`` scores 10.0
|
| 318 |
+
at exactly E *or* SE, instead of peaking at the ESE midpoint.
|
| 319 |
+
"""
|
| 320 |
+
diff = _arc_distance(wave_dir_deg, spot["ideal_swell"]["direction"])
|
| 321 |
+
|
| 322 |
+
# Half-width tolerance angle before severe drop-off (e.g., 45 degrees)
|
| 323 |
+
tolerance_deg = 45.0
|
| 324 |
+
score = 10.0 * _gaussian_decay(diff, 0.0, tolerance_deg / 1.5)
|
| 325 |
+
return max(0.0, min(10.0, score))
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def _score_wind(
|
| 329 |
+
wind_speed_kt: float,
|
| 330 |
+
wind_dir_deg: float,
|
| 331 |
+
spot: dict,
|
| 332 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 333 |
+
gust_kt: float | None = None,
|
| 334 |
+
) -> float:
|
| 335 |
+
"""Scores wind as the mean of a speed score and a direction score.
|
| 336 |
+
|
| 337 |
+
Speed: logistic roll-off centred at the effective cap
|
| 338 |
+
(:func:`wind_cap` — wind type table bounded by the skill profile),
|
| 339 |
+
so glassy air scores ~10 and howling air scores ~0 with no cliff.
|
| 340 |
+
Direction: cosine falloff from the nearest ideal (offshore) bearing
|
| 341 |
+
to absolute onshore. Gusts (optional, kt): gust > limit+10 knocks
|
| 342 |
+
2 points off, gust > limit+5 knocks 1 point off — same limit, no new
|
| 343 |
+
thresholds.
|
| 344 |
+
"""
|
| 345 |
+
limit = wind_cap(spot, skill_level)
|
| 346 |
+
|
| 347 |
+
offshore_arc = spot["ideal_wind"]["direction"]
|
| 348 |
+
diff = _arc_distance(wind_dir_deg, offshore_arc)
|
| 349 |
+
dir_score = 10.0 * max(0.0, math.cos(math.radians(diff / 2.0)))
|
| 350 |
+
|
| 351 |
+
speed_score = _logistic_wind_speed_score(wind_speed_kt, limit)
|
| 352 |
+
|
| 353 |
+
base = round((speed_score + dir_score) / 2.0, 2)
|
| 354 |
+
if gust_kt is not None:
|
| 355 |
+
try:
|
| 356 |
+
gust = float(gust_kt)
|
| 357 |
+
except (TypeError, ValueError):
|
| 358 |
+
gust = None
|
| 359 |
+
if gust is not None:
|
| 360 |
+
if gust > limit + 10:
|
| 361 |
+
base = max(0.0, round(base - 2.0, 2))
|
| 362 |
+
elif gust > limit + 5:
|
| 363 |
+
base = max(0.0, round(base - 1.0, 2))
|
| 364 |
+
return base
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def _score_period(period_s: float, skill_level: str = _DEFAULT_SKILL_LEVEL) -> float:
|
| 368 |
+
"""Evaluates swell period quality: 0 below 4 s, 10 from 14 s up.
|
| 369 |
+
|
| 370 |
+
Long groundswell is never penalised for experienced surfers — there
|
| 371 |
+
is no falloff above 14 s. Beginners get a gentle cap above their
|
| 372 |
+
``ideal_period_max_s`` (12 s): very long-period power is harder to
|
| 373 |
+
handle, so the score eases down to a floor of ~5 instead of 10.
|
| 374 |
+
"""
|
| 375 |
+
if period_s <= 4.0:
|
| 376 |
+
return 0.0
|
| 377 |
+
if period_s >= 14.0:
|
| 378 |
+
base = 10.0
|
| 379 |
+
else:
|
| 380 |
+
base = round(10.0 * (period_s - 4.0) / 10.0, 2)
|
| 381 |
+
try:
|
| 382 |
+
ideal_max = float(_SKILL_PROFILES[skill_level]["ideal_period_max_s"])
|
| 383 |
+
except (KeyError, TypeError, ValueError):
|
| 384 |
+
return base
|
| 385 |
+
if skill_level == "beginner" and period_s > ideal_max:
|
| 386 |
+
base = max(5.0, round(base - (period_s - ideal_max) * 0.4, 2))
|
| 387 |
+
return base
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# ---- Daylight filtering ----
|
| 391 |
+
|
| 392 |
+
def _daylight_windows(daily: dict | None) -> dict[str, tuple[str, str]]:
|
| 393 |
+
"""Map ``date → (sunrise_hhmm, sunset_hhmm)`` from a daily sun frame."""
|
| 394 |
+
out: dict[str, tuple[str, str]] = {}
|
| 395 |
+
if not isinstance(daily, dict):
|
| 396 |
+
return out
|
| 397 |
+
times = daily.get("time") or []
|
| 398 |
+
rises = daily.get("sunrise") or []
|
| 399 |
+
sets = daily.get("sunset") or []
|
| 400 |
+
for i, day in enumerate(times):
|
| 401 |
+
try:
|
| 402 |
+
rise, set_ = rises[i], sets[i]
|
| 403 |
+
except IndexError:
|
| 404 |
+
continue
|
| 405 |
+
if not (isinstance(rise, str) and isinstance(set_, str)):
|
| 406 |
+
continue
|
| 407 |
+
# ISO local "YYYY-MM-DDTHH:MM" — compare the HH:MM slice.
|
| 408 |
+
out[str(day)[:10]] = (rise[11:16], set_[11:16])
|
| 409 |
+
return out
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def is_daylight(iso_time: str, windows: dict[str, tuple[str, str]]) -> bool:
|
| 413 |
+
"""True when an hourly ISO timestamp falls between sunrise and sunset."""
|
| 414 |
+
day = str(iso_time)[:10]
|
| 415 |
+
window = windows.get(day)
|
| 416 |
+
if window is None:
|
| 417 |
+
return True # fail-open when the frame carries no sun times
|
| 418 |
+
rise, set_ = window
|
| 419 |
+
hhmm = str(iso_time)[11:16]
|
| 420 |
+
return rise <= hhmm <= set_
|
| 421 |
+
|
| 422 |
+
|
| 423 |
+
# ---- Main Interface Functions ----
|
| 424 |
+
|
| 425 |
+
def score_hour(
|
| 426 |
+
wave_height_m: float,
|
| 427 |
+
wave_period_s: float,
|
| 428 |
+
wind_speed_kt: float,
|
| 429 |
+
wind_direction_deg: float,
|
| 430 |
+
spot: dict,
|
| 431 |
+
wave_direction_deg: Optional[float] = None,
|
| 432 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 433 |
+
gust_kt: Optional[float] = None,
|
| 434 |
+
) -> dict[str, Any]:
|
| 435 |
+
"""Computes composite surf score (0-10) for a given hour and skill tier."""
|
| 436 |
+
if skill_level not in _SKILL_PROFILES:
|
| 437 |
+
raise ValueError(f"Invalid skill_level {skill_level!r}. Expected one of {SKILL_LEVELS}")
|
| 438 |
+
|
| 439 |
+
c_size = _score_swell_size(wave_height_m, spot, skill_level)
|
| 440 |
+
c_dir = (
|
| 441 |
+
_score_swell_direction(wave_direction_deg, spot)
|
| 442 |
+
if wave_direction_deg is not None
|
| 443 |
+
else None
|
| 444 |
+
)
|
| 445 |
+
c_wind = _score_wind(wind_speed_kt, wind_direction_deg, spot, skill_level, gust_kt=gust_kt)
|
| 446 |
+
c_period = _score_period(wave_period_s, skill_level)
|
| 447 |
+
|
| 448 |
+
components: dict[str, Optional[float]] = {
|
| 449 |
+
"swell_size": round(c_size, 2),
|
| 450 |
+
"swell_direction": round(c_dir, 2) if c_dir is not None else None,
|
| 451 |
+
"wind": round(c_wind, 2),
|
| 452 |
+
"period": round(c_period, 2),
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
# Weight redistribution when optional components are missing
|
| 456 |
+
active_weights = {k: v for k, v in _WEIGHTS.items() if components[k] is not None}
|
| 457 |
+
weight_sum = sum(active_weights.values())
|
| 458 |
+
|
| 459 |
+
composite = sum(
|
| 460 |
+
components[k] * (weight / weight_sum) # type: ignore
|
| 461 |
+
for k, weight in active_weights.items()
|
| 462 |
+
)
|
| 463 |
+
|
| 464 |
+
return {
|
| 465 |
+
"score": round(composite, 2),
|
| 466 |
+
"components": components,
|
| 467 |
+
"skill_level": skill_level,
|
| 468 |
+
"wave_height_m": wave_height_m,
|
| 469 |
+
"wave_period_s": wave_period_s,
|
| 470 |
+
"wind_speed_kt": wind_speed_kt,
|
| 471 |
+
"wind_direction_deg": wind_direction_deg,
|
| 472 |
+
"wave_direction_deg": wave_direction_deg,
|
| 473 |
+
"gust_kt": gust_kt,
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
|
| 477 |
+
def _pick_height(row: dict) -> float | None:
|
| 478 |
+
"""Prefer the swell component; fall back to the generic aggregate."""
|
| 479 |
+
for key in ("swell_wave_height", "wave_height"):
|
| 480 |
+
v = row.get(key)
|
| 481 |
+
if v is not None:
|
| 482 |
+
return v
|
| 483 |
+
return None
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
def _pick_direction(row: dict) -> float | None:
|
| 487 |
+
"""Prefer the swell direction; fall back to the generic aggregate."""
|
| 488 |
+
for key in ("swell_wave_direction", "wave_direction"):
|
| 489 |
+
v = row.get(key)
|
| 490 |
+
if v is not None:
|
| 491 |
+
return v
|
| 492 |
+
return None
|
| 493 |
+
|
| 494 |
+
|
| 495 |
+
def _pick_period(row: dict) -> float | None:
|
| 496 |
+
"""Prefer the swell period; fall back to the generic aggregate."""
|
| 497 |
+
for key in ("swell_wave_period", "wave_period"):
|
| 498 |
+
v = row.get(key)
|
| 499 |
+
if v is not None:
|
| 500 |
+
return v
|
| 501 |
+
return None
|
| 502 |
+
|
| 503 |
+
|
| 504 |
+
def score_week(
|
| 505 |
+
forecast: dict, spot: dict, skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 506 |
+
daylight_only: bool = True,
|
| 507 |
+
) -> list[dict[str, Any]]:
|
| 508 |
+
"""Score every (daylight) hour in a forecast frame for a target spot.
|
| 509 |
+
|
| 510 |
+
``daylight_only=True`` (default) drops night hours using
|
| 511 |
+
``forecast["daily"]`` sunrise/sunset; pass ``False`` to score every
|
| 512 |
+
hour. Each returned hour carries its ``"time"``. Hours missing any
|
| 513 |
+
required field are skipped.
|
| 514 |
+
"""
|
| 515 |
+
rows = forecast.get("hourly", [])
|
| 516 |
+
windows = _daylight_windows(forecast.get("daily")) if daylight_only else {}
|
| 517 |
+
results = []
|
| 518 |
+
|
| 519 |
+
for row in rows:
|
| 520 |
+
if daylight_only and not is_daylight(str(row.get("time", "")), windows):
|
| 521 |
+
continue
|
| 522 |
+
wave_height = _pick_height(row)
|
| 523 |
+
wave_period = _pick_period(row)
|
| 524 |
+
wind_speed = row.get("wind_speed_10m")
|
| 525 |
+
wind_dir = row.get("wind_direction_10m")
|
| 526 |
+
|
| 527 |
+
if None in (wave_height, wave_period, wind_speed, wind_dir):
|
| 528 |
+
continue
|
| 529 |
+
|
| 530 |
+
gust_raw = row.get("wind_gusts_10m")
|
| 531 |
+
try:
|
| 532 |
+
gust_kt = float(gust_raw) * 0.539957 if gust_raw is not None else None
|
| 533 |
+
except (TypeError, ValueError):
|
| 534 |
+
gust_kt = None
|
| 535 |
+
|
| 536 |
+
scored = score_hour(
|
| 537 |
+
wave_height_m=float(wave_height),
|
| 538 |
+
wave_period_s=float(wave_period),
|
| 539 |
+
wind_speed_kt=float(wind_speed) * 0.539957, # km/h to knots conversion
|
| 540 |
+
wind_direction_deg=float(wind_dir),
|
| 541 |
+
spot=spot,
|
| 542 |
+
wave_direction_deg=(
|
| 543 |
+
float(_pick_direction(row))
|
| 544 |
+
if _pick_direction(row) is not None
|
| 545 |
+
else None
|
| 546 |
+
),
|
| 547 |
+
skill_level=skill_level,
|
| 548 |
+
gust_kt=gust_kt,
|
| 549 |
+
)
|
| 550 |
+
scored["time"] = row["time"]
|
| 551 |
+
results.append(scored)
|
| 552 |
+
|
| 553 |
+
return results
|
| 554 |
+
|
| 555 |
+
|
| 556 |
+
def _percentile(xs: list[float], q: float) -> float:
|
| 557 |
+
"""Linear-interpolation percentile (q in 0..1) over a non-empty list."""
|
| 558 |
+
s = sorted(xs)
|
| 559 |
+
if len(s) == 1:
|
| 560 |
+
return float(s[0])
|
| 561 |
+
rank = q * (len(s) - 1)
|
| 562 |
+
lo = int(math.floor(rank))
|
| 563 |
+
hi = int(math.ceil(rank))
|
| 564 |
+
if lo == hi:
|
| 565 |
+
return float(s[lo])
|
| 566 |
+
frac = rank - lo
|
| 567 |
+
return float(s[lo] * (1 - frac) + s[hi] * frac)
|
| 568 |
+
|
| 569 |
+
|
| 570 |
+
def daily_summary(scored_hours: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
| 571 |
+
"""Per-date summary of scored hours: p75 score (consistency signal).
|
| 572 |
+
|
| 573 |
+
Returns ``[{"date", "p75", "best", "n", "surfable_hours"}]`` sorted by
|
| 574 |
+
date, where ``surfable_hours`` counts hours with score ≥ 6.
|
| 575 |
+
"""
|
| 576 |
+
by_date: dict[str, list[float]] = {}
|
| 577 |
+
for h in scored_hours:
|
| 578 |
+
t = h.get("time")
|
| 579 |
+
if not isinstance(t, str):
|
| 580 |
+
continue
|
| 581 |
+
try:
|
| 582 |
+
score = float(h["score"])
|
| 583 |
+
except (KeyError, TypeError, ValueError):
|
| 584 |
+
continue
|
| 585 |
+
by_date.setdefault(t[:10], []).append(score)
|
| 586 |
+
|
| 587 |
+
out = []
|
| 588 |
+
for date in sorted(by_date):
|
| 589 |
+
scores = by_date[date]
|
| 590 |
+
out.append(
|
| 591 |
+
{
|
| 592 |
+
"date": date,
|
| 593 |
+
"p75": round(_percentile(scores, 0.75), 2),
|
| 594 |
+
"best": round(max(scores), 2),
|
| 595 |
+
"n": len(scores),
|
| 596 |
+
"surfable_hours": sum(1 for s in scores if s >= SURFABLE_THRESHOLD),
|
| 597 |
+
}
|
| 598 |
+
)
|
| 599 |
+
return out
|
| 600 |
+
|
| 601 |
+
|
| 602 |
+
def rank_spots(
|
| 603 |
+
forecasts: dict[tuple[str, str], dict],
|
| 604 |
+
spots_list: list[dict],
|
| 605 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 606 |
+
) -> list[dict[str, Any]]:
|
| 607 |
+
"""Rank spots by (surfable hours with score ≥ 6, then best score).
|
| 608 |
+
|
| 609 |
+
``forecasts`` maps ``(name, region)`` → forecast frame. Spots with no
|
| 610 |
+
frame are skipped. Ties on surfable hours break toward the higher
|
| 611 |
+
single-hour best.
|
| 612 |
+
"""
|
| 613 |
+
ranked = []
|
| 614 |
+
|
| 615 |
+
for spot in spots_list:
|
| 616 |
+
key = (spot["name"], spot["region"])
|
| 617 |
+
if key not in forecasts:
|
| 618 |
+
continue
|
| 619 |
+
|
| 620 |
+
hours = score_week(forecasts[key], spot, skill_level=skill_level)
|
| 621 |
+
best_hour = max(hours, key=lambda h: h["score"]) if hours else None
|
| 622 |
+
surfable = sum(1 for h in hours if h["score"] >= SURFABLE_THRESHOLD)
|
| 623 |
+
|
| 624 |
+
ranked.append(
|
| 625 |
+
{
|
| 626 |
+
"name": spot["name"],
|
| 627 |
+
"region": spot["region"],
|
| 628 |
+
"skill_level": skill_level,
|
| 629 |
+
"surfable_hours": surfable,
|
| 630 |
+
"total_hours": len(hours),
|
| 631 |
+
"best_score": best_hour["score"] if best_hour else 0.0,
|
| 632 |
+
"best_time": best_hour["time"] if best_hour else None,
|
| 633 |
+
"best_hour": best_hour,
|
| 634 |
+
}
|
| 635 |
+
)
|
| 636 |
+
|
| 637 |
+
ranked.sort(key=lambda x: (x["surfable_hours"], x["best_score"]), reverse=True)
|
| 638 |
+
return ranked
|
| 639 |
+
|
| 640 |
+
|
| 641 |
+
# Backwards-compatible alias for the v1 entry point name.
|
| 642 |
+
def rank_spots_this_week(
|
| 643 |
+
forecasts: dict[tuple[str, str], dict],
|
| 644 |
+
spots_list: list[dict],
|
| 645 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 646 |
+
) -> list[dict[str, Any]]:
|
| 647 |
+
"""Alias of :func:`rank_spots` (v1 name)."""
|
| 648 |
+
return rank_spots(forecasts, spots_list, skill_level=skill_level)
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
__all__ = [
|
| 652 |
+
"SKILL_LEVELS",
|
| 653 |
+
"SURFABLE_THRESHOLD",
|
| 654 |
+
"DEFAULT_DIRECTION",
|
| 655 |
+
"normalize_skill",
|
| 656 |
+
"enriched_to_scoring_spot",
|
| 657 |
+
"get_coords",
|
| 658 |
+
"break_skill",
|
| 659 |
+
"wind_cap",
|
| 660 |
+
"is_daylight",
|
| 661 |
+
"score_hour",
|
| 662 |
+
"score_week",
|
| 663 |
+
"daily_summary",
|
| 664 |
+
"rank_spots",
|
| 665 |
+
"rank_spots_this_week",
|
| 666 |
+
]
|
wavereader/seafloor.py
ADDED
|
@@ -0,0 +1,459 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Seafloor world-model: bathymetry grid + deterministic shape analysis (v2 port).
|
| 2 |
+
|
| 3 |
+
Ported verbatim-first from ``app/seafloor.py``. v2 fixes:
|
| 4 |
+
|
| 5 |
+
* **lon step in slope math** — the v1 code used the latitude step for
|
| 6 |
+
both axes (``step_km = lat_step_km # approx square grid``). The
|
| 7 |
+
per-axis steps are now derived from the grid's own coordinates: the
|
| 8 |
+
latitude step × 111 km/deg, and the longitude step × 111 km/deg scaled
|
| 9 |
+
by ``cos(center latitude)``. (Numerically close to square for grids
|
| 10 |
+
built by :func:`_deg_offsets`, but exact for any grid.)
|
| 11 |
+
* **None-cell skipping** — every elevation read goes through
|
| 12 |
+
:func:`_cell`, so missing cells are skipped in the slope walk (not just
|
| 13 |
+
the anchor cell) instead of raising ``TypeError`` on ``float(None)``.
|
| 14 |
+
|
| 15 |
+
Data source: OpenTopoData ``gebco2020`` (GEBCO 2020 grid, ~450 m cells,
|
| 16 |
+
negative = below sea level), with ``etopo1`` as fallback. Free, no key,
|
| 17 |
+
100 locations per call — so a 9x9 grid (81 pts) fits in ONE request.
|
| 18 |
+
|
| 19 |
+
Pattern mirrors ``wavereader.openmeteo``: disk cache under
|
| 20 |
+
``.cache/seafloor``, stale-fallback on network failure. Static data →
|
| 21 |
+
long TTL (30 days).
|
| 22 |
+
|
| 23 |
+
The LLM never owns numbers: this module computes every stat + Plotly fig
|
| 24 |
+
deterministically; callers (UI tab, agent tool) only narrate the results.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import json
|
| 30 |
+
import math
|
| 31 |
+
import os
|
| 32 |
+
import time
|
| 33 |
+
from pathlib import Path
|
| 34 |
+
|
| 35 |
+
import httpx
|
| 36 |
+
|
| 37 |
+
OPENTOPO_URL = "https://api.opentopodata.org/v1/{dataset}"
|
| 38 |
+
PRIMARY_DATASET = "gebco2020"
|
| 39 |
+
FALLBACK_DATASET = "etopo1"
|
| 40 |
+
|
| 41 |
+
CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "seafloor"
|
| 42 |
+
CACHE_TTL_SECONDS = 30 * 24 * 3600
|
| 43 |
+
CACHE_VERSION = 1
|
| 44 |
+
|
| 45 |
+
GRID_N_DEFAULT = 9 # 9x9 = 81 pts, one API call
|
| 46 |
+
RADIUS_KM_DEFAULT = 1.2 # ~300 m spacing at 9x9
|
| 47 |
+
MAX_LOCATIONS_PER_CALL = 100
|
| 48 |
+
|
| 49 |
+
_KM_PER_DEG_LAT = 111.0
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _deg_offsets(lat: float, radius_km: float, n: int) -> tuple[list[float], list[float]]:
|
| 53 |
+
"""Lat/lng offsets for an n x n grid spanning ±radius_km."""
|
| 54 |
+
half = radius_km
|
| 55 |
+
dlat = half / 111.0
|
| 56 |
+
# Guard cos() near poles; breaks are all Australian latitudes anyway.
|
| 57 |
+
dlon = half / max(20.0, 111.0 * math.cos(math.radians(lat)))
|
| 58 |
+
if n <= 1:
|
| 59 |
+
return [0.0], [0.0]
|
| 60 |
+
lats = [((i / (n - 1)) * 2 - 1) * dlat for i in range(n)]
|
| 61 |
+
lngs = [((j / (n - 1)) * 2 - 1) * dlon for j in range(n)]
|
| 62 |
+
return lats, lngs
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def _cache_key(lat: float, lng: float, radius_km: float, n: int, dataset: str) -> str:
|
| 66 |
+
return f"v{CACHE_VERSION}_{dataset}_{round(lat, 4):.4f}_{round(lng, 4):.4f}_{radius_km:.2f}km_{n}x{n}"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _cache_path(lat: float, lng: float, radius_km: float, n: int, dataset: str) -> Path:
|
| 70 |
+
safe = _cache_key(lat, lng, radius_km, n, dataset).replace("/", "_")
|
| 71 |
+
return CACHE_DIR / f"{safe}.json"
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _load_cache(lat, lng, radius_km, n, dataset) -> tuple[dict, float] | None:
|
| 75 |
+
p = _cache_path(lat, lng, radius_km, n, dataset)
|
| 76 |
+
if not p.exists():
|
| 77 |
+
return None
|
| 78 |
+
try:
|
| 79 |
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
| 80 |
+
except (json.JSONDecodeError, OSError):
|
| 81 |
+
return None
|
| 82 |
+
if isinstance(raw, dict) and "fetched_at" in raw and "data" in raw:
|
| 83 |
+
return raw["data"], float(raw["fetched_at"])
|
| 84 |
+
return raw, 0.0
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _save_cache(lat, lng, radius_km, n, dataset, data: dict) -> None:
|
| 88 |
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 89 |
+
envelope = {"fetched_at": time.time(), "data": data}
|
| 90 |
+
tmp = _cache_path(lat, lng, radius_km, n, dataset).with_suffix(".tmp")
|
| 91 |
+
tmp.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
|
| 92 |
+
os.replace(tmp, _cache_path(lat, lng, radius_km, n, dataset))
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _fetch_dataset(lats: list[float], lngs: list[float], dataset: str) -> list[float | None]:
|
| 96 |
+
"""Fetch one n x n grid; returns row-major elevations (m, None on miss)."""
|
| 97 |
+
locs = [f"{la:.5f},{lo:.5f}" for la in lats for lo in lngs]
|
| 98 |
+
out: list[float | None] = []
|
| 99 |
+
with httpx.Client(timeout=30.0, follow_redirects=True) as client:
|
| 100 |
+
for i in range(0, len(locs), MAX_LOCATIONS_PER_CALL):
|
| 101 |
+
chunk = locs[i : i + MAX_LOCATIONS_PER_CALL]
|
| 102 |
+
resp = client.get(
|
| 103 |
+
OPENTOPO_URL.format(dataset=dataset),
|
| 104 |
+
params={"locations": "|".join(chunk)},
|
| 105 |
+
)
|
| 106 |
+
resp.raise_for_status()
|
| 107 |
+
body = resp.json()
|
| 108 |
+
if body.get("status") != "OK":
|
| 109 |
+
raise ValueError(f"OpenTopoData {dataset}: {body.get('status')}")
|
| 110 |
+
for r in body.get("results", []):
|
| 111 |
+
out.append(r.get("elevation"))
|
| 112 |
+
time.sleep(1.05) # OpenTopoData free tier: 1 call/sec
|
| 113 |
+
return out
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def get_grid(
|
| 117 |
+
lat: float, lng: float, radius_km: float = RADIUS_KM_DEFAULT, n: int = GRID_N_DEFAULT
|
| 118 |
+
) -> dict:
|
| 119 |
+
"""Fetch + cache the bathymetry grid around a point.
|
| 120 |
+
|
| 121 |
+
Returns ``{"lats", "lngs", "elev", "n", "radius_km", "dataset",
|
| 122 |
+
"center"}`` with ``elev`` row-major (lat-major) in metres.
|
| 123 |
+
Serves fresh cache without network; stale cache on API failure.
|
| 124 |
+
"""
|
| 125 |
+
n = max(3, min(10, int(n))) # 10x10=100 fits one call; 11x11 would split
|
| 126 |
+
radius_km = max(0.3, min(5.0, float(radius_km)))
|
| 127 |
+
dlat, dlon = _deg_offsets(lat, radius_km, n)
|
| 128 |
+
grid_lats = [round(lat + d, 5) for d in dlat]
|
| 129 |
+
grid_lngs = [round(lng + d, 5) for d in dlon]
|
| 130 |
+
|
| 131 |
+
for dataset in (PRIMARY_DATASET, FALLBACK_DATASET):
|
| 132 |
+
cached = _load_cache(lat, lng, radius_km, n, dataset)
|
| 133 |
+
if cached is not None and time.time() - cached[1] < CACHE_TTL_SECONDS:
|
| 134 |
+
return cached[0]
|
| 135 |
+
try:
|
| 136 |
+
elev = _fetch_dataset(grid_lats, grid_lngs, dataset)
|
| 137 |
+
data = {
|
| 138 |
+
"lats": grid_lats,
|
| 139 |
+
"lngs": grid_lngs,
|
| 140 |
+
"elev": elev,
|
| 141 |
+
"n": n,
|
| 142 |
+
"radius_km": radius_km,
|
| 143 |
+
"dataset": dataset,
|
| 144 |
+
"center": {"lat": lat, "lng": lng},
|
| 145 |
+
}
|
| 146 |
+
_save_cache(lat, lng, radius_km, n, dataset, data)
|
| 147 |
+
return data
|
| 148 |
+
except (httpx.HTTPError, ValueError, KeyError):
|
| 149 |
+
if cached is not None:
|
| 150 |
+
return cached[0]
|
| 151 |
+
continue # try fallback dataset
|
| 152 |
+
raise RuntimeError("Seafloor fetch failed (GEBCO + ETOPO1 unreachable, no cache)")
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _finite(vals: list) -> list[float]:
|
| 156 |
+
return [float(v) for v in vals if v is not None]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def _cell(elev: list, idx: int) -> float | None:
|
| 160 |
+
"""Elevation at a flat index, or None when missing/out of range."""
|
| 161 |
+
if 0 <= idx < len(elev):
|
| 162 |
+
v = elev[idx]
|
| 163 |
+
if v is None:
|
| 164 |
+
return None
|
| 165 |
+
try:
|
| 166 |
+
return float(v)
|
| 167 |
+
except (TypeError, ValueError):
|
| 168 |
+
return None
|
| 169 |
+
return None
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _grid_steps_km(grid: dict) -> tuple[float, float]:
|
| 173 |
+
"""Per-axis grid spacing in km: (lat_step, lon_step).
|
| 174 |
+
|
| 175 |
+
Derived from the grid's own coordinates — the longitude step is the
|
| 176 |
+
degree spacing × 111 km/deg scaled by ``cos(center latitude)`` —
|
| 177 |
+
instead of reusing the latitude step for both axes.
|
| 178 |
+
Falls back to the nominal ``radius_km`` spacing when coordinates are
|
| 179 |
+
degenerate.
|
| 180 |
+
"""
|
| 181 |
+
n = int(grid.get("n", GRID_N_DEFAULT))
|
| 182 |
+
lats = grid.get("lats") or []
|
| 183 |
+
lngs = grid.get("lngs") or []
|
| 184 |
+
center = grid.get("center") or {}
|
| 185 |
+
try:
|
| 186 |
+
center_lat = float(center.get("lat", -30.0))
|
| 187 |
+
except (TypeError, ValueError):
|
| 188 |
+
center_lat = -30.0
|
| 189 |
+
nominal = (2 * float(grid.get("radius_km", RADIUS_KM_DEFAULT))) / max(1, n - 1)
|
| 190 |
+
|
| 191 |
+
lat_step = nominal
|
| 192 |
+
if len(lats) >= 2:
|
| 193 |
+
try:
|
| 194 |
+
lat_step = abs(float(lats[1]) - float(lats[0])) * _KM_PER_DEG_LAT
|
| 195 |
+
except (TypeError, ValueError, IndexError):
|
| 196 |
+
lat_step = nominal
|
| 197 |
+
|
| 198 |
+
lon_step = nominal
|
| 199 |
+
if len(lngs) >= 2:
|
| 200 |
+
try:
|
| 201 |
+
lon_step = (
|
| 202 |
+
abs(float(lngs[1]) - float(lngs[0]))
|
| 203 |
+
* _KM_PER_DEG_LAT
|
| 204 |
+
* math.cos(math.radians(center_lat))
|
| 205 |
+
)
|
| 206 |
+
except (TypeError, ValueError, IndexError):
|
| 207 |
+
lon_step = nominal
|
| 208 |
+
|
| 209 |
+
return max(1e-6, lat_step), max(1e-6, lon_step)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def analyze_grid(grid: dict) -> dict:
|
| 213 |
+
"""Deterministic shape stats + markdown over a fetched grid."""
|
| 214 |
+
elev = grid.get("elev") or []
|
| 215 |
+
vals = _finite(elev)
|
| 216 |
+
if not vals:
|
| 217 |
+
return {"error": "Empty seafloor grid"}
|
| 218 |
+
n = int(grid.get("n", GRID_N_DEFAULT))
|
| 219 |
+
depths = [-v for v in vals if v < 0] # positive-down metres
|
| 220 |
+
land = [v for v in vals if v >= 0]
|
| 221 |
+
|
| 222 |
+
def _pct(xs: list[float], q: float) -> float:
|
| 223 |
+
s = sorted(xs)
|
| 224 |
+
return s[max(0, min(len(s) - 1, int(q * len(s))))]
|
| 225 |
+
|
| 226 |
+
# Center cell depth
|
| 227 |
+
ci = (n // 2) * n + (n // 2)
|
| 228 |
+
center_elev = _cell(elev, ci)
|
| 229 |
+
|
| 230 |
+
# Slope: max neighbour gradient across the grid (m per km), with the
|
| 231 |
+
# correct per-axis step (lon scaled by cos(lat)). None cells skipped.
|
| 232 |
+
lat_step_km, lon_step_km = _grid_steps_km(grid)
|
| 233 |
+
max_slope = 0.0
|
| 234 |
+
slopes: list[float] = []
|
| 235 |
+
for i in range(n):
|
| 236 |
+
for j in range(n):
|
| 237 |
+
v = _cell(elev, i * n + j)
|
| 238 |
+
if v is None:
|
| 239 |
+
continue
|
| 240 |
+
for di, dj, step_km in ((1, 0, lat_step_km), (0, 1, lon_step_km)):
|
| 241 |
+
ni, nj = i + di, j + dj
|
| 242 |
+
if ni >= n or nj >= n:
|
| 243 |
+
continue
|
| 244 |
+
w = _cell(elev, ni * n + nj)
|
| 245 |
+
if w is None:
|
| 246 |
+
continue
|
| 247 |
+
s = abs(w - v) / max(1e-6, step_km)
|
| 248 |
+
slopes.append(s)
|
| 249 |
+
max_slope = max(max_slope, s)
|
| 250 |
+
med_slope = _pct(slopes, 0.5) if slopes else 0.0
|
| 251 |
+
|
| 252 |
+
# Shelf vs steep classification on median + max slope
|
| 253 |
+
if med_slope >= 40:
|
| 254 |
+
shelf = "steep reef edge / drop-off"
|
| 255 |
+
elif med_slope >= 15:
|
| 256 |
+
shelf = "moderately sloping reef/shelf"
|
| 257 |
+
elif depths and _pct(depths, 0.5) <= 5:
|
| 258 |
+
shelf = "shallow, gently shelving platform"
|
| 259 |
+
else:
|
| 260 |
+
shelf = "gradual sandy shelf"
|
| 261 |
+
|
| 262 |
+
# Offshore range = relief across the box
|
| 263 |
+
relief = max(vals) - min(vals)
|
| 264 |
+
|
| 265 |
+
# Channel hint: deep outlier cells (>2x median depth) clustered off-center
|
| 266 |
+
channel_hint = False
|
| 267 |
+
if depths:
|
| 268 |
+
med_d = _pct(depths, 0.5)
|
| 269 |
+
deep_cells = sum(1 for d in depths if d > max(8.0, 2.0 * med_d))
|
| 270 |
+
channel_hint = deep_cells >= max(3, len(depths) // 8)
|
| 271 |
+
|
| 272 |
+
stats = {
|
| 273 |
+
"dataset": grid.get("dataset"),
|
| 274 |
+
"box_km": round(2 * float(grid.get("radius_km", 1.2)), 2),
|
| 275 |
+
"points": len(vals),
|
| 276 |
+
"center_elev_m": round(float(center_elev), 1) if center_elev is not None else None,
|
| 277 |
+
"min_elev_m": round(min(vals), 1),
|
| 278 |
+
"max_elev_m": round(max(vals), 1),
|
| 279 |
+
"mean_elev_m": round(sum(vals) / len(vals), 1),
|
| 280 |
+
"max_depth_m": round(max(depths), 1) if depths else 0.0,
|
| 281 |
+
"median_depth_m": round(_pct(depths, 0.5), 1) if depths else 0.0,
|
| 282 |
+
"land_fraction": round(len(land) / len(vals), 2),
|
| 283 |
+
"relief_m": round(relief, 1),
|
| 284 |
+
"median_slope_m_per_km": round(med_slope, 1),
|
| 285 |
+
"max_slope_m_per_km": round(max_slope, 1),
|
| 286 |
+
"shelf_class": shelf,
|
| 287 |
+
"channel_hint": channel_hint,
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
md = (
|
| 291 |
+
f"**Seafloor ({stats['dataset']}, {stats['box_km']} km box, {stats['points']} pts)** — "
|
| 292 |
+
f"centre {stats['center_elev_m']} m · "
|
| 293 |
+
f"deepest {stats['max_depth_m']} m · median depth {stats['median_depth_m']} m · "
|
| 294 |
+
f"relief {stats['relief_m']} m · land {stats['land_fraction'] * 100:.0f}%.\n\n"
|
| 295 |
+
f"- **Shape:** {shelf} (median slope {stats['median_slope_m_per_km']} m/km, "
|
| 296 |
+
f"max {stats['max_slope_m_per_km']} m/km).\n"
|
| 297 |
+
f"- **Channels:** {'possible deeper gutter(s) — compare the blue pockets on the map' if channel_hint else 'no strong channel signal at this resolution'}.\n"
|
| 298 |
+
f"- **Surf read:** {('steep drops focus swell fast — expect punchier, more tide-sensitive peaks' if 'steep' in shelf else ('mid-slope reef — swell jacks up over the edge, check the transect' if 'moderately' in shelf else 'gentle shelf spreads energy — softer, more forgiving, needs more swell'))}."
|
| 299 |
+
)
|
| 300 |
+
return {"stats": stats, "markdown": md}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ---- Plotly builders ----
|
| 304 |
+
|
| 305 |
+
_EARTH_COLORSCALE = [
|
| 306 |
+
[0.0, "#08306b"],
|
| 307 |
+
[0.25, "#2171b5"],
|
| 308 |
+
[0.45, "#6baed6"],
|
| 309 |
+
[0.55, "#fef0d9"],
|
| 310 |
+
[0.75, "#a8ddb5"],
|
| 311 |
+
[1.0, "#006d2c"],
|
| 312 |
+
]
|
| 313 |
+
|
| 314 |
+
_NO_DATA_FIG_TEXT = "No seafloor data"
|
| 315 |
+
|
| 316 |
+
|
| 317 |
+
def _empty_fig():
|
| 318 |
+
import plotly.graph_objects as go
|
| 319 |
+
|
| 320 |
+
fig = go.Figure()
|
| 321 |
+
fig.add_annotation(text=_NO_DATA_FIG_TEXT, showarrow=False, font={"size": 16})
|
| 322 |
+
return fig
|
| 323 |
+
|
| 324 |
+
|
| 325 |
+
def _grid_z(grid: dict) -> list[list[float | None]]:
|
| 326 |
+
n = int(grid.get("n", GRID_N_DEFAULT))
|
| 327 |
+
elev = grid.get("elev") or []
|
| 328 |
+
return [[elev[i * n + j] if i * n + j < len(elev) else None for j in range(n)] for i in range(n)]
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def build_depth_fig(grid: dict, name: str = ""):
|
| 332 |
+
"""2D depth map (blue = deep, green = land) with centre marker."""
|
| 333 |
+
import plotly.graph_objects as go
|
| 334 |
+
|
| 335 |
+
if not grid.get("elev"):
|
| 336 |
+
return _empty_fig()
|
| 337 |
+
z = _grid_z(grid)
|
| 338 |
+
fig = go.Figure(
|
| 339 |
+
go.Heatmap(
|
| 340 |
+
x=grid["lngs"],
|
| 341 |
+
y=grid["lats"],
|
| 342 |
+
z=z,
|
| 343 |
+
colorscale=_EARTH_COLORSCALE,
|
| 344 |
+
colorbar={"title": "m"},
|
| 345 |
+
hovertemplate="lat %{y:.4f}<br>lng %{x:.4f}<br>elev %{z:.0f} m<extra></extra>",
|
| 346 |
+
name="depth",
|
| 347 |
+
)
|
| 348 |
+
)
|
| 349 |
+
c = grid.get("center") or {}
|
| 350 |
+
if c:
|
| 351 |
+
fig.add_trace(
|
| 352 |
+
go.Scatter(
|
| 353 |
+
x=[c.get("lng")],
|
| 354 |
+
y=[c.get("lat")],
|
| 355 |
+
mode="markers",
|
| 356 |
+
marker={"size": 12, "color": "#FFD700", "symbol": "star",
|
| 357 |
+
"line": {"width": 1, "color": "#0b2c5c"}},
|
| 358 |
+
hovertemplate=f"{name} takeoff zone<extra></extra>",
|
| 359 |
+
name="break",
|
| 360 |
+
)
|
| 361 |
+
)
|
| 362 |
+
fig.update_layout(
|
| 363 |
+
title=f"Depth around {name}" if name else "Depth",
|
| 364 |
+
height=340,
|
| 365 |
+
margin={"l": 50, "r": 50, "t": 50, "b": 40},
|
| 366 |
+
xaxis={"title": "lng"},
|
| 367 |
+
yaxis={"title": "lat", "scaleanchor": "x", "scaleratio": 1},
|
| 368 |
+
paper_bgcolor="white",
|
| 369 |
+
plot_bgcolor="white",
|
| 370 |
+
)
|
| 371 |
+
return fig
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def build_surface_fig(grid: dict, name: str = ""):
|
| 375 |
+
"""3D seafloor surface (the 'world model' view)."""
|
| 376 |
+
import plotly.graph_objects as go
|
| 377 |
+
|
| 378 |
+
if not grid.get("elev"):
|
| 379 |
+
return _empty_fig()
|
| 380 |
+
z = _grid_z(grid)
|
| 381 |
+
fig = go.Figure(
|
| 382 |
+
go.Surface(
|
| 383 |
+
x=grid["lngs"],
|
| 384 |
+
y=grid["lats"],
|
| 385 |
+
z=z,
|
| 386 |
+
colorscale=_EARTH_COLORSCALE,
|
| 387 |
+
colorbar={"title": "m"},
|
| 388 |
+
hovertemplate="elev %{z:.0f} m<extra></extra>",
|
| 389 |
+
)
|
| 390 |
+
)
|
| 391 |
+
fig.update_layout(
|
| 392 |
+
title=f"3D seafloor — {name}" if name else "3D seafloor",
|
| 393 |
+
height=380,
|
| 394 |
+
margin={"l": 0, "r": 0, "t": 50, "b": 0},
|
| 395 |
+
scene={"xaxis": {"title": "lng"}, "yaxis": {"title": "lat"},
|
| 396 |
+
"zaxis": {"title": "m"}, "aspectmode": "auto"},
|
| 397 |
+
paper_bgcolor="white",
|
| 398 |
+
)
|
| 399 |
+
return fig
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
def build_transect_fig(grid: dict, name: str = ""):
|
| 403 |
+
"""Shore-normal-ish transects: W–E + S–N slices through the centre row/col."""
|
| 404 |
+
import plotly.graph_objects as go
|
| 405 |
+
|
| 406 |
+
if not grid.get("elev"):
|
| 407 |
+
return _empty_fig()
|
| 408 |
+
n = int(grid.get("n", GRID_N_DEFAULT))
|
| 409 |
+
z = _grid_z(grid)
|
| 410 |
+
mid = n // 2
|
| 411 |
+
fig = go.Figure()
|
| 412 |
+
fig.add_trace(
|
| 413 |
+
go.Scatter(
|
| 414 |
+
x=grid["lngs"], y=z[mid], mode="lines+markers",
|
| 415 |
+
line={"color": "#1f77b4", "width": 2},
|
| 416 |
+
name="W–E slice", hovertemplate="lng %{x:.4f}<br>elev %{y:.0f} m<extra></extra>",
|
| 417 |
+
)
|
| 418 |
+
)
|
| 419 |
+
fig.add_trace(
|
| 420 |
+
go.Scatter(
|
| 421 |
+
x=grid["lats"], y=[row[mid] for row in z], mode="lines+markers",
|
| 422 |
+
line={"color": "#ff7f0e", "width": 2},
|
| 423 |
+
name="S–N slice", hovertemplate="lat %{x:.4f}<br>elev %{y:.0f} m<extra></extra>",
|
| 424 |
+
)
|
| 425 |
+
)
|
| 426 |
+
fig.add_hline(y=0, line_width=1, line_dash="dash", line_color="#888")
|
| 427 |
+
fig.update_layout(
|
| 428 |
+
title=f"Transects through {name}" if name else "Transects",
|
| 429 |
+
height=260,
|
| 430 |
+
margin={"l": 50, "r": 30, "t": 50, "b": 40},
|
| 431 |
+
xaxis={"title": "degrees"},
|
| 432 |
+
yaxis={"title": "elevation (m, 0 = sea level)"},
|
| 433 |
+
paper_bgcolor="white",
|
| 434 |
+
plot_bgcolor="white",
|
| 435 |
+
legend={"orientation": "h", "y": 1.12, "x": 1.0, "xanchor": "right"},
|
| 436 |
+
)
|
| 437 |
+
return fig
|
| 438 |
+
|
| 439 |
+
|
| 440 |
+
def get_seafloor(
|
| 441 |
+
lat: float, lng: float, radius_km: float = RADIUS_KM_DEFAULT, n: int = GRID_N_DEFAULT
|
| 442 |
+
) -> dict:
|
| 443 |
+
"""One-call fetch + analysis: ``{"grid", "analysis", "stats"}``."""
|
| 444 |
+
grid = get_grid(lat, lng, radius_km=radius_km, n=n)
|
| 445 |
+
analysis = analyze_grid(grid)
|
| 446 |
+
return {"grid": grid, "analysis": analysis.get("markdown", ""),
|
| 447 |
+
"stats": analysis.get("stats", {})}
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
__all__ = [
|
| 451 |
+
"GRID_N_DEFAULT",
|
| 452 |
+
"RADIUS_KM_DEFAULT",
|
| 453 |
+
"get_grid",
|
| 454 |
+
"analyze_grid",
|
| 455 |
+
"build_depth_fig",
|
| 456 |
+
"build_surface_fig",
|
| 457 |
+
"build_transect_fig",
|
| 458 |
+
"get_seafloor",
|
| 459 |
+
]
|