Spaces:
Running
Running
Commit ·
fa1cfdb
1
Parent(s): 86d0a08
Bug fixes, daggr and gradio working
Browse files- .gitignore +3 -0
- README.md +2 -2
- ROADMAP.md +96 -122
- docs/ARCHITECTURE.md +117 -0
- pyproject.toml +4 -0
- tests/test_agent.py +85 -66
- tests/test_api.py +109 -0
- tests/test_app.py +117 -0
- tests/test_daggr.py +145 -0
- tests/test_forecasts.py +190 -0
- tests/test_scoring.py +76 -9
- tests/test_tools.py +82 -0
- uv.lock +0 -0
- wavereader/agent.py +173 -85
- wavereader/api.py +65 -15
- wavereader/app.py +856 -13
- wavereader/daggr_pipeline.py +254 -5
- wavereader/forecasts.py +79 -33
- wavereader/scoring.py +219 -134
- wavereader/spots.py +0 -9
- wavereader/tools.py +406 -21
.gitignore
CHANGED
|
@@ -5,3 +5,6 @@ __pycache__/
|
|
| 5 |
|
| 6 |
# Environment
|
| 7 |
.env
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
# Environment
|
| 7 |
.env
|
| 8 |
+
|
| 9 |
+
# Cache
|
| 10 |
+
.cache/
|
README.md
CHANGED
|
@@ -97,10 +97,10 @@ Gradio UI:
|
|
| 97 |
uv run python -m wavereader.app
|
| 98 |
```
|
| 99 |
|
| 100 |
-
|
| 101 |
|
| 102 |
```sh
|
| 103 |
-
uv run
|
| 104 |
```
|
| 105 |
|
| 106 |
## Built with open everything
|
|
|
|
| 97 |
uv run python -m wavereader.app
|
| 98 |
```
|
| 99 |
|
| 100 |
+
daggr "Morning Surf Report" canvas:
|
| 101 |
|
| 102 |
```sh
|
| 103 |
+
uv run python -m wavereader.daggr_pipeline
|
| 104 |
```
|
| 105 |
|
| 106 |
## Built with open everything
|
ROADMAP.md
CHANGED
|
@@ -1,134 +1,108 @@
|
|
| 1 |
-
# ROADMAP —
|
| 2 |
|
| 3 |
-
|
| 4 |
-
Contest submissions close **Sep 10** (3-day buffer). Deliverables: public HF
|
| 5 |
-
Space, polished repo, ~2-minute video posted on LinkedIn with #NVIDIAGTC
|
| 6 |
-
tagging Merve Noyan — doubling as an announcement of the Berlin move and
|
| 7 |
-
openness to ML work/research in Berlin.
|
| 8 |
|
| 9 |
-
|
| 10 |
-
one client · surf cams are stretch-only · demo lives on a HF Space ·
|
| 11 |
-
FastAPI + smolagents CodeAgent + Gradio + daggr (no LangChain, no htmx).
|
| 12 |
|
| 13 |
---
|
| 14 |
|
| 15 |
-
##
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
-
|
| 27 |
-
|
| 28 |
-
-
|
| 29 |
-
|
| 30 |
-
-
|
| 31 |
-
|
| 32 |
-
### Tue Sep 1 — Scoring engine
|
| 33 |
-
- `scoring.py`: deterministic 0–10 score per hour with per-component
|
| 34 |
-
breakdown (swell size vs ideal range, swell direction match, wind speed +
|
| 35 |
-
offshore alignment, wave period). Unit tests.
|
| 36 |
-
- Sanity-check rankings: Bells Beach should score on SW swell + light NW
|
| 37 |
-
wind; Kirra on long-period E.
|
| 38 |
-
- Outputs: "best hours today" and day-by-week profile per spot.
|
| 39 |
-
- 📹 **Capture:** score table / heatmap clip.
|
| 40 |
-
|
| 41 |
-
### Wed Sep 2 — Agent core
|
| 42 |
-
- `agent.py`: smolagents CodeAgent, streaming, trace capture; HF⇄NIM switch
|
| 43 |
-
via env vars. `tools.py`: 5 tools wired (get_forecast, score_week,
|
| 44 |
-
find_spots, get_spot_knowledge, rank_spots_this_week).
|
| 45 |
-
- System prompt contract: numbers come from tools; the model explains.
|
| 46 |
-
- 📹 **Capture:** the first complete agent answer, raw and candid — great b-roll.
|
| 47 |
-
|
| 48 |
-
### Thu Sep 3 — Flows + evals
|
| 49 |
-
- End-to-end flows: near-me weekly ranking with skill filter; today-at-X time
|
| 50 |
-
recommendation; spot Q&A using the knowledge tool.
|
| 51 |
-
- User profile (skill level, home region) carried through conversation.
|
| 52 |
-
- Write + run `evals/golden_questions.json`; fix prompt/tool gaps.
|
| 53 |
-
|
| 54 |
-
### Fri Sep 4 — API + UI
|
| 55 |
-
- `api.py`: FastAPI endpoints (`/spots` `/forecast` `/score` `/ask`).
|
| 56 |
-
- `app.py`: Gradio UI — **Australia map home page (select your break)**,
|
| 57 |
-
chat, **interactive seaborn-styled forecast charts** whenever the agent
|
| 58 |
-
surfaces wind/sea data, agent trace panel, HF/NIM model toggle.
|
| 59 |
-
- 📹 **Capture:** polished Demo Q1 + OpenAPI docs screen.
|
| 60 |
-
|
| 61 |
-
### Sat Sep 5 — daggr + ship
|
| 62 |
-
- `daggr_pipeline.py`: fixed "Morning Surf Report" DAG (geocode → find_spots
|
| 63 |
-
→ score_week → top-3 rank → InferenceNode narrates). Verify node outputs
|
| 64 |
-
look good on the canvas.
|
| 65 |
-
- Deploy to HF Space (secrets: `HF_TOKEN`, `NVIDIA_API_KEY`); test the public
|
| 66 |
-
URL end-to-end.
|
| 67 |
-
- Publish `data/break_details.json` as a HF dataset; README architecture
|
| 68 |
-
diagram; license.
|
| 69 |
-
- Stretch (only if everything else lands): live surf-cam snapshots → "is it
|
| 70 |
-
clean/right now" tool for a handful of famous breaks.
|
| 71 |
-
- 📹 **Capture:** daggr canvas walkthrough; Space opened on a phone.
|
| 72 |
-
|
| 73 |
-
### Sun Sep 6 — Rehearse + record
|
| 74 |
-
- Run evals against the live Space; fix anything that breaks.
|
| 75 |
-
- Full rehearsal, then record the main video (script below).
|
| 76 |
-
- Draft the LinkedIn post.
|
| 77 |
-
|
| 78 |
-
### Mon Sep 7 — Launch
|
| 79 |
-
- Edit and publish the video; post to LinkedIn (contest entry + Berlin
|
| 80 |
-
announcement); submit the contest entry.
|
| 81 |
-
- Sep 8–10: buffer for retakes / judge replies.
|
| 82 |
|
| 83 |
---
|
| 84 |
|
| 85 |
-
##
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
##
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
|
| 124 |
---
|
| 125 |
|
| 126 |
-
##
|
| 127 |
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ROADMAP — wavereader
|
| 2 |
|
| 3 |
+
**Goal:** Agentic surf forecaster for Australian breaks. Demo video by **Mon Sep 7, 2026** for NVIDIA GTC Golden Ticket contest (#NVIDIAGTC).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
+
**Stack:** Python 3.12, uv, FastAPI, smolagents CodeAgent, Gradio, daggr, Open-Meteo Marine, HF Inference + NVIDIA NIM.
|
|
|
|
|
|
|
| 6 |
|
| 7 |
---
|
| 8 |
|
| 9 |
+
## ✅ Done (through Thu Sep 3)
|
| 10 |
+
|
| 11 |
+
- [x] Repo restructure per `AGENTS.md`; Python 3.12; deps added; `langchain-community` dropped
|
| 12 |
+
- [x] NVIDIA NIM API key + HF token verified
|
| 13 |
+
- [x] Open-Meteo Marine spike: Snapper Rocks hourly fields look sane
|
| 14 |
+
- [x] `data/break_details.json` validation pass (coords, ranges, spot-check 10)
|
| 15 |
+
- [x] `spots.py`: search by name/city/region/skill
|
| 16 |
+
- [x] `forecasts.py`: Open-Meteo Marine + wind, hourly 7-day, disk cache
|
| 17 |
+
- [x] `scoring.py`: deterministic 0–10 score/hour, component breakdown, unit tests
|
| 18 |
+
- [x] Scoring sanity checks: Bells (SW swell + NW wind), Kirra (long-period E)
|
| 19 |
+
- [x] `agent.py`: smolagents CodeAgent, streaming, trace capture, HF⇄NIM switch
|
| 20 |
+
- [x] `tools.py`: 5 tools wired (get_forecast, score_week, find_spots, get_spot_knowledge, rank_spots_this_week)
|
| 21 |
+
- [x] System prompt: numbers from tools, model explains
|
| 22 |
+
- [x] End-to-end flows: near-me weekly ranking with skill filter, best-hours-today, spot Q&A
|
| 23 |
+
- [ ] User profile (skill, home region) carried through conversation (skill filter works via chat; no persistent profile yet)
|
| 24 |
+
- [ ] `evals/golden_questions.json` written; spot-checked manually (ranking+skill, best-hours, spot Q&A) — automated runner still TODO
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
---
|
| 27 |
|
| 28 |
+
### User Data & Personalization
|
| 29 |
+
|
| 30 |
+
- [ ] **User profile model** — `User` class with:
|
| 31 |
+
- `skill_level`: beginner / intermediate / advanced / pro → feeds `scoring.py`
|
| 32 |
+
- `home_region`: state/region for default "near me"
|
| 33 |
+
- `preferences`:
|
| 34 |
+
- `wave_direction`: left / right / both
|
| 35 |
+
- `state`: filter by Australian state
|
| 36 |
+
- `location`: lat/lon + radius for "near me"
|
| 37 |
+
- `other_preferences`: tags (reef, beach, point, crowd_tolerance, etc.)
|
| 38 |
+
- [ ] **Skill level → scoring engine**
|
| 39 |
+
- Adjust ideal swell size per skill (beginner: 0.5–1.5m, pro: 2–4m+)
|
| 40 |
+
- Weight wind/period components differently per skill
|
| 41 |
+
- Return skill-adjusted score in `/score`
|
| 42 |
+
- [ ] **Preferences → pre-filter breaks** (before scoring)
|
| 43 |
+
- Filter by `wave_direction` match
|
| 44 |
+
- Filter by `state` match
|
| 45 |
+
- Filter by `location` radius (haversine)
|
| 46 |
+
- Filter by `other_preferences` tags
|
| 47 |
+
- Score only filtered subset
|
| 48 |
+
- [ ] **Custom local break (max 1/user)**
|
| 49 |
+
- Fields: name, lat, lon, region, optimal_swell_direction, optimal_swell_size_range, optimal_wind_direction, skill_level_suitability, notes
|
| 50 |
+
- Validate coords against Open-Meteo grid
|
| 51 |
+
- Merge into user's break list for ranking; persist in profile
|
| 52 |
+
- UI: Gradio form (modal/sidebar) with "Add my local break"
|
| 53 |
+
|
| 54 |
+
### Infrastructure & Polish
|
| 55 |
+
|
| 56 |
+
- [ ] Persist user profiles (SQLite or HF dataset) — survive restarts
|
| 57 |
+
- [ ] Auth (email/token or HF OAuth) for multi-user Space
|
| 58 |
+
- [ ] Migrate `data/break_details.json` → proper DB for concurrent writes
|
| 59 |
+
- [ ] Add tide data source (stretch: WillyWeather integration)
|
| 60 |
+
|
| 61 |
+
### API + UI
|
| 62 |
+
|
| 63 |
+
- [x] `api.py`: FastAPI endpoints (`/spots`, `/forecast`, `/score`, `/ask`)
|
| 64 |
+
- [x] `app.py`: Gradio UI — Australia map home page, chat, interactive seaborn-styled charts, agent trace panel, HF/NIM toggle
|
| 65 |
+
- [ ] 📹 Capture: polished Demo Q1 + OpenAPI docs screen
|
| 66 |
+
|
| 67 |
+
### daggr + Deploy
|
| 68 |
+
|
| 69 |
+
- [x] `daggr_pipeline.py`: "Morning Surf Report" DAG (find_spots → fetch forecasts → score → rank → InferenceNode narrates); embeds in the UI via iframe
|
| 70 |
+
- [ ] Deploy to HF Space (secrets: `HF_TOKEN`, `NVIDIA_API_KEY`); test public URL end-to-end
|
| 71 |
+
- [ ] Publish `data/break_details.json` as HF dataset; README architecture diagram; license
|
| 72 |
+
- [ ] 📹 Capture: daggr canvas walkthrough; Space on phone
|
| 73 |
+
- [ ] Stretch: live surf-cam snapshots tool for famous breaks
|
| 74 |
+
|
| 75 |
+
### Rehearse + Record
|
| 76 |
+
|
| 77 |
+
- [ ] Run evals against live Space; fix breaks
|
| 78 |
+
- [ ] Full rehearsal → record main video
|
| 79 |
+
- [ ] Draft LinkedIn post
|
| 80 |
+
|
| 81 |
+
### Launch
|
| 82 |
+
|
| 83 |
+
- [ ] Edit + publish video; post to LinkedIn (contest + Berlin announcement); submit entry
|
| 84 |
+
- [ ] Sep 8–10 buffer for retakes / judge replies
|
| 85 |
|
| 86 |
---
|
| 87 |
|
| 88 |
+
## Video Script (~2 min)
|
| 89 |
|
| 90 |
+
1. **Hook (0:00–0:10)** — "I moved to Berlin — 16,000 km from surf. Built an agent to find waves back home."
|
| 91 |
+
2. **Problem (0:10–0:25)** — Local knowledge: which winds suit which break. Show `break_details.json` + raw forecast.
|
| 92 |
+
3. **Demo Q1 (0:25–0:55)** — "Where should I surf near Byron Bay this weekend? Intermediate longboarder." → ranked cards + trace panel.
|
| 93 |
+
4. **Demo Q2 (0:55–1:15)** — "What time should I surf Snapper Rocks tomorrow?" → interactive hourly chart + best-window reasoning.
|
| 94 |
+
5. **Reasoning canvas (1:15–1:35)** — daggr Morning Surf Report: click through node outputs live.
|
| 95 |
+
6. **Under the hood (1:35–1:50)** — FastAPI OpenAPI docs; HF ↔ NIM toggle; break knowledge base as HF dataset.
|
| 96 |
+
7. **CTA (1:50–2:10)** — Open models (Qwen via HF + NVIDIA NIM). Free GTC Berlin ticket 🤞 #NVIDIAGTC. New in Berlin — DMs open for ML work.
|
| 97 |
+
|
| 98 |
+
---
|
| 99 |
+
|
| 100 |
+
## Risks & Fallbacks
|
| 101 |
+
|
| 102 |
+
| Risk | Mitigation |
|
| 103 |
+
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------- |
|
| 104 |
+
| Daggr beta misbehaves (no conditional branching) | Agent trace panel = reasoning demo; swap canvas shot for trace walkthrough |
|
| 105 |
+
| Open-Meteo grid points sit offshore | Fetch slightly seaward coords; validate against known break behavior |
|
| 106 |
+
| Demo-day API flakiness / rate limits | Disk-cached forecasts; daily milestone clips = no rebuild needed for retakes |
|
| 107 |
+
| Tool-loop unreliability | Small tool set, strict structured outputs, `golden_questions.json` gates recording |
|
| 108 |
+
| Model availability on either router | Provider-agnostic client; same model on HF + NIM; fallback must support tool calling + `json_schema` |
|
docs/ARCHITECTURE.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Architecture
|
| 2 |
+
|
| 3 |
+
How wavereader turns a natural-language question into a grounded surf
|
| 4 |
+
recommendation. The one-sentence version: **the LLM orchestrates, code owns
|
| 5 |
+
the numbers.**
|
| 6 |
+
|
| 7 |
+
```
|
| 8 |
+
question ──▶ SurfAgent (smolagents CodeAgent, Qwen3-Next-80B)
|
| 9 |
+
│ writes python code that calls tools
|
| 10 |
+
▼
|
| 11 |
+
tools.py ──▶ spots.py (knowledge base)
|
| 12 |
+
│ forecasts.py (Open-Meteo + disk cache)
|
| 13 |
+
│ scoring.py (deterministic 0–10 score)
|
| 14 |
+
▼
|
| 15 |
+
streaming answer + AgentTrace ──▶ Gradio UI (trace panel + auto-charts)
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
## Modules
|
| 19 |
+
|
| 20 |
+
| Module | Role | Key facts |
|
| 21 |
+
|---|---|---|
|
| 22 |
+
| `spots.py` | Loads + validates `data/breaks.json` / `break_details.json`; search | Records keyed by `(name, region)`; coords validated against per-state bounding boxes; 6 regions (NSW, QLD, VIC, WA, SA, TAS) |
|
| 23 |
+
| `forecasts.py` | Open-Meteo Marine + weather client | Hourly, 7-day horizon; merges two endpoints on `time`; disk cache with stale fallback (below) |
|
| 24 |
+
| `scoring.py` | Deterministic surf-quality score | Pure functions, no I/O; per-hour 0–10 score + component breakdown (below) |
|
| 25 |
+
| `agent.py` | smolagents CodeAgent wrapper | `SurfAgent`, provider switching, streaming, `AgentTrace`; `max_steps=10` |
|
| 26 |
+
| `tools.py` | Agent tools + matplotlib plot builders | 5 core tools; plain functions resolve spot by `(name, region)` then delegate |
|
| 27 |
+
| `api.py` | FastAPI backend | `/spots`, `/forecast`, `/score`, `/ask` — see [API.md](API.md) |
|
| 28 |
+
| `app.py` | Gradio UI | Map-first layout, streaming chat, trace panel, auto-charts — see [UI_GUIDE.md](UI_GUIDE.md) |
|
| 29 |
+
| `daggr_pipeline.py` | Fixed Morning Surf Report DAG | Visualises the deterministic pipeline only, **never** the dynamic chat loop (daggr beta has no conditional branching) |
|
| 30 |
+
|
| 31 |
+
## The model boundary
|
| 32 |
+
|
| 33 |
+
`SYSTEM_PROMPT` in `agent.py` enforces the product's core rule: the agent
|
| 34 |
+
never computes a number. It writes code that calls tools, reads the results,
|
| 35 |
+
and explains them. Everything numeric comes from:
|
| 36 |
+
|
| 37 |
+
- **Forecasts** — Open-Meteo responses, normalised to hourly rows
|
| 38 |
+
(`time`, `wave_height`, `wave_period`, `wave_direction`, `wind_speed_10m`,
|
| 39 |
+
`wind_direction_10m`).
|
| 40 |
+
- **Scores** — `scoring.score_hour()`, weighted:
|
| 41 |
+
swell_size 0.30 · swell_direction 0.20 · wind 0.30 · period 0.20.
|
| 42 |
+
|
| 43 |
+
Component logic (all continuous curves, no cliff edges):
|
| 44 |
+
|
| 45 |
+
- **Swell size** — 10 inside the spot's ideal `size_ft_min–max` window,
|
| 46 |
+
Gaussian decay outside, zero above the skill profile's `max_safe_size_ft`.
|
| 47 |
+
- **Swell direction** — Gaussian on angular distance from the spot's ideal
|
| 48 |
+
direction (45° tolerance); `None` when Open-Meteo has no wave direction,
|
| 49 |
+
and its weight is redistributed across the remaining components (never
|
| 50 |
+
silently substituted with wind direction).
|
| 51 |
+
- **Wind** — mean of a speed score and a direction score. Speed: glassy
|
| 52 |
+
(≤ 5 kt) is always 10, above `min(spot max, skill max)` it is 0.
|
| 53 |
+
Direction: cosine falloff from perfect offshore to absolute onshore.
|
| 54 |
+
- **Period** — linear ramp, 0 at ≤ 4 s up to 10 at ≥ 14 s; long groundswell
|
| 55 |
+
is never penalised.
|
| 56 |
+
|
| 57 |
+
Skill profiles (`beginner / intermediate / advanced / expert`) cap wave size,
|
| 58 |
+
wind tolerance and set the comfort band. Every scored row carries its raw
|
| 59 |
+
inputs back (`wave_height_m`, `wind_speed_kt`, …) so the UI and trace can
|
| 60 |
+
show *why* a score is what it is.
|
| 61 |
+
|
| 62 |
+
## Forecast fetching and the demo-survivability cache
|
| 63 |
+
|
| 64 |
+
- Grid points sit offshore: coords are snapped **0.13° seaward** — Tasmania
|
| 65 |
+
and the south coast shift south, the east coast (lon ≥ 147) shifts east,
|
| 66 |
+
the west coast (lon ≤ 125) shifts west.
|
| 67 |
+
- Responses are cached to disk at `.cache/forecasts/{lat}_{lon}_{days}.json`
|
| 68 |
+
as `{"fetched_at": ..., "data": ...}` envelopes, TTL **6 hours**.
|
| 69 |
+
- On a network failure the most recent entry — however stale — is served
|
| 70 |
+
instead of raising; the app only errors when there is no data at all. This
|
| 71 |
+
is deliberate: the demo video must survive API flakiness and rate limits.
|
| 72 |
+
- On UI startup a background thread warms the cache for all 100 breaks
|
| 73 |
+
(disable with `WAVEREADER_WARM_CACHE=0`).
|
| 74 |
+
|
| 75 |
+
## Agent ↔ UI contract
|
| 76 |
+
|
| 77 |
+
`SurfAgent.run_stream(question)` yields:
|
| 78 |
+
|
| 79 |
+
- `("model", delta)` — LLM token chunks (the agent's code-as-reasoning)
|
| 80 |
+
- `("tool", tool_name)` — a tool call is happening
|
| 81 |
+
- `("final", answer)` — the clean final answer
|
| 82 |
+
|
| 83 |
+
Every run also fills an `AgentTrace` (`trace.to_json()`), with event types
|
| 84 |
+
`tool_call` / `tool_result` / `llm_chunk` / `final_answer`. The UI renders
|
| 85 |
+
this JSON in the trace panel and scrapes the code steps for
|
| 86 |
+
`get_forecast` / `score_week` / `get_spot_knowledge` /
|
| 87 |
+
`rank_spots_this_week(...)` invocations — whichever spots the agent touched
|
| 88 |
+
get interactive plotly charts rendered beneath the chat. Charts are drawn by
|
| 89 |
+
the UI, never by the agent, so the model's context stays small and the
|
| 90 |
+
graphics stay interactive.
|
| 91 |
+
|
| 92 |
+
Note for tool-call tracing: smolagents CodeAgents emit one
|
| 93 |
+
`python_interpreter` call per step whose "arguments" are the generated code
|
| 94 |
+
blob — the trace panel detects real tool usage inside the blob by regex.
|
| 95 |
+
|
| 96 |
+
## Provider switching
|
| 97 |
+
|
| 98 |
+
Both hosts speak the OpenAI chat-completions protocol, so one client class
|
| 99 |
+
(smolagents `OpenAIServerModel`) covers the switch:
|
| 100 |
+
|
| 101 |
+
| Provider | Base URL | Env var |
|
| 102 |
+
|---|---|---|
|
| 103 |
+
| `hf` (default) | `https://router.huggingface.co/v1` | `HF_TOKEN` |
|
| 104 |
+
| `nim` | `https://integrate.api.nvidia.com/v1` | `NVIDIA_API_KEY` |
|
| 105 |
+
|
| 106 |
+
When both tokens are set, NIM wins auto-detection; the UI exposes an explicit
|
| 107 |
+
radio switch. `Qwen/Qwen3-Next-80B-A3B-Instruct` is the only model verified
|
| 108 |
+
for both OpenAI-style tool calling and strict `json_schema` on the HF router
|
| 109 |
+
— read `data/GENERATION.md` before swapping models.
|
| 110 |
+
|
| 111 |
+
## Testing
|
| 112 |
+
|
| 113 |
+
`uv run pytest` (134 tests) covers scoring curves, cache behaviour/offsets,
|
| 114 |
+
spot validation, tool wiring, the agent (mocked model), the API, the daggr
|
| 115 |
+
pipeline and the UI chart builders. The agent tests mock the LLM, so the
|
| 116 |
+
suite runs offline; the forecast tests mock HTTP but exercise the real cache
|
| 117 |
+
code paths via `tmp_path`.
|
pyproject.toml
CHANGED
|
@@ -11,8 +11,12 @@ dependencies = [
|
|
| 11 |
"gradio",
|
| 12 |
"httpx",
|
| 13 |
"huggingface-hub>=1.29.0",
|
|
|
|
| 14 |
"openai",
|
|
|
|
| 15 |
"pydantic",
|
|
|
|
|
|
|
| 16 |
"smolagents>=1.26.0",
|
| 17 |
]
|
| 18 |
|
|
|
|
| 11 |
"gradio",
|
| 12 |
"httpx",
|
| 13 |
"huggingface-hub>=1.29.0",
|
| 14 |
+
"matplotlib>=3.11.1",
|
| 15 |
"openai",
|
| 16 |
+
"plotly>=7.0.0",
|
| 17 |
"pydantic",
|
| 18 |
+
"pyyaml",
|
| 19 |
+
"seaborn>=0.13.2",
|
| 20 |
"smolagents>=1.26.0",
|
| 21 |
]
|
| 22 |
|
tests/test_agent.py
CHANGED
|
@@ -3,10 +3,12 @@
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
| 6 |
-
import os
|
| 7 |
from unittest.mock import MagicMock, patch
|
| 8 |
|
| 9 |
import pytest
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
from wavereader.agent import (
|
| 12 |
AgentTrace,
|
|
@@ -118,9 +120,9 @@ class TestScoreWeekTool:
|
|
| 118 |
def test_forward_spot_not_found(self):
|
| 119 |
tool = ScoreWeekTool()
|
| 120 |
with patch("wavereader.agent.score_week") as mock_score:
|
| 121 |
-
mock_score.return_value =
|
| 122 |
result = tool.forward("Bad", "XX")
|
| 123 |
-
assert
|
| 124 |
|
| 125 |
|
| 126 |
class TestFindSpotsTool:
|
|
@@ -253,93 +255,106 @@ class TestSurfAgentRun:
|
|
| 253 |
assert agent.trace.question == "What's the surf like?"
|
| 254 |
assert agent.trace.provider == PROVIDER_HF
|
| 255 |
|
| 256 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
mock_agent = MagicMock()
|
| 258 |
-
mock_agent.run.return_value = iter(
|
| 259 |
|
| 260 |
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 261 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 262 |
chunks = list(agent.run_stream("Test question"))
|
| 263 |
|
| 264 |
-
assert chunks == [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 265 |
assert agent.trace is not None
|
|
|
|
|
|
|
|
|
|
| 266 |
|
| 267 |
def test_run_stream_captures_tool_calls_in_trace(self, mock_build_model):
|
| 268 |
-
"""
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
tool_call =
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
tool_calls=[tool_call],
|
| 278 |
-
observations="forecast data",
|
| 279 |
-
is_final_answer=False,
|
| 280 |
)
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
)
|
| 288 |
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
result = step_results[call_count["count"]]
|
| 294 |
-
call_count["count"] += 1
|
| 295 |
-
return result
|
| 296 |
-
|
| 297 |
-
# Use a real object instead of MagicMock so step can be dynamically replaced
|
| 298 |
-
class MockAgent:
|
| 299 |
-
def __init__(self):
|
| 300 |
-
self.step = mock_step
|
| 301 |
|
| 302 |
def run(self, question, stream=False):
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
yield "chunk 1"
|
| 307 |
-
self.step(memory_step_without_tool)
|
| 308 |
-
yield "chunk 2"
|
| 309 |
-
return "final answer"
|
| 310 |
-
|
| 311 |
-
mock_agent = MockAgent()
|
| 312 |
|
| 313 |
-
with patch("wavereader.agent.CodeAgent", return_value=
|
| 314 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 315 |
-
list(agent.run_stream("Test"))
|
| 316 |
|
|
|
|
| 317 |
trace = agent.trace
|
| 318 |
tool_call_events = [e for e in trace.events if e.type == "tool_call"]
|
| 319 |
tool_result_events = [e for e in trace.events if e.type == "tool_result"]
|
| 320 |
|
| 321 |
assert len(tool_call_events) == 1
|
| 322 |
assert tool_call_events[0].data["name"] == "get_forecast"
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
assert tool_result_events
|
| 328 |
-
assert tool_result_events[
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 334 |
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 337 |
-
|
| 338 |
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
assert
|
|
|
|
| 343 |
|
| 344 |
|
| 345 |
# ---------------------------------------------------------------------------
|
|
@@ -357,13 +372,17 @@ def test_run_agent_function(mock_build_model):
|
|
| 357 |
|
| 358 |
|
| 359 |
def test_run_agent_stream_function(mock_build_model):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 360 |
mock_agent = MagicMock()
|
| 361 |
-
mock_agent.run.return_value = iter(
|
| 362 |
|
| 363 |
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 364 |
result = list(run_agent_stream("Test question", provider=PROVIDER_HF))
|
| 365 |
|
| 366 |
-
assert result == ["stream", "
|
| 367 |
|
| 368 |
|
| 369 |
# ---------------------------------------------------------------------------
|
|
|
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import json
|
|
|
|
| 6 |
from unittest.mock import MagicMock, patch
|
| 7 |
|
| 8 |
import pytest
|
| 9 |
+
from smolagents.agents import ToolOutput
|
| 10 |
+
from smolagents.memory import FinalAnswerStep, ToolCall as SmolToolCall
|
| 11 |
+
from smolagents.models import ChatMessageStreamDelta
|
| 12 |
|
| 13 |
from wavereader.agent import (
|
| 14 |
AgentTrace,
|
|
|
|
| 120 |
def test_forward_spot_not_found(self):
|
| 121 |
tool = ScoreWeekTool()
|
| 122 |
with patch("wavereader.agent.score_week") as mock_score:
|
| 123 |
+
mock_score.return_value = {"error": "Spot 'Bad' (XX) not found"}
|
| 124 |
result = tool.forward("Bad", "XX")
|
| 125 |
+
assert "error" in result
|
| 126 |
|
| 127 |
|
| 128 |
class TestFindSpotsTool:
|
|
|
|
| 255 |
assert agent.trace.question == "What's the surf like?"
|
| 256 |
assert agent.trace.provider == PROVIDER_HF
|
| 257 |
|
| 258 |
+
def test_run_stream_yields_deltas_and_final(self, mock_build_model):
|
| 259 |
+
events = [
|
| 260 |
+
ChatMessageStreamDelta(content="Hello "),
|
| 261 |
+
ChatMessageStreamDelta(content="world"),
|
| 262 |
+
FinalAnswerStep(output="Hello world"),
|
| 263 |
+
]
|
| 264 |
mock_agent = MagicMock()
|
| 265 |
+
mock_agent.run.return_value = iter(events)
|
| 266 |
|
| 267 |
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 268 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 269 |
chunks = list(agent.run_stream("Test question"))
|
| 270 |
|
| 271 |
+
assert chunks == [
|
| 272 |
+
("model", "Hello "),
|
| 273 |
+
("model", "world"),
|
| 274 |
+
("final", "Hello world"),
|
| 275 |
+
]
|
| 276 |
assert agent.trace is not None
|
| 277 |
+
final_events = [e for e in agent.trace.events if e.type == "final_answer"]
|
| 278 |
+
assert len(final_events) == 1
|
| 279 |
+
assert final_events[0].data["answer"] == "Hello world"
|
| 280 |
|
| 281 |
def test_run_stream_captures_tool_calls_in_trace(self, mock_build_model):
|
| 282 |
+
"""Trace captures tool calls/results flowing through _step_stream.
|
| 283 |
+
|
| 284 |
+
Mirrors real smolagents: _run_stream yields whatever _step_stream
|
| 285 |
+
yields, so tool events surface via the wrapped generator.
|
| 286 |
+
"""
|
| 287 |
+
tool_call = SmolToolCall(
|
| 288 |
+
name="get_forecast",
|
| 289 |
+
arguments={"spot_name": "Snapper Rocks", "region": "QLD"},
|
| 290 |
+
id="call-1",
|
|
|
|
|
|
|
|
|
|
| 291 |
)
|
| 292 |
+
tool_output = ToolOutput(
|
| 293 |
+
id="call-1",
|
| 294 |
+
output={"hourly": []},
|
| 295 |
+
is_final_answer=False,
|
| 296 |
+
observation="forecast data",
|
| 297 |
+
tool_call=tool_call,
|
| 298 |
)
|
| 299 |
|
| 300 |
+
class FakeAgent:
|
| 301 |
+
def _step_stream(self, memory_step):
|
| 302 |
+
yield tool_call
|
| 303 |
+
yield tool_output
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
|
| 305 |
def run(self, question, stream=False):
|
| 306 |
+
for output in self._step_stream(None):
|
| 307 |
+
yield output
|
| 308 |
+
yield FinalAnswerStep(output="done")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
+
with patch("wavereader.agent.CodeAgent", return_value=FakeAgent()):
|
| 311 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 312 |
+
results = list(agent.run_stream("Test"))
|
| 313 |
|
| 314 |
+
assert ("final", "done") in results
|
| 315 |
trace = agent.trace
|
| 316 |
tool_call_events = [e for e in trace.events if e.type == "tool_call"]
|
| 317 |
tool_result_events = [e for e in trace.events if e.type == "tool_result"]
|
| 318 |
|
| 319 |
assert len(tool_call_events) == 1
|
| 320 |
assert tool_call_events[0].data["name"] == "get_forecast"
|
| 321 |
+
assert tool_call_events[0].data["arguments"] == {
|
| 322 |
+
"spot_name": "Snapper Rocks",
|
| 323 |
+
"region": "QLD",
|
| 324 |
+
}
|
| 325 |
+
assert len(tool_result_events) == 1
|
| 326 |
+
assert tool_result_events[0].data["observation"] == "forecast data"
|
| 327 |
+
assert tool_result_events[0].data["name"] == "get_forecast"
|
| 328 |
+
|
| 329 |
+
def test_run_captures_tool_calls_in_trace(self, mock_build_model):
|
| 330 |
+
"""The non-streaming run() path also captures tool activity."""
|
| 331 |
+
tool_call = SmolToolCall(name="find_spots", arguments={"region": "QLD"}, id="call-2")
|
| 332 |
+
tool_output = ToolOutput(
|
| 333 |
+
id="call-2",
|
| 334 |
+
output=[],
|
| 335 |
+
is_final_answer=False,
|
| 336 |
+
observation="2 spots",
|
| 337 |
+
tool_call=tool_call,
|
| 338 |
+
)
|
| 339 |
|
| 340 |
+
class FakeAgent:
|
| 341 |
+
def _step_stream(self, memory_step):
|
| 342 |
+
yield tool_call
|
| 343 |
+
yield tool_output
|
| 344 |
+
|
| 345 |
+
def run(self, question, stream=False):
|
| 346 |
+
list(self._step_stream({"step": 1}))
|
| 347 |
+
return "answer"
|
| 348 |
+
|
| 349 |
+
with patch("wavereader.agent.CodeAgent", return_value=FakeAgent()):
|
| 350 |
agent = SurfAgent(provider=PROVIDER_HF)
|
| 351 |
+
result = agent.run("Test")
|
| 352 |
|
| 353 |
+
assert result == "answer"
|
| 354 |
+
tool_call_events = [e for e in agent.trace.events if e.type == "tool_call"]
|
| 355 |
+
tool_result_events = [e for e in agent.trace.events if e.type == "tool_result"]
|
| 356 |
+
assert tool_call_events[0].data["name"] == "find_spots"
|
| 357 |
+
assert tool_result_events[0].data["observation"] == "2 spots"
|
| 358 |
|
| 359 |
|
| 360 |
# ---------------------------------------------------------------------------
|
|
|
|
| 372 |
|
| 373 |
|
| 374 |
def test_run_agent_stream_function(mock_build_model):
|
| 375 |
+
events = [
|
| 376 |
+
ChatMessageStreamDelta(content="stream"),
|
| 377 |
+
FinalAnswerStep(output="streaming done"),
|
| 378 |
+
]
|
| 379 |
mock_agent = MagicMock()
|
| 380 |
+
mock_agent.run.return_value = iter(events)
|
| 381 |
|
| 382 |
with patch("wavereader.agent.CodeAgent", return_value=mock_agent):
|
| 383 |
result = list(run_agent_stream("Test question", provider=PROVIDER_HF))
|
| 384 |
|
| 385 |
+
assert result == [("model", "stream"), ("final", "streaming done")]
|
| 386 |
|
| 387 |
|
| 388 |
# ---------------------------------------------------------------------------
|
tests/test_api.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.api — FastAPI endpoints."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
import pytest
|
| 8 |
+
from fastapi.testclient import TestClient
|
| 9 |
+
|
| 10 |
+
from wavereader.api import app
|
| 11 |
+
|
| 12 |
+
client = TestClient(app)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def test_root():
|
| 16 |
+
resp = client.get("/")
|
| 17 |
+
assert resp.status_code == 200
|
| 18 |
+
data = resp.json()
|
| 19 |
+
assert data["name"] == "wavereader"
|
| 20 |
+
assert data["status"] == "ok"
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def test_list_spots():
|
| 24 |
+
resp = client.get("/spots?limit=3")
|
| 25 |
+
assert resp.status_code == 200
|
| 26 |
+
spots = resp.json()
|
| 27 |
+
assert len(spots) <= 3
|
| 28 |
+
assert all("name" in s for s in spots)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_list_spots_by_region():
|
| 32 |
+
resp = client.get("/spots?region=QLD&limit=5")
|
| 33 |
+
assert resp.status_code == 200
|
| 34 |
+
spots = resp.json()
|
| 35 |
+
assert all(s["region"] == "QLD" for s in spots)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_get_spot():
|
| 39 |
+
resp = client.get("/spots/Snapper Rocks?region=QLD")
|
| 40 |
+
assert resp.status_code == 200
|
| 41 |
+
spot = resp.json()
|
| 42 |
+
assert spot["name"] == "Snapper Rocks"
|
| 43 |
+
assert spot["region"] == "QLD"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_get_spot_not_found():
|
| 47 |
+
resp = client.get("/spots/Nonexistent?region=XX")
|
| 48 |
+
assert resp.status_code == 404
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _fake_forecast(lat: float, lon: float, days: int = 7) -> dict:
|
| 52 |
+
"""Offline stand-in for Open-Meteo so tests never hit the network."""
|
| 53 |
+
return {
|
| 54 |
+
"hourly": [
|
| 55 |
+
{
|
| 56 |
+
"time": f"2024-01-01T{h:02d}:00",
|
| 57 |
+
"wave_height": 1.5,
|
| 58 |
+
"wave_period": 10,
|
| 59 |
+
"wave_direction": 135,
|
| 60 |
+
"wind_speed_10m": 10,
|
| 61 |
+
"wind_direction_10m": 135,
|
| 62 |
+
}
|
| 63 |
+
for h in range(24)
|
| 64 |
+
]
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def test_forecast():
|
| 69 |
+
with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
|
| 70 |
+
resp = client.get("/forecast?spot=Snapper Rocks®ion=QLD&days=2")
|
| 71 |
+
assert resp.status_code == 200
|
| 72 |
+
data = resp.json()
|
| 73 |
+
assert "hourly" in data
|
| 74 |
+
assert len(data["hourly"]) > 0
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def test_forecast_spot_not_found():
|
| 78 |
+
resp = client.get("/forecast?spot=Nonexistent®ion=XX")
|
| 79 |
+
assert resp.status_code == 404
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def test_score():
|
| 83 |
+
with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
|
| 84 |
+
resp = client.get("/score?spot=Snapper Rocks®ion=QLD&days=2")
|
| 85 |
+
assert resp.status_code == 200
|
| 86 |
+
data = resp.json()
|
| 87 |
+
assert isinstance(data, list)
|
| 88 |
+
assert len(data) > 0
|
| 89 |
+
assert "score" in data[0]
|
| 90 |
+
assert "components" in data[0]
|
| 91 |
+
assert "time" in data[0]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def test_score_spot_not_found():
|
| 95 |
+
resp = client.get("/score?spot=Nonexistent®ion=XX")
|
| 96 |
+
assert resp.status_code == 404
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def test_ask_missing_config_returns_503():
|
| 100 |
+
with patch("wavereader.api.SurfAgent", side_effect=ValueError("HF_TOKEN environment variable not set")):
|
| 101 |
+
resp = client.post("/ask", json={"question": "How's Snapper?"})
|
| 102 |
+
assert resp.status_code == 503
|
| 103 |
+
assert "HF_TOKEN" in resp.json()["detail"]
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_ask_agent_failure_returns_502():
|
| 107 |
+
with patch("wavereader.api.SurfAgent", side_effect=RuntimeError("boom")):
|
| 108 |
+
resp = client.post("/ask", json={"question": "How's Snapper?"})
|
| 109 |
+
assert resp.status_code == 502
|
tests/test_app.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.app — Gradio UI chart functions and helpers."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
|
| 7 |
+
from wavereader.app import (
|
| 8 |
+
create_australia_map,
|
| 9 |
+
create_seaborn_template,
|
| 10 |
+
create_wave_height_chart,
|
| 11 |
+
create_score_chart,
|
| 12 |
+
create_ranking_chart,
|
| 13 |
+
update_trace_panel,
|
| 14 |
+
build_ui,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def test_australia_map_creates_figure():
|
| 19 |
+
fig = create_australia_map()
|
| 20 |
+
assert fig is not None
|
| 21 |
+
assert len(fig.data) >= 1
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def test_seaborn_template_has_keys():
|
| 25 |
+
t = create_seaborn_template()
|
| 26 |
+
assert "layout" in t
|
| 27 |
+
assert "font" in t["layout"]
|
| 28 |
+
assert "paper_bgcolor" in t["layout"]
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def test_wave_height_chart_empty():
|
| 32 |
+
fig = create_wave_height_chart({}, "Spot")
|
| 33 |
+
assert fig is not None
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def test_wave_height_chart_with_data():
|
| 37 |
+
forecast = {
|
| 38 |
+
"hourly": [
|
| 39 |
+
{
|
| 40 |
+
"time": f"2024-01-01T{i:02d}:00",
|
| 41 |
+
"wave_height": 1.5,
|
| 42 |
+
"wave_period": 10,
|
| 43 |
+
"wind_speed_10m": 15,
|
| 44 |
+
"wind_direction_10m": 135,
|
| 45 |
+
}
|
| 46 |
+
for i in range(24)
|
| 47 |
+
]
|
| 48 |
+
}
|
| 49 |
+
fig = create_wave_height_chart(forecast, "Snapper Rocks")
|
| 50 |
+
assert len(fig.data) == 3 # wave height, period, wind
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_score_chart_empty():
|
| 54 |
+
fig = create_score_chart([], "Spot")
|
| 55 |
+
assert fig is not None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_score_chart_with_data():
|
| 59 |
+
scores = [
|
| 60 |
+
{
|
| 61 |
+
"time": f"2024-01-01T{i:02d}:00",
|
| 62 |
+
"score": 7.5,
|
| 63 |
+
"components": {
|
| 64 |
+
"swell_size": 8.0,
|
| 65 |
+
"swell_direction": 7.0,
|
| 66 |
+
"wind": 7.5,
|
| 67 |
+
"period": 7.5,
|
| 68 |
+
},
|
| 69 |
+
}
|
| 70 |
+
for i in range(24)
|
| 71 |
+
]
|
| 72 |
+
fig = create_score_chart(scores, "Snapper Rocks")
|
| 73 |
+
assert len(fig.data) == 5 # total + 4 components
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def test_ranking_chart_empty():
|
| 77 |
+
fig = create_ranking_chart([], "NSW")
|
| 78 |
+
assert fig is not None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def test_ranking_chart_with_data():
|
| 82 |
+
ranked = [
|
| 83 |
+
{"name": "A", "region": "NSW", "best_score": 8.5, "best_time": "2024-01-01T12:00"},
|
| 84 |
+
{"name": "B", "region": "NSW", "best_score": 6.0, "best_time": "2024-01-01T08:00"},
|
| 85 |
+
]
|
| 86 |
+
fig = create_ranking_chart(ranked, "NSW")
|
| 87 |
+
assert len(fig.data) >= 1
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_update_trace_panel_empty():
|
| 91 |
+
result = update_trace_panel("")
|
| 92 |
+
assert "No trace yet" in result
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def test_update_trace_panel_with_data():
|
| 96 |
+
trace = {
|
| 97 |
+
"question": "Best spot?",
|
| 98 |
+
"model": "Qwen",
|
| 99 |
+
"provider": "hf",
|
| 100 |
+
"events": [
|
| 101 |
+
{"type": "tool_call", "data": {"name": "score_week", "arguments": {"spot_name": "A"}}},
|
| 102 |
+
{"type": "tool_result", "data": {"observation": "[{score: 8.5}]"}},
|
| 103 |
+
],
|
| 104 |
+
}
|
| 105 |
+
result = update_trace_panel(json.dumps(trace))
|
| 106 |
+
assert "Best spot?" in result
|
| 107 |
+
assert "score_week" in result
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def test_update_trace_panel_invalid_json():
|
| 111 |
+
result = update_trace_panel("not json")
|
| 112 |
+
assert result == "not json"
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_build_ui_creates_blocks():
|
| 116 |
+
demo = build_ui()
|
| 117 |
+
assert demo is not None
|
tests/test_daggr.py
ADDED
|
@@ -0,0 +1,145 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.daggr_pipeline — Morning Surf Report DAG."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from wavereader.daggr_pipeline import (
|
| 6 |
+
build_pipeline,
|
| 7 |
+
find_spots_fn,
|
| 8 |
+
fetch_forecasts_fn,
|
| 9 |
+
score_forecasts_fn,
|
| 10 |
+
rank_spots_fn,
|
| 11 |
+
format_report_fn,
|
| 12 |
+
_narration_preprocess,
|
| 13 |
+
_narration_postprocess,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def test_find_spots_fn():
|
| 18 |
+
result = find_spots_fn("QLD", skill=None)
|
| 19 |
+
assert "spots" in result
|
| 20 |
+
assert "region" in result
|
| 21 |
+
assert result["region"] == "QLD"
|
| 22 |
+
assert len(result["spots"]) > 0
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def test_find_spots_fn_all_sentinel_means_no_filter():
|
| 26 |
+
"""The UI's "all" skill sentinel must not be passed to the substring match."""
|
| 27 |
+
unfiltered = find_spots_fn("QLD", skill=None)
|
| 28 |
+
all_sentinel = find_spots_fn("QLD", skill="all")
|
| 29 |
+
assert len(all_sentinel["spots"]) == len(unfiltered["spots"]) > 0
|
| 30 |
+
assert all_sentinel["skill"] is None
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_find_spots_fn_with_skill():
|
| 34 |
+
result = find_spots_fn("QLD", skill="beginner")
|
| 35 |
+
assert "spots" in result
|
| 36 |
+
for spot in result["spots"]:
|
| 37 |
+
assert "beginner" in spot["additional_details"]["skill_level"].lower()
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_pipeline_string_keys_end_to_end(tmp_path, monkeypatch):
|
| 41 |
+
"""fetch → score → rank works with "{name}|{region}" string keys."""
|
| 42 |
+
from wavereader import forecasts as forecasts_mod
|
| 43 |
+
|
| 44 |
+
fake = {
|
| 45 |
+
"hourly": [
|
| 46 |
+
{
|
| 47 |
+
"time": "2024-01-01T06:00",
|
| 48 |
+
"wave_height": 1.5,
|
| 49 |
+
"wave_period": 12,
|
| 50 |
+
"wave_direction": 135,
|
| 51 |
+
"wind_speed_10m": 8,
|
| 52 |
+
"wind_direction_10m": 180,
|
| 53 |
+
}
|
| 54 |
+
]
|
| 55 |
+
}
|
| 56 |
+
monkeypatch.setattr(forecasts_mod, "get_forecast", lambda lat, lng, days=7: fake)
|
| 57 |
+
|
| 58 |
+
spots_found = find_spots_fn("QLD")["spots"][:3]
|
| 59 |
+
fetched = fetch_forecasts_fn(spots_found)
|
| 60 |
+
assert fetched["forecasts"]
|
| 61 |
+
for key in fetched["forecasts"]:
|
| 62 |
+
assert isinstance(key, str) and "|" in key
|
| 63 |
+
|
| 64 |
+
scored = score_forecasts_fn(fetched["forecasts"], spots_found)
|
| 65 |
+
assert set(scored["scored"]) == set(fetched["forecasts"])
|
| 66 |
+
|
| 67 |
+
ranked = rank_spots_fn(scored["scored"], spots_found)
|
| 68 |
+
assert len(ranked["ranked"]) == len(spots_found)
|
| 69 |
+
assert ranked["ranked"][0]["best_score"] >= 0
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_narration_preprocess_flat_prompt():
|
| 73 |
+
inputs = {
|
| 74 |
+
"ranked": [{"name": "A", "region": "QLD", "best_score": 8.0}],
|
| 75 |
+
"region": "QLD",
|
| 76 |
+
}
|
| 77 |
+
result = _narration_preprocess(inputs)
|
| 78 |
+
# daggr's text-generation path sends the first value as a plain prompt
|
| 79 |
+
assert list(result.keys())[0] == "prompt"
|
| 80 |
+
assert isinstance(result["prompt"], str)
|
| 81 |
+
assert "QLD" in result["prompt"]
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def test_narration_postprocess_handles_shapes():
|
| 85 |
+
assert _narration_postprocess("plain text") == {"ai_report": "plain text"}
|
| 86 |
+
assert _narration_postprocess([{"generated_text": "gen"}]) == {"ai_report": "gen"}
|
| 87 |
+
assert _narration_postprocess(42) == {"ai_report": "42"}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def test_format_report_fn_empty():
|
| 91 |
+
result = format_report_fn([], "NSW")
|
| 92 |
+
assert "report" in result
|
| 93 |
+
assert "No spots" in result["report"]
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_format_report_fn_with_data():
|
| 97 |
+
ranked = [
|
| 98 |
+
{
|
| 99 |
+
"name": "Test Spot",
|
| 100 |
+
"region": "QLD",
|
| 101 |
+
"best_score": 8.5,
|
| 102 |
+
"best_time": "2024-01-01T12:00",
|
| 103 |
+
"best_hour": {
|
| 104 |
+
"time": "2024-01-01T12:00",
|
| 105 |
+
"score": 8.5,
|
| 106 |
+
"components": {"swell_size": 9.0, "swell_direction": 8.0, "wind": 8.5, "period": 8.5},
|
| 107 |
+
},
|
| 108 |
+
}
|
| 109 |
+
]
|
| 110 |
+
result = format_report_fn(ranked, "QLD")
|
| 111 |
+
assert "report" in result
|
| 112 |
+
assert "Test Spot" in result["report"]
|
| 113 |
+
assert "8.5" in result["report"]
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def test_format_report_fn_missing_direction_shows_na():
|
| 117 |
+
ranked = [
|
| 118 |
+
{
|
| 119 |
+
"name": "Test Spot",
|
| 120 |
+
"region": "QLD",
|
| 121 |
+
"best_score": 8.5,
|
| 122 |
+
"best_time": "2024-01-01T12:00",
|
| 123 |
+
"best_hour": {
|
| 124 |
+
"time": "2024-01-01T12:00",
|
| 125 |
+
"score": 8.5,
|
| 126 |
+
"components": {"swell_size": 9.0, "swell_direction": None, "wind": 8.5, "period": 8.5},
|
| 127 |
+
},
|
| 128 |
+
}
|
| 129 |
+
]
|
| 130 |
+
result = format_report_fn(ranked, "QLD")
|
| 131 |
+
assert "n/a" in result["report"]
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def test_build_pipeline():
|
| 135 |
+
graph = build_pipeline()
|
| 136 |
+
assert graph is not None
|
| 137 |
+
entry = graph.get_entry_nodes()
|
| 138 |
+
assert len(entry) == 1
|
| 139 |
+
assert entry[0].name == "User Input"
|
| 140 |
+
outputs = graph.get_output_nodes()
|
| 141 |
+
assert "Narrate Report" in outputs
|
| 142 |
+
assert "Format Report" in outputs
|
| 143 |
+
order = graph.get_execution_order()
|
| 144 |
+
assert order[0] == "User Input"
|
| 145 |
+
assert order[-1] in ("Narrate Report", "Format Report")
|
tests/test_forecasts.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Unit tests for wavereader.forecasts — cache, offsets, merge, fallbacks."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import time
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
import httpx
|
| 10 |
+
import pytest
|
| 11 |
+
|
| 12 |
+
from wavereader import forecasts
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# ---------------------------------------------------------------------------
|
| 16 |
+
# Seaward offsets
|
| 17 |
+
# ---------------------------------------------------------------------------
|
| 18 |
+
|
| 19 |
+
def test_offset_east_coast_moves_east():
|
| 20 |
+
# Snapper Rocks (QLD): seaward is east, not the old fixed south-east
|
| 21 |
+
rlat, rlon = forecasts._round_coords(-28.173, 153.556)
|
| 22 |
+
assert rlon > 153.556
|
| 23 |
+
assert rlat == pytest.approx(-28.173, abs=0.01)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_offset_west_coast_moves_west():
|
| 27 |
+
# Margaret River region (WA): seaward is west
|
| 28 |
+
rlat, rlon = forecasts._round_coords(-33.95, 115.0)
|
| 29 |
+
assert rlon < 115.0
|
| 30 |
+
assert rlat == pytest.approx(-33.95, abs=0.01)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_offset_south_coast_moves_south():
|
| 34 |
+
# Bells Beach (VIC): seaward is south
|
| 35 |
+
rlat, rlon = forecasts._round_coords(-38.37, 144.25)
|
| 36 |
+
assert rlat < -38.37
|
| 37 |
+
assert rlon == pytest.approx(144.25, abs=0.01)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def test_offset_tasmania_moves_south():
|
| 41 |
+
rlat, _ = forecasts._round_coords(-41.0, 145.0)
|
| 42 |
+
assert rlat < -41.0
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ---------------------------------------------------------------------------
|
| 46 |
+
# Cache behaviour
|
| 47 |
+
# ---------------------------------------------------------------------------
|
| 48 |
+
|
| 49 |
+
@pytest.fixture
|
| 50 |
+
def cache_env(tmp_path, monkeypatch):
|
| 51 |
+
"""Point the cache at a temp dir and freeze time deterministically."""
|
| 52 |
+
monkeypatch.setattr(forecasts, "CACHE_DIR", tmp_path)
|
| 53 |
+
return tmp_path
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def test_cache_roundtrip_and_ttl(cache_env):
|
| 57 |
+
now = time.time()
|
| 58 |
+
with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"time": "t"}]}) as fetch:
|
| 59 |
+
first = forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 60 |
+
assert fetch.call_count == 1
|
| 61 |
+
assert first["hourly"][0]["time"] == "t"
|
| 62 |
+
|
| 63 |
+
# Envelope has a timestamp
|
| 64 |
+
raw = json.loads(next(cache_env.glob("*.json")).read_text())
|
| 65 |
+
assert raw["fetched_at"] == pytest.approx(now, abs=30)
|
| 66 |
+
|
| 67 |
+
# Fresh cache: served without a second network call
|
| 68 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 69 |
+
assert fetch.call_count == 1
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def test_stale_cache_is_refetched(cache_env):
|
| 73 |
+
with patch.object(forecasts, "_fetch_open_meteo", side_effect=[{"hourly": [{"v": 1}]}, {"hourly": [{"v": 2}]}]):
|
| 74 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 75 |
+
|
| 76 |
+
# Age the cache entry beyond the TTL
|
| 77 |
+
p = next(cache_env.glob("*.json"))
|
| 78 |
+
raw = json.loads(p.read_text())
|
| 79 |
+
raw["fetched_at"] = time.time() - forecasts.CACHE_TTL_SECONDS - 1
|
| 80 |
+
p.write_text(json.dumps(raw))
|
| 81 |
+
|
| 82 |
+
with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"v": 3}]}) as fetch:
|
| 83 |
+
refreshed = forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 84 |
+
assert fetch.call_count == 1
|
| 85 |
+
assert refreshed["hourly"][0]["v"] == 3
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def test_http_failure_falls_back_to_stale_cache(cache_env):
|
| 89 |
+
with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": [{"v": 1}]}):
|
| 90 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 91 |
+
|
| 92 |
+
# Age it, then make the network fail
|
| 93 |
+
p = next(cache_env.glob("*.json"))
|
| 94 |
+
raw = json.loads(p.read_text())
|
| 95 |
+
raw["fetched_at"] = 0.0
|
| 96 |
+
p.write_text(json.dumps(raw))
|
| 97 |
+
|
| 98 |
+
def boom(*args, **kwargs):
|
| 99 |
+
raise httpx.ConnectError("network down")
|
| 100 |
+
|
| 101 |
+
with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
|
| 102 |
+
served = forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 103 |
+
assert served["hourly"][0]["v"] == 1
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def test_http_failure_no_cache_raises(cache_env):
|
| 107 |
+
def boom(*args, **kwargs):
|
| 108 |
+
raise httpx.ConnectError("network down")
|
| 109 |
+
|
| 110 |
+
with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
|
| 111 |
+
with pytest.raises(httpx.ConnectError):
|
| 112 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def test_corrupt_cache_is_ignored(cache_env):
|
| 116 |
+
cache_env.joinpath("-28.00_153.00_2.json").write_text("{not json")
|
| 117 |
+
with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": []}) as fetch:
|
| 118 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 119 |
+
assert fetch.call_count == 1
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def test_legacy_cache_format_still_loads(cache_env):
|
| 123 |
+
# Pre-envelope files (plain frame) are accepted and treated as stale;
|
| 124 |
+
# served when the network fails.
|
| 125 |
+
cache_env.joinpath("-28.00_153.00_2.json").write_text(json.dumps({"hourly": [{"legacy": True}]}))
|
| 126 |
+
|
| 127 |
+
def boom(*args, **kwargs):
|
| 128 |
+
raise httpx.ConnectError("network down")
|
| 129 |
+
|
| 130 |
+
with patch.object(forecasts, "_fetch_open_meteo", side_effect=boom):
|
| 131 |
+
served = forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 132 |
+
assert served["hourly"][0]["legacy"] is True
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def test_cache_key_includes_days(cache_env):
|
| 136 |
+
with patch.object(forecasts, "_fetch_open_meteo", return_value={"hourly": []}):
|
| 137 |
+
forecasts.get_forecast(-28.0, 153.0, days=2)
|
| 138 |
+
forecasts.get_forecast(-28.0, 153.0, days=7)
|
| 139 |
+
keys = {p.name for p in cache_env.glob("*.json")}
|
| 140 |
+
assert len(keys) == 2
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
# ---------------------------------------------------------------------------
|
| 144 |
+
# _build_normalized
|
| 145 |
+
# ---------------------------------------------------------------------------
|
| 146 |
+
|
| 147 |
+
def _marine_payload(times, heights):
|
| 148 |
+
return {"hourly": {
|
| 149 |
+
"time": times,
|
| 150 |
+
"wave_height": heights,
|
| 151 |
+
"wave_period": [10.0] * len(times),
|
| 152 |
+
"wave_direction": [135.0] * len(times),
|
| 153 |
+
"wind_wave_height": [0.5] * len(times),
|
| 154 |
+
"swell_wave_height": [1.0] * len(times),
|
| 155 |
+
}}
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
def _weather_payload(times):
|
| 159 |
+
return {"hourly": {
|
| 160 |
+
"time": times,
|
| 161 |
+
"wind_speed_10m": [18.5] * len(times),
|
| 162 |
+
"wind_direction_10m": [180.0] * len(times),
|
| 163 |
+
}}
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def test_build_normalized_merges_on_time():
|
| 167 |
+
times = ["2024-01-01T00:00", "2024-01-01T01:00"]
|
| 168 |
+
merged = forecasts._build_normalized(_marine_payload(times, [1.2, 1.4]), _weather_payload(times))
|
| 169 |
+
rows = merged["hourly"]
|
| 170 |
+
assert len(rows) == 2
|
| 171 |
+
assert rows[0]["wave_height"] == 1.2
|
| 172 |
+
assert rows[0]["wind_speed_10m"] == 18.5
|
| 173 |
+
assert rows[1]["wave_period"] == 10.0
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def test_build_normalized_missing_weather_hour():
|
| 177 |
+
marine_times = ["2024-01-01T00:00", "2024-01-01T01:00"]
|
| 178 |
+
weather_times = ["2024-01-01T00:00"] # weather ends an hour early
|
| 179 |
+
merged = forecasts._build_normalized(
|
| 180 |
+
_marine_payload(marine_times, [1.2, 1.4]), _weather_payload(weather_times)
|
| 181 |
+
)
|
| 182 |
+
rows = merged["hourly"]
|
| 183 |
+
assert "wind_speed_10m" in rows[0]
|
| 184 |
+
assert "wind_speed_10m" not in rows[1]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def test_build_normalized_keeps_nulls():
|
| 188 |
+
times = ["2024-01-01T00:00", "2024-01-01T01:00"]
|
| 189 |
+
merged = forecasts._build_normalized(_marine_payload(times, [1.2, None]), _weather_payload(times))
|
| 190 |
+
assert merged["hourly"][1]["wave_height"] is None
|
tests/test_scoring.py
CHANGED
|
@@ -83,8 +83,8 @@ def test_swell_size_outside_low():
|
|
| 83 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 84 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 85 |
}
|
| 86 |
-
#
|
| 87 |
-
s = score_hour(
|
| 88 |
assert s < 10.0 and s > 0.0
|
| 89 |
|
| 90 |
|
|
@@ -93,16 +93,24 @@ def test_swell_size_outside_high():
|
|
| 93 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 94 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 95 |
}
|
| 96 |
-
#
|
| 97 |
-
s = score_hour(1.
|
| 98 |
assert s < 10.0 and s > 0.0
|
| 99 |
|
| 100 |
|
| 101 |
-
def
|
|
|
|
| 102 |
spot = {
|
| 103 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 104 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 105 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
assert score_hour(3.0, 10, 5, 135, spot)["components"]["swell_size"] == 0.0
|
| 107 |
|
| 108 |
|
|
@@ -116,7 +124,8 @@ def test_swell_direction_perfect_match():
|
|
| 116 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 117 |
}
|
| 118 |
# SE = 135°
|
| 119 |
-
|
|
|
|
| 120 |
|
| 121 |
|
| 122 |
def test_swell_direction_opposite():
|
|
@@ -125,7 +134,8 @@ def test_swell_direction_opposite():
|
|
| 125 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 126 |
}
|
| 127 |
# SE (135) vs NW (315) → 180° diff → 0
|
| 128 |
-
|
|
|
|
| 129 |
|
| 130 |
|
| 131 |
# ---------------------------------------------------------------------------
|
|
@@ -175,8 +185,9 @@ def test_period_great():
|
|
| 175 |
|
| 176 |
|
| 177 |
def test_period_too_long():
|
| 178 |
-
#
|
| 179 |
assert _score_period(14.0) == 10.0
|
|
|
|
| 180 |
assert _score_period(8.0) > _score_period(5.0)
|
| 181 |
|
| 182 |
|
|
@@ -193,7 +204,26 @@ def test_score_hour_shape():
|
|
| 193 |
}
|
| 194 |
assert 0 <= result["score"] <= 10
|
| 195 |
for v in result["components"].values():
|
| 196 |
-
assert 0 <= v <= 10
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 197 |
|
| 198 |
|
| 199 |
def test_score_hour_contains_raw_inputs():
|
|
@@ -276,6 +306,43 @@ def test_score_week_has_time_and_score():
|
|
| 276 |
assert "score" in results[0]
|
| 277 |
|
| 278 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 279 |
# ---------------------------------------------------------------------------
|
| 280 |
# rank_spots_this_week
|
| 281 |
# ---------------------------------------------------------------------------
|
|
|
|
| 83 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 84 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 85 |
}
|
| 86 |
+
# inside the decay band below the ideal range (~0.91-1.22 m)
|
| 87 |
+
s = score_hour(1.0, 10, 5, 135, spot)["components"]["swell_size"]
|
| 88 |
assert s < 10.0 and s > 0.0
|
| 89 |
|
| 90 |
|
|
|
|
| 93 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 94 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 95 |
}
|
| 96 |
+
# inside the decay band above the ideal range (~1.52-1.83 m)
|
| 97 |
+
s = score_hour(1.7, 10, 5, 135, spot)["components"]["swell_size"]
|
| 98 |
assert s < 10.0 and s > 0.0
|
| 99 |
|
| 100 |
|
| 101 |
+
def test_swell_size_decay_is_continuous():
|
| 102 |
+
"""Score falls smoothly through the lower edge of the ideal range."""
|
| 103 |
spot = {
|
| 104 |
"ideal_swell": {"size_ft_min": 4, "size_ft_max": 5, "direction": "SE"},
|
| 105 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 106 |
}
|
| 107 |
+
inside = score_hour(1.3, 10, 5, 135, spot)["components"]["swell_size"]
|
| 108 |
+
edge = score_hour(1.21, 10, 5, 135, spot)["components"]["swell_size"]
|
| 109 |
+
below = score_hour(1.1, 10, 5, 135, spot)["components"]["swell_size"]
|
| 110 |
+
assert inside == 10.0
|
| 111 |
+
assert edge <= 10.0
|
| 112 |
+
assert 0.0 < below < edge
|
| 113 |
+
# far outside the decay band → 0
|
| 114 |
assert score_hour(3.0, 10, 5, 135, spot)["components"]["swell_size"] == 0.0
|
| 115 |
|
| 116 |
|
|
|
|
| 124 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 125 |
}
|
| 126 |
# SE = 135°
|
| 127 |
+
result = score_hour(1.5, 10, 5, 135, spot, wave_direction_deg=135)
|
| 128 |
+
assert result["components"]["swell_direction"] == 10.0
|
| 129 |
|
| 130 |
|
| 131 |
def test_swell_direction_opposite():
|
|
|
|
| 134 |
"ideal_wind": {"strength_kt_max": 10, "direction": "S"},
|
| 135 |
}
|
| 136 |
# SE (135) vs NW (315) → 180° diff → 0
|
| 137 |
+
result = score_hour(1.5, 10, 5, 315, spot, wave_direction_deg=315)
|
| 138 |
+
assert result["components"]["swell_direction"] == 0.0
|
| 139 |
|
| 140 |
|
| 141 |
# ---------------------------------------------------------------------------
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
def test_period_too_long():
|
| 188 |
+
# 14 s+ is a perfect 10 (no falloff for very long periods)
|
| 189 |
assert _score_period(14.0) == 10.0
|
| 190 |
+
assert _score_period(20.0) == 10.0
|
| 191 |
assert _score_period(8.0) > _score_period(5.0)
|
| 192 |
|
| 193 |
|
|
|
|
| 204 |
}
|
| 205 |
assert 0 <= result["score"] <= 10
|
| 206 |
for v in result["components"].values():
|
| 207 |
+
assert v is None or 0 <= v <= 10
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def test_score_hour_missing_wave_direction():
|
| 211 |
+
"""Unknown swell direction → component None, weight redistributed, no wind substitution."""
|
| 212 |
+
result = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=None)
|
| 213 |
+
assert result["components"]["swell_direction"] is None
|
| 214 |
+
assert result["wave_direction_deg"] is None
|
| 215 |
+
# Direction was NOT silently replaced with wind direction (135 ≠ 0 → not perfect)
|
| 216 |
+
assert result["components"]["swell_direction"] != 10.0
|
| 217 |
+
assert 0 <= result["score"] <= 10
|
| 218 |
+
# With redistributed weights the remaining components still dominate
|
| 219 |
+
assert result["score"] > 0
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def test_score_hour_missing_direction_differs_from_wind_substitution():
|
| 223 |
+
"""Score without swell direction must differ from one scoring wind as swell."""
|
| 224 |
+
with_dir = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=0)
|
| 225 |
+
without_dir = score_hour(1.5, 10, 5, 135, _SPOT, wave_direction_deg=None)
|
| 226 |
+
assert without_dir["score"] != with_dir["score"]
|
| 227 |
|
| 228 |
|
| 229 |
def test_score_hour_contains_raw_inputs():
|
|
|
|
| 306 |
assert "score" in results[0]
|
| 307 |
|
| 308 |
|
| 309 |
+
def test_score_week_skips_null_hours():
|
| 310 |
+
"""Open-Meteo returns null marine hours beyond the horizon — skip, don't crash."""
|
| 311 |
+
forecast = {
|
| 312 |
+
"hourly": [
|
| 313 |
+
{
|
| 314 |
+
"time": "2024-01-01T00:00",
|
| 315 |
+
"wave_height": 1.5,
|
| 316 |
+
"wave_period": 10,
|
| 317 |
+
"wind_speed_10m": 10,
|
| 318 |
+
"wind_direction_10m": 135,
|
| 319 |
+
"wave_direction": 130,
|
| 320 |
+
},
|
| 321 |
+
{
|
| 322 |
+
"time": "2024-01-01T01:00",
|
| 323 |
+
"wave_height": None,
|
| 324 |
+
"wave_period": None,
|
| 325 |
+
"wind_speed_10m": 10,
|
| 326 |
+
"wind_direction_10m": 135,
|
| 327 |
+
"wave_direction": None,
|
| 328 |
+
},
|
| 329 |
+
]
|
| 330 |
+
}
|
| 331 |
+
results = score_week(forecast, _SPOT)
|
| 332 |
+
assert len(results) == 1
|
| 333 |
+
assert results[0]["time"] == "2024-01-01T00:00"
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def test_score_week_all_null_returns_empty():
|
| 337 |
+
forecast = {
|
| 338 |
+
"hourly": [
|
| 339 |
+
{"time": "2024-01-01T00:00", "wave_height": None, "wave_period": None,
|
| 340 |
+
"wind_speed_10m": None, "wind_direction_10m": None}
|
| 341 |
+
]
|
| 342 |
+
}
|
| 343 |
+
assert score_week(forecast, _SPOT) == []
|
| 344 |
+
|
| 345 |
+
|
| 346 |
# ---------------------------------------------------------------------------
|
| 347 |
# rank_spots_this_week
|
| 348 |
# ---------------------------------------------------------------------------
|
tests/test_tools.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Direct wiring tests for wavereader.tools — with forecasts mocked, spots real."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
from wavereader import tools
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
def _fake_forecast(lat, lng, days=7):
|
| 11 |
+
return {
|
| 12 |
+
"hourly": [
|
| 13 |
+
{
|
| 14 |
+
"time": f"2024-01-01T{h:02d}:00",
|
| 15 |
+
"wave_height": 1.5,
|
| 16 |
+
"wave_period": 11,
|
| 17 |
+
"wave_direction": 135,
|
| 18 |
+
"wind_speed_10m": 9,
|
| 19 |
+
"wind_direction_10m": 180,
|
| 20 |
+
}
|
| 21 |
+
for h in range(6)
|
| 22 |
+
]
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_get_forecast_known_spot():
|
| 27 |
+
with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast) as mock_fc:
|
| 28 |
+
result = tools.get_forecast("Snapper Rocks", "QLD")
|
| 29 |
+
assert "hourly" in result
|
| 30 |
+
mock_fc.assert_called_once()
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def test_get_forecast_unknown_spot():
|
| 34 |
+
result = tools.get_forecast("Nowhere", "XX")
|
| 35 |
+
assert "error" in result
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def test_score_week_known_spot():
|
| 39 |
+
with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
|
| 40 |
+
result = tools.score_week("Snapper Rocks", "QLD")
|
| 41 |
+
assert isinstance(result, list)
|
| 42 |
+
assert len(result) == 6
|
| 43 |
+
assert "score" in result[0]
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_score_week_unknown_spot_returns_error_dict():
|
| 47 |
+
"""Bad spot name must be distinguishable from no scoreable hours."""
|
| 48 |
+
result = tools.score_week("Nowhere", "XX")
|
| 49 |
+
assert isinstance(result, dict)
|
| 50 |
+
assert "error" in result
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def test_find_spots_by_query():
|
| 54 |
+
results = tools.find_spots(query="Snapper")
|
| 55 |
+
assert any(r["name"] == "Snapper Rocks" for r in results)
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def test_find_spots_by_skill():
|
| 59 |
+
results = tools.find_spots(query="Bells", skill="advanced")
|
| 60 |
+
for r in results:
|
| 61 |
+
assert "advanced" in r["additional_details"]["skill_level"].lower()
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def test_get_spot_knowledge():
|
| 65 |
+
result = tools.get_spot_knowledge("Bells Beach", "VIC")
|
| 66 |
+
assert result["name"] == "Bells Beach"
|
| 67 |
+
assert "ideal_swell" in result
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def test_get_spot_knowledge_unknown():
|
| 71 |
+
assert "error" in tools.get_spot_knowledge("Nowhere", "XX")
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def test_rank_spots_this_week_ranks_descending():
|
| 75 |
+
with patch("wavereader.forecasts.get_forecast", side_effect=_fake_forecast):
|
| 76 |
+
ranked = tools.rank_spots_this_week("TAS", skill=None)
|
| 77 |
+
assert ranked, "expected at least one TAS break"
|
| 78 |
+
scores = [r["best_score"] for r in ranked]
|
| 79 |
+
assert scores == sorted(scores, reverse=True)
|
| 80 |
+
for r in ranked:
|
| 81 |
+
assert r["region"] == "TAS"
|
| 82 |
+
assert r["best_time"]
|
uv.lock
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
wavereader/agent.py
CHANGED
|
@@ -8,23 +8,26 @@ OpenAI-style tool calling and strict ``json_schema`` on the HF router — see
|
|
| 8 |
``data/GENERATION.md`` before swapping models).
|
| 9 |
|
| 10 |
The agent interprets and explains only; all numbers come from scoring.py /
|
| 11 |
-
forecasts.py via the tools in tools.py. Streaming + trace capture
|
| 12 |
-
trace panel
|
|
|
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
|
|
|
| 17 |
import json
|
| 18 |
import os
|
| 19 |
-
|
| 20 |
from dataclasses import dataclass, field
|
| 21 |
-
from typing import Any
|
| 22 |
|
| 23 |
-
from smolagents import CodeAgent, InferenceClientModel, PromptTemplates, Tool, tool
|
| 24 |
import yaml
|
| 25 |
-
import
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
from wavereader import forecasts, scoring, spots
|
| 28 |
from wavereader.tools import (
|
| 29 |
find_spots,
|
| 30 |
get_forecast,
|
|
@@ -40,10 +43,20 @@ IMPORTANT RULES:
|
|
| 40 |
2. You interpret and explain the data returned by tools. You do not invent wave heights, wind speeds, or surf scores.
|
| 41 |
3. Tide information is qualitative only (from the knowledge base). Open-Meteo does not provide tides — never promise tide curves.
|
| 42 |
4. When the user asks about a specific spot, use get_spot_knowledge first to understand its ideal conditions.
|
| 43 |
-
5.
|
| 44 |
-
6.
|
| 45 |
-
7.
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
"""
|
| 48 |
|
| 49 |
PROVIDER_HF = "hf"
|
|
@@ -57,7 +70,8 @@ NIM_BASE_URL = "https://integrate.api.nvidia.com/v1"
|
|
| 57 |
@dataclass
|
| 58 |
class TraceEvent:
|
| 59 |
"""A single event in the agent trace."""
|
| 60 |
-
|
|
|
|
| 61 |
timestamp: float
|
| 62 |
data: dict[str, Any]
|
| 63 |
|
|
@@ -65,25 +79,29 @@ class TraceEvent:
|
|
| 65 |
@dataclass
|
| 66 |
class AgentTrace:
|
| 67 |
"""Captured trace of an agent run."""
|
|
|
|
| 68 |
events: list[TraceEvent] = field(default_factory=list)
|
| 69 |
question: str = ""
|
| 70 |
model: str = ""
|
| 71 |
provider: str = ""
|
| 72 |
|
| 73 |
def add_event(self, event_type: str, data: dict[str, Any]) -> None:
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 76 |
|
| 77 |
def to_json(self) -> str:
|
| 78 |
-
return json.dumps(
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
| 87 |
|
| 88 |
|
| 89 |
class GetForecastTool(Tool):
|
|
@@ -101,7 +119,11 @@ class GetForecastTool(Tool):
|
|
| 101 |
|
| 102 |
class ScoreWeekTool(Tool):
|
| 103 |
name = "score_week"
|
| 104 |
-
description =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 105 |
inputs = {
|
| 106 |
"spot_name": {"type": "string", "description": "Name of the surf spot"},
|
| 107 |
"region": {"type": "string", "description": "Australian state/region"},
|
|
@@ -114,16 +136,26 @@ class ScoreWeekTool(Tool):
|
|
| 114 |
|
| 115 |
class FindSpotsTool(Tool):
|
| 116 |
name = "find_spots"
|
| 117 |
-
description =
|
|
|
|
|
|
|
|
|
|
| 118 |
inputs = {
|
| 119 |
-
"query": {"type": "string", "description": "
|
|
|
|
| 120 |
"skill": {"type": "string", "description": "Skill level filter (beginner, intermediate, advanced, expert)", "nullable": True},
|
| 121 |
"limit": {"type": "integer", "description": "Maximum number of results to return", "nullable": True},
|
| 122 |
}
|
| 123 |
output_type = "object"
|
| 124 |
|
| 125 |
-
def forward(
|
| 126 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
|
| 128 |
|
| 129 |
class GetSpotKnowledgeTool(Tool):
|
|
@@ -141,7 +173,11 @@ class GetSpotKnowledgeTool(Tool):
|
|
| 141 |
|
| 142 |
class RankSpotsThisWeekTool(Tool):
|
| 143 |
name = "rank_spots_this_week"
|
| 144 |
-
description =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
inputs = {
|
| 146 |
"region": {"type": "string", "description": "Australian state/region"},
|
| 147 |
"skill": {"type": "string", "description": "Skill level filter", "nullable": True},
|
|
@@ -161,38 +197,44 @@ TOOLS = [
|
|
| 161 |
]
|
| 162 |
|
| 163 |
|
| 164 |
-
def _build_model(provider: str, model: str | None = None) ->
|
| 165 |
-
"""Build the
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
model_id = model or DEFAULT_MODEL
|
| 167 |
|
| 168 |
if provider == PROVIDER_HF:
|
| 169 |
token = os.getenv("HF_TOKEN")
|
| 170 |
if not token:
|
| 171 |
-
raise ValueError(
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
model_id=model_id,
|
| 174 |
api_base=HF_BASE_URL,
|
| 175 |
-
|
| 176 |
)
|
| 177 |
elif provider == PROVIDER_NIM:
|
| 178 |
api_key = os.getenv("NVIDIA_API_KEY")
|
| 179 |
if not api_key:
|
| 180 |
-
raise ValueError(
|
| 181 |
-
|
|
|
|
|
|
|
| 182 |
model_id=model_id,
|
| 183 |
api_base=NIM_BASE_URL,
|
| 184 |
-
|
| 185 |
)
|
| 186 |
else:
|
| 187 |
raise ValueError(f"Unknown provider: {provider}. Use 'hf' or 'nim'")
|
| 188 |
|
| 189 |
|
| 190 |
def _detect_provider() -> str:
|
| 191 |
-
"""Auto-detect provider from available env vars."""
|
| 192 |
if os.getenv("NVIDIA_API_KEY"):
|
| 193 |
return PROVIDER_NIM
|
| 194 |
-
if os.getenv("HF_TOKEN"):
|
| 195 |
-
return PROVIDER_HF
|
| 196 |
return PROVIDER_HF
|
| 197 |
|
| 198 |
|
|
@@ -207,8 +249,12 @@ class SurfAgent:
|
|
| 207 |
self.provider = provider or _detect_provider()
|
| 208 |
self.model = model or DEFAULT_MODEL
|
| 209 |
self._model = _build_model(self.provider, self.model)
|
| 210 |
-
# Load default prompt templates
|
| 211 |
-
default_templates_yaml =
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
default_templates = yaml.safe_load(default_templates_yaml)
|
| 213 |
default_templates["system_prompt"] = SYSTEM_PROMPT
|
| 214 |
prompt_templates = PromptTemplates(**default_templates)
|
|
@@ -224,54 +270,88 @@ class SurfAgent:
|
|
| 224 |
def trace(self) -> AgentTrace | None:
|
| 225 |
return self._trace
|
| 226 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
def run(self, question: str) -> str:
|
| 228 |
"""Run the agent synchronously and return the final answer."""
|
| 229 |
-
self._trace = AgentTrace(
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
self._trace = AgentTrace(question=question, model=self.model, provider=self.provider)
|
| 235 |
-
|
| 236 |
-
# Wrap the agent's run to capture trace events
|
| 237 |
-
from smolagents.agents import MultiStepAgent
|
| 238 |
-
from smolagents.memory import ActionStep
|
| 239 |
-
|
| 240 |
-
original_step = self._agent.step
|
| 241 |
-
|
| 242 |
-
def traced_step(memory_step: ActionStep, *args, **kwargs):
|
| 243 |
-
# Capture tool calls
|
| 244 |
-
if memory_step.tool_calls:
|
| 245 |
-
for tc in memory_step.tool_calls:
|
| 246 |
-
self._trace.add_event("tool_call", {
|
| 247 |
-
"name": tc.name,
|
| 248 |
-
"arguments": tc.arguments,
|
| 249 |
-
"id": tc.id,
|
| 250 |
-
})
|
| 251 |
-
# Capture tool results
|
| 252 |
-
if memory_step.observations:
|
| 253 |
-
for obs in memory_step.observations:
|
| 254 |
-
self._trace.add_event("tool_result", {"observation": str(obs)})
|
| 255 |
-
return original_step(memory_step, *args, **kwargs)
|
| 256 |
-
|
| 257 |
-
self._agent.step = traced_step
|
| 258 |
-
|
| 259 |
try:
|
| 260 |
-
|
| 261 |
-
if self._trace:
|
| 262 |
-
self._trace.add_event("llm_chunk", {"chunk": str(chunk)})
|
| 263 |
-
yield chunk
|
| 264 |
finally:
|
| 265 |
-
self._agent.
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
"""Run the surf agent and return the answer."""
|
| 270 |
agent = SurfAgent(provider=provider, model=model)
|
| 271 |
return agent.run(question)
|
| 272 |
|
| 273 |
|
| 274 |
-
def run_agent_stream(
|
|
|
|
|
|
|
| 275 |
"""Run the surf agent with streaming."""
|
| 276 |
agent = SurfAgent(provider=provider, model=model)
|
| 277 |
yield from agent.run_stream(question)
|
|
@@ -279,9 +359,17 @@ def run_agent_stream(question: str, provider: str | None = None, model: str | No
|
|
| 279 |
|
| 280 |
if __name__ == "__main__":
|
| 281 |
import sys
|
| 282 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
print(f"Question: {question}\n")
|
| 284 |
print("Answer:")
|
| 285 |
-
for chunk in run_agent_stream(question):
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
``data/GENERATION.md`` before swapping models).
|
| 9 |
|
| 10 |
The agent interprets and explains only; all numbers come from scoring.py /
|
| 11 |
+
forecasts.py via the tools in tools.py. Streaming + trace capture feed the UI
|
| 12 |
+
trace panel; the UI renders charts itself from the trace (the agent never
|
| 13 |
+
needs to plot).
|
| 14 |
"""
|
| 15 |
|
| 16 |
from __future__ import annotations
|
| 17 |
|
| 18 |
+
import importlib.resources
|
| 19 |
import json
|
| 20 |
import os
|
| 21 |
+
import time
|
| 22 |
from dataclasses import dataclass, field
|
| 23 |
+
from typing import Any, Generator
|
| 24 |
|
|
|
|
| 25 |
import yaml
|
| 26 |
+
from smolagents import CodeAgent, InferenceClientModel, OpenAIServerModel, PromptTemplates, Tool
|
| 27 |
+
from smolagents.agents import ToolOutput
|
| 28 |
+
from smolagents.memory import FinalAnswerStep, ToolCall as SmolToolCall
|
| 29 |
+
from smolagents.models import ChatMessageStreamDelta
|
| 30 |
|
|
|
|
| 31 |
from wavereader.tools import (
|
| 32 |
find_spots,
|
| 33 |
get_forecast,
|
|
|
|
| 43 |
2. You interpret and explain the data returned by tools. You do not invent wave heights, wind speeds, or surf scores.
|
| 44 |
3. Tide information is qualitative only (from the knowledge base). Open-Meteo does not provide tides — never promise tide curves.
|
| 45 |
4. When the user asks about a specific spot, use get_spot_knowledge first to understand its ideal conditions.
|
| 46 |
+
5. Always cite the data source (tool name) when giving numbers.
|
| 47 |
+
6. Be concise and practical — surfers want actionable recommendations.
|
| 48 |
+
7. The UI renders charts automatically whenever you surface forecast or score data — never describe plots or say you cannot show them.
|
| 49 |
+
|
| 50 |
+
CALL TOOLS EXACTLY LIKE THIS (keyword spellings matter):
|
| 51 |
+
- find_spots(query="QLD", skill="beginner") # query = spot name OR state code: NSW, QLD, VIC, WA, SA, TAS
|
| 52 |
+
- get_spot_knowledge(spot_name="Snapper Rocks", region="QLD")
|
| 53 |
+
- get_forecast(spot_name="Snapper Rocks", region="QLD")
|
| 54 |
+
- score_week(spot_name="Snapper Rocks", region="QLD") # ONE spot per call
|
| 55 |
+
- rank_spots_this_week(region="QLD", skill="beginner") # use this to COMPARE many spots in a state
|
| 56 |
+
|
| 57 |
+
Workflow: to answer "where should I surf in <state>", call rank_spots_this_week first,
|
| 58 |
+
then get_spot_knowledge + score_week for the top 1-2 spots. Never invent keyword
|
| 59 |
+
names — use exactly the ones above.
|
| 60 |
"""
|
| 61 |
|
| 62 |
PROVIDER_HF = "hf"
|
|
|
|
| 70 |
@dataclass
|
| 71 |
class TraceEvent:
|
| 72 |
"""A single event in the agent trace."""
|
| 73 |
+
|
| 74 |
+
type: str # "tool_call", "tool_result", "llm_chunk", "final_answer", "error"
|
| 75 |
timestamp: float
|
| 76 |
data: dict[str, Any]
|
| 77 |
|
|
|
|
| 79 |
@dataclass
|
| 80 |
class AgentTrace:
|
| 81 |
"""Captured trace of an agent run."""
|
| 82 |
+
|
| 83 |
events: list[TraceEvent] = field(default_factory=list)
|
| 84 |
question: str = ""
|
| 85 |
model: str = ""
|
| 86 |
provider: str = ""
|
| 87 |
|
| 88 |
def add_event(self, event_type: str, data: dict[str, Any]) -> None:
|
| 89 |
+
self.events.append(
|
| 90 |
+
TraceEvent(type=event_type, timestamp=time.time(), data=data)
|
| 91 |
+
)
|
| 92 |
|
| 93 |
def to_json(self) -> str:
|
| 94 |
+
return json.dumps(
|
| 95 |
+
{
|
| 96 |
+
"question": self.question,
|
| 97 |
+
"model": self.model,
|
| 98 |
+
"provider": self.provider,
|
| 99 |
+
"events": [
|
| 100 |
+
{"type": e.type, "timestamp": e.timestamp, "data": e.data}
|
| 101 |
+
for e in self.events
|
| 102 |
+
],
|
| 103 |
+
}
|
| 104 |
+
)
|
| 105 |
|
| 106 |
|
| 107 |
class GetForecastTool(Tool):
|
|
|
|
| 119 |
|
| 120 |
class ScoreWeekTool(Tool):
|
| 121 |
name = "score_week"
|
| 122 |
+
description = (
|
| 123 |
+
"Get hour-by-hour surf scores for the next 7 days at ONE spot. "
|
| 124 |
+
"Example: score_week(spot_name=\"Snapper Rocks\", region=\"QLD\"). "
|
| 125 |
+
"To compare many spots, use rank_spots_this_week instead."
|
| 126 |
+
)
|
| 127 |
inputs = {
|
| 128 |
"spot_name": {"type": "string", "description": "Name of the surf spot"},
|
| 129 |
"region": {"type": "string", "description": "Australian state/region"},
|
|
|
|
| 136 |
|
| 137 |
class FindSpotsTool(Tool):
|
| 138 |
name = "find_spots"
|
| 139 |
+
description = (
|
| 140 |
+
"Search breaks by name or state code with optional skill filter. "
|
| 141 |
+
"Example: find_spots(query=\"QLD\", skill=\"beginner\")"
|
| 142 |
+
)
|
| 143 |
inputs = {
|
| 144 |
+
"query": {"type": "string", "description": "Spot name OR Australian state code (NSW, QLD, VIC, WA, SA, TAS)", "nullable": True},
|
| 145 |
+
"region": {"type": "string", "description": "Alias for query when searching a state code", "nullable": True},
|
| 146 |
"skill": {"type": "string", "description": "Skill level filter (beginner, intermediate, advanced, expert)", "nullable": True},
|
| 147 |
"limit": {"type": "integer", "description": "Maximum number of results to return", "nullable": True},
|
| 148 |
}
|
| 149 |
output_type = "object"
|
| 150 |
|
| 151 |
+
def forward(
|
| 152 |
+
self,
|
| 153 |
+
query: str = "",
|
| 154 |
+
region: str | None = None,
|
| 155 |
+
skill: str | None = None,
|
| 156 |
+
limit: int = 10,
|
| 157 |
+
) -> list[dict]:
|
| 158 |
+
return find_spots(query=query or region or "", skill=skill, limit=limit)
|
| 159 |
|
| 160 |
|
| 161 |
class GetSpotKnowledgeTool(Tool):
|
|
|
|
| 173 |
|
| 174 |
class RankSpotsThisWeekTool(Tool):
|
| 175 |
name = "rank_spots_this_week"
|
| 176 |
+
description = (
|
| 177 |
+
"Rank ALL spots in a state by their best score this week. "
|
| 178 |
+
"Example: rank_spots_this_week(region=\"QLD\", skill=\"beginner\"). "
|
| 179 |
+
"Use this first for 'where should I surf' questions."
|
| 180 |
+
)
|
| 181 |
inputs = {
|
| 182 |
"region": {"type": "string", "description": "Australian state/region"},
|
| 183 |
"skill": {"type": "string", "description": "Skill level filter", "nullable": True},
|
|
|
|
| 197 |
]
|
| 198 |
|
| 199 |
|
| 200 |
+
def _build_model(provider: str, model: str | None = None) -> OpenAIServerModel:
|
| 201 |
+
"""Build the OpenAI-compatible model client for the given provider.
|
| 202 |
+
|
| 203 |
+
Both the HF router and NVIDIA NIM speak the OpenAI chat-completions
|
| 204 |
+
protocol, so one client class covers the HF⇄NIM switch.
|
| 205 |
+
"""
|
| 206 |
model_id = model or DEFAULT_MODEL
|
| 207 |
|
| 208 |
if provider == PROVIDER_HF:
|
| 209 |
token = os.getenv("HF_TOKEN")
|
| 210 |
if not token:
|
| 211 |
+
raise ValueError(
|
| 212 |
+
"HF_TOKEN environment variable not set for Hugging Face provider"
|
| 213 |
+
)
|
| 214 |
+
return OpenAIServerModel(
|
| 215 |
model_id=model_id,
|
| 216 |
api_base=HF_BASE_URL,
|
| 217 |
+
api_key=token,
|
| 218 |
)
|
| 219 |
elif provider == PROVIDER_NIM:
|
| 220 |
api_key = os.getenv("NVIDIA_API_KEY")
|
| 221 |
if not api_key:
|
| 222 |
+
raise ValueError(
|
| 223 |
+
"NVIDIA_API_KEY environment variable not set for NVIDIA NIM provider"
|
| 224 |
+
)
|
| 225 |
+
return OpenAIServerModel(
|
| 226 |
model_id=model_id,
|
| 227 |
api_base=NIM_BASE_URL,
|
| 228 |
+
api_key=api_key,
|
| 229 |
)
|
| 230 |
else:
|
| 231 |
raise ValueError(f"Unknown provider: {provider}. Use 'hf' or 'nim'")
|
| 232 |
|
| 233 |
|
| 234 |
def _detect_provider() -> str:
|
| 235 |
+
"""Auto-detect provider from available env vars (NIM preferred)."""
|
| 236 |
if os.getenv("NVIDIA_API_KEY"):
|
| 237 |
return PROVIDER_NIM
|
|
|
|
|
|
|
| 238 |
return PROVIDER_HF
|
| 239 |
|
| 240 |
|
|
|
|
| 249 |
self.provider = provider or _detect_provider()
|
| 250 |
self.model = model or DEFAULT_MODEL
|
| 251 |
self._model = _build_model(self.provider, self.model)
|
| 252 |
+
# Load smolagents' default prompt templates, then override system prompt
|
| 253 |
+
default_templates_yaml = (
|
| 254 |
+
importlib.resources.files("smolagents.prompts")
|
| 255 |
+
.joinpath("code_agent.yaml")
|
| 256 |
+
.read_text()
|
| 257 |
+
)
|
| 258 |
default_templates = yaml.safe_load(default_templates_yaml)
|
| 259 |
default_templates["system_prompt"] = SYSTEM_PROMPT
|
| 260 |
prompt_templates = PromptTemplates(**default_templates)
|
|
|
|
| 270 |
def trace(self) -> AgentTrace | None:
|
| 271 |
return self._trace
|
| 272 |
|
| 273 |
+
def _capture_item(self, item: Any) -> bool:
|
| 274 |
+
"""Record a tool_call/tool_result from a smolagents stream item.
|
| 275 |
+
|
| 276 |
+
Returns True if the item was consumed as a tool event.
|
| 277 |
+
"""
|
| 278 |
+
if self._trace is None:
|
| 279 |
+
return False
|
| 280 |
+
if isinstance(item, SmolToolCall):
|
| 281 |
+
self._trace.add_event(
|
| 282 |
+
"tool_call",
|
| 283 |
+
{"name": item.name, "arguments": item.arguments, "id": item.id},
|
| 284 |
+
)
|
| 285 |
+
return True
|
| 286 |
+
if isinstance(item, ToolOutput):
|
| 287 |
+
name = item.tool_call.name if item.tool_call is not None else None
|
| 288 |
+
self._trace.add_event(
|
| 289 |
+
"tool_result",
|
| 290 |
+
{"name": name, "observation": str(item.observation)},
|
| 291 |
+
)
|
| 292 |
+
return True
|
| 293 |
+
return False
|
| 294 |
+
|
| 295 |
+
def _traced_step_stream(self, original_step_stream):
|
| 296 |
+
"""Wrap a _step_stream generator so run() also captures tool events."""
|
| 297 |
+
|
| 298 |
+
def traced(memory_step, *args, **kwargs):
|
| 299 |
+
for item in original_step_stream(memory_step, *args, **kwargs):
|
| 300 |
+
self._capture_item(item)
|
| 301 |
+
yield item
|
| 302 |
+
|
| 303 |
+
return traced
|
| 304 |
+
|
| 305 |
def run(self, question: str) -> str:
|
| 306 |
"""Run the agent synchronously and return the final answer."""
|
| 307 |
+
self._trace = AgentTrace(
|
| 308 |
+
question=question, model=self.model, provider=self.provider
|
| 309 |
+
)
|
| 310 |
+
original_step_stream = self._agent._step_stream
|
| 311 |
+
self._agent._step_stream = self._traced_step_stream(original_step_stream)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
try:
|
| 313 |
+
return self._agent.run(question)
|
|
|
|
|
|
|
|
|
|
| 314 |
finally:
|
| 315 |
+
self._agent._step_stream = original_step_stream
|
| 316 |
+
|
| 317 |
+
def run_stream(
|
| 318 |
+
self, question: str
|
| 319 |
+
) -> Generator[tuple[str, Any], None, None]:
|
| 320 |
+
"""Run the agent with streaming.
|
| 321 |
+
|
| 322 |
+
Yields ("model", delta) for LLM tokens, ("tool", tool_name) when a
|
| 323 |
+
tool call fires, and ("final", answer) at the end. Tool activity is
|
| 324 |
+
captured into the trace from the stream itself.
|
| 325 |
+
"""
|
| 326 |
+
self._trace = AgentTrace(
|
| 327 |
+
question=question, model=self.model, provider=self.provider
|
| 328 |
+
)
|
| 329 |
+
for item in self._agent.run(question, stream=True):
|
| 330 |
+
if isinstance(item, ChatMessageStreamDelta):
|
| 331 |
+
if item.content:
|
| 332 |
+
self._trace.add_event("llm_chunk", {"chunk": item.content})
|
| 333 |
+
yield ("model", item.content)
|
| 334 |
+
continue
|
| 335 |
+
if isinstance(item, FinalAnswerStep):
|
| 336 |
+
self._trace.add_event("final_answer", {"answer": item.output})
|
| 337 |
+
yield ("final", item.output)
|
| 338 |
+
continue
|
| 339 |
+
if self._capture_item(item) and isinstance(item, SmolToolCall):
|
| 340 |
+
name = item.name if isinstance(item.name, str) else "code action"
|
| 341 |
+
yield ("tool", name)
|
| 342 |
+
|
| 343 |
+
|
| 344 |
+
def run_agent(
|
| 345 |
+
question: str, provider: str | None = None, model: str | None = None
|
| 346 |
+
) -> str:
|
| 347 |
"""Run the surf agent and return the answer."""
|
| 348 |
agent = SurfAgent(provider=provider, model=model)
|
| 349 |
return agent.run(question)
|
| 350 |
|
| 351 |
|
| 352 |
+
def run_agent_stream(
|
| 353 |
+
question: str, provider: str | None = None, model: str | None = None
|
| 354 |
+
):
|
| 355 |
"""Run the surf agent with streaming."""
|
| 356 |
agent = SurfAgent(provider=provider, model=model)
|
| 357 |
yield from agent.run_stream(question)
|
|
|
|
| 359 |
|
| 360 |
if __name__ == "__main__":
|
| 361 |
import sys
|
| 362 |
+
|
| 363 |
+
question = (
|
| 364 |
+
" ".join(sys.argv[1:])
|
| 365 |
+
if len(sys.argv) > 1
|
| 366 |
+
else "What time should I surf Snapper Rocks today?"
|
| 367 |
+
)
|
| 368 |
print(f"Question: {question}\n")
|
| 369 |
print("Answer:")
|
| 370 |
+
for kind, chunk in run_agent_stream(question):
|
| 371 |
+
if kind == "model":
|
| 372 |
+
print(chunk, end="", flush=True)
|
| 373 |
+
elif kind == "tool":
|
| 374 |
+
print(f"\n[tool: {chunk}]", flush=True)
|
| 375 |
+
print()
|
wavereader/api.py
CHANGED
|
@@ -6,35 +6,85 @@ and any non-Gradio consumer. Endpoints: /spots, /forecast, /score, /ask.
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
-
from fastapi import FastAPI
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
|
| 11 |
app = FastAPI(title="wavereader", version="0.1.0")
|
| 12 |
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
@app.get("/")
|
| 15 |
async def read_root():
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
@app.get("/spots")
|
| 20 |
-
async def list_spots(query: str = "", region: str | None = None, skill: str | None = None):
|
| 21 |
-
"""Search/validate breaks via spots.py.
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
|
| 24 |
|
| 25 |
@app.get("/forecast")
|
| 26 |
-
async def forecast(spot: str, days: int = 7):
|
| 27 |
-
"""Hourly marine + wind forecast via forecasts.py.
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
@app.get("/score")
|
| 32 |
-
async def score(spot: str, days: int = 7):
|
| 33 |
-
"""Deterministic hourly surf scores via scoring.py.
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
raise NotImplementedError
|
|
|
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
+
from fastapi import FastAPI, HTTPException
|
| 10 |
+
from pydantic import BaseModel
|
| 11 |
+
|
| 12 |
+
from wavereader import forecasts, scoring, spots
|
| 13 |
+
from wavereader.agent import run_agent, SurfAgent
|
| 14 |
|
| 15 |
app = FastAPI(title="wavereader", version="0.1.0")
|
| 16 |
|
| 17 |
|
| 18 |
+
class AskRequest(BaseModel):
|
| 19 |
+
question: str
|
| 20 |
+
provider: str | None = None
|
| 21 |
+
model: str | None = None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
class AskResponse(BaseModel):
|
| 25 |
+
answer: str
|
| 26 |
+
trace: str | None = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
@app.get("/")
|
| 30 |
async def read_root():
|
| 31 |
+
"""Health check."""
|
| 32 |
+
return {"name": "wavereader", "version": "0.1.0", "status": "ok"}
|
| 33 |
|
| 34 |
|
| 35 |
@app.get("/spots")
|
| 36 |
+
async def list_spots(query: str = "", region: str | None = None, skill: str | None = None, limit: int = 10):
|
| 37 |
+
"""Search/validate breaks via spots.py."""
|
| 38 |
+
results = spots.find_spots(query=query, region=region, skill=skill, limit=limit)
|
| 39 |
+
return [b.model_dump() for b in results]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@app.get("/spots/{spot_name}")
|
| 43 |
+
async def get_spot(spot_name: str, region: str):
|
| 44 |
+
"""Get full knowledge-base record for a spot keyed by (name, region).
|
| 45 |
+
|
| 46 |
+
``region`` is a required query param: ``/spots/Snapper Rocks?region=QLD``.
|
| 47 |
+
"""
|
| 48 |
+
spot = spots.get_spot(spot_name, region)
|
| 49 |
+
if spot is None:
|
| 50 |
+
raise HTTPException(status_code=404, detail=f"Spot '{spot_name}' ({region}) not found")
|
| 51 |
+
return spot.model_dump()
|
| 52 |
|
| 53 |
|
| 54 |
@app.get("/forecast")
|
| 55 |
+
async def forecast(spot: str, region: str, days: int = 7):
|
| 56 |
+
"""Hourly marine + wind forecast via forecasts.py."""
|
| 57 |
+
s = spots.get_spot(spot, region)
|
| 58 |
+
if s is None:
|
| 59 |
+
raise HTTPException(status_code=404, detail=f"Spot '{spot}' ({region}) not found")
|
| 60 |
+
return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng, days=days)
|
| 61 |
|
| 62 |
|
| 63 |
@app.get("/score")
|
| 64 |
+
async def score(spot: str, region: str, days: int = 7):
|
| 65 |
+
"""Deterministic hourly surf scores via scoring.py."""
|
| 66 |
+
s = spots.get_spot(spot, region)
|
| 67 |
+
if s is None:
|
| 68 |
+
raise HTTPException(status_code=404, detail=f"Spot '{spot}' ({region}) not found")
|
| 69 |
+
fc = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng, days=days)
|
| 70 |
+
return scoring.score_week(fc, s.model_dump())
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
@app.post("/ask", response_model=AskResponse)
|
| 74 |
+
async def ask(request: AskRequest):
|
| 75 |
+
"""Agent answer via agent.py tool loop."""
|
| 76 |
+
try:
|
| 77 |
+
agent = SurfAgent(provider=request.provider, model=request.model)
|
| 78 |
+
answer = agent.run(request.question)
|
| 79 |
+
except ValueError as exc:
|
| 80 |
+
# Missing/unknown provider config (e.g. unset HF_TOKEN/NVIDIA_API_KEY)
|
| 81 |
+
raise HTTPException(status_code=503, detail=str(exc)) from exc
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
raise HTTPException(status_code=502, detail=f"Agent run failed: {exc}") from exc
|
| 84 |
+
trace_json = agent.trace.to_json() if agent.trace else None
|
| 85 |
+
return AskResponse(answer=answer, trace=trace_json)
|
| 86 |
|
| 87 |
|
| 88 |
+
if __name__ == "__main__":
|
| 89 |
+
import uvicorn
|
| 90 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
wavereader/app.py
CHANGED
|
@@ -1,24 +1,867 @@
|
|
| 1 |
-
"""Gradio
|
|
|
|
|
|
|
| 2 |
|
| 3 |
-
|
| 4 |
-
agent, interactive seaborn-styled charts (plotly/altair aesthetics) rendered
|
| 5 |
-
whenever the agent surfaces forecast/wind data, and the agent trace panel.
|
| 6 |
-
Calls the core modules (and FastAPI endpoints) — no custom frontend this
|
| 7 |
-
week.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"""
|
| 19 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
demo = build_ui()
|
| 24 |
-
demo.launch()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio UI for wavereader: Australia map home, agent chat with trace panel,
|
| 2 |
+
interactive seaborn-styled plotly charts, weekly rankings, and the daggr
|
| 3 |
+
Morning Surf Report canvas.
|
| 4 |
|
| 5 |
+
Run with: uv run python -m wavereader.app
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"""
|
| 7 |
|
| 8 |
from __future__ import annotations
|
| 9 |
|
| 10 |
+
import json
|
| 11 |
+
import os
|
| 12 |
+
import re
|
| 13 |
+
import subprocess
|
| 14 |
+
import sys
|
| 15 |
+
import threading
|
| 16 |
|
| 17 |
+
import gradio as gr
|
| 18 |
+
import plotly.graph_objects as go
|
| 19 |
|
| 20 |
+
from wavereader import spots, tools
|
| 21 |
+
from wavereader.agent import PROVIDER_HF, PROVIDER_NIM, SurfAgent
|
| 22 |
+
|
| 23 |
+
# ---------------------------------------------------------------------------
|
| 24 |
+
# Styling: seaborn "muted" palette applied to plotly
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
|
| 27 |
+
MUTED = {
|
| 28 |
+
"blue": "#4878D0",
|
| 29 |
+
"orange": "#EE854A",
|
| 30 |
+
"green": "#6ACC64",
|
| 31 |
+
"red": "#D65F5F",
|
| 32 |
+
"purple": "#B47CC7",
|
| 33 |
+
"brown": "#8C613C",
|
| 34 |
+
"pink": "#DC7EC0",
|
| 35 |
+
"gray": "#797979",
|
| 36 |
+
"gold": "#D5BB67",
|
| 37 |
+
"cyan": "#82C6E2",
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
SKILL_COLORS = {
|
| 41 |
+
"beginner": MUTED["green"],
|
| 42 |
+
"intermediate": MUTED["cyan"],
|
| 43 |
+
"advanced": MUTED["orange"],
|
| 44 |
+
"expert": MUTED["red"],
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
REGIONS = ["NSW", "QLD", "VIC", "WA", "SA", "TAS"]
|
| 48 |
+
SKILL_FILTERS = ["all", "beginner", "intermediate", "advanced", "expert"]
|
| 49 |
+
|
| 50 |
+
_DEG_TO_COMPASS = [
|
| 51 |
+
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
|
| 52 |
+
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
|
| 53 |
+
]
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _compass(deg) -> str:
|
| 57 |
+
if deg is None:
|
| 58 |
+
return "–"
|
| 59 |
+
try:
|
| 60 |
+
deg = float(deg)
|
| 61 |
+
except (TypeError, ValueError):
|
| 62 |
+
return "–"
|
| 63 |
+
return _DEG_TO_COMPASS[int((deg % 360) / 22.5) % 16]
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def _primary_skill(skill_level: str) -> str:
|
| 67 |
+
"""Collapse compound skill strings ('beginner to intermediate') to a bucket."""
|
| 68 |
+
s = (skill_level or "").lower()
|
| 69 |
+
if "elite" in s or "professional" in s:
|
| 70 |
+
return "expert"
|
| 71 |
+
for level in ("beginner", "intermediate", "advanced", "expert"):
|
| 72 |
+
if level in s:
|
| 73 |
+
return level
|
| 74 |
+
return "intermediate"
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _matches_skill(skill_level: str, wanted: str) -> bool:
|
| 78 |
+
if wanted in (None, "", "all"):
|
| 79 |
+
return True
|
| 80 |
+
s = (skill_level or "").lower()
|
| 81 |
+
if wanted == "expert":
|
| 82 |
+
return "expert" in s or "elite" in s or "professional" in s
|
| 83 |
+
return wanted in s
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def create_seaborn_template() -> dict:
|
| 87 |
+
"""Plotly layout template with seaborn aesthetics (whitegrid + muted)."""
|
| 88 |
+
return {
|
| 89 |
+
"layout": {
|
| 90 |
+
"font": {"family": "DejaVu Sans, Helvetica, Arial, sans-serif", "size": 13, "color": "#3b3b3b"},
|
| 91 |
+
"paper_bgcolor": "white",
|
| 92 |
+
"plot_bgcolor": "white",
|
| 93 |
+
"colorway": list(MUTED.values()),
|
| 94 |
+
"title": {"font": {"size": 17, "color": "#1f2d3d"}, "x": 0.01, "xanchor": "left"},
|
| 95 |
+
"legend": {"bgcolor": "rgba(0,0,0,0)", "orientation": "h", "y": 1.08},
|
| 96 |
+
"xaxis": {"gridcolor": "#e6e6e6", "zerolinecolor": "#e6e6e6", "linecolor": "#cccccc"},
|
| 97 |
+
"yaxis": {"gridcolor": "#e6e6e6", "zerolinecolor": "#e6e6e6", "linecolor": "#cccccc"},
|
| 98 |
+
"margin": {"l": 60, "r": 70, "t": 70, "b": 55},
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _style(fig: go.Figure) -> go.Figure:
|
| 104 |
+
fig.update_layout(**create_seaborn_template()["layout"])
|
| 105 |
+
return fig
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _empty_fig(message: str) -> go.Figure:
|
| 109 |
+
fig = go.Figure()
|
| 110 |
+
fig.add_annotation(text=message, showarrow=False, font=dict(size=15, color="#888888"))
|
| 111 |
+
fig.update_xaxes(visible=False)
|
| 112 |
+
fig.update_yaxes(visible=False)
|
| 113 |
+
return _style(fig)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ---------------------------------------------------------------------------
|
| 117 |
+
# Charts (plotly, seaborn-styled)
|
| 118 |
+
# ---------------------------------------------------------------------------
|
| 119 |
+
|
| 120 |
+
def create_australia_map(skill_filter: str = "all") -> go.Figure:
|
| 121 |
+
"""Scattergeo of all knowledge-base breaks, coloured by skill bucket."""
|
| 122 |
+
lats, lngs, names, texts, colors = [], [], [], [], []
|
| 123 |
+
for b in spots.load_breaks():
|
| 124 |
+
if not _matches_skill(b.additional_details.skill_level, skill_filter):
|
| 125 |
+
continue
|
| 126 |
+
swell = b.ideal_swell
|
| 127 |
+
wind = b.ideal_wind
|
| 128 |
+
lats.append(b.coordinates.lat)
|
| 129 |
+
lngs.append(b.coordinates.lng)
|
| 130 |
+
names.append(b.name)
|
| 131 |
+
colors.append(SKILL_COLORS.get(_primary_skill(b.additional_details.skill_level), MUTED["gray"]))
|
| 132 |
+
texts.append(
|
| 133 |
+
f"<b>{b.name}</b> ({b.region})<br>"
|
| 134 |
+
f"Skill: {b.additional_details.skill_level}<br>"
|
| 135 |
+
f"Ideal swell: {swell.size_ft_min}–{swell.size_ft_max} ft {swell.direction}<br>"
|
| 136 |
+
f"Ideal wind: ≤{wind.strength_kt_max} kt {wind.direction}"
|
| 137 |
+
)
|
| 138 |
+
|
| 139 |
+
fig = go.Figure(
|
| 140 |
+
go.Scattergeo(
|
| 141 |
+
lat=lats,
|
| 142 |
+
lon=lngs,
|
| 143 |
+
text=names,
|
| 144 |
+
hovertext=texts,
|
| 145 |
+
hoverinfo="text",
|
| 146 |
+
mode="markers",
|
| 147 |
+
marker=dict(size=9, color=colors, opacity=0.85, line=dict(width=1, color="white")),
|
| 148 |
+
showlegend=False,
|
| 149 |
+
)
|
| 150 |
+
)
|
| 151 |
+
fig.update_layout(
|
| 152 |
+
title=dict(text="Australian Surf Breaks", font=dict(size=17, color="#1f2d3d")),
|
| 153 |
+
geo=dict(
|
| 154 |
+
scope="world",
|
| 155 |
+
projection=dict(type="mercator"),
|
| 156 |
+
center=dict(lat=-25.5, lon=134.5),
|
| 157 |
+
lonaxis_range=[111, 156],
|
| 158 |
+
lataxis_range=[-44, -9.5],
|
| 159 |
+
showland=True,
|
| 160 |
+
landcolor="#f3efe4",
|
| 161 |
+
showocean=True,
|
| 162 |
+
oceancolor="#d7e9f5",
|
| 163 |
+
showcountries=False,
|
| 164 |
+
showcoastlines=True,
|
| 165 |
+
coastlinecolor="#9bb8cc",
|
| 166 |
+
showlakes=False,
|
| 167 |
+
bgcolor="#d7e9f5",
|
| 168 |
+
),
|
| 169 |
+
height=560,
|
| 170 |
+
margin={"l": 10, "r": 10, "t": 50, "b": 10},
|
| 171 |
+
font={"family": "DejaVu Sans, Helvetica, Arial, sans-serif", "size": 13, "color": "#3b3b3b"},
|
| 172 |
+
)
|
| 173 |
+
return fig
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def create_wave_height_chart(forecast: dict, spot_name: str) -> go.Figure:
|
| 177 |
+
"""Hourly wave height (left axis) + period and wind speed (right axis)."""
|
| 178 |
+
hourly = (forecast or {}).get("hourly") or []
|
| 179 |
+
if not hourly:
|
| 180 |
+
return _empty_fig(f"No forecast data available for {spot_name}")
|
| 181 |
+
|
| 182 |
+
times = [h.get("time", "") for h in hourly]
|
| 183 |
+
wave = [h.get("wave_height") for h in hourly]
|
| 184 |
+
period = [h.get("wave_period") for h in hourly]
|
| 185 |
+
wind = [h.get("wind_speed_10m") for h in hourly]
|
| 186 |
+
wind_dir = [h.get("wind_direction_10m") for h in hourly]
|
| 187 |
+
wave_dir = [h.get("wave_direction") or h.get("swell_wave_direction") for h in hourly]
|
| 188 |
+
|
| 189 |
+
fig = go.Figure()
|
| 190 |
+
fig.add_trace(
|
| 191 |
+
go.Scatter(
|
| 192 |
+
x=times, y=wave, name="Wave height (m)", mode="lines",
|
| 193 |
+
line=dict(color=MUTED["blue"], width=3), fill="tozeroy",
|
| 194 |
+
fillcolor="rgba(72,120,208,0.12)",
|
| 195 |
+
customdata=list(zip([_compass(d) for d in wave_dir], [d for d in wave_dir])),
|
| 196 |
+
hovertemplate="%{x}<br>Wave height: %{y:.2f} m<br>Swell dir: %{customdata[0]} (%{customdata[1]}°)<extra></extra>",
|
| 197 |
+
)
|
| 198 |
+
)
|
| 199 |
+
fig.add_trace(
|
| 200 |
+
go.Scatter(
|
| 201 |
+
x=times, y=period, name="Period (s)", mode="lines",
|
| 202 |
+
line=dict(color=MUTED["purple"], width=2, dash="dot"),
|
| 203 |
+
yaxis="y2",
|
| 204 |
+
hovertemplate="%{x}<br>Period: %{y:.1f} s<extra></extra>",
|
| 205 |
+
)
|
| 206 |
+
)
|
| 207 |
+
fig.add_trace(
|
| 208 |
+
go.Scatter(
|
| 209 |
+
x=times, y=wind, name="Wind (km/h)", mode="lines",
|
| 210 |
+
line=dict(color=MUTED["red"], width=2),
|
| 211 |
+
yaxis="y2",
|
| 212 |
+
customdata=[_compass(d) for d in wind_dir],
|
| 213 |
+
hovertemplate="%{x}<br>Wind: %{y:.1f} km/h %{customdata}<extra></extra>",
|
| 214 |
+
)
|
| 215 |
+
)
|
| 216 |
+
fig.update_layout(
|
| 217 |
+
title=f"Wave & Wind Forecast — {spot_name}",
|
| 218 |
+
xaxis=dict(title="Time (local)"),
|
| 219 |
+
yaxis=dict(title="Wave height (m)", rangemode="tozero"),
|
| 220 |
+
yaxis2=dict(title="Period (s) / Wind (km/h)", overlaying="y", side="right", showgrid=False),
|
| 221 |
+
hovermode="x unified",
|
| 222 |
+
)
|
| 223 |
+
return _style(fig)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
def create_score_chart(scores: list, spot_name: str) -> go.Figure:
|
| 227 |
+
"""Hourly surf score (bold) with the four deterministic components."""
|
| 228 |
+
if not scores:
|
| 229 |
+
return _empty_fig(f"No score data available for {spot_name}")
|
| 230 |
+
|
| 231 |
+
times = [s.get("time", "") for s in scores]
|
| 232 |
+
total = [s.get("score") for s in scores]
|
| 233 |
+
comps = scores[0].get("components") or {}
|
| 234 |
+
|
| 235 |
+
fig = go.Figure()
|
| 236 |
+
component_colors = {
|
| 237 |
+
"swell_size": MUTED["blue"],
|
| 238 |
+
"swell_direction": MUTED["cyan"],
|
| 239 |
+
"wind": MUTED["red"],
|
| 240 |
+
"period": MUTED["purple"],
|
| 241 |
+
}
|
| 242 |
+
for comp in ("swell_size", "swell_direction", "wind", "period"):
|
| 243 |
+
if comp in comps:
|
| 244 |
+
fig.add_trace(
|
| 245 |
+
go.Scatter(
|
| 246 |
+
x=times,
|
| 247 |
+
y=[(s.get("components") or {}).get(comp) for s in scores],
|
| 248 |
+
name=comp.replace("_", " ").title(),
|
| 249 |
+
mode="lines",
|
| 250 |
+
line=dict(color=component_colors[comp], width=1.5, dash="dot"),
|
| 251 |
+
opacity=0.75,
|
| 252 |
+
hovertemplate="%{x}<br>" + comp.replace("_", " ") + ": %{y:.1f}<extra></extra>",
|
| 253 |
+
)
|
| 254 |
+
)
|
| 255 |
+
fig.add_trace(
|
| 256 |
+
go.Scatter(
|
| 257 |
+
x=times, y=total, name="Surf score", mode="lines",
|
| 258 |
+
line=dict(color="#1f2d3d", width=3.5),
|
| 259 |
+
hovertemplate="%{x}<br>Score: %{y:.1f}/10<extra></extra>",
|
| 260 |
+
)
|
| 261 |
+
)
|
| 262 |
+
fig.update_layout(
|
| 263 |
+
title=f"Hourly Surf Score — {spot_name}",
|
| 264 |
+
xaxis=dict(title="Time (local)"),
|
| 265 |
+
yaxis=dict(title="Score (0–10)", range=[0, 10]),
|
| 266 |
+
hovermode="x unified",
|
| 267 |
+
)
|
| 268 |
+
return _style(fig)
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def create_ranking_chart(ranked: list, region: str) -> go.Figure:
|
| 272 |
+
"""Horizontal bars of each spot's best score this week."""
|
| 273 |
+
if not ranked:
|
| 274 |
+
return _empty_fig(f"No ranking data for {region}")
|
| 275 |
+
spots_sorted = sorted(ranked, key=lambda r: r.get("best_score") or 0)
|
| 276 |
+
names = [f"{r.get('name', '?')} ({r.get('region', '')})" for r in spots_sorted]
|
| 277 |
+
values = [r.get("best_score") or 0 for r in spots_sorted]
|
| 278 |
+
best_times = [r.get("best_time", "–") for r in spots_sorted]
|
| 279 |
+
|
| 280 |
+
fig = go.Figure(
|
| 281 |
+
go.Bar(
|
| 282 |
+
x=values,
|
| 283 |
+
y=names,
|
| 284 |
+
orientation="h",
|
| 285 |
+
marker=dict(
|
| 286 |
+
color=values,
|
| 287 |
+
colorscale=[[0, MUTED["cyan"]], [0.6, MUTED["blue"]], [1, MUTED["green"]]],
|
| 288 |
+
line=dict(width=0),
|
| 289 |
+
),
|
| 290 |
+
customdata=best_times,
|
| 291 |
+
hovertemplate="%{y}<br>Best score: %{x:.1f}/10<br>Best time: %{customdata}<extra></extra>",
|
| 292 |
+
)
|
| 293 |
+
)
|
| 294 |
+
fig.update_layout(
|
| 295 |
+
title=f"Best Scores This Week — {region}",
|
| 296 |
+
xaxis=dict(title="Best hourly score (0–10)", range=[0, 10]),
|
| 297 |
+
yaxis=dict(title=""),
|
| 298 |
+
height=max(360, 28 * len(names) + 120),
|
| 299 |
+
)
|
| 300 |
+
return _style(fig)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
# ---------------------------------------------------------------------------
|
| 304 |
+
# Trace panel
|
| 305 |
+
# ---------------------------------------------------------------------------
|
| 306 |
+
|
| 307 |
+
_TOOL_EMOJI = {
|
| 308 |
+
"get_forecast": "🌊",
|
| 309 |
+
"score_week": "🧮",
|
| 310 |
+
"find_spots": "🔎",
|
| 311 |
+
"get_spot_knowledge": "📚",
|
| 312 |
+
"rank_spots_this_week": "🏆",
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
# Tool invocations inside CodeAgent python_interpreter code blobs
|
| 316 |
+
_TOOL_CALL_RE = re.compile(
|
| 317 |
+
r"\b(get_forecast|score_week|find_spots|get_spot_knowledge|rank_spots_this_week)\(([^)]*)"
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def _code_step_summary(args) -> tuple[str, list[tuple[str, str]]]:
|
| 322 |
+
"""Summarise a python_interpreter code blob: (display_code, tool calls)."""
|
| 323 |
+
code = args if isinstance(args, str) else str(args)
|
| 324 |
+
calls = [
|
| 325 |
+
(tool, re.findall(r"[\"']([^\"']+)[\"']", argstr))
|
| 326 |
+
for tool, argstr in _TOOL_CALL_RE.findall(code)
|
| 327 |
+
]
|
| 328 |
+
snippet = code.strip().replace("```", "")
|
| 329 |
+
if len(snippet) > 400:
|
| 330 |
+
snippet = snippet[:400] + " …"
|
| 331 |
+
return snippet, calls
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
def update_trace_panel(trace_json: str) -> str:
|
| 335 |
+
"""Render an AgentTrace JSON blob as readable markdown for the UI."""
|
| 336 |
+
if not trace_json or not trace_json.strip():
|
| 337 |
+
return (
|
| 338 |
+
"### 🔍 Agent Trace\n\nNo trace yet — ask the agent a question and "
|
| 339 |
+
"watch it work: every tool call, the data it saw, and its reasoning "
|
| 340 |
+
"appear here."
|
| 341 |
+
)
|
| 342 |
+
try:
|
| 343 |
+
trace = json.loads(trace_json)
|
| 344 |
+
except (json.JSONDecodeError, TypeError):
|
| 345 |
+
return trace_json
|
| 346 |
+
|
| 347 |
+
lines = ["### 🔍 Agent Trace"]
|
| 348 |
+
question = trace.get("question", "")
|
| 349 |
+
if question:
|
| 350 |
+
lines.append(f"**Question:** {question}")
|
| 351 |
+
model = trace.get("model", "")
|
| 352 |
+
provider = trace.get("provider", "")
|
| 353 |
+
if model or provider:
|
| 354 |
+
lines.append(f"**Model:** `{model}` via `{provider}`")
|
| 355 |
+
lines.append("")
|
| 356 |
+
|
| 357 |
+
n_chunks = 0
|
| 358 |
+
for evt in trace.get("events", []):
|
| 359 |
+
etype = evt.get("type", "")
|
| 360 |
+
data = evt.get("data", {})
|
| 361 |
+
if etype == "tool_call":
|
| 362 |
+
name = data.get("name", "?")
|
| 363 |
+
args = data.get("arguments", {})
|
| 364 |
+
if name == "python_interpreter":
|
| 365 |
+
snippet, calls = _code_step_summary(args)
|
| 366 |
+
called = ", ".join(f"`{tool}`" for tool, _ in calls) if calls else ""
|
| 367 |
+
lines.append(f"- 🧠 **code step** {f'→ calls {called}' if called else ''}")
|
| 368 |
+
lines.append(f"\n```python\n{snippet}\n```\n")
|
| 369 |
+
else:
|
| 370 |
+
emoji = _TOOL_EMOJI.get(name, "🔧")
|
| 371 |
+
if isinstance(args, str):
|
| 372 |
+
args_display = args
|
| 373 |
+
else:
|
| 374 |
+
args_display = ", ".join(
|
| 375 |
+
f"{k}=`{v}`" for k, v in args.items() if v is not None
|
| 376 |
+
)
|
| 377 |
+
lines.append(f"- {emoji} **`{name}`**({args_display})")
|
| 378 |
+
elif etype == "tool_result":
|
| 379 |
+
obs = str(data.get("observation", ""))
|
| 380 |
+
if len(obs) > 220:
|
| 381 |
+
obs = obs[:220] + "…"
|
| 382 |
+
lines.append(f" - ↳ *{obs}*")
|
| 383 |
+
elif etype == "llm_chunk":
|
| 384 |
+
n_chunks += 1
|
| 385 |
+
elif etype == "final_answer":
|
| 386 |
+
lines.append("- ✅ **Final answer** composed")
|
| 387 |
+
if n_chunks:
|
| 388 |
+
lines.append(f"- 💬 Streamed {n_chunks} token chunks")
|
| 389 |
+
|
| 390 |
+
return "\n".join(lines)
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
# ---------------------------------------------------------------------------
|
| 394 |
+
# Data helpers for the UI
|
| 395 |
+
# ---------------------------------------------------------------------------
|
| 396 |
+
|
| 397 |
+
def _spot_label(b) -> str:
|
| 398 |
+
return f"{b.name} ({b.region})"
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _all_spot_choices() -> list[str]:
|
| 402 |
+
return sorted(_spot_label(b) for b in spots.load_breaks())
|
| 403 |
+
|
| 404 |
+
|
| 405 |
+
def _parse_spot_label(label: str):
|
| 406 |
+
m = re.match(r"^(.*) \(([A-Z]{2,3})\)$", label or "")
|
| 407 |
+
if not m:
|
| 408 |
+
return None
|
| 409 |
+
return spots.get_spot(m.group(1), m.group(2))
|
| 410 |
+
|
| 411 |
+
|
| 412 |
+
def _spot_card(spot) -> str:
|
| 413 |
+
if spot is None:
|
| 414 |
+
return "_Select a break to see its profile._"
|
| 415 |
+
|
| 416 |
+
def badge(text: str, color: str) -> str:
|
| 417 |
+
return (
|
| 418 |
+
f"<span class='skill-badge' style='background:{color}22; color:{color}; "
|
| 419 |
+
f"border:1px solid {color}66'>{text}</span>"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
d = spot.additional_details
|
| 423 |
+
swell = spot.ideal_swell
|
| 424 |
+
wind = spot.ideal_wind
|
| 425 |
+
badges = " ".join(
|
| 426 |
+
badge(t, SKILL_COLORS.get(_primary_skill(t), MUTED["gray"]))
|
| 427 |
+
for t in (d.skill_level, d.break_type, d.break_direction)
|
| 428 |
+
)
|
| 429 |
+
hazards = d.hazards or "—"
|
| 430 |
+
return f"""
|
| 431 |
+
### 🏄 {spot.name} · {spot.region}
|
| 432 |
+
|
| 433 |
+
{badges}
|
| 434 |
+
|
| 435 |
+
{spot.short_description}
|
| 436 |
+
|
| 437 |
+
| Ideal conditions | |
|
| 438 |
+
|---|---|
|
| 439 |
+
| **Swell** | {swell.size_ft_min}–{swell.size_ft_max} ft from {swell.direction} |
|
| 440 |
+
| **Wind** | offshore/low up to {wind.strength_kt_max} kt ({wind.direction}) |
|
| 441 |
+
| **Tide** | {spot.ideal_tide} *(qualitative)* |
|
| 442 |
+
| **Bottom** | {d.bottom_type} · {d.break_surface} |
|
| 443 |
+
| **Best season** | {d.best_season} |
|
| 444 |
+
|
| 445 |
+
⚠️ **Hazards:** {hazards}
|
| 446 |
+
|
| 447 |
+
{d.other_notes or ""}
|
| 448 |
+
"""
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _get_spot_data(spot):
|
| 452 |
+
"""Forecast + scores for a spot, with a small in-process cache."""
|
| 453 |
+
if spot is None:
|
| 454 |
+
return None, None
|
| 455 |
+
cache = getattr(_get_spot_data, "_cache", None)
|
| 456 |
+
if cache is None:
|
| 457 |
+
cache = _get_spot_data._cache = {}
|
| 458 |
+
key = (spot.name, spot.region)
|
| 459 |
+
if key not in cache:
|
| 460 |
+
forecast = tools.get_forecast(spot.name, spot.region)
|
| 461 |
+
if "error" in forecast:
|
| 462 |
+
return None, None
|
| 463 |
+
scores = tools.score_week(spot.name, spot.region)
|
| 464 |
+
if isinstance(scores, dict) and "error" in scores:
|
| 465 |
+
scores = []
|
| 466 |
+
cache[key] = (forecast, scores)
|
| 467 |
+
return cache[key]
|
| 468 |
+
|
| 469 |
+
|
| 470 |
+
def _guess_region(name: str) -> str | None:
|
| 471 |
+
"""Resolve a spot's region from the knowledge base by exact name."""
|
| 472 |
+
for b in spots.load_breaks():
|
| 473 |
+
if b.name.lower() == name.lower():
|
| 474 |
+
return b.region
|
| 475 |
+
return None
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
def _extract_trace_targets(trace_json: str) -> tuple[list[tuple[str, str | None]], list[str]]:
|
| 479 |
+
"""What the agent looked at, from python_interpreter code blobs.
|
| 480 |
+
|
| 481 |
+
Returns (spot_pairs, rank_regions): spot (name, region-or-None) pairs for
|
| 482 |
+
forecast/score/knowledge calls, and regions passed to ranking calls.
|
| 483 |
"""
|
| 484 |
+
spot_pairs: list[tuple[str, str | None]] = []
|
| 485 |
+
rank_regions: list[str] = []
|
| 486 |
+
try:
|
| 487 |
+
trace = json.loads(trace_json)
|
| 488 |
+
except (json.JSONDecodeError, TypeError):
|
| 489 |
+
return spot_pairs, rank_regions
|
| 490 |
+
for evt in trace.get("events", []):
|
| 491 |
+
if evt.get("type") != "tool_call":
|
| 492 |
+
continue
|
| 493 |
+
args = evt.get("data", {}).get("arguments")
|
| 494 |
+
code = args if isinstance(args, str) else json.dumps(args or {})
|
| 495 |
+
for tool, vals in (
|
| 496 |
+
(t, re.findall(r"[\"']([^\"']+)[\"']", a))
|
| 497 |
+
for t, a in _TOOL_CALL_RE.findall(code)
|
| 498 |
+
):
|
| 499 |
+
if tool in ("get_forecast", "score_week") and len(vals) >= 2:
|
| 500 |
+
pair = (vals[0], vals[1])
|
| 501 |
+
elif tool == "get_spot_knowledge" and vals:
|
| 502 |
+
pair = (vals[0], vals[1] if len(vals) >= 2 else None)
|
| 503 |
+
elif tool == "rank_spots_this_week" and vals:
|
| 504 |
+
if vals[0] not in rank_regions:
|
| 505 |
+
rank_regions.append(vals[0])
|
| 506 |
+
continue
|
| 507 |
+
else:
|
| 508 |
+
continue
|
| 509 |
+
if pair not in spot_pairs:
|
| 510 |
+
spot_pairs.append(pair)
|
| 511 |
+
return spot_pairs, rank_regions
|
| 512 |
|
| 513 |
|
| 514 |
+
# ---------------------------------------------------------------------------
|
| 515 |
+
# Agent chat
|
| 516 |
+
# ---------------------------------------------------------------------------
|
| 517 |
+
|
| 518 |
+
_agents: dict[str, SurfAgent] = {}
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
def _get_agent(provider: str) -> SurfAgent:
|
| 522 |
+
if provider not in _agents:
|
| 523 |
+
_agents[provider] = SurfAgent(provider=provider)
|
| 524 |
+
return _agents[provider]
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def _provider_available(provider: str) -> bool:
|
| 528 |
+
if provider == PROVIDER_NIM:
|
| 529 |
+
return bool(os.getenv("NVIDIA_API_KEY"))
|
| 530 |
+
return bool(os.getenv("HF_TOKEN"))
|
| 531 |
+
|
| 532 |
+
|
| 533 |
+
def chat_submit(message: str, history: list, provider: str):
|
| 534 |
+
"""Streaming chat handler: yields (chatbot, trace_md, score_chart, wave_chart)."""
|
| 535 |
+
history = list(history or [])
|
| 536 |
+
if not message or not message.strip():
|
| 537 |
+
yield history, gr.update(), gr.update(), gr.update()
|
| 538 |
+
return
|
| 539 |
+
|
| 540 |
+
history.append({"role": "user", "content": message})
|
| 541 |
+
history.append({"role": "assistant", "content": "…"})
|
| 542 |
+
|
| 543 |
+
try:
|
| 544 |
+
agent = _get_agent(provider)
|
| 545 |
+
except ValueError as exc:
|
| 546 |
+
history[-1]["content"] = (
|
| 547 |
+
f"⚠️ **Model provider not configured.**\n\n{exc}\n\n"
|
| 548 |
+
"Set `HF_TOKEN` or `NVIDIA_API_KEY` in your environment and restart. "
|
| 549 |
+
"Everything else (map, charts, rankings, scores) works without tokens."
|
| 550 |
+
)
|
| 551 |
+
yield history, gr.update(), gr.update(), gr.update()
|
| 552 |
+
return
|
| 553 |
+
|
| 554 |
+
thinking: list[str] = []
|
| 555 |
+
final_answer = ""
|
| 556 |
+
trace_json = ""
|
| 557 |
+
for kind, chunk in agent.run_stream(message):
|
| 558 |
+
if kind == "model":
|
| 559 |
+
thinking.append(chunk)
|
| 560 |
+
tail = "".join(thinking)[-600:]
|
| 561 |
+
history[-1]["content"] = f"*🧠 thinking…*\n\n```python\n{tail}\n```"
|
| 562 |
+
yield history, gr.update(), gr.update(), gr.update()
|
| 563 |
+
elif kind == "tool":
|
| 564 |
+
history[-1]["content"] = f"*🔧 calling `{chunk}`…*"
|
| 565 |
+
yield history, gr.update(), gr.update(), gr.update()
|
| 566 |
+
elif kind == "final":
|
| 567 |
+
final_answer = chunk if isinstance(chunk, str) else str(chunk)
|
| 568 |
+
trace_json = agent.trace.to_json() if agent.trace else ""
|
| 569 |
+
|
| 570 |
+
history[-1]["content"] = final_answer or "*(empty answer)*"
|
| 571 |
+
trace_md = update_trace_panel(trace_json)
|
| 572 |
+
|
| 573 |
+
# Auto-render interactive charts for what the agent looked at
|
| 574 |
+
score_fig: object = gr.update()
|
| 575 |
+
wave_fig: object = gr.update()
|
| 576 |
+
spot_pairs, rank_regions = _extract_trace_targets(trace_json)
|
| 577 |
+
if spot_pairs:
|
| 578 |
+
name, region = spot_pairs[0]
|
| 579 |
+
region = region or _guess_region(name)
|
| 580 |
+
spot = spots.get_spot(name, region) if region else None
|
| 581 |
+
if spot is not None:
|
| 582 |
+
forecast, scores = _get_spot_data(spot)
|
| 583 |
+
if scores:
|
| 584 |
+
score_fig = create_score_chart(scores[-48:], spot.name)
|
| 585 |
+
if forecast:
|
| 586 |
+
wave_fig = create_wave_height_chart(
|
| 587 |
+
{"hourly": forecast["hourly"][-48:]}, spot.name
|
| 588 |
+
)
|
| 589 |
+
elif rank_regions:
|
| 590 |
+
ranked = tools.rank_spots_this_week(rank_regions[0], None)
|
| 591 |
+
score_fig = create_ranking_chart(ranked, rank_regions[0])
|
| 592 |
+
|
| 593 |
+
yield history, trace_md, score_fig, wave_fig
|
| 594 |
+
|
| 595 |
+
|
| 596 |
+
# ---------------------------------------------------------------------------
|
| 597 |
+
# daggr Morning Surf Report + cache warming (started only by main())
|
| 598 |
+
# ---------------------------------------------------------------------------
|
| 599 |
+
|
| 600 |
+
def _start_daggr_server(host: str = "127.0.0.1", port: int = 7861) -> None:
|
| 601 |
+
"""Launch the Morning Surf Report canvas as its own process.
|
| 602 |
+
|
| 603 |
+
A second Gradio instance inside this process is unreliable, so the daggr
|
| 604 |
+
app runs standalone; the tab's iframe just points at it.
|
| 605 |
+
"""
|
| 606 |
+
try:
|
| 607 |
+
subprocess.Popen(
|
| 608 |
+
[sys.executable, "-m", "wavereader.daggr_pipeline"],
|
| 609 |
+
stdout=subprocess.DEVNULL,
|
| 610 |
+
stderr=subprocess.DEVNULL,
|
| 611 |
+
start_new_session=True,
|
| 612 |
+
)
|
| 613 |
+
except Exception:
|
| 614 |
+
pass # canvas tab falls back to a "how to start" note
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _warm_forecast_cache() -> None:
|
| 618 |
+
"""Prefetch all break forecasts into the disk cache (demo survivability)."""
|
| 619 |
+
for b in spots.load_breaks():
|
| 620 |
+
try:
|
| 621 |
+
tools.get_forecast(b.name, b.region)
|
| 622 |
+
except Exception:
|
| 623 |
+
continue
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def _start_background_services() -> None:
|
| 627 |
+
if os.getenv("WAVEREADER_EMBED_DAGGR", "1") == "1":
|
| 628 |
+
threading.Thread(target=_start_daggr_server, daemon=True).start()
|
| 629 |
+
if os.getenv("WAVEREADER_WARM_CACHE", "1") == "1":
|
| 630 |
+
threading.Thread(target=_warm_forecast_cache, daemon=True).start()
|
| 631 |
+
|
| 632 |
+
|
| 633 |
+
# ---------------------------------------------------------------------------
|
| 634 |
+
# UI assembly
|
| 635 |
+
# ---------------------------------------------------------------------------
|
| 636 |
+
|
| 637 |
+
_CUSTOM_CSS = """
|
| 638 |
+
.gradio-container {max-width: 1400px !important; margin: 0 auto;}
|
| 639 |
+
#wr-header {
|
| 640 |
+
background: linear-gradient(120deg, #0b3d5c 0%, #1173a6 55%, #19b3a6 100%);
|
| 641 |
+
border-radius: 16px; padding: 26px 30px !important; color: white;
|
| 642 |
+
margin-bottom: 14px;
|
| 643 |
+
}
|
| 644 |
+
#wr-header h1 {color: white; margin: 0 0 6px 0; font-size: 2em; letter-spacing: 0.5px;}
|
| 645 |
+
#wr-header p {color: #d7ecf7; margin: 0; font-size: 1.02em;}
|
| 646 |
+
.skill-badge {
|
| 647 |
+
display: inline-block; padding: 2px 10px; border-radius: 999px;
|
| 648 |
+
font-size: 0.8em; font-weight: 600; white-space: nowrap;
|
| 649 |
+
}
|
| 650 |
+
#wr-trace {
|
| 651 |
+
background: #f7fafc; border: 1px solid #e2e8f0; border-radius: 12px;
|
| 652 |
+
padding: 10px 14px; font-size: 0.92em; max-height: 640px; overflow-y: auto;
|
| 653 |
+
}
|
| 654 |
+
#wr-trace code {background: #eef2f7; padding: 1px 5px; border-radius: 5px;}
|
| 655 |
+
footer {display: none !important;}
|
| 656 |
+
"""
|
| 657 |
+
|
| 658 |
+
|
| 659 |
+
def build_ui() -> gr.Blocks:
|
| 660 |
+
"""Assemble the full wavereader demo UI."""
|
| 661 |
+
spot_choices = _all_spot_choices()
|
| 662 |
+
hf_ok, nim_ok = _provider_available(PROVIDER_HF), _provider_available(PROVIDER_NIM)
|
| 663 |
+
|
| 664 |
+
with gr.Blocks(
|
| 665 |
+
title="WaveReader — Agentic Surf Forecaster",
|
| 666 |
+
theme=gr.themes.Soft(
|
| 667 |
+
primary_hue="sky",
|
| 668 |
+
secondary_hue="blue",
|
| 669 |
+
neutral_hue="slate",
|
| 670 |
+
font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"],
|
| 671 |
+
),
|
| 672 |
+
css=_CUSTOM_CSS,
|
| 673 |
+
) as demo:
|
| 674 |
+
|
| 675 |
+
# ------------------------------------------------ header
|
| 676 |
+
status = "🟢 agent ready" if (hf_ok or nim_ok) else "🔴 set HF_TOKEN or NVIDIA_API_KEY for the agent"
|
| 677 |
+
gr.HTML(
|
| 678 |
+
f"""<div id="wr-header">
|
| 679 |
+
<h1>🏄 WaveReader</h1>
|
| 680 |
+
<p>Agentic surf forecaster for Australian breaks — deterministic scores, visible reasoning.
|
| 681 |
+
· Model: <code>Qwen/Qwen3-Next-80B-A3B-Instruct</code> · {status}</p>
|
| 682 |
+
</div>"""
|
| 683 |
+
)
|
| 684 |
+
|
| 685 |
+
with gr.Tabs():
|
| 686 |
+
|
| 687 |
+
# ------------------------------------------------ tab: explore (map home)
|
| 688 |
+
with gr.Tab("🗺️ Explore"):
|
| 689 |
+
with gr.Row():
|
| 690 |
+
with gr.Column(scale=3):
|
| 691 |
+
map_plot = gr.Plot(create_australia_map(), label="Surf breaks across Australia", show_label=False)
|
| 692 |
+
map_skill_filter = gr.Dropdown(
|
| 693 |
+
choices=SKILL_FILTERS, value="all", label="Filter map by skill"
|
| 694 |
+
)
|
| 695 |
+
with gr.Column(scale=2):
|
| 696 |
+
gr.Markdown("### Pick your break")
|
| 697 |
+
spot_dropdown = gr.Dropdown(
|
| 698 |
+
choices=spot_choices,
|
| 699 |
+
value=None,
|
| 700 |
+
label="Search 100 breaks (name or region)",
|
| 701 |
+
info="Type to filter — e.g. 'Snapper' or 'QLD'",
|
| 702 |
+
)
|
| 703 |
+
spot_card_md = gr.Markdown(_spot_card(None))
|
| 704 |
+
spot_quick_btn = gr.Button(
|
| 705 |
+
"📊 Show forecast charts for this spot", variant="secondary"
|
| 706 |
+
)
|
| 707 |
+
|
| 708 |
+
# ------------------------------------------------ tab: agent chat
|
| 709 |
+
with gr.Tab("🤖 Surf Agent"):
|
| 710 |
+
with gr.Row():
|
| 711 |
+
with gr.Column(scale=5):
|
| 712 |
+
provider_radio = gr.Radio(
|
| 713 |
+
choices=[
|
| 714 |
+
("Hugging Face 🤗", PROVIDER_HF),
|
| 715 |
+
("NVIDIA NIM ⚡", PROVIDER_NIM),
|
| 716 |
+
],
|
| 717 |
+
value=PROVIDER_HF if hf_ok else PROVIDER_NIM,
|
| 718 |
+
label="Model hosting",
|
| 719 |
+
)
|
| 720 |
+
chatbot = gr.Chatbot(
|
| 721 |
+
height=500,
|
| 722 |
+
label="WaveReader agent",
|
| 723 |
+
placeholder="Ask: *Where should I surf near Byron Bay this weekend as an intermediate?*",
|
| 724 |
+
)
|
| 725 |
+
with gr.Row():
|
| 726 |
+
chat_input = gr.Textbox(
|
| 727 |
+
placeholder="Ask about any break, your skill level, best hours…",
|
| 728 |
+
scale=8,
|
| 729 |
+
show_label=False,
|
| 730 |
+
autofocus=True,
|
| 731 |
+
)
|
| 732 |
+
chat_send = gr.Button("Send 🚀", variant="primary", scale=1)
|
| 733 |
+
gr.Examples(
|
| 734 |
+
examples=[
|
| 735 |
+
"Which QLD spots suit a beginner this week?",
|
| 736 |
+
"What time should I surf Snapper Rocks tomorrow?",
|
| 737 |
+
"Rank NSW breaks for an advanced surfer and explain the top pick.",
|
| 738 |
+
],
|
| 739 |
+
inputs=[chat_input],
|
| 740 |
+
)
|
| 741 |
+
with gr.Column(scale=3):
|
| 742 |
+
trace_panel = gr.Markdown(update_trace_panel(""), elem_id="wr-trace")
|
| 743 |
+
gr.Markdown("### Charts the agent surfaced")
|
| 744 |
+
with gr.Row():
|
| 745 |
+
chat_score_chart = gr.Plot(
|
| 746 |
+
_empty_fig("Ask about a spot — its score chart appears here"),
|
| 747 |
+
label="Hourly surf score",
|
| 748 |
+
)
|
| 749 |
+
chat_wave_chart = gr.Plot(
|
| 750 |
+
_empty_fig("Ask about a spot — its forecast chart appears here"),
|
| 751 |
+
label="Wave & wind forecast",
|
| 752 |
+
)
|
| 753 |
+
|
| 754 |
+
# ------------------------------------------------ tab: charts
|
| 755 |
+
with gr.Tab("📊 Forecast Charts"):
|
| 756 |
+
with gr.Row():
|
| 757 |
+
chart_spot = gr.Dropdown(choices=spot_choices, value=None, label="Spot")
|
| 758 |
+
chart_load_btn = gr.Button("Load charts", variant="primary")
|
| 759 |
+
with gr.Row():
|
| 760 |
+
chart_wave_plot = gr.Plot(label="Wave height · period · wind")
|
| 761 |
+
with gr.Row():
|
| 762 |
+
chart_score_plot = gr.Plot(label="Hourly surf score + components")
|
| 763 |
+
|
| 764 |
+
# ------------------------------------------------ tab: rankings
|
| 765 |
+
with gr.Tab("🏆 Weekly Rankings"):
|
| 766 |
+
with gr.Row():
|
| 767 |
+
rank_region = gr.Dropdown(choices=REGIONS, value="NSW", label="Region")
|
| 768 |
+
rank_skill = gr.Dropdown(choices=SKILL_FILTERS, value="all", label="Skill level")
|
| 769 |
+
rank_btn = gr.Button("Rank this week", variant="primary")
|
| 770 |
+
rank_plot = gr.Plot(label="Best scores this week")
|
| 771 |
+
rank_table = gr.Dataframe(
|
| 772 |
+
headers=["Spot", "Region", "Best score", "Best time"],
|
| 773 |
+
label="Ranked table",
|
| 774 |
+
interactive=False,
|
| 775 |
+
)
|
| 776 |
+
|
| 777 |
+
# ------------------------------------------------ tab: morning report
|
| 778 |
+
with gr.Tab("📋 Morning Surf Report"):
|
| 779 |
+
gr.Markdown(
|
| 780 |
+
"The fixed **Morning Surf Report** pipeline (daggr): pick a region + "
|
| 781 |
+
"skill → find spots → fetch forecasts → score → rank → narrated report "
|
| 782 |
+
"with deterministic scores."
|
| 783 |
+
)
|
| 784 |
+
gr.HTML(
|
| 785 |
+
"""<div style="border-radius:12px; border:1px solid #e2e8f0; overflow:hidden;">
|
| 786 |
+
<iframe src="http://127.0.0.1:7861" style="width:100%; height:760px; border:0;"></iframe>
|
| 787 |
+
</div>
|
| 788 |
+
<p><i>If the canvas is empty, start the pipeline app:
|
| 789 |
+
<code>uv run python -m wavereader.daggr_pipeline</code> (it runs alongside this UI on port 7861).</i></p>"""
|
| 790 |
+
)
|
| 791 |
+
|
| 792 |
+
# ------------------------------------------------ events: explore
|
| 793 |
+
map_skill_filter.change(
|
| 794 |
+
lambda skill: create_australia_map(skill),
|
| 795 |
+
inputs=[map_skill_filter],
|
| 796 |
+
outputs=[map_plot],
|
| 797 |
+
)
|
| 798 |
+
|
| 799 |
+
def show_spot_card(label):
|
| 800 |
+
return _spot_card(_parse_spot_label(label))
|
| 801 |
+
|
| 802 |
+
spot_dropdown.change(show_spot_card, inputs=[spot_dropdown], outputs=[spot_card_md])
|
| 803 |
+
|
| 804 |
+
def jump_to_charts(label):
|
| 805 |
+
spot = _parse_spot_label(label)
|
| 806 |
+
if spot is None:
|
| 807 |
+
return gr.update(), gr.update(), gr.update()
|
| 808 |
+
forecast, scores = _get_spot_data(spot)
|
| 809 |
+
wave = create_wave_height_chart(forecast, spot.name) if forecast else gr.update()
|
| 810 |
+
score = create_score_chart(scores, spot.name) if scores else gr.update()
|
| 811 |
+
return wave, score, gr.update(value=label)
|
| 812 |
+
|
| 813 |
+
spot_quick_btn.click(
|
| 814 |
+
jump_to_charts,
|
| 815 |
+
inputs=[spot_dropdown],
|
| 816 |
+
outputs=[chart_wave_plot, chart_score_plot, chart_spot],
|
| 817 |
+
)
|
| 818 |
+
|
| 819 |
+
# ------------------------------------------------ events: chat
|
| 820 |
+
chat_send.click(
|
| 821 |
+
chat_submit,
|
| 822 |
+
inputs=[chat_input, chatbot, provider_radio],
|
| 823 |
+
outputs=[chatbot, trace_panel, chat_score_chart, chat_wave_chart],
|
| 824 |
+
)
|
| 825 |
+
chat_input.submit(
|
| 826 |
+
chat_submit,
|
| 827 |
+
inputs=[chat_input, chatbot, provider_radio],
|
| 828 |
+
outputs=[chatbot, trace_panel, chat_score_chart, chat_wave_chart],
|
| 829 |
+
)
|
| 830 |
+
|
| 831 |
+
# ------------------------------------------------ events: charts tab
|
| 832 |
+
def load_charts(label):
|
| 833 |
+
spot = _parse_spot_label(label)
|
| 834 |
+
if spot is None:
|
| 835 |
+
return _empty_fig("Pick a spot above"), _empty_fig("Pick a spot above")
|
| 836 |
+
forecast, scores = _get_spot_data(spot)
|
| 837 |
+
wave = create_wave_height_chart(forecast, spot.name) if forecast else _empty_fig("No forecast")
|
| 838 |
+
score = create_score_chart(scores, spot.name) if scores else _empty_fig("No scores")
|
| 839 |
+
return wave, score
|
| 840 |
+
|
| 841 |
+
chart_load_btn.click(load_charts, inputs=[chart_spot], outputs=[chart_wave_plot, chart_score_plot])
|
| 842 |
+
chart_spot.change(load_charts, inputs=[chart_spot], outputs=[chart_wave_plot, chart_score_plot])
|
| 843 |
+
|
| 844 |
+
# ------------------------------------------------ events: rankings
|
| 845 |
+
def run_ranking(region, skill):
|
| 846 |
+
skill_arg = None if skill == "all" else skill
|
| 847 |
+
ranked = tools.rank_spots_this_week(region, skill_arg)
|
| 848 |
+
fig = create_ranking_chart(ranked, region)
|
| 849 |
+
table = [
|
| 850 |
+
[r.get("name"), r.get("region"), round(r.get("best_score") or 0, 1), r.get("best_time")]
|
| 851 |
+
for r in ranked
|
| 852 |
+
]
|
| 853 |
+
return fig, table
|
| 854 |
+
|
| 855 |
+
rank_btn.click(run_ranking, inputs=[rank_region, rank_skill], outputs=[rank_plot, rank_table])
|
| 856 |
+
|
| 857 |
+
return demo
|
| 858 |
+
|
| 859 |
+
|
| 860 |
+
def main() -> None:
|
| 861 |
+
_start_background_services()
|
| 862 |
demo = build_ui()
|
| 863 |
+
demo.launch(server_name="0.0.0.0", server_port=7860, show_error=True)
|
| 864 |
+
|
| 865 |
+
|
| 866 |
+
if __name__ == "__main__":
|
| 867 |
+
main()
|
wavereader/daggr_pipeline.py
CHANGED
|
@@ -2,15 +2,264 @@
|
|
| 2 |
|
| 3 |
Daggr is beta and has NO conditional branching: this visualises the fixed
|
| 4 |
morning pipeline only, never the dynamic chat agent loop.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
-
def
|
| 11 |
-
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
"""
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
Daggr is beta and has NO conditional branching: this visualises the fixed
|
| 4 |
morning pipeline only, never the dynamic chat agent loop.
|
| 5 |
+
|
| 6 |
+
Pipeline:
|
| 7 |
+
InputNode(region, skill) → FindSpotsFn → FetchForecastsFn →
|
| 8 |
+
ScoreForecastsFn → RankSpotsFn → { FormatReportFn (deterministic),
|
| 9 |
+
InferenceNode (narrates) }
|
| 10 |
+
|
| 11 |
+
Forecasts/scores flow between nodes keyed by ``"{name}|{region}"`` strings
|
| 12 |
+
(tuple keys don't survive a round-trip through gr.JSON).
|
| 13 |
"""
|
| 14 |
|
| 15 |
from __future__ import annotations
|
| 16 |
|
| 17 |
+
import json
|
| 18 |
+
from typing import Any
|
| 19 |
+
|
| 20 |
+
import gradio as gr
|
| 21 |
+
from daggr import Graph, FnNode, InputNode, InferenceNode
|
| 22 |
+
|
| 23 |
+
from wavereader import forecasts, scoring, spots
|
| 24 |
+
|
| 25 |
+
NARRATION_PROMPT = (
|
| 26 |
+
"You are a surf forecaster. Given a ranked list of spots with scores, "
|
| 27 |
+
"write a concise, enthusiastic morning surf report. Keep it under 200 "
|
| 28 |
+
"words. Include specific times and scores. Be practical and actionable."
|
| 29 |
+
)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _spot_key(spot: dict) -> str:
|
| 33 |
+
return f"{spot['name']}|{spot['region']}"
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _split_key(key: str) -> tuple[str, str]:
|
| 37 |
+
name, region = key.split("|", 1)
|
| 38 |
+
return name, region
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def find_spots_fn(region: str, skill: str | None = None) -> dict[str, Any]:
|
| 42 |
+
"""Find breaks in a region with optional skill filter.
|
| 43 |
+
|
| 44 |
+
The UI's "all" sentinel means no filter — ``spots.find_spots`` does a
|
| 45 |
+
substring skill match, so "all" must become None.
|
| 46 |
+
"""
|
| 47 |
+
if skill in (None, "", "all"):
|
| 48 |
+
skill = None
|
| 49 |
+
breaks = spots.find_spots(region=region, skill=skill, limit=20)
|
| 50 |
+
return {
|
| 51 |
+
"spots": [b.model_dump() for b in breaks],
|
| 52 |
+
"region": region,
|
| 53 |
+
"skill": skill,
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _find_spots_node_fn(region: str, skill: str | None = None):
|
| 58 |
+
"""daggr adapter: FnNode maps tuple returns to output ports in order.
|
| 59 |
+
|
| 60 |
+
``find_spots_fn`` returns a dict for direct/test use; daggr's executor
|
| 61 |
+
treats a dict return as a single value for the first port only, so the
|
| 62 |
+
node unwraps it into ``(spots, region, skill)``.
|
| 63 |
+
"""
|
| 64 |
+
result = find_spots_fn(region, skill)
|
| 65 |
+
return result["spots"], result["region"], result["skill"]
|
| 66 |
+
|
| 67 |
|
| 68 |
+
def fetch_forecasts_fn(spots_list: list[dict]) -> dict[str, Any]:
|
| 69 |
+
"""Fetch forecasts for all spots."""
|
| 70 |
+
forecasts_map = {}
|
| 71 |
+
for spot in spots_list:
|
| 72 |
+
fc = forecasts.get_forecast(
|
| 73 |
+
spot["coordinates"]["lat"], spot["coordinates"]["lng"], days=7
|
| 74 |
+
)
|
| 75 |
+
forecasts_map[_spot_key(spot)] = fc
|
| 76 |
+
return {"forecasts": forecasts_map}
|
| 77 |
|
| 78 |
+
|
| 79 |
+
def score_forecasts_fn(forecasts_map: dict, spots_list: list[dict]) -> dict[str, Any]:
|
| 80 |
+
"""Score forecasts for all spots."""
|
| 81 |
+
scored = {}
|
| 82 |
+
for spot in spots_list:
|
| 83 |
+
key = _spot_key(spot)
|
| 84 |
+
if key in forecasts_map:
|
| 85 |
+
scored[key] = scoring.score_week(forecasts_map[key], spot)
|
| 86 |
+
return {"scored": scored}
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def rank_spots_fn(scored: dict, spots_list: list[dict]) -> dict[str, Any]:
|
| 90 |
+
"""Rank spots by best score from the already-scored hourly rows.
|
| 91 |
+
|
| 92 |
+
Scores were computed in the previous node keyed by ``"{name}|{region}"``
|
| 93 |
+
strings (tuple keys don't survive a round-trip through gr.JSON), so rank
|
| 94 |
+
directly here rather than re-entering ``scoring.rank_spots_this_week``.
|
| 95 |
"""
|
| 96 |
+
ranked = []
|
| 97 |
+
for spot in spots_list:
|
| 98 |
+
key = _spot_key(spot)
|
| 99 |
+
hours = scored.get(key) or []
|
| 100 |
+
best_hour = max(hours, key=lambda h: h["score"]) if hours else None
|
| 101 |
+
ranked.append(
|
| 102 |
+
{
|
| 103 |
+
"name": spot["name"],
|
| 104 |
+
"region": spot["region"],
|
| 105 |
+
"best_score": best_hour["score"] if best_hour else 0.0,
|
| 106 |
+
"best_time": best_hour["time"] if best_hour else None,
|
| 107 |
+
"best_hour": best_hour,
|
| 108 |
+
}
|
| 109 |
+
)
|
| 110 |
+
ranked.sort(key=lambda x: x["best_score"], reverse=True)
|
| 111 |
+
return {"ranked": ranked}
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def format_report_fn(ranked: list[dict], region: str) -> dict[str, str]:
|
| 115 |
+
"""Format the deterministic morning surf report (authoritative output)."""
|
| 116 |
+
if not ranked:
|
| 117 |
+
return {"report": f"No spots found in {region}."}
|
| 118 |
+
|
| 119 |
+
lines = [f"☀️ **Morning Surf Report — {region}**", ""]
|
| 120 |
+
lines.append("📅 Top picks for this week:")
|
| 121 |
+
lines.append("")
|
| 122 |
+
|
| 123 |
+
for i, spot in enumerate(ranked[:5], 1):
|
| 124 |
+
best = spot["best_hour"]
|
| 125 |
+
time_str = best["time"].split("T")[1][:5] if best and best.get("time") else "N/A"
|
| 126 |
+
score = best["score"] if best else 0
|
| 127 |
+
comps = best["components"] if best else {}
|
| 128 |
+
|
| 129 |
+
emoji = "🟢" if score >= 7 else "🟡" if score >= 5 else "🔴"
|
| 130 |
+
lines.append(f"{i}. {emoji} **{spot['name']}** — Score: {score:.1f}/10 @ {time_str}")
|
| 131 |
+
lines.append(
|
| 132 |
+
f" Swell: {comps.get('swell_size') or 0:.1f} | "
|
| 133 |
+
f"Dir: {comps.get('swell_direction') if comps.get('swell_direction') is not None else 'n/a'} | "
|
| 134 |
+
f"Wind: {comps.get('wind') or 0:.1f} | Period: {comps.get('period') or 0:.1f}"
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
lines.append("")
|
| 138 |
+
lines.append("💡 *Scores are deterministic (0-10) based on ideal swell/wind/period for each break.*")
|
| 139 |
+
lines.append("*Tide is qualitative only — check local tide charts.*")
|
| 140 |
+
|
| 141 |
+
return {"report": "\n".join(lines)}
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _narration_preprocess(inputs: dict) -> dict[str, str]:
|
| 145 |
+
"""Flatten ranked spots into a single text-generation prompt.
|
| 146 |
+
|
| 147 |
+
daggr's text-generation path calls ``client.text_generation(prompt)``
|
| 148 |
+
with the first input value — an OpenAI chat payload would be dumped in
|
| 149 |
+
as a list of dicts, so keep it a plain string.
|
| 150 |
+
"""
|
| 151 |
+
prompt = (
|
| 152 |
+
f"{NARRATION_PROMPT}\n\n"
|
| 153 |
+
f"Region: {inputs['region']}\n"
|
| 154 |
+
f"Ranked spots: {json.dumps(inputs['ranked'][:5], default=str)}\n\n"
|
| 155 |
+
"Write a morning surf report."
|
| 156 |
+
)
|
| 157 |
+
return {"prompt": prompt}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def _narration_postprocess(result: Any) -> dict[str, str]:
|
| 161 |
+
"""Extract text from a text_generation result (str or list of dicts)."""
|
| 162 |
+
if isinstance(result, str):
|
| 163 |
+
text = result
|
| 164 |
+
elif isinstance(result, list) and result and isinstance(result[0], dict):
|
| 165 |
+
text = result[0].get("generated_text", str(result))
|
| 166 |
+
else:
|
| 167 |
+
text = str(result)
|
| 168 |
+
return {"ai_report": text}
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
def build_pipeline() -> Graph:
|
| 172 |
+
"""Assemble the fixed morning-report DAG."""
|
| 173 |
+
input_node = InputNode(
|
| 174 |
+
name="User Input",
|
| 175 |
+
ports={
|
| 176 |
+
"region": gr.Dropdown(
|
| 177 |
+
choices=["NSW", "QLD", "VIC", "WA", "SA", "TAS"],
|
| 178 |
+
label="Region",
|
| 179 |
+
value="NSW",
|
| 180 |
+
),
|
| 181 |
+
"skill": gr.Dropdown(
|
| 182 |
+
choices=["beginner", "intermediate", "advanced", "expert", "all"],
|
| 183 |
+
label="Skill Level",
|
| 184 |
+
value="all",
|
| 185 |
+
),
|
| 186 |
+
},
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
find_spots_node = FnNode(
|
| 190 |
+
_find_spots_node_fn,
|
| 191 |
+
name="Find Spots",
|
| 192 |
+
inputs={
|
| 193 |
+
"region": input_node.region,
|
| 194 |
+
"skill": input_node.skill,
|
| 195 |
+
},
|
| 196 |
+
outputs={"spots": gr.JSON(), "region": gr.Textbox(), "skill": gr.Textbox()},
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
fetch_forecasts_node = FnNode(
|
| 200 |
+
fetch_forecasts_fn,
|
| 201 |
+
name="Fetch Forecasts",
|
| 202 |
+
inputs={"spots_list": find_spots_node.spots},
|
| 203 |
+
outputs={"forecasts": gr.JSON()},
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
score_forecasts_node = FnNode(
|
| 207 |
+
score_forecasts_fn,
|
| 208 |
+
name="Score Forecasts",
|
| 209 |
+
inputs={
|
| 210 |
+
"forecasts_map": fetch_forecasts_node.forecasts,
|
| 211 |
+
"spots_list": find_spots_node.spots,
|
| 212 |
+
},
|
| 213 |
+
outputs={"scored": gr.JSON()},
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
rank_spots_node = FnNode(
|
| 217 |
+
rank_spots_fn,
|
| 218 |
+
name="Rank Spots",
|
| 219 |
+
inputs={
|
| 220 |
+
"scored": score_forecasts_node.scored,
|
| 221 |
+
"spots_list": find_spots_node.spots,
|
| 222 |
+
},
|
| 223 |
+
outputs={"ranked": gr.JSON()},
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
# Narrate with the LLM — narration only; scores stay deterministic.
|
| 227 |
+
narrate_node = InferenceNode(
|
| 228 |
+
model="Qwen/Qwen3-Next-80B-A3B-Instruct",
|
| 229 |
+
name="Narrate Report",
|
| 230 |
+
inputs={
|
| 231 |
+
"ranked": rank_spots_node.ranked,
|
| 232 |
+
"region": find_spots_node.region,
|
| 233 |
+
},
|
| 234 |
+
outputs={"ai_report": gr.Markdown()},
|
| 235 |
+
preprocess=_narration_preprocess,
|
| 236 |
+
postprocess=_narration_postprocess,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
format_report_node = FnNode(
|
| 240 |
+
format_report_fn,
|
| 241 |
+
name="Format Report",
|
| 242 |
+
inputs={
|
| 243 |
+
"ranked": rank_spots_node.ranked,
|
| 244 |
+
"region": find_spots_node.region,
|
| 245 |
+
},
|
| 246 |
+
outputs={"report": gr.Markdown()},
|
| 247 |
+
)
|
| 248 |
+
|
| 249 |
+
graph = Graph("Morning Surf Report", nodes=[input_node])
|
| 250 |
+
for node in (
|
| 251 |
+
find_spots_node,
|
| 252 |
+
fetch_forecasts_node,
|
| 253 |
+
score_forecasts_node,
|
| 254 |
+
rank_spots_node,
|
| 255 |
+
narrate_node,
|
| 256 |
+
format_report_node,
|
| 257 |
+
):
|
| 258 |
+
graph.add(node)
|
| 259 |
+
|
| 260 |
+
return graph
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
if __name__ == "__main__":
|
| 264 |
+
graph = build_pipeline()
|
| 265 |
+
graph.launch(host="0.0.0.0", port=7861)
|
wavereader/forecasts.py
CHANGED
|
@@ -11,16 +11,21 @@ Two endpoints, merged on ``time``:
|
|
| 11 |
knots for scoring) and ``wind_direction_10m`` (deg).
|
| 12 |
|
| 13 |
Grid points snap ~10 km offshore of a spot's coords (Snapper Rocks
|
| 14 |
-
-28.173,153.556 resolved to -28.04,153.63). Fetch slightly seaward
|
| 15 |
-
|
|
|
|
| 16 |
|
| 17 |
Cache every response to disk: the demo must survive API flakiness and
|
| 18 |
-
rate limits.
|
|
|
|
|
|
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
import json
|
|
|
|
|
|
|
| 24 |
from pathlib import Path
|
| 25 |
|
| 26 |
import httpx
|
|
@@ -37,35 +42,70 @@ MARINE_HOURLY_FIELDS = (
|
|
| 37 |
)
|
| 38 |
WEATHER_HOURLY_FIELDS = ("wind_speed_10m", "wind_direction_10m")
|
| 39 |
|
| 40 |
-
|
|
|
|
| 41 |
|
| 42 |
CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "forecasts"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
|
| 45 |
def _round_coords(lat: float, lon: float) -> tuple[float, float]:
|
| 46 |
-
|
|
|
|
| 47 |
|
| 48 |
|
| 49 |
def _cache_key(lat: float, lon: float, days: int) -> str:
|
| 50 |
-
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def _cache_path(lat: float, lon: float, days: int) -> Path:
|
| 54 |
return CACHE_DIR / f"{_cache_key(lat, lon, days)}.json"
|
| 55 |
|
| 56 |
|
| 57 |
-
def _load_cache(lat: float, lon: float, days: int) -> dict | None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
p = _cache_path(lat, lon, days)
|
| 59 |
-
if p.exists():
|
| 60 |
-
return
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
|
| 64 |
def _save_cache(lat: float, lon: float, days: int, data: dict) -> None:
|
|
|
|
| 65 |
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
)
|
|
|
|
| 69 |
|
| 70 |
|
| 71 |
def _build_normalized(marine: dict, weather: dict) -> dict:
|
|
@@ -98,17 +138,8 @@ def _build_normalized(marine: dict, weather: dict) -> dict:
|
|
| 98 |
return {"hourly": frames}
|
| 99 |
|
| 100 |
|
| 101 |
-
def
|
| 102 |
-
"""
|
| 103 |
-
|
| 104 |
-
Returns a normalised frame: ``{"hourly": [{"time": ..., "wave_height": ...,
|
| 105 |
-
"wind_speed_10m": ..., ...}, ...]}``. Disk-cached by rounded coords + days.
|
| 106 |
-
"""
|
| 107 |
-
rlat, rlon = _round_coords(lat, lon)
|
| 108 |
-
cached = _load_cache(rlat, rlon, days)
|
| 109 |
-
if cached is not None:
|
| 110 |
-
return cached
|
| 111 |
-
|
| 112 |
params: dict[str, str | int | float] = {
|
| 113 |
"latitude": rlat,
|
| 114 |
"longitude": rlon,
|
|
@@ -123,14 +154,29 @@ def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
|
|
| 123 |
resp_w.raise_for_status()
|
| 124 |
weather = resp_w.json()
|
| 125 |
|
| 126 |
-
|
| 127 |
-
_save_cache(rlat, rlon, days, merged)
|
| 128 |
-
return merged
|
| 129 |
|
| 130 |
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
knots for scoring) and ``wind_direction_10m`` (deg).
|
| 12 |
|
| 13 |
Grid points snap ~10 km offshore of a spot's coords (Snapper Rocks
|
| 14 |
+
-28.173,153.556 resolved to -28.04,153.63). Fetch slightly seaward of the
|
| 15 |
+
spot: east-coast breaks shift east, west-coast breaks shift west,
|
| 16 |
+
south-coast breaks (SA/VIC/TAS) shift south.
|
| 17 |
|
| 18 |
Cache every response to disk: the demo must survive API flakiness and
|
| 19 |
+
rate limits. Fresh cache (within ``CACHE_TTL_SECONDS``) is served without
|
| 20 |
+
a network call; when the API fails, a stale cache entry is served as a
|
| 21 |
+
fallback rather than raising. Horizon: hourly, 7 days.
|
| 22 |
"""
|
| 23 |
|
| 24 |
from __future__ import annotations
|
| 25 |
|
| 26 |
import json
|
| 27 |
+
import os
|
| 28 |
+
import time
|
| 29 |
from pathlib import Path
|
| 30 |
|
| 31 |
import httpx
|
|
|
|
| 42 |
)
|
| 43 |
WEATHER_HOURLY_FIELDS = ("wind_speed_10m", "wind_direction_10m")
|
| 44 |
|
| 45 |
+
# ~0.13° is roughly 14 km — enough to move off the coast onto a marine grid point.
|
| 46 |
+
_SEAWARD_OFFSET_DEG = 0.13
|
| 47 |
|
| 48 |
CACHE_DIR = Path(__file__).resolve().parent.parent / ".cache" / "forecasts"
|
| 49 |
+
CACHE_TTL_SECONDS = 6 * 3600
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _seaward_offset(lat: float, lon: float) -> tuple[float, float]:
|
| 53 |
+
"""Offset a spot's coords toward open ocean so Open-Meteo resolves a marine grid point.
|
| 54 |
+
|
| 55 |
+
Direction is inferred from position: Tasmania and the south coast shift
|
| 56 |
+
south, the east coast (lon >= 147) shifts east, the west coast
|
| 57 |
+
(lon <= 125) shifts west.
|
| 58 |
+
"""
|
| 59 |
+
if lat <= -39.5: # Tasmania: open ocean to the south
|
| 60 |
+
dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
|
| 61 |
+
elif lon >= 147: # east coast (QLD/NSW)
|
| 62 |
+
dlat, dlon = 0.0, _SEAWARD_OFFSET_DEG
|
| 63 |
+
elif lon <= 125: # west coast (WA)
|
| 64 |
+
dlat, dlon = 0.0, -_SEAWARD_OFFSET_DEG
|
| 65 |
+
else: # south coast (SA/VIC)
|
| 66 |
+
dlat, dlon = -_SEAWARD_OFFSET_DEG, 0.0
|
| 67 |
+
return dlat, dlon
|
| 68 |
|
| 69 |
|
| 70 |
def _round_coords(lat: float, lon: float) -> tuple[float, float]:
|
| 71 |
+
dlat, dlon = _seaward_offset(lat, lon)
|
| 72 |
+
return round(lat + dlat, 2), round(lon + dlon, 2)
|
| 73 |
|
| 74 |
|
| 75 |
def _cache_key(lat: float, lon: float, days: int) -> str:
|
| 76 |
+
"""Cache key from the *raw* spot coords (offset is a fetch detail only)."""
|
| 77 |
+
return f"{round(lat, 2):.2f}_{round(lon, 2):.2f}_{days}"
|
| 78 |
|
| 79 |
|
| 80 |
def _cache_path(lat: float, lon: float, days: int) -> Path:
|
| 81 |
return CACHE_DIR / f"{_cache_key(lat, lon, days)}.json"
|
| 82 |
|
| 83 |
|
| 84 |
+
def _load_cache(lat: float, lon: float, days: int) -> tuple[dict, float] | None:
|
| 85 |
+
"""Return ``(data, fetched_at)`` from disk, or None if absent/corrupt.
|
| 86 |
+
|
| 87 |
+
Legacy cache files written before the envelope format are returned with
|
| 88 |
+
``fetched_at = 0`` so they still work as a stale fallback.
|
| 89 |
+
"""
|
| 90 |
p = _cache_path(lat, lon, days)
|
| 91 |
+
if not p.exists():
|
| 92 |
+
return None
|
| 93 |
+
try:
|
| 94 |
+
raw = json.loads(p.read_text(encoding="utf-8"))
|
| 95 |
+
except (json.JSONDecodeError, OSError):
|
| 96 |
+
return None
|
| 97 |
+
if isinstance(raw, dict) and "fetched_at" in raw and "data" in raw:
|
| 98 |
+
return raw["data"], float(raw["fetched_at"])
|
| 99 |
+
return raw, 0.0
|
| 100 |
|
| 101 |
|
| 102 |
def _save_cache(lat: float, lon: float, days: int, data: dict) -> None:
|
| 103 |
+
"""Write the cache envelope atomically (crash-safe write + rename)."""
|
| 104 |
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
| 105 |
+
envelope = {"fetched_at": time.time(), "data": data}
|
| 106 |
+
tmp = _cache_path(lat, lon, days).with_suffix(".tmp")
|
| 107 |
+
tmp.write_text(json.dumps(envelope, ensure_ascii=False), encoding="utf-8")
|
| 108 |
+
os.replace(tmp, _cache_path(lat, lon, days))
|
| 109 |
|
| 110 |
|
| 111 |
def _build_normalized(marine: dict, weather: dict) -> dict:
|
|
|
|
| 138 |
return {"hourly": frames}
|
| 139 |
|
| 140 |
|
| 141 |
+
def _fetch_open_meteo(rlat: float, rlon: float, days: int) -> dict:
|
| 142 |
+
"""Call both Open-Meteo endpoints and return the merged frame."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
params: dict[str, str | int | float] = {
|
| 144 |
"latitude": rlat,
|
| 145 |
"longitude": rlon,
|
|
|
|
| 154 |
resp_w.raise_for_status()
|
| 155 |
weather = resp_w.json()
|
| 156 |
|
| 157 |
+
return _build_normalized(marine, weather)
|
|
|
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
+
def get_forecast(lat: float, lon: float, days: int = 7) -> dict:
|
| 161 |
+
"""Fetch marine + weather hourly data for a coordinate, merged on time.
|
| 162 |
+
|
| 163 |
+
Returns a normalised frame: ``{"hourly": [{"time": ..., "wave_height": ...,
|
| 164 |
+
"wind_speed_10m": ..., ...}, ...]}``. Disk-cached by rounded coords + days:
|
| 165 |
+
entries younger than ``CACHE_TTL_SECONDS`` are served directly; on a network
|
| 166 |
+
failure the most recent cache entry (however old) is served as a fallback.
|
| 167 |
+
Raises only when there is no usable data at all.
|
| 168 |
+
"""
|
| 169 |
+
rlat, rlon = _round_coords(lat, lon)
|
| 170 |
+
cached = _load_cache(lat, lon, days)
|
| 171 |
+
if cached is not None and time.time() - cached[1] < CACHE_TTL_SECONDS:
|
| 172 |
+
return cached[0]
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
merged = _fetch_open_meteo(rlat, rlon, days)
|
| 176 |
+
except (httpx.HTTPError, KeyError, ValueError):
|
| 177 |
+
if cached is not None:
|
| 178 |
+
return cached[0]
|
| 179 |
+
raise
|
| 180 |
+
|
| 181 |
+
_save_cache(lat, lon, days, merged)
|
| 182 |
+
return merged
|
wavereader/scoring.py
CHANGED
|
@@ -1,130 +1,189 @@
|
|
| 1 |
-
"""Deterministic surf-quality scoring.
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
(swell size vs ideal, swell direction match, wind speed + offshore
|
| 6 |
-
alignment, wave period). Tide is qualitative only — Open-Meteo has no
|
| 7 |
-
tides, so it never feeds the numeric score.
|
| 8 |
"""
|
| 9 |
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
import math
|
|
|
|
| 13 |
|
| 14 |
-
# ---- 16-
|
| 15 |
|
| 16 |
_COMPASS_DEG: dict[str, float] = {
|
| 17 |
-
"N": 0, "NNE": 22.5, "NE": 45, "ENE": 67.5,
|
| 18 |
-
"E": 90, "ESE": 112.5, "SE": 135, "SSE": 157.5,
|
| 19 |
-
"S": 180, "SSW": 202.5, "SW": 225, "WSW": 247.5,
|
| 20 |
-
"W": 270, "WNW": 292.5, "NW": 315, "NNW": 337.5,
|
| 21 |
}
|
| 22 |
|
| 23 |
-
# Weights for the four components → sum to 1.0
|
| 24 |
_WEIGHTS = {
|
| 25 |
-
"swell_size": 0.
|
| 26 |
"swell_direction": 0.20,
|
| 27 |
"wind": 0.30,
|
| 28 |
-
"period": 0.
|
| 29 |
}
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
-
|
| 33 |
-
|
| 34 |
|
| 35 |
-
|
| 36 |
-
and comma-separated values ('SE, S, E') by averaging the endpoints.
|
| 37 |
-
"""
|
| 38 |
-
# Normalize separators: replace commas with slashes
|
| 39 |
-
direction = direction.replace(",", "/")
|
| 40 |
-
parts = direction.replace(" ", "").split("/")
|
| 41 |
-
degs = [_COMPASS_DEG[p] for p in parts]
|
| 42 |
-
return sum(degs) / len(degs)
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
def _angular_diff(a: float, b: float) -> float:
|
| 46 |
-
"""Shortest angular distance between two bearings in degrees."""
|
| 47 |
-
d = abs(a - b) % 360
|
| 48 |
-
return d if d <= 180 else 360 - d
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
|
| 52 |
-
"""Score 0-10 based on how close wave_height_m is to the ideal range.
|
| 53 |
-
|
| 54 |
-
Ideal size is stored in feet; convert to metres for comparison.
|
| 55 |
-
Perfect score (10) inside the range, linear decay to 0 at ±30 % outside.
|
| 56 |
-
"""
|
| 57 |
-
ideal = spot["ideal_swell"]
|
| 58 |
-
min_m = ideal["size_ft_min"] * 0.3048
|
| 59 |
-
max_m = ideal["size_ft_max"] * 0.3048
|
| 60 |
-
|
| 61 |
-
margin = max(max_m - min_m, 0.1)
|
| 62 |
-
low_cutoff = min_m - margin
|
| 63 |
-
high_cutoff = max_m + margin
|
| 64 |
-
|
| 65 |
-
if low_cutoff <= wave_height_m <= high_cutoff:
|
| 66 |
-
if min_m <= wave_height_m <= max_m:
|
| 67 |
-
return 10.0
|
| 68 |
-
# Partial credit inside the extended range
|
| 69 |
-
if wave_height_m < min_m:
|
| 70 |
-
return 10.0 - 10.0 * (min_m - wave_height_m) / margin
|
| 71 |
-
return 10.0 - 10.0 * (wave_height_m - max_m) / margin
|
| 72 |
-
|
| 73 |
-
return max(
|
| 74 |
-
10.0 * (1.0 - (low_cutoff - wave_height_m) / margin)
|
| 75 |
-
if wave_height_m < low_cutoff
|
| 76 |
-
else 10.0 * (1.0 - (wave_height_m - high_cutoff) / margin),
|
| 77 |
-
0.0,
|
| 78 |
-
)
|
| 79 |
|
| 80 |
|
| 81 |
def _score_swell_direction(wave_dir_deg: float, spot: dict) -> float:
|
| 82 |
-
"""
|
| 83 |
-
|
| 84 |
-
0° difference → 10; 90° → ~3; 180° → 0.
|
| 85 |
-
"""
|
| 86 |
ideal_deg = _dir_to_deg(spot["ideal_swell"]["direction"])
|
| 87 |
diff = _angular_diff(wave_dir_deg, ideal_deg)
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
-
def _score_wind(
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
Direction
|
| 97 |
"""
|
| 98 |
-
|
| 99 |
-
|
|
|
|
| 100 |
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
|
|
|
|
|
|
|
|
|
| 108 |
|
| 109 |
-
return speed_score
|
| 110 |
|
| 111 |
|
| 112 |
def _score_period(period_s: float) -> float:
|
| 113 |
-
"""
|
| 114 |
|
| 115 |
-
|
| 116 |
-
falling off after ~16 s.
|
| 117 |
"""
|
| 118 |
-
if period_s >= 14.0:
|
| 119 |
-
return 10.0
|
| 120 |
if period_s <= 4.0:
|
| 121 |
return 0.0
|
| 122 |
-
if period_s >=
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
return 6.0 * (period_s - 4.0) / 6.0
|
| 127 |
|
|
|
|
| 128 |
|
| 129 |
def score_hour(
|
| 130 |
wave_height_m: float,
|
|
@@ -132,81 +191,107 @@ def score_hour(
|
|
| 132 |
wind_speed_kt: float,
|
| 133 |
wind_direction_deg: float,
|
| 134 |
spot: dict,
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
c_period = _score_period(wave_period_s)
|
| 145 |
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
)
|
| 152 |
|
| 153 |
return {
|
| 154 |
-
"score": round(
|
| 155 |
-
"components":
|
| 156 |
-
|
| 157 |
-
"swell_direction": round(c_dir, 2),
|
| 158 |
-
"wind": round(c_wind, 2),
|
| 159 |
-
"period": round(c_period, 2),
|
| 160 |
-
},
|
| 161 |
"wave_height_m": wave_height_m,
|
| 162 |
"wave_period_s": wave_period_s,
|
| 163 |
"wind_speed_kt": wind_speed_kt,
|
| 164 |
"wind_direction_deg": wind_direction_deg,
|
|
|
|
| 165 |
}
|
| 166 |
|
| 167 |
|
| 168 |
-
def score_week(
|
| 169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 170 |
|
| 171 |
-
``forecast`` is the normalised ``{"hourly": [...]}`` frame from
|
| 172 |
-
``forecasts.py``. Each hour row must carry ``time``,
|
| 173 |
-
``wave_height``, ``wave_period``, ``wind_speed_10m`` and
|
| 174 |
-
``wind_direction_10m``.
|
| 175 |
-
"""
|
| 176 |
-
rows = forecast["hourly"]
|
| 177 |
-
results: list[dict] = []
|
| 178 |
for row in rows:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
scored = score_hour(
|
| 180 |
-
wave_height_m=float(
|
| 181 |
-
wave_period_s=float(
|
| 182 |
-
wind_speed_kt=float(
|
| 183 |
-
wind_direction_deg=float(
|
| 184 |
spot=spot,
|
|
|
|
|
|
|
| 185 |
)
|
| 186 |
scored["time"] = row["time"]
|
| 187 |
results.append(scored)
|
|
|
|
| 188 |
return results
|
| 189 |
|
| 190 |
|
| 191 |
-
def rank_spots_this_week(
|
| 192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
|
| 194 |
-
``forecasts`` maps ``(name, region)`` → normalised forecast frame.
|
| 195 |
-
Returns entries sorted by best score descending.
|
| 196 |
-
"""
|
| 197 |
-
ranked: list[dict] = []
|
| 198 |
for spot in spots_list:
|
| 199 |
key = (spot["name"], spot["region"])
|
| 200 |
if key not in forecasts:
|
| 201 |
continue
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
ranked.sort(key=lambda x: x["best_score"], reverse=True)
|
| 212 |
return ranked
|
|
|
|
| 1 |
+
"""Deterministic surf-quality scoring module.
|
| 2 |
|
| 3 |
+
Provides non-linear, robust surf quality evaluations calibrated by surfer skill level.
|
| 4 |
+
Calculates component scores (0.0 to 10.0) using continuous curves and physics-aware decay.
|
|
|
|
|
|
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
from __future__ import annotations
|
| 8 |
|
| 9 |
import math
|
| 10 |
+
from typing import TypedDict, Any, Dict, List, Tuple, Optional
|
| 11 |
|
| 12 |
+
# ---- 16-Point Compass Reference ----
|
| 13 |
|
| 14 |
_COMPASS_DEG: dict[str, float] = {
|
| 15 |
+
"N": 0.0, "NNE": 22.5, "NE": 45.0, "ENE": 67.5,
|
| 16 |
+
"E": 90.0, "ESE": 112.5, "SE": 135.0, "SSE": 157.5,
|
| 17 |
+
"S": 180.0, "SSW": 202.5, "SW": 225.0, "WSW": 247.5,
|
| 18 |
+
"W": 270.0, "WNW": 292.5, "NW": 315.0, "NNW": 337.5,
|
| 19 |
}
|
| 20 |
|
|
|
|
| 21 |
_WEIGHTS = {
|
| 22 |
+
"swell_size": 0.30,
|
| 23 |
"swell_direction": 0.20,
|
| 24 |
"wind": 0.30,
|
| 25 |
+
"period": 0.20,
|
| 26 |
}
|
| 27 |
|
| 28 |
+
# Skill Profile Thresholds (values in feet, knots, seconds)
|
| 29 |
+
_SKILL_PROFILES: dict[str, dict[str, Any]] = {
|
| 30 |
+
"beginner": {
|
| 31 |
+
"comfort_size_min_ft": 1.0,
|
| 32 |
+
"comfort_size_max_ft": 3.5,
|
| 33 |
+
"max_safe_size_ft": 4.5,
|
| 34 |
+
"max_wind_kt": 12.0,
|
| 35 |
+
"ideal_period_max_s": 12.0,
|
| 36 |
+
},
|
| 37 |
+
"intermediate": {
|
| 38 |
+
"comfort_size_min_ft": 2.0,
|
| 39 |
+
"comfort_size_max_ft": 6.5,
|
| 40 |
+
"max_safe_size_ft": 8.5,
|
| 41 |
+
"max_wind_kt": 18.0,
|
| 42 |
+
"ideal_period_max_s": 16.0,
|
| 43 |
+
},
|
| 44 |
+
"advanced": {
|
| 45 |
+
"comfort_size_min_ft": 3.0,
|
| 46 |
+
"comfort_size_max_ft": 12.0,
|
| 47 |
+
"max_safe_size_ft": 18.0,
|
| 48 |
+
"max_wind_kt": 25.0,
|
| 49 |
+
"ideal_period_max_s": 22.0,
|
| 50 |
+
},
|
| 51 |
+
"expert": {
|
| 52 |
+
"comfort_size_min_ft": 4.0,
|
| 53 |
+
"comfort_size_max_ft": 30.0,
|
| 54 |
+
"max_safe_size_ft": 50.0,
|
| 55 |
+
"max_wind_kt": 35.0,
|
| 56 |
+
"ideal_period_max_s": 25.0,
|
| 57 |
+
},
|
| 58 |
+
}
|
| 59 |
|
| 60 |
+
SKILL_LEVELS = tuple(_SKILL_PROFILES.keys())
|
| 61 |
+
_DEFAULT_SKILL_LEVEL = "intermediate"
|
| 62 |
|
| 63 |
+
# ---- Helpers ----
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
+
def _dir_to_deg(direction: str) -> float:
|
| 66 |
+
"""Parses cardinal strings, slash-separated, or 'to' directions to degrees."""
|
| 67 |
+
normalized = direction.replace(",", "/").replace(" to ", "/").replace(" ", "").upper()
|
| 68 |
+
parts = normalized.split("/")
|
| 69 |
+
degs = [_COMPASS_DEG[p] for p in parts if p in _COMPASS_DEG]
|
| 70 |
+
if not degs:
|
| 71 |
+
raise ValueError(f"Invalid compass direction input: {direction!r}")
|
| 72 |
+
|
| 73 |
+
# Cyclic mean for angles
|
| 74 |
+
sin_sum = sum(math.sin(math.radians(d)) for d in degs)
|
| 75 |
+
cos_sum = sum(math.cos(math.radians(d)) for d in degs)
|
| 76 |
+
mean_deg = math.degrees(math.atan2(sin_sum, cos_sum)) % 360
|
| 77 |
+
return round(mean_deg, 1)
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
|
| 80 |
+
def _angular_diff(a: float, b: float) -> float:
|
| 81 |
+
"""Calculates shortest arc distance between two bearings in degrees."""
|
| 82 |
+
d = abs(a - b) % 360.0
|
| 83 |
+
return d if d <= 180.0 else 360.0 - d
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _gaussian_decay(value: float, target: float, sigma: float) -> float:
|
| 87 |
+
"""Smooth Gaussian decay yielding 1.0 at target and trailing to 0.0."""
|
| 88 |
+
return math.exp(-((value - target) ** 2) / (2 * (sigma ** 2)))
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
# ---- Core Scoring Components ----
|
| 92 |
+
|
| 93 |
+
def _score_swell_size(wave_height_m: float, spot: dict, skill_level: str) -> float:
|
| 94 |
+
"""Evaluates swell size considering spot ideals and surfer safety constraints."""
|
| 95 |
+
profile = _SKILL_PROFILES[skill_level]
|
| 96 |
+
wave_height_ft = wave_height_m * 3.28084
|
| 97 |
+
|
| 98 |
+
spot_ideal = spot["ideal_swell"]
|
| 99 |
+
spot_min_ft = float(spot_ideal["size_ft_min"])
|
| 100 |
+
spot_max_ft = float(spot_ideal["size_ft_max"])
|
| 101 |
+
|
| 102 |
+
# 1. Spot Suitability (Does the spot work?)
|
| 103 |
+
if spot_min_ft <= wave_height_ft <= spot_max_ft:
|
| 104 |
+
spot_score = 10.0
|
| 105 |
+
elif wave_height_ft < spot_min_ft:
|
| 106 |
+
sigma = max(1.0, spot_min_ft * 0.4)
|
| 107 |
+
spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_min_ft, sigma)
|
| 108 |
+
else:
|
| 109 |
+
sigma = max(1.5, spot_max_ft * 0.5)
|
| 110 |
+
spot_score = 10.0 * _gaussian_decay(wave_height_ft, spot_max_ft, sigma)
|
| 111 |
+
|
| 112 |
+
# 2. Safety & Comfort Filter
|
| 113 |
+
comf_min = profile["comfort_size_min_ft"]
|
| 114 |
+
comf_max = profile["comfort_size_max_ft"]
|
| 115 |
+
max_safe = profile["max_safe_size_ft"]
|
| 116 |
+
|
| 117 |
+
if wave_height_ft > max_safe:
|
| 118 |
+
# Zero tolerance for dangerous wave heights relative to skill
|
| 119 |
+
return 0.0
|
| 120 |
+
|
| 121 |
+
skill_mult = 1.0
|
| 122 |
+
if wave_height_ft < comf_min:
|
| 123 |
+
skill_mult = max(0.2, wave_height_ft / comf_min)
|
| 124 |
+
elif wave_height_ft > comf_max:
|
| 125 |
+
# Smooth penalty down to zero at max_safe_size
|
| 126 |
+
overage = wave_height_ft - comf_max
|
| 127 |
+
allowed_headroom = max_safe - comf_max
|
| 128 |
+
skill_mult = max(0.0, 1.0 - (overage / allowed_headroom) ** 1.5)
|
| 129 |
|
| 130 |
+
return round(spot_score * skill_mult, 2)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
def _score_swell_direction(wave_dir_deg: float, spot: dict) -> float:
|
| 134 |
+
"""Calculates directional match relative to optimal spot window."""
|
|
|
|
|
|
|
|
|
|
| 135 |
ideal_deg = _dir_to_deg(spot["ideal_swell"]["direction"])
|
| 136 |
diff = _angular_diff(wave_dir_deg, ideal_deg)
|
| 137 |
+
|
| 138 |
+
# Half-width tolerance angle before severe drop-off (e.g., 45 degrees)
|
| 139 |
+
tolerance_deg = 45.0
|
| 140 |
+
score = 10.0 * _gaussian_decay(diff, 0.0, tolerance_deg / 1.5)
|
| 141 |
+
return max(0.0, min(10.0, score))
|
| 142 |
|
| 143 |
|
| 144 |
+
def _score_wind(
|
| 145 |
+
wind_speed_kt: float,
|
| 146 |
+
wind_dir_deg: float,
|
| 147 |
+
spot: dict,
|
| 148 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 149 |
+
) -> float:
|
| 150 |
+
"""Scores wind as the mean of a speed score and a direction score.
|
| 151 |
|
| 152 |
+
Speed: glassy (<= 5 kt) is always perfect; above the tolerable limit
|
| 153 |
+
(the spot's ``strength_kt_max``, capped by the skill profile) it scores 0.
|
| 154 |
+
Direction: cosine falloff from perfect offshore to absolute onshore.
|
| 155 |
"""
|
| 156 |
+
profile_max = _SKILL_PROFILES[skill_level]["max_wind_kt"]
|
| 157 |
+
spot_max = float(spot["ideal_wind"]["strength_kt_max"])
|
| 158 |
+
limit = min(spot_max, profile_max)
|
| 159 |
|
| 160 |
+
offshore_dir_deg = _dir_to_deg(spot["ideal_wind"]["direction"])
|
| 161 |
+
diff = _angular_diff(wind_dir_deg, offshore_dir_deg)
|
| 162 |
+
dir_score = 10.0 * max(0.0, math.cos(math.radians(diff / 2.0)))
|
| 163 |
|
| 164 |
+
if wind_speed_kt <= 5.0:
|
| 165 |
+
speed_score = 10.0 # glassy regardless of direction
|
| 166 |
+
elif wind_speed_kt <= limit:
|
| 167 |
+
speed_score = 10.0
|
| 168 |
+
else:
|
| 169 |
+
speed_score = 0.0
|
| 170 |
|
| 171 |
+
return round((speed_score + dir_score) / 2.0, 2)
|
| 172 |
|
| 173 |
|
| 174 |
def _score_period(period_s: float) -> float:
|
| 175 |
+
"""Evaluates swell period quality: 0 below 4 s, 10 from 14 s up.
|
| 176 |
|
| 177 |
+
Long groundswell is never penalised — there is no falloff above 14 s.
|
|
|
|
| 178 |
"""
|
|
|
|
|
|
|
| 179 |
if period_s <= 4.0:
|
| 180 |
return 0.0
|
| 181 |
+
if period_s >= 14.0:
|
| 182 |
+
return 10.0
|
| 183 |
+
return round(10.0 * (period_s - 4.0) / 10.0, 2)
|
| 184 |
+
|
|
|
|
| 185 |
|
| 186 |
+
# ---- Main Interface Functions ----
|
| 187 |
|
| 188 |
def score_hour(
|
| 189 |
wave_height_m: float,
|
|
|
|
| 191 |
wind_speed_kt: float,
|
| 192 |
wind_direction_deg: float,
|
| 193 |
spot: dict,
|
| 194 |
+
wave_direction_deg: Optional[float] = None,
|
| 195 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 196 |
+
) -> dict[str, Any]:
|
| 197 |
+
"""Computes composite surf score (0-10) for a given hour and skill tier."""
|
| 198 |
+
if skill_level not in _SKILL_PROFILES:
|
| 199 |
+
raise ValueError(f"Invalid skill_level {skill_level!r}. Expected one of {SKILL_LEVELS}")
|
| 200 |
+
|
| 201 |
+
c_size = _score_swell_size(wave_height_m, spot, skill_level)
|
| 202 |
+
c_dir = (
|
| 203 |
+
_score_swell_direction(wave_direction_deg, spot)
|
| 204 |
+
if wave_direction_deg is not None
|
| 205 |
+
else None
|
| 206 |
+
)
|
| 207 |
+
c_wind = _score_wind(wind_speed_kt, wind_direction_deg, spot, skill_level)
|
| 208 |
c_period = _score_period(wave_period_s)
|
| 209 |
|
| 210 |
+
components: dict[str, Optional[float]] = {
|
| 211 |
+
"swell_size": round(c_size, 2),
|
| 212 |
+
"swell_direction": round(c_dir, 2) if c_dir is not None else None,
|
| 213 |
+
"wind": round(c_wind, 2),
|
| 214 |
+
"period": round(c_period, 2),
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
# Weight redistribution when optional components are missing
|
| 218 |
+
active_weights = {k: v for k, v in _WEIGHTS.items() if components[k] is not None}
|
| 219 |
+
weight_sum = sum(active_weights.values())
|
| 220 |
+
|
| 221 |
+
composite = sum(
|
| 222 |
+
components[k] * (weight / weight_sum) # type: ignore
|
| 223 |
+
for k, weight in active_weights.items()
|
| 224 |
)
|
| 225 |
|
| 226 |
return {
|
| 227 |
+
"score": round(composite, 2),
|
| 228 |
+
"components": components,
|
| 229 |
+
"skill_level": skill_level,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
"wave_height_m": wave_height_m,
|
| 231 |
"wave_period_s": wave_period_s,
|
| 232 |
"wind_speed_kt": wind_speed_kt,
|
| 233 |
"wind_direction_deg": wind_direction_deg,
|
| 234 |
+
"wave_direction_deg": wave_direction_deg,
|
| 235 |
}
|
| 236 |
|
| 237 |
|
| 238 |
+
def score_week(
|
| 239 |
+
forecast: dict, spot: dict, skill_level: str = _DEFAULT_SKILL_LEVEL
|
| 240 |
+
) -> list[dict[str, Any]]:
|
| 241 |
+
"""Evaluates every hour in a forecast payload for a target spot."""
|
| 242 |
+
rows = forecast.get("hourly", [])
|
| 243 |
+
results = []
|
| 244 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 245 |
for row in rows:
|
| 246 |
+
wave_height = row.get("wave_height")
|
| 247 |
+
wave_period = row.get("wave_period")
|
| 248 |
+
wind_speed = row.get("wind_speed_10m")
|
| 249 |
+
wind_dir = row.get("wind_direction_10m")
|
| 250 |
+
|
| 251 |
+
if None in (wave_height, wave_period, wind_speed, wind_dir):
|
| 252 |
+
continue
|
| 253 |
+
|
| 254 |
scored = score_hour(
|
| 255 |
+
wave_height_m=float(wave_height),
|
| 256 |
+
wave_period_s=float(wave_period),
|
| 257 |
+
wind_speed_kt=float(wind_speed) * 0.539957, # km/h to knots conversion
|
| 258 |
+
wind_direction_deg=float(wind_dir),
|
| 259 |
spot=spot,
|
| 260 |
+
wave_direction_deg=float(row["wave_direction"]) if row.get("wave_direction") is not None else None,
|
| 261 |
+
skill_level=skill_level,
|
| 262 |
)
|
| 263 |
scored["time"] = row["time"]
|
| 264 |
results.append(scored)
|
| 265 |
+
|
| 266 |
return results
|
| 267 |
|
| 268 |
|
| 269 |
+
def rank_spots_this_week(
|
| 270 |
+
forecasts: dict[tuple[str, str], dict],
|
| 271 |
+
spots_list: list[dict],
|
| 272 |
+
skill_level: str = _DEFAULT_SKILL_LEVEL,
|
| 273 |
+
) -> list[dict[str, Any]]:
|
| 274 |
+
"""Ranks provided spots by their maximum achievable hourly score."""
|
| 275 |
+
ranked = []
|
| 276 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 277 |
for spot in spots_list:
|
| 278 |
key = (spot["name"], spot["region"])
|
| 279 |
if key not in forecasts:
|
| 280 |
continue
|
| 281 |
+
|
| 282 |
+
hours = score_week(forecasts[key], spot, skill_level=skill_level)
|
| 283 |
+
best_hour = max(hours, key=lambda h: h["score"]) if hours else None
|
| 284 |
+
|
| 285 |
+
ranked.append(
|
| 286 |
+
{
|
| 287 |
+
"name": spot["name"],
|
| 288 |
+
"region": spot["region"],
|
| 289 |
+
"skill_level": skill_level,
|
| 290 |
+
"best_score": best_hour["score"] if best_hour else 0.0,
|
| 291 |
+
"best_time": best_hour["time"] if best_hour else None,
|
| 292 |
+
"best_hour": best_hour,
|
| 293 |
+
}
|
| 294 |
+
)
|
| 295 |
+
|
| 296 |
ranked.sort(key=lambda x: x["best_score"], reverse=True)
|
| 297 |
return ranked
|
wavereader/spots.py
CHANGED
|
@@ -52,11 +52,6 @@ class IdealSwell(BaseModel):
|
|
| 52 |
size_ft_max: float
|
| 53 |
direction: str
|
| 54 |
|
| 55 |
-
@field_validator("size_ft_min")
|
| 56 |
-
@classmethod
|
| 57 |
-
def size_min_lt_max(cls, v: float) -> float:
|
| 58 |
-
return v
|
| 59 |
-
|
| 60 |
@model_validator(mode="after")
|
| 61 |
def check_swell_sizes(self) -> "IdealSwell":
|
| 62 |
if self.size_ft_min >= self.size_ft_max:
|
|
@@ -124,10 +119,6 @@ class Break(BaseModel):
|
|
| 124 |
return v
|
| 125 |
|
| 126 |
|
| 127 |
-
def _load_raw() -> list[dict]:
|
| 128 |
-
return json.loads(DATA_PATH.read_text(encoding="utf-8"))
|
| 129 |
-
|
| 130 |
-
|
| 131 |
def load_breaks(path: Path | str | None = None) -> list[Break]:
|
| 132 |
"""Load and validate ``data/break_details.json``.
|
| 133 |
|
|
|
|
| 52 |
size_ft_max: float
|
| 53 |
direction: str
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
@model_validator(mode="after")
|
| 56 |
def check_swell_sizes(self) -> "IdealSwell":
|
| 57 |
if self.size_ft_min >= self.size_ft_max:
|
|
|
|
| 119 |
return v
|
| 120 |
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
def load_breaks(path: Path | str | None = None) -> list[Break]:
|
| 123 |
"""Load and validate ``data/break_details.json``.
|
| 124 |
|
wavereader/tools.py
CHANGED
|
@@ -1,47 +1,273 @@
|
|
| 1 |
-
"""Agent tools:
|
| 2 |
|
| 3 |
-
|
| 4 |
-
|
|
|
|
| 5 |
"""
|
| 6 |
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
from wavereader import forecasts, scoring, spots
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"
|
| 17 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
def get_forecast(spot_name: str, region: str) -> dict:
|
| 21 |
-
"""Hourly marine + wind forecast for a named spot."""
|
| 22 |
s = spots.get_spot(spot_name, region)
|
| 23 |
if s is None:
|
| 24 |
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
| 25 |
return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 26 |
|
| 27 |
|
| 28 |
-
def score_week(spot_name: str, region: str) -> list[dict]:
|
| 29 |
-
"""Hour-by-hour surf scores for the next 7 days at a spot."""
|
| 30 |
s = spots.get_spot(spot_name, region)
|
| 31 |
if s is None:
|
| 32 |
-
return
|
| 33 |
forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 34 |
return scoring.score_week(forecast, s.model_dump())
|
| 35 |
|
| 36 |
|
| 37 |
def find_spots(query: str = "", skill: str | None = None, limit: int = 10) -> list[dict]:
|
| 38 |
-
"""Search breaks by name/region with optional skill filter."""
|
| 39 |
results = spots.find_spots(query=query, skill=skill, limit=limit)
|
| 40 |
return [b.model_dump() for b in results]
|
| 41 |
|
| 42 |
|
| 43 |
def get_spot_knowledge(spot_name: str, region: str) -> dict:
|
| 44 |
-
"""Knowledge-base profile for a spot (ideal swell/wind/tide)."""
|
| 45 |
s = spots.get_spot(spot_name, region)
|
| 46 |
if s is None:
|
| 47 |
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
|
@@ -49,15 +275,174 @@ def get_spot_knowledge(spot_name: str, region: str) -> dict:
|
|
| 49 |
|
| 50 |
|
| 51 |
def rank_spots_this_week(region: str, skill: str | None = None) -> list[dict]:
|
| 52 |
-
"""Rank spots in a region by their best score this week."""
|
| 53 |
spots_list = spots.find_spots(region=region, skill=skill)
|
| 54 |
if not spots_list:
|
| 55 |
return []
|
| 56 |
-
forecasts_map
|
| 57 |
for b in spots_list:
|
| 58 |
d = forecasts.get_forecast(b.coordinates.lat, b.coordinates.lng)
|
| 59 |
forecasts_map[(b.name, b.region)] = d
|
| 60 |
return scoring.rank_spots_this_week(
|
| 61 |
forecasts_map,
|
| 62 |
[b.model_dump() for b in spots_list],
|
| 63 |
-
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Agent tools: forecast/score/knowledge lookups plus matplotlib plot builders.
|
| 2 |
|
| 3 |
+
Plain functions resolve a spot by ``(name, region)`` and delegate to
|
| 4 |
+
``forecasts``/``scoring``/``spots``; the smolagents ``Tool`` subclasses below
|
| 5 |
+
expose them to the CodeAgent. The LLM never computes numbers itself.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
import base64
|
| 9 |
+
import io
|
| 10 |
+
import matplotlib
|
| 11 |
+
matplotlib.use("Agg") # Use non-interactive backend for server-side rendering
|
| 12 |
+
import matplotlib.pyplot as plt
|
| 13 |
+
import numpy as np
|
| 14 |
+
import pandas as pd
|
| 15 |
+
import seaborn as sns
|
| 16 |
+
from smolagents import Tool
|
| 17 |
from wavereader import forecasts, scoring, spots
|
| 18 |
|
| 19 |
+
# Configure Seaborn global styling
|
| 20 |
+
sns.set_theme(style="whitegrid", palette="muted")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _encode_fig_to_base64(fig) -> str:
|
| 24 |
+
"""Helper to convert a matplotlib figure to base64 string."""
|
| 25 |
+
buffer = io.BytesIO()
|
| 26 |
+
fig.savefig(buffer, format="png", bbox_inches="tight", dpi=150)
|
| 27 |
+
buffer.seek(0)
|
| 28 |
+
encoded = base64.b64encode(buffer.getvalue()).decode("utf-8")
|
| 29 |
+
plt.close(fig)
|
| 30 |
+
return encoded
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def draw_directional_arrows(ax, x_coords, y_coords, degrees, color="black"):
|
| 34 |
+
"""Annotate plot points with direction arrows pointing towards the direction vector."""
|
| 35 |
+
for x, y, deg in zip(x_coords, y_coords, degrees):
|
| 36 |
+
if pd.isna(deg):
|
| 37 |
+
continue
|
| 38 |
+
# Convert degrees to radians (standard meteorology: 0° N, 90° E)
|
| 39 |
+
rad = np.radians(90 - deg)
|
| 40 |
+
dx = np.cos(rad) * 0.4
|
| 41 |
+
dy = np.sin(rad) * 0.4
|
| 42 |
+
ax.annotate(
|
| 43 |
+
"",
|
| 44 |
+
xy=(x + dx, y + dy),
|
| 45 |
+
xytext=(x - dx, y - dy),
|
| 46 |
+
arrowprops=dict(
|
| 47 |
+
arrowstyle="->",
|
| 48 |
+
color=color,
|
| 49 |
+
lw=1.5,
|
| 50 |
+
mutation_scale=12,
|
| 51 |
+
),
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def plot_daily_surf_overview(spot_name: str, region: str) -> dict:
|
| 56 |
+
s = spots.get_spot(spot_name, region)
|
| 57 |
+
if s is None:
|
| 58 |
+
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
| 59 |
+
|
| 60 |
+
forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 61 |
+
scores = scoring.score_week(forecast, s.model_dump())
|
| 62 |
+
|
| 63 |
+
df = pd.DataFrame(scores)
|
| 64 |
+
|
| 65 |
+
# Dynamic date field resolution to prevent KeyError
|
| 66 |
+
date_col = next((col for col in ["date", "day", "datetime", "timestamp"] if col in df.columns), None)
|
| 67 |
+
if date_col:
|
| 68 |
+
df["date_label"] = pd.to_datetime(df[date_col]).dt.strftime("%a %b %d")
|
| 69 |
+
else:
|
| 70 |
+
df["date_label"] = [f"Day {i+1}" for i in range(len(df))]
|
| 71 |
+
|
| 72 |
+
fig, axes = plt.subplots(3, 1, figsize=(10, 8), sharex=True)
|
| 73 |
+
fig.suptitle(
|
| 74 |
+
f"7-Day Surf Overview: {spot_name.title()} ({region.upper()})",
|
| 75 |
+
fontsize=16,
|
| 76 |
+
fontweight="bold",
|
| 77 |
+
)
|
| 78 |
+
|
| 79 |
+
# 1. Overall Daily Score
|
| 80 |
+
sns.barplot(
|
| 81 |
+
data=df,
|
| 82 |
+
x="date_label",
|
| 83 |
+
y="score",
|
| 84 |
+
ax=axes[0],
|
| 85 |
+
palette="Blues_d",
|
| 86 |
+
hue="date_label",
|
| 87 |
+
legend=False,
|
| 88 |
+
)
|
| 89 |
+
axes[0].set_ylabel("Overall Score (0-10)", fontweight="bold")
|
| 90 |
+
axes[0].set_ylim(0, 10)
|
| 91 |
+
for p in axes[0].patches:
|
| 92 |
+
axes[0].annotate(
|
| 93 |
+
f"{p.get_height():.1f}",
|
| 94 |
+
(p.get_x() + p.get_width() / 2.0, p.get_height()),
|
| 95 |
+
ha="center",
|
| 96 |
+
va="center",
|
| 97 |
+
xytext=(0, 5),
|
| 98 |
+
textcoords="offset points",
|
| 99 |
+
fontweight="bold",
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
# 2. Daily Swell Size & Direction
|
| 103 |
+
sns.lineplot(
|
| 104 |
+
data=df,
|
| 105 |
+
x="date_label",
|
| 106 |
+
y="swell_height",
|
| 107 |
+
ax=axes[1],
|
| 108 |
+
marker="o",
|
| 109 |
+
color="teal",
|
| 110 |
+
linewidth=2.5,
|
| 111 |
+
)
|
| 112 |
+
if "swell_direction" in df.columns:
|
| 113 |
+
draw_directional_arrows(
|
| 114 |
+
axes[1],
|
| 115 |
+
range(len(df)),
|
| 116 |
+
df["swell_height"],
|
| 117 |
+
df["swell_direction"],
|
| 118 |
+
color="darkslategrey",
|
| 119 |
+
)
|
| 120 |
+
axes[1].set_ylabel("Swell Height (m)", fontweight="bold")
|
| 121 |
+
|
| 122 |
+
# 3. Daily Wind Speed & Direction
|
| 123 |
+
sns.lineplot(
|
| 124 |
+
data=df,
|
| 125 |
+
x="date_label",
|
| 126 |
+
y="wind_speed",
|
| 127 |
+
ax=axes[2],
|
| 128 |
+
marker="s",
|
| 129 |
+
color="crimson",
|
| 130 |
+
linewidth=2.5,
|
| 131 |
+
)
|
| 132 |
+
if "wind_direction" in df.columns:
|
| 133 |
+
draw_directional_arrows(
|
| 134 |
+
axes[2],
|
| 135 |
+
range(len(df)),
|
| 136 |
+
df["wind_speed"],
|
| 137 |
+
df["wind_direction"],
|
| 138 |
+
color="darkred",
|
| 139 |
+
)
|
| 140 |
+
axes[2].set_ylabel("Wind Speed (kts)", fontweight="bold")
|
| 141 |
+
axes[2].set_xlabel("Date", fontweight="bold")
|
| 142 |
+
|
| 143 |
+
plt.xticks(rotation=15)
|
| 144 |
+
plt.tight_layout()
|
| 145 |
+
return {"image_base64": _encode_fig_to_base64(fig), "format": "png"}
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def plot_hourly_surf_drilldown(
|
| 149 |
+
spot_name: str, region: str, date_str: str | None = None
|
| 150 |
+
) -> dict:
|
| 151 |
+
s = spots.get_spot(spot_name, region)
|
| 152 |
+
if s is None:
|
| 153 |
+
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
| 154 |
+
|
| 155 |
+
forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 156 |
+
|
| 157 |
+
# Safely retrieve hourly data structure
|
| 158 |
+
hourly_raw = forecast.get("hourly") or forecast.get("forecast", [])
|
| 159 |
+
df = pd.DataFrame(hourly_raw)
|
| 160 |
+
if df.empty:
|
| 161 |
+
return {"error": "No hourly forecast data available"}
|
| 162 |
+
|
| 163 |
+
# Dynamic timestamp field resolution to prevent KeyError
|
| 164 |
+
ts_col = next((col for col in ["timestamp", "time", "date", "datetime"] if col in df.columns), None)
|
| 165 |
+
if ts_col is None:
|
| 166 |
+
return {"error": "No valid timestamp field found in hourly data"}
|
| 167 |
+
|
| 168 |
+
df["parsed_timestamp"] = pd.to_datetime(df[ts_col])
|
| 169 |
+
|
| 170 |
+
# Target specific date or default to the first available date
|
| 171 |
+
if date_str:
|
| 172 |
+
target_date = pd.to_datetime(date_str).date()
|
| 173 |
+
df = df[df["parsed_timestamp"].dt.date == target_date]
|
| 174 |
+
else:
|
| 175 |
+
df = df[df["parsed_timestamp"].dt.date == df["parsed_timestamp"].dt.date.iloc[0]]
|
| 176 |
+
|
| 177 |
+
if df.empty:
|
| 178 |
+
return {"error": f"No data found for date {date_str}"}
|
| 179 |
+
|
| 180 |
+
df["hour_str"] = df["parsed_timestamp"].dt.strftime("%H:%M")
|
| 181 |
+
|
| 182 |
+
fig, axes = plt.subplots(3, 1, figsize=(12, 9), sharex=True)
|
| 183 |
+
display_date = df["parsed_timestamp"].dt.strftime("%Y-%m-%d").iloc[0]
|
| 184 |
+
fig.suptitle(
|
| 185 |
+
f"Hourly Drilldown for {spot_name.title()} ({display_date})",
|
| 186 |
+
fontsize=16,
|
| 187 |
+
fontweight="bold",
|
| 188 |
+
)
|
| 189 |
+
|
| 190 |
+
# 1. Hourly Scores
|
| 191 |
+
sns.lineplot(
|
| 192 |
+
data=df,
|
| 193 |
+
x="hour_str",
|
| 194 |
+
y="score",
|
| 195 |
+
ax=axes[0],
|
| 196 |
+
color="purple",
|
| 197 |
+
marker="o",
|
| 198 |
+
linewidth=2,
|
| 199 |
+
)
|
| 200 |
+
axes[0].fill_between(
|
| 201 |
+
df["hour_str"], df["score"], color="purple", alpha=0.15
|
| 202 |
+
)
|
| 203 |
+
axes[0].set_ylabel("Score (0-10)", fontweight="bold")
|
| 204 |
+
axes[0].set_ylim(0, 10)
|
| 205 |
+
|
| 206 |
+
# 2. Hourly Swell Size & Direction
|
| 207 |
+
sns.lineplot(
|
| 208 |
+
data=df,
|
| 209 |
+
x="hour_str",
|
| 210 |
+
y="swell_height",
|
| 211 |
+
ax=axes[1],
|
| 212 |
+
color="dodgerblue",
|
| 213 |
+
marker="o",
|
| 214 |
+
)
|
| 215 |
+
if "swell_direction" in df.columns:
|
| 216 |
+
draw_directional_arrows(
|
| 217 |
+
axes[1],
|
| 218 |
+
range(len(df)),
|
| 219 |
+
df["swell_height"],
|
| 220 |
+
df["swell_direction"],
|
| 221 |
+
color="navy",
|
| 222 |
+
)
|
| 223 |
+
axes[1].set_ylabel("Swell (m)", fontweight="bold")
|
| 224 |
+
|
| 225 |
+
# 3. Hourly Wind Speed & Direction
|
| 226 |
+
sns.lineplot(
|
| 227 |
+
data=df,
|
| 228 |
+
x="hour_str",
|
| 229 |
+
y="wind_speed",
|
| 230 |
+
ax=axes[2],
|
| 231 |
+
color="orangered",
|
| 232 |
+
marker="o",
|
| 233 |
+
)
|
| 234 |
+
if "wind_direction" in df.columns:
|
| 235 |
+
draw_directional_arrows(
|
| 236 |
+
axes[2],
|
| 237 |
+
range(len(df)),
|
| 238 |
+
df["wind_speed"],
|
| 239 |
+
df["wind_direction"],
|
| 240 |
+
color="darkred",
|
| 241 |
+
)
|
| 242 |
+
axes[2].set_ylabel("Wind (kts)", fontweight="bold")
|
| 243 |
+
axes[2].set_xlabel("Time", fontweight="bold")
|
| 244 |
+
|
| 245 |
+
plt.xticks(rotation=45)
|
| 246 |
+
plt.tight_layout()
|
| 247 |
+
return {"image_base64": _encode_fig_to_base64(fig), "format": "png"}
|
| 248 |
|
| 249 |
|
| 250 |
def get_forecast(spot_name: str, region: str) -> dict:
|
|
|
|
| 251 |
s = spots.get_spot(spot_name, region)
|
| 252 |
if s is None:
|
| 253 |
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
| 254 |
return forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 255 |
|
| 256 |
|
| 257 |
+
def score_week(spot_name: str, region: str) -> dict | list[dict]:
|
|
|
|
| 258 |
s = spots.get_spot(spot_name, region)
|
| 259 |
if s is None:
|
| 260 |
+
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
| 261 |
forecast = forecasts.get_forecast(s.coordinates.lat, s.coordinates.lng)
|
| 262 |
return scoring.score_week(forecast, s.model_dump())
|
| 263 |
|
| 264 |
|
| 265 |
def find_spots(query: str = "", skill: str | None = None, limit: int = 10) -> list[dict]:
|
|
|
|
| 266 |
results = spots.find_spots(query=query, skill=skill, limit=limit)
|
| 267 |
return [b.model_dump() for b in results]
|
| 268 |
|
| 269 |
|
| 270 |
def get_spot_knowledge(spot_name: str, region: str) -> dict:
|
|
|
|
| 271 |
s = spots.get_spot(spot_name, region)
|
| 272 |
if s is None:
|
| 273 |
return {"error": f"Spot '{spot_name}' ({region}) not found"}
|
|
|
|
| 275 |
|
| 276 |
|
| 277 |
def rank_spots_this_week(region: str, skill: str | None = None) -> list[dict]:
|
|
|
|
| 278 |
spots_list = spots.find_spots(region=region, skill=skill)
|
| 279 |
if not spots_list:
|
| 280 |
return []
|
| 281 |
+
forecasts_map = {}
|
| 282 |
for b in spots_list:
|
| 283 |
d = forecasts.get_forecast(b.coordinates.lat, b.coordinates.lng)
|
| 284 |
forecasts_map[(b.name, b.region)] = d
|
| 285 |
return scoring.rank_spots_this_week(
|
| 286 |
forecasts_map,
|
| 287 |
[b.model_dump() for b in spots_list],
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
class GetForecastTool(Tool):
|
| 292 |
+
name = "get_forecast"
|
| 293 |
+
description = "Retrieve the wave forecast for a specific surf spot."
|
| 294 |
+
inputs = {
|
| 295 |
+
"spot_name": {
|
| 296 |
+
"type": "string",
|
| 297 |
+
"description": "Name of the surf spot.",
|
| 298 |
+
},
|
| 299 |
+
"region": {
|
| 300 |
+
"type": "string",
|
| 301 |
+
"description": "Region or state code where the spot is located (e.g., 'QLD').",
|
| 302 |
+
},
|
| 303 |
+
}
|
| 304 |
+
output_type = "object"
|
| 305 |
+
|
| 306 |
+
def forward(self, spot_name: str, region: str) -> dict:
|
| 307 |
+
return get_forecast(spot_name=spot_name, region=region)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
class ScoreWeekTool(Tool):
|
| 311 |
+
name = "score_week"
|
| 312 |
+
description = "Score the conditions of a surf spot for the upcoming week based on its forecast."
|
| 313 |
+
inputs = {
|
| 314 |
+
"spot_name": {
|
| 315 |
+
"type": "string",
|
| 316 |
+
"description": "Name of the surf spot.",
|
| 317 |
+
},
|
| 318 |
+
"region": {
|
| 319 |
+
"type": "string",
|
| 320 |
+
"description": "Region or state code where the spot is located.",
|
| 321 |
+
},
|
| 322 |
+
}
|
| 323 |
+
output_type = "object"
|
| 324 |
+
|
| 325 |
+
def forward(self, spot_name: str, region: str) -> dict | list[dict]:
|
| 326 |
+
return score_week(spot_name=spot_name, region=region)
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
class FindSpotsTool(Tool):
|
| 330 |
+
name = "find_spots"
|
| 331 |
+
description = "Search for surf spots based on query, skill level, and limit."
|
| 332 |
+
inputs = {
|
| 333 |
+
"query": {
|
| 334 |
+
"type": "string",
|
| 335 |
+
"description": "Search query or region for surf spots. Use State code (e.g., 'QLD') or specific spot names.",
|
| 336 |
+
"nullable": True,
|
| 337 |
+
},
|
| 338 |
+
"skill": {
|
| 339 |
+
"type": "string",
|
| 340 |
+
"description": "Skill level (e.g., beginner, intermediate, advanced).",
|
| 341 |
+
"nullable": True,
|
| 342 |
+
},
|
| 343 |
+
"limit": {
|
| 344 |
+
"type": "integer",
|
| 345 |
+
"description": "Maximum number of spots to return.",
|
| 346 |
+
"nullable": True,
|
| 347 |
+
},
|
| 348 |
+
}
|
| 349 |
+
output_type = "object"
|
| 350 |
+
|
| 351 |
+
def forward(
|
| 352 |
+
self, query: str = "", skill: str | None = None, limit: int = 10
|
| 353 |
+
) -> list[dict]:
|
| 354 |
+
return find_spots(query=query, skill=skill, limit=limit)
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
class GetSpotKnowledgeTool(Tool):
|
| 358 |
+
name = "get_spot_knowledge"
|
| 359 |
+
description = "Retrieve metadata and detailed knowledge for a specific surf spot."
|
| 360 |
+
inputs = {
|
| 361 |
+
"spot_name": {
|
| 362 |
+
"type": "string",
|
| 363 |
+
"description": "Name of the surf spot.",
|
| 364 |
+
},
|
| 365 |
+
"region": {
|
| 366 |
+
"type": "string",
|
| 367 |
+
"description": "Region or state code where the spot is located.",
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
output_type = "object"
|
| 371 |
+
|
| 372 |
+
def forward(self, spot_name: str, region: str) -> dict:
|
| 373 |
+
return get_spot_knowledge(spot_name=spot_name, region=region)
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
class RankSpotsThisWeekTool(Tool):
|
| 377 |
+
name = "rank_spots_this_week"
|
| 378 |
+
description = "Rank spots in a region for the upcoming week based on scored surf conditions."
|
| 379 |
+
inputs = {
|
| 380 |
+
"region": {
|
| 381 |
+
"type": "string",
|
| 382 |
+
"description": "Region or state code to rank spots in (e.g., 'QLD').",
|
| 383 |
+
},
|
| 384 |
+
"skill": {
|
| 385 |
+
"type": "string",
|
| 386 |
+
"description": "Skill level filter (e.g., beginner, intermediate, advanced).",
|
| 387 |
+
"nullable": True,
|
| 388 |
+
},
|
| 389 |
+
}
|
| 390 |
+
output_type = "object"
|
| 391 |
+
|
| 392 |
+
def forward(
|
| 393 |
+
self, region: str, skill: str | None = None
|
| 394 |
+
) -> list[dict]:
|
| 395 |
+
return rank_spots_this_week(region=region, skill=skill)
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
class PlotDailySurfOverviewTool(Tool):
|
| 399 |
+
name = "plot_daily_surf_overview"
|
| 400 |
+
description = (
|
| 401 |
+
"Generates a 7-day daily overview graph for a surf spot showing score, "
|
| 402 |
+
"swell height/direction, and wind speed/direction. Returns a base64 encoded PNG."
|
| 403 |
+
)
|
| 404 |
+
inputs = {
|
| 405 |
+
"spot_name": {
|
| 406 |
+
"type": "string",
|
| 407 |
+
"description": "Name of the surf spot.",
|
| 408 |
+
},
|
| 409 |
+
"region": {
|
| 410 |
+
"type": "string",
|
| 411 |
+
"description": "Region or state code (e.g., 'QLD').",
|
| 412 |
+
},
|
| 413 |
+
}
|
| 414 |
+
output_type = "object"
|
| 415 |
+
|
| 416 |
+
def forward(self, spot_name: str, region: str) -> dict:
|
| 417 |
+
return plot_daily_surf_overview(spot_name=spot_name, region=region)
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
class PlotHourlySurfDrilldownTool(Tool):
|
| 421 |
+
name = "plot_hourly_surf_drilldown"
|
| 422 |
+
description = (
|
| 423 |
+
"Generates an hourly drilldown plot for a specific day at a surf spot showing "
|
| 424 |
+
"hourly score, swell height/direction, and wind speed/direction. Returns a base64 encoded PNG."
|
| 425 |
+
)
|
| 426 |
+
inputs = {
|
| 427 |
+
"spot_name": {
|
| 428 |
+
"type": "string",
|
| 429 |
+
"description": "Name of the surf spot.",
|
| 430 |
+
},
|
| 431 |
+
"region": {
|
| 432 |
+
"type": "string",
|
| 433 |
+
"description": "Region or state code (e.g., 'QLD').",
|
| 434 |
+
},
|
| 435 |
+
"date_str": {
|
| 436 |
+
"type": "string",
|
| 437 |
+
"description": "Date to drill down into (YYYY-MM-DD). If omitted, defaults to the first available date.",
|
| 438 |
+
"nullable": True,
|
| 439 |
+
},
|
| 440 |
+
}
|
| 441 |
+
output_type = "object"
|
| 442 |
+
|
| 443 |
+
def forward(
|
| 444 |
+
self, spot_name: str, region: str, date_str: str | None = None
|
| 445 |
+
) -> dict:
|
| 446 |
+
return plot_hourly_surf_drilldown(
|
| 447 |
+
spot_name=spot_name, region=region, date_str=date_str
|
| 448 |
+
)
|